From 16f557cb9aa8fd5385df4621aa4fa9af7ec544f2 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Fri, 27 Jun 2025 09:06:43 +0100 Subject: [PATCH 1/4] Support C# format strings in config --- .../Extensions/ShouldlyExtensions.cs | 24 +++++ .../StringFormatWithExtensionTests.cs | 25 ++++++ .../Helpers/DateFormatterTests.cs | 39 ++++++++ .../Helpers/EdgeCaseTests.cs | 60 +++++++++++++ .../Helpers/FormattableFormatterTests.cs | 41 +++++++++ .../Helpers/InputSanitizerTests.cs | 82 +++++++++++++++++ .../Helpers/NumericFormatterTests.cs | 38 ++++++++ .../Helpers/SanitizeEnvVarNameTests.cs | 67 ++++++++++++++ .../Helpers/SanitizeMemberNameTests.cs | 78 ++++++++++++++++ .../Helpers/StringFormatterTests.cs | 76 ++++++++++++++++ .../VariableProviderTests.cs | 30 +++++++ src/GitVersion.Core/Core/RegexPatterns.cs | 43 ++++++++- .../Extensions/StringExtensions.cs | 20 +++++ src/GitVersion.Core/Helpers/DateFormatter.cs | 31 +++++++ .../Helpers/ExpressionCompiler.cs | 20 +++++ .../Helpers/FormattableFormatter.cs | 43 +++++++++ .../Helpers/IExpressionCompiler.cs | 7 ++ .../Helpers/IInputSanitizer.cs | 11 +++ .../Helpers/IMemberResolver.cs | 6 ++ .../Helpers/IValueFormatter.cs | 11 +++ src/GitVersion.Core/Helpers/InputSanitizer.cs | 66 ++++++++++++++ src/GitVersion.Core/Helpers/MemberResolver.cs | 74 ++++++++++++++++ .../Helpers/NumericFormatter.cs | 48 ++++++++++ .../Helpers/StringFormatWith.cs | 88 +++++++++++++------ .../Helpers/StringFormatter.cs | 54 ++++++++++++ src/GitVersion.Core/Helpers/ValueFormatter.cs | 36 ++++++++ 26 files changed, 1089 insertions(+), 29 deletions(-) create mode 100644 src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs create mode 100644 src/GitVersion.Core/Helpers/DateFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/ExpressionCompiler.cs create mode 100644 src/GitVersion.Core/Helpers/FormattableFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/IExpressionCompiler.cs create mode 100644 src/GitVersion.Core/Helpers/IInputSanitizer.cs create mode 100644 src/GitVersion.Core/Helpers/IMemberResolver.cs create mode 100644 src/GitVersion.Core/Helpers/IValueFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/InputSanitizer.cs create mode 100644 src/GitVersion.Core/Helpers/MemberResolver.cs create mode 100644 src/GitVersion.Core/Helpers/NumericFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/StringFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/ValueFormatter.cs diff --git a/src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs b/src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs new file mode 100644 index 0000000000..bac7a4346e --- /dev/null +++ b/src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs @@ -0,0 +1,24 @@ +namespace GitVersion.Core.Tests.Extensions; + +public static class ShouldlyExtensions +{ + /// + /// Asserts that the action throws an exception of type TException + /// with the expected message. + /// + public static void ShouldThrowWithMessage(this Action action, string expectedMessage) where TException : Exception + { + var ex = Should.Throw(action); + ex.Message.ShouldBe(expectedMessage); + } + + /// + /// Asserts that the action throws an exception of type TException, + /// and allows further assertion on the exception instance. + /// + public static void ShouldThrow(this Action action, Action additionalAssertions) where TException : Exception + { + var ex = Should.Throw(action); + additionalAssertions(ex); + } +} diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 791625aeaa..144b69baf0 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -244,4 +244,29 @@ public void FormatProperty_NullObject_WithFallback_QuotedAndEmpty() var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo("")); } + + [Test] + public void FormatAssemblyInformationalVersionWithSemanticVersionCustomFormattedCommitsSinceVersionSource() + { + var semanticVersion = new SemanticVersion + { + Major = 1, + Minor = 2, + Patch = 3, + PreReleaseTag = new SemanticVersionPreReleaseTag(string.Empty, 9, true), + BuildMetaData = new SemanticVersionBuildMetaData("Branch.main") + { + Branch = "main", + VersionSourceSha = "versionSourceSha", + Sha = "commitSha", + ShortSha = "commitShortSha", + CommitsSinceVersionSource = 42, + CommitDate = DateTimeOffset.Parse("2014-03-06 23:59:59Z") + } + }; + const string target = "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}"; + const string expected = "1.2.3-0042"; + var actual = target.FormatWith(semanticVersion, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } } diff --git a/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs new file mode 100644 index 0000000000..2fa58b3fb6 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs @@ -0,0 +1,39 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class DateFormatterTests +{ + [Test] + public void Priority_ShouldBe2() => new DateFormatter().Priority.ShouldBe(2); + + [Test] + public void TryFormat_NullValue_ReturnsFalse() + { + var sut = new DateFormatter(); + var result = sut.TryFormat(null, "yyyy-MM-dd", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("2021-01-01", "date:yyyy-MM-dd", "2021-01-01")] + [TestCase("2021-01-01T12:00:00Z", "date:yyyy-MM-ddTHH:mm:ssZ", "2021-01-01T12:00:00Z")] + public void TryFormat_ValidDateFormats_ReturnsExpectedResult(string input, string format, string expected) + { + var date = DateTime.Parse(input); + var sut = new DateFormatter(); + var result = sut.TryFormat(date, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + + [Test] + public void TryFormat_UnsupportedFormat_ReturnsFalse() + { + var sut = new DateFormatter(); + var result = sut.TryFormat(DateTime.Now, "unsupported", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs b/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs new file mode 100644 index 0000000000..9aacdb7e9d --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs @@ -0,0 +1,60 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +public partial class InputSanitizerTests +{ + [TestFixture] + public class EdgeCaseTests : InputSanitizerTests + { + [TestCase(49)] + [TestCase(50)] + public void SanitizeFormat_WithBoundaryLengths_ReturnsInput(int length) + { + var input = new string('x', length); + new InputSanitizer().SanitizeFormat(input).ShouldBe(input); + } + + [TestCase(199)] + [TestCase(200)] + public void SanitizeEnvVarName_WithBoundaryLengths_ReturnsInput(int length) + { + var input = new string('A', length); + new InputSanitizer().SanitizeEnvVarName(input).ShouldBe(input); + } + + [TestCase(99)] + [TestCase(100)] + public void SanitizeMemberName_WithBoundaryLengths_ReturnsInput(int length) + { + var input = new string('A', length); + new InputSanitizer().SanitizeMemberName(input).ShouldBe(input); + } + + [Test] + public void SanitizeFormat_WithUnicode_ReturnsInput() + { + const string unicodeFormat = "测试format"; + new InputSanitizer().SanitizeFormat(unicodeFormat).ShouldBe(unicodeFormat); + } + + [Test] + public void SanitizeEnvVarName_WithUnicode_ThrowsArgumentException() + { + const string unicodeEnvVar = "测试_VAR"; + Action act = () => new InputSanitizer().SanitizeEnvVarName(unicodeEnvVar); + act.ShouldThrowWithMessage( + $"Environment variable name contains disallowed characters: '{unicodeEnvVar}'"); + } + + [Test] + public void SanitizeMemberName_WithUnicode_ThrowsArgumentException() + { + const string unicodeMember = "测试Member"; + Action act = () => new InputSanitizer().SanitizeMemberName(unicodeMember); + act.ShouldThrowWithMessage( + $"Member name contains disallowed characters: '{unicodeMember}'"); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs new file mode 100644 index 0000000000..6145397ec5 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs @@ -0,0 +1,41 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class FormattableFormatterTests +{ + [Test] + public void Priority_ShouldBe2() => new FormattableFormatter().Priority.ShouldBe(2); + + [Test] + public void TryFormat_NullValue_ReturnsFalse() + { + var sut = new FormattableFormatter(); + var result = sut.TryFormat(null, "G", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase(123.456, "F2", "123.46")] + [TestCase(1234.456, "F2", "1234.46")] + public void TryFormat_ValidFormats_ReturnsExpectedResult(object input, string format, string expected) + { + var sut = new FormattableFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + + [TestCase(123.456, "C", "Format 'C' is not supported in FormattableFormatter")] + [TestCase(123.456, "P", "Format 'P' is not supported in FormattableFormatter")] + [TestCase(1234567890, "N0", "Format 'N0' is not supported in FormattableFormatter")] + [TestCase(1234567890, "Z", "Format 'Z' is not supported in FormattableFormatter")] + public void TryFormat_UnsupportedFormat_ReturnsFalse(object input, string format, string expected) + { + var sut = new FormattableFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBe(expected); + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs b/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs new file mode 100644 index 0000000000..c0899c13ad --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs @@ -0,0 +1,82 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public partial class InputSanitizerTests +{ + [TestFixture] + public class SanitizeFormatTests : InputSanitizerTests + { + [Test] + public void SanitizeFormat_WithValidFormat_ReturnsInput() + { + var sut = new InputSanitizer(); + const string validFormat = "yyyy-MM-dd"; + sut.SanitizeFormat(validFormat).ShouldBe(validFormat); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void SanitizeFormat_WithEmptyOrWhitespace_ThrowsFormatException(string invalidFormat) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeFormat(invalidFormat); + act.ShouldThrowWithMessage("Format string cannot be empty."); + } + + [Test] + public void SanitizeFormat_WithTooLongFormat_ThrowsFormatException() + { + var sut = new InputSanitizer(); + var longFormat = new string('x', 51); + Action act = () => sut.SanitizeFormat(longFormat); + act.ShouldThrowWithMessage("Format string too long: 'xxxxxxxxxxxxxxxxxxxx...'"); + } + + [Test] + public void SanitizeFormat_WithMaxValidLength_ReturnsInput() + { + var sut = new InputSanitizer(); + var maxLengthFormat = new string('x', 50); + sut.SanitizeFormat(maxLengthFormat).ShouldBe(maxLengthFormat); + } + + [TestCase("\r", TestName = "SanitizeFormat_ControlChar_CR")] + [TestCase("\n", TestName = "SanitizeFormat_ControlChar_LF")] + [TestCase("\0", TestName = "SanitizeFormat_ControlChar_Null")] + [TestCase("\x01", TestName = "SanitizeFormat_ControlChar_0x01")] + [TestCase("\x1F", TestName = "SanitizeFormat_ControlChar_0x1F")] + public void SanitizeFormat_WithControlCharacters_ThrowsFormatException(string controlChar) + { + var sut = new InputSanitizer(); + var formatWithControl = $"valid{controlChar}format"; + Action act = () => sut.SanitizeFormat(formatWithControl); + act.ShouldThrowWithMessage("Format string contains invalid control characters"); + } + + [Test] + public void SanitizeFormat_WithTabCharacter_ReturnsInput() + { + var sut = new InputSanitizer(); + const string formatWithTab = "format\twith\ttab"; + sut.SanitizeFormat(formatWithTab).ShouldBe(formatWithTab); + } + + [TestCase("yyyy-MM-dd")] + [TestCase("HH:mm:ss")] + [TestCase("0.00")] + [TestCase("C2")] + [TestCase("X8")] + [TestCase("format with spaces")] + [TestCase("format-with-dashes")] + [TestCase("format_with_underscores")] + public void SanitizeFormat_WithValidFormats_ReturnsInput(string validFormat) + { + var sut = new InputSanitizer(); + sut.SanitizeFormat(validFormat).ShouldBe(validFormat); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs new file mode 100644 index 0000000000..0d34e2fa57 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs @@ -0,0 +1,38 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class NumericFormatterTests +{ + [Test] + public void Priority_ShouldBe1() => new NumericFormatter().Priority.ShouldBe(1); + [Test] + public void TryFormat_NullValue_ReturnsFalse() + { + var sut = new NumericFormatter(); + var result = sut.TryFormat(null, "n", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("1234.5678", "n", "1,234.57")] + [TestCase("1234.5678", "f2", "1234.57")] + [TestCase("1234.5678", "f0", "1235")] + [TestCase("1234.5678", "g", "1234.5678")] + public void TryFormat_ValidFormats_ReturnsExpectedResult(string input, string format, string expected) + { + var sut = new NumericFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + [Test] + public void TryFormat_UnsupportedFormat_ReturnsFalse() + { + var sut = new NumericFormatter(); + var result = sut.TryFormat(1234.5678, "z", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs b/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs new file mode 100644 index 0000000000..d66ce416a1 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs @@ -0,0 +1,67 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +public partial class InputSanitizerTests +{ + [TestFixture] + public class SanitizeEnvVarNameTests : InputSanitizerTests + { + [Test] + public void SanitizeEnvVarName_WithValidName_ReturnsInput() + { + var sut = new InputSanitizer(); + const string validName = "VALID_ENV_VAR"; + sut.SanitizeEnvVarName(validName).ShouldBe(validName); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void SanitizeEnvVarName_WithEmptyOrWhitespace_ThrowsArgumentException(string invalidName) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeEnvVarName(invalidName); + act.ShouldThrowWithMessage("Environment variable name cannot be null or empty."); + } + + [Test] + public void SanitizeEnvVarName_WithTooLongName_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + var longName = new string('A', 201); + Action act = () => sut.SanitizeEnvVarName(longName); + act.ShouldThrowWithMessage("Environment variable name too long: 'AAAAAAAAAAAAAAAAAAAA...'"); + } + + [Test] + public void SanitizeEnvVarName_WithMaxValidLength_ReturnsInput() + { + var sut = new InputSanitizer(); + var maxLengthName = new string('A', 200); + sut.SanitizeEnvVarName(maxLengthName).ShouldBe(maxLengthName); + } + + [Test] + public void SanitizeEnvVarName_WithInvalidCharacters_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + const string invalidName = "INVALID@NAME"; + Action act = () => sut.SanitizeEnvVarName(invalidName); + act.ShouldThrowWithMessage("Environment variable name contains disallowed characters: 'INVALID@NAME'"); + } + + [TestCase("PATH")] + [TestCase("HOME")] + [TestCase("USER_NAME")] + [TestCase("MY_VAR_123")] + [TestCase("_PRIVATE_VAR")] + [TestCase("VAR123")] + public void SanitizeEnvVarName_WithValidNames_ReturnsInput(string validName) + { + var sut = new InputSanitizer(); + sut.SanitizeEnvVarName(validName).ShouldBe(validName); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs b/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs new file mode 100644 index 0000000000..2fc4d07b48 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs @@ -0,0 +1,78 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +public partial class InputSanitizerTests +{ + [TestFixture] + public class SanitizeMemberNameTests : InputSanitizerTests + { + [Test] + public void SanitizeMemberName_WithValidName_ReturnsInput() + { + var sut = new InputSanitizer(); + const string validName = "ValidMemberName"; + sut.SanitizeMemberName(validName).ShouldBe(validName); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void SanitizeMemberName_WithEmptyOrWhitespace_ThrowsArgumentException(string invalidName) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeMemberName(invalidName); + act.ShouldThrowWithMessage("Member name cannot be empty."); + } + + [Test] + public void SanitizeMemberName_WithTooLongName_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + var longName = new string('A', 101); + Action act = () => sut.SanitizeMemberName(longName); + act.ShouldThrowWithMessage("Member name too long: 'AAAAAAAAAAAAAAAAAAAA...'"); + } + + [Test] + public void SanitizeMemberName_WithMaxValidLength_ReturnsInput() + { + var sut = new InputSanitizer(); + var maxLengthName = new string('A', 100); + sut.SanitizeMemberName(maxLengthName).ShouldBe(maxLengthName); + } + + [Test] + public void SanitizeMemberName_WithInvalidCharacters_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + const string invalidName = "Invalid@Member"; + Action act = () => sut.SanitizeMemberName(invalidName); + act.ShouldThrowWithMessage("Member name contains disallowed characters: 'Invalid@Member'"); + } + + [TestCase("PropertyName")] + [TestCase("FieldName")] + [TestCase("Member123")] + [TestCase("_privateMember")] + [TestCase("CamelCaseName")] + [TestCase("PascalCaseName")] + [TestCase("member_with_underscores")] + public void SanitizeMemberName_WithValidNames_ReturnsInput(string validName) + { + var sut = new InputSanitizer(); + sut.SanitizeMemberName(validName).ShouldBe(validName); + } + + [TestCase("member.nested")] + [TestCase("Parent.Child.GrandChild")] + public void SanitizeMemberName_WithDottedNames_HandledByRegex(string dottedName) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeMemberName(dottedName); + + act.ShouldNotThrow(); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs new file mode 100644 index 0000000000..e1eb140277 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs @@ -0,0 +1,76 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class StringFormatterTests +{ + [Test] + public void Priority_ShouldBe2() => new StringFormatter().Priority.ShouldBe(2); + + [TestCase("u")] + [TestCase("")] + [TestCase(" ")] + [TestCase("invalid")] + public void TryFormat_NullValue_ReturnsFalse(string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(null, format, out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("hello", "u", "HELLO")] + [TestCase("HELLO", "l", "hello")] + [TestCase("hello world", "t", "Hello World")] + [TestCase("hELLO", "s", "Hello")] + [TestCase("hello world", "c", "HelloWorld")] + public void TryFormat_ValidFormats_ReturnsExpectedResult(string input, string format, string expected) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + + [TestCase("", "s")] + [TestCase("", "u")] + [TestCase("", "l")] + [TestCase("", "t")] + [TestCase("", "c")] + public void TryFormat_EmptyStringWithValidFormat_ReturnsEmpty(string input, string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBeEmpty(); + } + + [TestCase("test", "")] + [TestCase("test", " ")] + [TestCase("test", "invalid")] + [TestCase("invalid", "")] + [TestCase("invalid", " ")] + [TestCase("invalid", "invalid")] + public void TryFormat_ValidStringWithInvalidFormat_ReturnsFalse(string input, string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("", "")] + [TestCase("", " ")] + [TestCase("", "invalid")] + [TestCase(" ", "")] + [TestCase(" ", " ")] + [TestCase(" ", "invalid")] + public void TryFormat_EmptyOrWhitespaceStringWithInvalidFormat_ReturnsTrue(string input, string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs b/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs index dc96e7c4f7..5795da40ed 100644 --- a/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs +++ b/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs @@ -284,4 +284,34 @@ public void ProvidesVariablesInContinuousDeploymentModeForMainBranchWithEmptyLab variables.ToJson().ShouldMatchApproved(x => x.SubFolder("Approved")); } + + [Test] + public void Format_Allows_CSharp_FormatStrings() + { + var semanticVersion = new SemanticVersion + { + Major = 1, + Minor = 2, + Patch = 3, + PreReleaseTag = new(string.Empty, 9, true), + BuildMetaData = new("Branch.main") + { + Branch = "main", + VersionSourceSha = "versionSourceSha", + Sha = "commitSha", + ShortSha = "commitShortSha", + CommitsSinceVersionSource = 42, + CommitDate = DateTimeOffset.Parse("2014-03-06 23:59:59Z") + } + }; + + var configuration = GitFlowConfigurationBuilder.New + .WithTagPreReleaseWeight(0) + .WithAssemblyInformationalFormat("{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}") + .Build(); + var preReleaseWeight = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName("develop")).PreReleaseWeight; + var variables = this.variableProvider.GetVariablesFor(semanticVersion, configuration, preReleaseWeight); + + variables.InformationalVersion.ShouldBe("1.2.3-0042"); + } } diff --git a/src/GitVersion.Core/Core/RegexPatterns.cs b/src/GitVersion.Core/Core/RegexPatterns.cs index 9bdb704808..a84161731d 100644 --- a/src/GitVersion.Core/Core/RegexPatterns.cs +++ b/src/GitVersion.Core/Core/RegexPatterns.cs @@ -29,6 +29,8 @@ public static Regex GetOrAdd([StringSyntax(StringSyntaxAttribute.Regex)] string [Common.SwitchArgumentRegexPattern] = Common.SwitchArgumentRegex, [Common.ObscurePasswordRegexPattern] = Common.ObscurePasswordRegex, [Common.ExpandTokensRegexPattern] = Common.ExpandTokensRegex, + [Common.SanitizeEnvVarNameRegexPattern] = Common.SanitizeEnvVarNameRegex, + [Common.SanitizeMemberNameRegexPattern] = Common.SanitizeMemberNameRegex, [Common.SanitizeNameRegexPattern] = Common.SanitizeNameRegex, [Configuration.DefaultTagPrefixRegexPattern] = Configuration.DefaultTagPrefixRegex, [Configuration.DefaultVersionInBranchRegexPattern] = Configuration.DefaultVersionInBranchRegex, @@ -84,7 +86,38 @@ internal static partial class Common internal const string ObscurePasswordRegexPattern = "(https?://)(.+)(:.+@)"; [StringSyntax(StringSyntaxAttribute.Regex)] - internal const string ExpandTokensRegexPattern = """{((env:(?\w+))|(?\w+))(\s+(\?\?)??\s+((?\w+)|"(?.*)"))??}"""; + internal const string ExpandTokensRegexPattern = """ + \{ # Opening brace + (?: # Start of either env or member expression + env:(?!env:)(?[A-Za-z_][A-Za-z0-9_]*) # Only a single env: prefix, not followed by another env: + | # OR + (?[A-Za-z_][A-Za-z0-9_]*) # member/property name + (?: # Optional format specifier + :(?[A-Za-z0-9\.\-,]+) # Colon followed by format string (no spaces, ?, or }), format cannot contain colon + )? # Format is optional + ) # End group for env or member + (?: # Optional fallback group + \s*\?\?\s+ # '??' operator with optional whitespace: exactly two question marks for fallback + (?: # Fallback value alternatives: + (?\w+) # A single word fallback + | # OR + "(?[^"]*)" # A quoted string fallback + ) + )? # Fallback is optional + \} + """; + + /// + /// Allow alphanumeric, underscore, colon (for custom format specification), hyphen, and dot + /// + [StringSyntax(StringSyntaxAttribute.Regex, Options)] + internal const string SanitizeEnvVarNameRegexPattern = @"^[A-Za-z0-9_:\-\.]+$"; + + /// + /// Allow alphanumeric, underscore, and dot for property/field access + /// + [StringSyntax(StringSyntaxAttribute.Regex, Options)] + internal const string SanitizeMemberNameRegexPattern = @"^[A-Za-z0-9_\.]+$"; [StringSyntax(StringSyntaxAttribute.Regex, Options)] internal const string SanitizeNameRegexPattern = "[^a-zA-Z0-9-]"; @@ -95,9 +128,15 @@ internal static partial class Common [GeneratedRegex(ObscurePasswordRegexPattern, Options)] public static partial Regex ObscurePasswordRegex(); - [GeneratedRegex(ExpandTokensRegexPattern, Options)] + [GeneratedRegex(ExpandTokensRegexPattern, RegexOptions.IgnorePatternWhitespace | Options)] public static partial Regex ExpandTokensRegex(); + [GeneratedRegex(SanitizeEnvVarNameRegexPattern, Options)] + public static partial Regex SanitizeEnvVarNameRegex(); + + [GeneratedRegex(SanitizeMemberNameRegexPattern, Options)] + public static partial Regex SanitizeMemberNameRegex(); + [GeneratedRegex(SanitizeNameRegexPattern, Options)] public static partial Regex SanitizeNameRegex(); } diff --git a/src/GitVersion.Core/Extensions/StringExtensions.cs b/src/GitVersion.Core/Extensions/StringExtensions.cs index 317fe51704..f7b73127ea 100644 --- a/src/GitVersion.Core/Extensions/StringExtensions.cs +++ b/src/GitVersion.Core/Extensions/StringExtensions.cs @@ -30,4 +30,24 @@ public static bool IsEquivalentTo(this string self, string? other) => public static string WithPrefixIfNotNullOrEmpty(this string value, string prefix) => string.IsNullOrEmpty(value) ? value : prefix + value; + + internal static string PascalCase(this string input) + { + var sb = new StringBuilder(input.Length); + var capitalizeNext = true; + + foreach (var c in input) + { + if (!char.IsLetterOrDigit(c)) + { + capitalizeNext = true; + continue; + } + + sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : char.ToLowerInvariant(c)); + capitalizeNext = false; + } + + return sb.ToString(); + } } diff --git a/src/GitVersion.Core/Helpers/DateFormatter.cs b/src/GitVersion.Core/Helpers/DateFormatter.cs new file mode 100644 index 0000000000..17fc03e0c8 --- /dev/null +++ b/src/GitVersion.Core/Helpers/DateFormatter.cs @@ -0,0 +1,31 @@ +using System.Globalization; + +namespace GitVersion.Helpers; + +internal class DateFormatter : IValueFormatter +{ + public int Priority => 2; + + public bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (value is DateTime dt && format.StartsWith("date:")) + { + var dateFormat = RemoveDatePrefix(format); + result = dt.ToString(dateFormat, CultureInfo.InvariantCulture); + return true; + } + + if (value is string dateStr && DateTime.TryParse(dateStr, out var parsedDate) && format.StartsWith("date:")) + { + var dateFormat = format.Substring(5); + result = parsedDate.ToString(dateFormat, CultureInfo.InvariantCulture); + return true; + } + + return false; + } + + private static string RemoveDatePrefix(string format) => format.Substring(5); +} diff --git a/src/GitVersion.Core/Helpers/ExpressionCompiler.cs b/src/GitVersion.Core/Helpers/ExpressionCompiler.cs new file mode 100644 index 0000000000..270b3e8308 --- /dev/null +++ b/src/GitVersion.Core/Helpers/ExpressionCompiler.cs @@ -0,0 +1,20 @@ +using System.Linq.Expressions; + +namespace GitVersion.Helpers; + +internal class ExpressionCompiler : IExpressionCompiler +{ + public Func CompileGetter(Type type, MemberInfo[] memberPath) + { + var param = Expression.Parameter(typeof(object)); + Expression body = Expression.Convert(param, type); + + foreach (var member in memberPath) + { + body = Expression.PropertyOrField(body, member.Name); + } + + body = Expression.Convert(body, typeof(object)); + return Expression.Lambda>(body, param).Compile(); + } +} diff --git a/src/GitVersion.Core/Helpers/FormattableFormatter.cs b/src/GitVersion.Core/Helpers/FormattableFormatter.cs new file mode 100644 index 0000000000..8835827f10 --- /dev/null +++ b/src/GitVersion.Core/Helpers/FormattableFormatter.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace GitVersion.Helpers; + +internal class FormattableFormatter : IValueFormatter +{ + public int Priority => 2; + + public bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (string.IsNullOrWhiteSpace(format)) + return false; + + if (IsBlockedFormat(format)) + { + result = $"Format '{format}' is not supported in {nameof(FormattableFormatter)}"; + return false; + } + + if (value is IFormattable formattable) + { + try + { + result = formattable.ToString(format, CultureInfo.InvariantCulture); + return true; + } + catch (FormatException) + { + result = $"Format '{format}' is not supported in {nameof(FormattableFormatter)}"; + return false; + } + } + + return false; + } + + private static bool IsBlockedFormat(string format) => + format.Equals("C", StringComparison.OrdinalIgnoreCase) || + format.Equals("P", StringComparison.OrdinalIgnoreCase) || + format.StartsWith("N", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/GitVersion.Core/Helpers/IExpressionCompiler.cs b/src/GitVersion.Core/Helpers/IExpressionCompiler.cs new file mode 100644 index 0000000000..e270f3a4de --- /dev/null +++ b/src/GitVersion.Core/Helpers/IExpressionCompiler.cs @@ -0,0 +1,7 @@ +namespace GitVersion.Helpers +{ + internal interface IExpressionCompiler + { + Func CompileGetter(Type type, MemberInfo[] memberPath); + } +} diff --git a/src/GitVersion.Core/Helpers/IInputSanitizer.cs b/src/GitVersion.Core/Helpers/IInputSanitizer.cs new file mode 100644 index 0000000000..f130457a94 --- /dev/null +++ b/src/GitVersion.Core/Helpers/IInputSanitizer.cs @@ -0,0 +1,11 @@ +namespace GitVersion.Helpers +{ + internal interface IInputSanitizer + { + string SanitizeEnvVarName(string name); + + string SanitizeFormat(string format); + + string SanitizeMemberName(string memberName); + } +} diff --git a/src/GitVersion.Core/Helpers/IMemberResolver.cs b/src/GitVersion.Core/Helpers/IMemberResolver.cs new file mode 100644 index 0000000000..9805e7f978 --- /dev/null +++ b/src/GitVersion.Core/Helpers/IMemberResolver.cs @@ -0,0 +1,6 @@ +namespace GitVersion.Helpers; + +internal interface IMemberResolver +{ + MemberInfo[] ResolveMemberPath(Type type, string memberExpression); +} diff --git a/src/GitVersion.Core/Helpers/IValueFormatter.cs b/src/GitVersion.Core/Helpers/IValueFormatter.cs new file mode 100644 index 0000000000..eec804e032 --- /dev/null +++ b/src/GitVersion.Core/Helpers/IValueFormatter.cs @@ -0,0 +1,11 @@ +namespace GitVersion.Helpers; + +internal interface IValueFormatter +{ + bool TryFormat(object? value, string format, out string result); + + /// + /// Lower number = higher priority + /// + int Priority { get; } +} diff --git a/src/GitVersion.Core/Helpers/InputSanitizer.cs b/src/GitVersion.Core/Helpers/InputSanitizer.cs new file mode 100644 index 0000000000..7a33484a9f --- /dev/null +++ b/src/GitVersion.Core/Helpers/InputSanitizer.cs @@ -0,0 +1,66 @@ +using GitVersion.Core; + +namespace GitVersion.Helpers; + +internal class InputSanitizer : IInputSanitizer +{ + public string SanitizeFormat(string format) + { + if (string.IsNullOrWhiteSpace(format)) + { + throw new FormatException("Format string cannot be empty."); + } + + if (format.Length > 50) + { + throw new FormatException($"Format string too long: '{format[..20]}...'"); + } + + if (format.Any(c => char.IsControl(c) && c != '\t')) + { + throw new FormatException("Format string contains invalid control characters"); + } + + return format; + } + + public string SanitizeEnvVarName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Environment variable name cannot be null or empty."); + } + + if (name.Length > 200) + { + throw new ArgumentException($"Environment variable name too long: '{name[..20]}...'"); + } + + if (!RegexPatterns.Cache.GetOrAdd(RegexPatterns.Common.SanitizeEnvVarNameRegexPattern).IsMatch(name)) + { + throw new ArgumentException($"Environment variable name contains disallowed characters: '{name}'"); + } + + return name; + } + + public string SanitizeMemberName(string memberName) + { + if (string.IsNullOrWhiteSpace(memberName)) + { + throw new ArgumentException("Member name cannot be empty."); + } + + if (memberName.Length > 100) + { + throw new ArgumentException($"Member name too long: '{memberName[..20]}...'"); + } + + if (!RegexPatterns.Cache.GetOrAdd(RegexPatterns.Common.SanitizeMemberNameRegexPattern).IsMatch(memberName)) + { + throw new ArgumentException($"Member name contains disallowed characters: '{memberName}'"); + } + + return memberName; + } +} diff --git a/src/GitVersion.Core/Helpers/MemberResolver.cs b/src/GitVersion.Core/Helpers/MemberResolver.cs new file mode 100644 index 0000000000..4df54bd04f --- /dev/null +++ b/src/GitVersion.Core/Helpers/MemberResolver.cs @@ -0,0 +1,74 @@ +namespace GitVersion.Helpers; + +internal class MemberResolver : IMemberResolver +{ + public MemberInfo[] ResolveMemberPath(Type type, string memberExpression) + { + var memberNames = memberExpression.Split('.'); + var path = new List(); + var currentType = type; + + foreach (var memberName in memberNames) + { + var member = FindDirectMember(currentType, memberName); + if (member == null) + { + var recursivePath = FindMemberRecursive(type, memberName, []); + return recursivePath == null + ? throw new ArgumentException($"'{memberName}' is not a property or field on type '{type.Name}'") + : [.. recursivePath]; + } + + path.Add(member); + currentType = GetMemberType(member); + } + + return [.. path]; + } + + public static List? FindMemberRecursive(Type type, string memberName, HashSet visited) + { + if (!visited.Add(type)) + { + return null; + } + + var member = FindDirectMember(type, memberName); + if (member != null) + { + return [member]; + } + + foreach (var prop in type.GetProperties()) + { + var nestedPath = FindMemberRecursive(prop.PropertyType, memberName, visited); + if (nestedPath != null) + { + nestedPath.Insert(0, prop); + return nestedPath; + } + } + + foreach (var field in type.GetFields()) + { + var nestedPath = FindMemberRecursive(field.FieldType, memberName, visited); + if (nestedPath != null) + { + nestedPath.Insert(0, field); + return nestedPath; + } + } + + return null; + } + + private static MemberInfo? FindDirectMember(Type type, string memberName) + => type.GetProperty(memberName) ?? (MemberInfo?)type.GetField(memberName); + + private static Type GetMemberType(MemberInfo member) => member switch + { + PropertyInfo p => p.PropertyType, + FieldInfo f => f.FieldType, + _ => throw new ArgumentException($"Unsupported member type: {member.GetType()}") + }; +} diff --git a/src/GitVersion.Core/Helpers/NumericFormatter.cs b/src/GitVersion.Core/Helpers/NumericFormatter.cs new file mode 100644 index 0000000000..8c1f4d4acf --- /dev/null +++ b/src/GitVersion.Core/Helpers/NumericFormatter.cs @@ -0,0 +1,48 @@ +using System.Globalization; + +namespace GitVersion.Helpers; + +internal class NumericFormatter : IValueFormatter +{ + public int Priority => 1; + + public bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (value is not string s) + { + return false; + } + + // Integer formatting + if (format.All(char.IsDigit) && int.TryParse(s, out var i)) + { + result = i.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + // Hexadecimal formatting + if ((format.StartsWith('X') || format.StartsWith('x')) && int.TryParse(s, out var hex)) + { + result = hex.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + // Floating point formatting + if ("FEGNCP".Contains(char.ToUpperInvariant(format[0])) && double.TryParse(s, out var d)) + { + result = d.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + // Decimal formatting + if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) + { + result = dec.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + return false; + } +} diff --git a/src/GitVersion.Core/Helpers/StringFormatWith.cs b/src/GitVersion.Core/Helpers/StringFormatWith.cs index d36d5df01d..4786d244a3 100644 --- a/src/GitVersion.Core/Helpers/StringFormatWith.cs +++ b/src/GitVersion.Core/Helpers/StringFormatWith.cs @@ -1,4 +1,3 @@ -using System.Linq.Expressions; using System.Text.RegularExpressions; using GitVersion.Core; @@ -6,6 +5,12 @@ namespace GitVersion.Helpers; internal static class StringFormatWithExtension { + internal static IExpressionCompiler ExpressionCompiler { get; set; } = new ExpressionCompiler(); + + internal static IInputSanitizer InputSanitizer { get; set; } = new InputSanitizer(); + + internal static IMemberResolver MemberResolver { get; set; } = new MemberResolver(); + /// /// Formats the , replacing each expression wrapped in curly braces /// with the corresponding property from the or . @@ -33,38 +38,67 @@ public static string FormatWith(this string template, T? source, IEnvironment ArgumentNullException.ThrowIfNull(template); ArgumentNullException.ThrowIfNull(source); + var result = new StringBuilder(); + var lastIndex = 0; + foreach (var match in RegexPatterns.Common.ExpandTokensRegex().Matches(template).Cast()) { - string propertyValue; - var fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null; - - if (match.Groups["envvar"].Success) - { - var envVar = match.Groups["envvar"].Value; - propertyValue = environment.GetEnvironmentVariable(envVar) ?? fallback - ?? throw new ArgumentException($"Environment variable {envVar} not found and no fallback string provided"); - } - else - { - var objType = source.GetType(); - var memberAccessExpression = match.Groups["member"].Value; - var expression = CompileDataBinder(objType, memberAccessExpression); - // It would be better to throw if the expression and fallback produce null, but provide an empty string for back compat. - propertyValue = expression(source)?.ToString() ?? fallback ?? ""; - } - - template = template.Replace(match.Value, propertyValue); + var replacement = EvaluateMatch(match, source, environment); + result.Append(template, lastIndex, match.Index - lastIndex); + result.Append(replacement); + lastIndex = match.Index + match.Length; + } + + result.Append(template, lastIndex, template.Length - lastIndex); + return result.ToString(); + } + + private static string EvaluateMatch(Match match, T source, IEnvironment environment) + { + var fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null; + + if (match.Groups["envvar"].Success) + { + return EvaluateEnvVar(match.Groups["envvar"].Value, fallback, environment); + } + + if (match.Groups["member"].Success) + { + var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; + return EvaluateMember(source, match.Groups["member"].Value, format, fallback); } - return template; + throw new ArgumentException($"Invalid token format: '{match.Value}'"); + } + + private static string EvaluateEnvVar(string name, string? fallback, IEnvironment env) + { + var safeName = InputSanitizer.SanitizeEnvVarName(name); + return env.GetEnvironmentVariable(safeName) + ?? fallback + ?? throw new ArgumentException($"Environment variable {safeName} not found and no fallback provided"); } - private static Func CompileDataBinder(Type type, string expr) + private static string EvaluateMember(T source, string member, string? format, string? fallback) { - var param = Expression.Parameter(typeof(object)); - Expression body = Expression.Convert(param, type); - body = expr.Split('.').Aggregate(body, Expression.PropertyOrField); - body = Expression.Convert(body, typeof(object)); // Convert result in case the body produces a Nullable value type. - return Expression.Lambda>(body, param).Compile(); + var safeMember = InputSanitizer.SanitizeMemberName(member); + var memberPath = MemberResolver.ResolveMemberPath(source!.GetType(), safeMember); + var getter = ExpressionCompiler.CompileGetter(source.GetType(), memberPath); + var value = getter(source); + + if (value is null) + { + return fallback ?? string.Empty; + } + + if (format is not null && ValueFormatter.TryFormat( + value, + InputSanitizer.SanitizeFormat(format), + out var formatted)) + { + return formatted; + } + + return value.ToString() ?? fallback ?? string.Empty; } } diff --git a/src/GitVersion.Core/Helpers/StringFormatter.cs b/src/GitVersion.Core/Helpers/StringFormatter.cs new file mode 100644 index 0000000000..12b6d55395 --- /dev/null +++ b/src/GitVersion.Core/Helpers/StringFormatter.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using GitVersion.Extensions; + +namespace GitVersion.Helpers; + +internal class StringFormatter : IValueFormatter +{ + public int Priority => 2; + + public bool TryFormat(object? value, string format, out string result) + { + if (value is not string stringValue) + { + result = string.Empty; + return false; + } + + if (string.IsNullOrWhiteSpace(stringValue)) + { + result = string.Empty; + return true; + } + + switch (format) + { + case "u": + result = stringValue.ToUpperInvariant(); + return true; + case "l": + result = stringValue.ToLowerInvariant(); + return true; + case "t": + result = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(stringValue.ToLowerInvariant()); + return true; + case "s": + if (stringValue.Length == 1) + { + result = stringValue.ToUpperInvariant(); + } + else + { + result = char.ToUpperInvariant(stringValue[0]) + stringValue[1..].ToLowerInvariant(); + } + + return true; + case "c": + result = stringValue.PascalCase(); + return true; + default: + result = string.Empty; + return false; + } + } +} diff --git a/src/GitVersion.Core/Helpers/ValueFormatter.cs b/src/GitVersion.Core/Helpers/ValueFormatter.cs new file mode 100644 index 0000000000..a947f44424 --- /dev/null +++ b/src/GitVersion.Core/Helpers/ValueFormatter.cs @@ -0,0 +1,36 @@ +namespace GitVersion.Helpers; + +internal static class ValueFormatter +{ + private static readonly List _formatters = + [ + new StringFormatter(), + new FormattableFormatter(), + new NumericFormatter(), + new DateFormatter() + ]; + + public static bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (value is null) + { + return false; + } + + foreach (var formatter in _formatters.OrderBy(f => f.Priority)) + { + if (formatter.TryFormat(value, format, out result)) + { + return true; + } + } + + return false; + } + + public static void RegisterFormatter(IValueFormatter formatter) => _formatters.Add(formatter); + + public static void RemoveFormatter() where T : IValueFormatter => _formatters.RemoveAll(f => f is T); +} From 74cbb00da5a2eae7a92955d763a5a3b2e5a0b5ef Mon Sep 17 00:00:00 2001 From: 9swampy Date: Tue, 15 Jul 2025 19:19:20 +0100 Subject: [PATCH 2/4] Address PR comments. --- .../{Helpers => Formatting}/DateFormatterTests.cs | 4 ++-- .../FormattableFormatterTests.cs | 4 ++-- .../NumericFormatterTests.cs | 4 ++-- .../StringFormatterTests.cs | 4 ++-- .../{Helpers => Formatting}/DateFormatter.cs | 2 +- .../FormattableFormatter.cs | 2 +- .../{Helpers => Formatting}/IValueFormatter.cs | 2 +- .../{Helpers => Formatting}/NumericFormatter.cs | 6 ++---- .../{Helpers => Formatting}/StringFormatter.cs | 4 +--- .../{Helpers => Formatting}/ValueFormatter.cs | 14 +++++--------- src/GitVersion.Core/Helpers/StringFormatWith.cs | 1 + 11 files changed, 20 insertions(+), 27 deletions(-) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/DateFormatterTests.cs (93%) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/FormattableFormatterTests.cs (95%) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/NumericFormatterTests.cs (93%) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/StringFormatterTests.cs (96%) rename src/GitVersion.Core/{Helpers => Formatting}/DateFormatter.cs (96%) rename src/GitVersion.Core/{Helpers => Formatting}/FormattableFormatter.cs (97%) rename src/GitVersion.Core/{Helpers => Formatting}/IValueFormatter.cs (86%) rename src/GitVersion.Core/{Helpers => Formatting}/NumericFormatter.cs (88%) rename src/GitVersion.Core/{Helpers => Formatting}/StringFormatter.cs (95%) rename src/GitVersion.Core/{Helpers => Formatting}/ValueFormatter.cs (68%) diff --git a/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs similarity index 93% rename from src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs rename to src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs index 2fa58b3fb6..9e63116630 100644 --- a/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs @@ -1,6 +1,6 @@ -using GitVersion.Helpers; +using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Core.Tests.Formatting; [TestFixture] public class DateFormatterTests diff --git a/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs similarity index 95% rename from src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs rename to src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs index 6145397ec5..3243344733 100644 --- a/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs @@ -1,6 +1,6 @@ -using GitVersion.Helpers; +using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Core.Tests.Formatting; [TestFixture] public class FormattableFormatterTests diff --git a/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs similarity index 93% rename from src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs rename to src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs index 0d34e2fa57..af336615f5 100644 --- a/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs @@ -1,6 +1,6 @@ -using GitVersion.Helpers; +using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Core.Tests.Formatting; [TestFixture] public class NumericFormatterTests diff --git a/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs similarity index 96% rename from src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs rename to src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs index e1eb140277..7c9024fe79 100644 --- a/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs @@ -1,6 +1,6 @@ -using GitVersion.Helpers; +using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Core.Tests.Formatting; [TestFixture] public class StringFormatterTests diff --git a/src/GitVersion.Core/Helpers/DateFormatter.cs b/src/GitVersion.Core/Formatting/DateFormatter.cs similarity index 96% rename from src/GitVersion.Core/Helpers/DateFormatter.cs rename to src/GitVersion.Core/Formatting/DateFormatter.cs index 17fc03e0c8..c03f8f0ffd 100644 --- a/src/GitVersion.Core/Helpers/DateFormatter.cs +++ b/src/GitVersion.Core/Formatting/DateFormatter.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal class DateFormatter : IValueFormatter { diff --git a/src/GitVersion.Core/Helpers/FormattableFormatter.cs b/src/GitVersion.Core/Formatting/FormattableFormatter.cs similarity index 97% rename from src/GitVersion.Core/Helpers/FormattableFormatter.cs rename to src/GitVersion.Core/Formatting/FormattableFormatter.cs index 8835827f10..c261d0f112 100644 --- a/src/GitVersion.Core/Helpers/FormattableFormatter.cs +++ b/src/GitVersion.Core/Formatting/FormattableFormatter.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal class FormattableFormatter : IValueFormatter { diff --git a/src/GitVersion.Core/Helpers/IValueFormatter.cs b/src/GitVersion.Core/Formatting/IValueFormatter.cs similarity index 86% rename from src/GitVersion.Core/Helpers/IValueFormatter.cs rename to src/GitVersion.Core/Formatting/IValueFormatter.cs index eec804e032..81c0f88c13 100644 --- a/src/GitVersion.Core/Helpers/IValueFormatter.cs +++ b/src/GitVersion.Core/Formatting/IValueFormatter.cs @@ -1,4 +1,4 @@ -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal interface IValueFormatter { diff --git a/src/GitVersion.Core/Helpers/NumericFormatter.cs b/src/GitVersion.Core/Formatting/NumericFormatter.cs similarity index 88% rename from src/GitVersion.Core/Helpers/NumericFormatter.cs rename to src/GitVersion.Core/Formatting/NumericFormatter.cs index 8c1f4d4acf..80469a3bda 100644 --- a/src/GitVersion.Core/Helpers/NumericFormatter.cs +++ b/src/GitVersion.Core/Formatting/NumericFormatter.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal class NumericFormatter : IValueFormatter { @@ -11,9 +11,7 @@ public bool TryFormat(object? value, string format, out string result) result = string.Empty; if (value is not string s) - { return false; - } // Integer formatting if (format.All(char.IsDigit) && int.TryParse(s, out var i)) @@ -23,7 +21,7 @@ public bool TryFormat(object? value, string format, out string result) } // Hexadecimal formatting - if ((format.StartsWith('X') || format.StartsWith('x')) && int.TryParse(s, out var hex)) + if (format.StartsWith("X", StringComparison.OrdinalIgnoreCase) && int.TryParse(s, out var hex)) { result = hex.ToString(format, CultureInfo.InvariantCulture); return true; diff --git a/src/GitVersion.Core/Helpers/StringFormatter.cs b/src/GitVersion.Core/Formatting/StringFormatter.cs similarity index 95% rename from src/GitVersion.Core/Helpers/StringFormatter.cs rename to src/GitVersion.Core/Formatting/StringFormatter.cs index 12b6d55395..779bc62f6e 100644 --- a/src/GitVersion.Core/Helpers/StringFormatter.cs +++ b/src/GitVersion.Core/Formatting/StringFormatter.cs @@ -1,7 +1,7 @@ using System.Globalization; using GitVersion.Extensions; -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal class StringFormatter : IValueFormatter { @@ -34,9 +34,7 @@ public bool TryFormat(object? value, string format, out string result) return true; case "s": if (stringValue.Length == 1) - { result = stringValue.ToUpperInvariant(); - } else { result = char.ToUpperInvariant(stringValue[0]) + stringValue[1..].ToLowerInvariant(); diff --git a/src/GitVersion.Core/Helpers/ValueFormatter.cs b/src/GitVersion.Core/Formatting/ValueFormatter.cs similarity index 68% rename from src/GitVersion.Core/Helpers/ValueFormatter.cs rename to src/GitVersion.Core/Formatting/ValueFormatter.cs index a947f44424..e4495675b6 100644 --- a/src/GitVersion.Core/Helpers/ValueFormatter.cs +++ b/src/GitVersion.Core/Formatting/ValueFormatter.cs @@ -1,8 +1,8 @@ -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal static class ValueFormatter { - private static readonly List _formatters = + private static readonly List formatters = [ new StringFormatter(), new FormattableFormatter(), @@ -15,22 +15,18 @@ public static bool TryFormat(object? value, string format, out string result) result = string.Empty; if (value is null) - { return false; - } - foreach (var formatter in _formatters.OrderBy(f => f.Priority)) + foreach (var formatter in formatters.OrderBy(f => f.Priority)) { if (formatter.TryFormat(value, format, out result)) - { return true; - } } return false; } - public static void RegisterFormatter(IValueFormatter formatter) => _formatters.Add(formatter); + public static void RegisterFormatter(IValueFormatter formatter) => formatters.Add(formatter); - public static void RemoveFormatter() where T : IValueFormatter => _formatters.RemoveAll(f => f is T); + public static void RemoveFormatter() where T : IValueFormatter => formatters.RemoveAll(f => f is T); } diff --git a/src/GitVersion.Core/Helpers/StringFormatWith.cs b/src/GitVersion.Core/Helpers/StringFormatWith.cs index 4786d244a3..76bf64192a 100644 --- a/src/GitVersion.Core/Helpers/StringFormatWith.cs +++ b/src/GitVersion.Core/Helpers/StringFormatWith.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using GitVersion.Core; +using GitVersion.Formatting; namespace GitVersion.Helpers; From 646a63491be303e8e15c481dc1c5cea7592136c1 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Sat, 19 Jul 2025 22:05:18 +0100 Subject: [PATCH 3/4] Address PR comments II. Apply SOLI>>D<< to ValueFormatter. --- .../GitVersion.Common.csproj | 1 + .../Formatting/DateFormatterTests.cs | 13 +- .../Formatting/FormattableFormatterTests.cs | 11 +- .../Formatting/ValueFormatterTests.cs | 128 ++++++++++++++++++ .../Extensions/StringExtensions.cs | 5 +- .../Formatting/DateFormatter.cs | 16 +-- .../ExpressionCompiler.cs | 2 +- .../Formatting/FormattableFormatter.cs | 17 +-- .../IExpressionCompiler.cs | 2 +- .../IMemberResolver.cs | 2 +- .../Formatting/IValueFormatter.cs | 4 + .../Formatting/IValueFormatterCombiner.cs | 8 ++ .../Formatting/InvariantFormatter.cs | 11 ++ .../{Helpers => Formatting}/MemberResolver.cs | 6 +- .../Formatting/NumericFormatter.cs | 14 +- .../Formatting/StringFormatter.cs | 16 +-- .../Formatting/ValueFormatter.cs | 36 +++-- .../Helpers/StringFormatWith.cs | 2 +- 18 files changed, 215 insertions(+), 79 deletions(-) create mode 100644 src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs rename src/GitVersion.Core/{Helpers => Formatting}/ExpressionCompiler.cs (94%) rename src/GitVersion.Core/{Helpers => Formatting}/IExpressionCompiler.cs (80%) rename src/GitVersion.Core/{Helpers => Formatting}/IMemberResolver.cs (77%) create mode 100644 src/GitVersion.Core/Formatting/IValueFormatterCombiner.cs create mode 100644 src/GitVersion.Core/Formatting/InvariantFormatter.cs rename src/GitVersion.Core/{Helpers => Formatting}/MemberResolver.cs (96%) diff --git a/new-cli/GitVersion.Common/GitVersion.Common.csproj b/new-cli/GitVersion.Common/GitVersion.Common.csproj index 91e48ec0d7..eeaecefaee 100644 --- a/new-cli/GitVersion.Common/GitVersion.Common.csproj +++ b/new-cli/GitVersion.Common/GitVersion.Common.csproj @@ -10,6 +10,7 @@ + diff --git a/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs index 9e63116630..90180a5277 100644 --- a/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs @@ -17,8 +17,8 @@ public void TryFormat_NullValue_ReturnsFalse() formatted.ShouldBeEmpty(); } - [TestCase("2021-01-01", "date:yyyy-MM-dd", "2021-01-01")] - [TestCase("2021-01-01T12:00:00Z", "date:yyyy-MM-ddTHH:mm:ssZ", "2021-01-01T12:00:00Z")] + [TestCase("2021-01-01", "yyyy-MM-dd", "2021-01-01")] + [TestCase("2021-01-01T12:00:00Z", "yyyy-MM-ddTHH:mm:ssZ", "2021-01-01T12:00:00Z")] public void TryFormat_ValidDateFormats_ReturnsExpectedResult(string input, string format, string expected) { var date = DateTime.Parse(input); @@ -27,13 +27,4 @@ public void TryFormat_ValidDateFormats_ReturnsExpectedResult(string input, strin result.ShouldBeTrue(); formatted.ShouldBe(expected); } - - [Test] - public void TryFormat_UnsupportedFormat_ReturnsFalse() - { - var sut = new DateFormatter(); - var result = sut.TryFormat(DateTime.Now, "unsupported", out var formatted); - result.ShouldBeFalse(); - formatted.ShouldBeEmpty(); - } } diff --git a/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs index 3243344733..e42422c7ad 100644 --- a/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs @@ -1,4 +1,5 @@ -using GitVersion.Formatting; +using System.Globalization; +using GitVersion.Formatting; namespace GitVersion.Core.Tests.Formatting; @@ -12,13 +13,16 @@ public class FormattableFormatterTests public void TryFormat_NullValue_ReturnsFalse() { var sut = new FormattableFormatter(); - var result = sut.TryFormat(null, "G", out var formatted); + var result = sut.TryFormat(null, "G", CultureInfo.InvariantCulture, out var formatted); result.ShouldBeFalse(); formatted.ShouldBeEmpty(); } [TestCase(123.456, "F2", "123.46")] [TestCase(1234.456, "F2", "1234.46")] + [TestCase(123.456, "C", "¤123.46")] + [TestCase(123.456, "P", "12,345.60 %")] + [TestCase(1234567890, "N0", "1,234,567,890")] public void TryFormat_ValidFormats_ReturnsExpectedResult(object input, string format, string expected) { var sut = new FormattableFormatter(); @@ -27,9 +31,6 @@ public void TryFormat_ValidFormats_ReturnsExpectedResult(object input, string fo formatted.ShouldBe(expected); } - [TestCase(123.456, "C", "Format 'C' is not supported in FormattableFormatter")] - [TestCase(123.456, "P", "Format 'P' is not supported in FormattableFormatter")] - [TestCase(1234567890, "N0", "Format 'N0' is not supported in FormattableFormatter")] [TestCase(1234567890, "Z", "Format 'Z' is not supported in FormattableFormatter")] public void TryFormat_UnsupportedFormat_ReturnsFalse(object input, string format, string expected) { diff --git a/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs new file mode 100644 index 0000000000..fef51ec4dc --- /dev/null +++ b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs @@ -0,0 +1,128 @@ +using System.Globalization; +using GitVersion.Formatting; + +namespace GitVersion.Core.Tests.Formatting; + +[TestFixture] +public class ValueFormatterTests +{ + [Test] + public void TryFormat_NullValue_ReturnsFalse() + { + var result = ValueFormatter.Default.TryFormat(null, "any", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [Test] + public void TryFormat_String_UsesStringFormatter() + { + var result = ValueFormatter.Default.TryFormat("hello", "u", out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe("HELLO"); + } + + [Test] + public void TryFormat_Number_UsesNumericFormatter() + { + var result = ValueFormatter.Default.TryFormat(1234.5678, "n", out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe("1,234.57"); + } + + [Test] + public void TryFormat_Date_UsesDateFormatter() + { + var date = new DateTime(2023, 12, 25); + var result = ValueFormatter.Default.TryFormat(date, "yyyy-MM-dd", out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe("2023-12-25"); + } + + [Test] + public void TryFormat_FormattableObject_UsesFormattableFormatter() + { + var value = 123.456m; + var result = ValueFormatter.Default.TryFormat(value, "C", out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe("�123.46"); + } + + [Test] + public void TryFormat_InvalidFormat_ReturnsFalse() + { + var result = ValueFormatter.Default.TryFormat("test", "invalidformat", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [Test] + public void RegisterFormatter_AddsNewFormatter() + { + var customFormatter = new TestFormatter { Priority = 0 }; + IValueFormatterCombiner sut = new ValueFormatter(); + sut.RegisterFormatter(customFormatter); + var result = sut.TryFormat("test", "custom", out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe("CUSTOM:test"); + } + + [Test] + public void RemoveFormatter_RemovesExistingFormatter() + { + IValueFormatterCombiner sut = new ValueFormatter(); + // First verify numeric formatting works + sut.TryFormat(123.45, "n1", out var before); + before.ShouldBe("123.5"); + + sut.RemoveFormatter(); + + // Now numeric formatting will still happen, but via the FormattableFormatter + var result = sut.TryFormat(123.45, "n1", out var afterFormatted); + result.ShouldBeTrue(); + afterFormatted.ShouldBe("123.5"); + + sut.RemoveFormatter(); + + // Now numeric formatting will now not be handled by any formatter that remains + result = sut.TryFormat(123.45, "n1", out var afterNotFormatted); + result.ShouldBeFalse(); + afterNotFormatted.ShouldBeEmpty(); + } + + [Test] + public void Formatters_ExecuteInPriorityOrder() + { + IValueFormatterCombiner sut = new ValueFormatter(); + var highPriorityFormatter = new TestFormatter { Priority = 0 }; + var lowPriorityFormatter = new TestFormatter { Priority = 99 }; + + sut.RegisterFormatter(lowPriorityFormatter); + sut.RegisterFormatter(highPriorityFormatter); + var result = sut.TryFormat("test", "custom", out var formatted); + result.ShouldBeTrue(); + + // Should use the high priority formatter first + formatted.ShouldBe("CUSTOM:test"); + } + + private class TestFormatter : IValueFormatter + { + public int Priority { get; init; } + + public bool TryFormat(object? value, string format, out string result) + { + if (format == "custom" && value is string str) + { + result = $"CUSTOM:{str}"; + return true; + } + + result = string.Empty; + return false; + } + + public bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) + => TryFormat(value, format, out result); + } +} diff --git a/src/GitVersion.Core/Extensions/StringExtensions.cs b/src/GitVersion.Core/Extensions/StringExtensions.cs index f7b73127ea..ad86f6833e 100644 --- a/src/GitVersion.Core/Extensions/StringExtensions.cs +++ b/src/GitVersion.Core/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using GitVersion.Core; namespace GitVersion.Extensions; @@ -31,7 +32,7 @@ public static bool IsEquivalentTo(this string self, string? other) => public static string WithPrefixIfNotNullOrEmpty(this string value, string prefix) => string.IsNullOrEmpty(value) ? value : prefix + value; - internal static string PascalCase(this string input) + internal static string PascalCase(this string input, CultureInfo cultureInfo) { var sb = new StringBuilder(input.Length); var capitalizeNext = true; @@ -44,7 +45,7 @@ internal static string PascalCase(this string input) continue; } - sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : char.ToLowerInvariant(c)); + sb.Append(capitalizeNext ? cultureInfo.TextInfo.ToUpper(c) : cultureInfo.TextInfo.ToLower(c)); capitalizeNext = false; } diff --git a/src/GitVersion.Core/Formatting/DateFormatter.cs b/src/GitVersion.Core/Formatting/DateFormatter.cs index c03f8f0ffd..670fba6d13 100644 --- a/src/GitVersion.Core/Formatting/DateFormatter.cs +++ b/src/GitVersion.Core/Formatting/DateFormatter.cs @@ -2,30 +2,26 @@ namespace GitVersion.Formatting; -internal class DateFormatter : IValueFormatter +internal class DateFormatter : InvariantFormatter, IValueFormatter { public int Priority => 2; - public bool TryFormat(object? value, string format, out string result) + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) { result = string.Empty; - if (value is DateTime dt && format.StartsWith("date:")) + if (value is DateTime dt) { - var dateFormat = RemoveDatePrefix(format); - result = dt.ToString(dateFormat, CultureInfo.InvariantCulture); + result = dt.ToString(format, cultureInfo); return true; } - if (value is string dateStr && DateTime.TryParse(dateStr, out var parsedDate) && format.StartsWith("date:")) + if (value is string dateStr && DateTime.TryParse(dateStr, out var parsedDate)) { - var dateFormat = format.Substring(5); - result = parsedDate.ToString(dateFormat, CultureInfo.InvariantCulture); + result = parsedDate.ToString(format, cultureInfo); return true; } return false; } - - private static string RemoveDatePrefix(string format) => format.Substring(5); } diff --git a/src/GitVersion.Core/Helpers/ExpressionCompiler.cs b/src/GitVersion.Core/Formatting/ExpressionCompiler.cs similarity index 94% rename from src/GitVersion.Core/Helpers/ExpressionCompiler.cs rename to src/GitVersion.Core/Formatting/ExpressionCompiler.cs index 270b3e8308..3425cca04f 100644 --- a/src/GitVersion.Core/Helpers/ExpressionCompiler.cs +++ b/src/GitVersion.Core/Formatting/ExpressionCompiler.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal class ExpressionCompiler : IExpressionCompiler { diff --git a/src/GitVersion.Core/Formatting/FormattableFormatter.cs b/src/GitVersion.Core/Formatting/FormattableFormatter.cs index c261d0f112..2ec2089ac3 100644 --- a/src/GitVersion.Core/Formatting/FormattableFormatter.cs +++ b/src/GitVersion.Core/Formatting/FormattableFormatter.cs @@ -2,28 +2,22 @@ namespace GitVersion.Formatting; -internal class FormattableFormatter : IValueFormatter +internal class FormattableFormatter : InvariantFormatter, IValueFormatter { public int Priority => 2; - public bool TryFormat(object? value, string format, out string result) + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) { result = string.Empty; if (string.IsNullOrWhiteSpace(format)) return false; - if (IsBlockedFormat(format)) - { - result = $"Format '{format}' is not supported in {nameof(FormattableFormatter)}"; - return false; - } - if (value is IFormattable formattable) { try { - result = formattable.ToString(format, CultureInfo.InvariantCulture); + result = formattable.ToString(format, cultureInfo); return true; } catch (FormatException) @@ -35,9 +29,4 @@ public bool TryFormat(object? value, string format, out string result) return false; } - - private static bool IsBlockedFormat(string format) => - format.Equals("C", StringComparison.OrdinalIgnoreCase) || - format.Equals("P", StringComparison.OrdinalIgnoreCase) || - format.StartsWith("N", StringComparison.OrdinalIgnoreCase); } diff --git a/src/GitVersion.Core/Helpers/IExpressionCompiler.cs b/src/GitVersion.Core/Formatting/IExpressionCompiler.cs similarity index 80% rename from src/GitVersion.Core/Helpers/IExpressionCompiler.cs rename to src/GitVersion.Core/Formatting/IExpressionCompiler.cs index e270f3a4de..82be9811e9 100644 --- a/src/GitVersion.Core/Helpers/IExpressionCompiler.cs +++ b/src/GitVersion.Core/Formatting/IExpressionCompiler.cs @@ -1,4 +1,4 @@ -namespace GitVersion.Helpers +namespace GitVersion.Formatting { internal interface IExpressionCompiler { diff --git a/src/GitVersion.Core/Helpers/IMemberResolver.cs b/src/GitVersion.Core/Formatting/IMemberResolver.cs similarity index 77% rename from src/GitVersion.Core/Helpers/IMemberResolver.cs rename to src/GitVersion.Core/Formatting/IMemberResolver.cs index 9805e7f978..67ee347c83 100644 --- a/src/GitVersion.Core/Helpers/IMemberResolver.cs +++ b/src/GitVersion.Core/Formatting/IMemberResolver.cs @@ -1,4 +1,4 @@ -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal interface IMemberResolver { diff --git a/src/GitVersion.Core/Formatting/IValueFormatter.cs b/src/GitVersion.Core/Formatting/IValueFormatter.cs index 81c0f88c13..45b93f77a7 100644 --- a/src/GitVersion.Core/Formatting/IValueFormatter.cs +++ b/src/GitVersion.Core/Formatting/IValueFormatter.cs @@ -1,9 +1,13 @@ +using System.Globalization; + namespace GitVersion.Formatting; internal interface IValueFormatter { bool TryFormat(object? value, string format, out string result); + bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result); + /// /// Lower number = higher priority /// diff --git a/src/GitVersion.Core/Formatting/IValueFormatterCombiner.cs b/src/GitVersion.Core/Formatting/IValueFormatterCombiner.cs new file mode 100644 index 0000000000..b3a9c32921 --- /dev/null +++ b/src/GitVersion.Core/Formatting/IValueFormatterCombiner.cs @@ -0,0 +1,8 @@ +namespace GitVersion.Formatting; + +internal interface IValueFormatterCombiner : IValueFormatter +{ + void RegisterFormatter(IValueFormatter formatter); + + void RemoveFormatter() where T : IValueFormatter; +} diff --git a/src/GitVersion.Core/Formatting/InvariantFormatter.cs b/src/GitVersion.Core/Formatting/InvariantFormatter.cs new file mode 100644 index 0000000000..2d953d55ed --- /dev/null +++ b/src/GitVersion.Core/Formatting/InvariantFormatter.cs @@ -0,0 +1,11 @@ +using System.Globalization; + +namespace GitVersion.Formatting; + +internal abstract class InvariantFormatter +{ + public bool TryFormat(object? value, string format, out string result) + => TryFormat(value, format, CultureInfo.InvariantCulture, out result); + + public abstract bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result); +} diff --git a/src/GitVersion.Core/Helpers/MemberResolver.cs b/src/GitVersion.Core/Formatting/MemberResolver.cs similarity index 96% rename from src/GitVersion.Core/Helpers/MemberResolver.cs rename to src/GitVersion.Core/Formatting/MemberResolver.cs index 4df54bd04f..f1fde1bc55 100644 --- a/src/GitVersion.Core/Helpers/MemberResolver.cs +++ b/src/GitVersion.Core/Formatting/MemberResolver.cs @@ -1,4 +1,4 @@ -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal class MemberResolver : IMemberResolver { @@ -29,15 +29,11 @@ public MemberInfo[] ResolveMemberPath(Type type, string memberExpression) public static List? FindMemberRecursive(Type type, string memberName, HashSet visited) { if (!visited.Add(type)) - { return null; - } var member = FindDirectMember(type, memberName); if (member != null) - { return [member]; - } foreach (var prop in type.GetProperties()) { diff --git a/src/GitVersion.Core/Formatting/NumericFormatter.cs b/src/GitVersion.Core/Formatting/NumericFormatter.cs index 80469a3bda..44d14ecc21 100644 --- a/src/GitVersion.Core/Formatting/NumericFormatter.cs +++ b/src/GitVersion.Core/Formatting/NumericFormatter.cs @@ -2,11 +2,11 @@ namespace GitVersion.Formatting; -internal class NumericFormatter : IValueFormatter +internal class NumericFormatter : InvariantFormatter, IValueFormatter { public int Priority => 1; - public bool TryFormat(object? value, string format, out string result) + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) { result = string.Empty; @@ -16,28 +16,28 @@ public bool TryFormat(object? value, string format, out string result) // Integer formatting if (format.All(char.IsDigit) && int.TryParse(s, out var i)) { - result = i.ToString(format, CultureInfo.InvariantCulture); + result = i.ToString(format, cultureInfo); return true; } // Hexadecimal formatting if (format.StartsWith("X", StringComparison.OrdinalIgnoreCase) && int.TryParse(s, out var hex)) { - result = hex.ToString(format, CultureInfo.InvariantCulture); + result = hex.ToString(format, cultureInfo); return true; } // Floating point formatting if ("FEGNCP".Contains(char.ToUpperInvariant(format[0])) && double.TryParse(s, out var d)) { - result = d.ToString(format, CultureInfo.InvariantCulture); + result = d.ToString(format, cultureInfo); return true; } // Decimal formatting - if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) + if (decimal.TryParse(s, NumberStyles.Any, cultureInfo, out var dec)) { - result = dec.ToString(format, CultureInfo.InvariantCulture); + result = dec.ToString(format, cultureInfo); return true; } diff --git a/src/GitVersion.Core/Formatting/StringFormatter.cs b/src/GitVersion.Core/Formatting/StringFormatter.cs index 779bc62f6e..53022d074e 100644 --- a/src/GitVersion.Core/Formatting/StringFormatter.cs +++ b/src/GitVersion.Core/Formatting/StringFormatter.cs @@ -3,11 +3,11 @@ namespace GitVersion.Formatting; -internal class StringFormatter : IValueFormatter +internal class StringFormatter : InvariantFormatter, IValueFormatter { public int Priority => 2; - public bool TryFormat(object? value, string format, out string result) + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) { if (value is not string stringValue) { @@ -24,25 +24,25 @@ public bool TryFormat(object? value, string format, out string result) switch (format) { case "u": - result = stringValue.ToUpperInvariant(); + result = cultureInfo.TextInfo.ToUpper(stringValue); return true; case "l": - result = stringValue.ToLowerInvariant(); + result = cultureInfo.TextInfo.ToLower(stringValue); return true; case "t": - result = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(stringValue.ToLowerInvariant()); + result = cultureInfo.TextInfo.ToTitleCase(cultureInfo.TextInfo.ToLower(stringValue)); return true; case "s": if (stringValue.Length == 1) - result = stringValue.ToUpperInvariant(); + result = cultureInfo.TextInfo.ToUpper(stringValue); else { - result = char.ToUpperInvariant(stringValue[0]) + stringValue[1..].ToLowerInvariant(); + result = cultureInfo.TextInfo.ToUpper(stringValue[0]) + cultureInfo.TextInfo.ToLower(stringValue[1..]); } return true; case "c": - result = stringValue.PascalCase(); + result = stringValue.PascalCase(cultureInfo); return true; default: result = string.Empty; diff --git a/src/GitVersion.Core/Formatting/ValueFormatter.cs b/src/GitVersion.Core/Formatting/ValueFormatter.cs index e4495675b6..0e0be49645 100644 --- a/src/GitVersion.Core/Formatting/ValueFormatter.cs +++ b/src/GitVersion.Core/Formatting/ValueFormatter.cs @@ -1,21 +1,31 @@ +using System.Globalization; + namespace GitVersion.Formatting; -internal static class ValueFormatter +internal class ValueFormatter : InvariantFormatter, IValueFormatterCombiner { - private static readonly List formatters = - [ - new StringFormatter(), - new FormattableFormatter(), - new NumericFormatter(), - new DateFormatter() - ]; - - public static bool TryFormat(object? value, string format, out string result) + private readonly List formatters; + + internal static IValueFormatter Default { get; } = new ValueFormatter(); + + public int Priority => 0; + + internal ValueFormatter() + => formatters = + [ + new StringFormatter(), + new FormattableFormatter(), + new NumericFormatter(), + new DateFormatter() + ]; + + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) { result = string.Empty; - if (value is null) + { return false; + } foreach (var formatter in formatters.OrderBy(f => f.Priority)) { @@ -26,7 +36,7 @@ public static bool TryFormat(object? value, string format, out string result) return false; } - public static void RegisterFormatter(IValueFormatter formatter) => formatters.Add(formatter); + void IValueFormatterCombiner.RegisterFormatter(IValueFormatter formatter) => formatters.Add(formatter); - public static void RemoveFormatter() where T : IValueFormatter => formatters.RemoveAll(f => f is T); + void IValueFormatterCombiner.RemoveFormatter() => formatters.RemoveAll(f => f is T); } diff --git a/src/GitVersion.Core/Helpers/StringFormatWith.cs b/src/GitVersion.Core/Helpers/StringFormatWith.cs index 76bf64192a..8f9e0aa9a5 100644 --- a/src/GitVersion.Core/Helpers/StringFormatWith.cs +++ b/src/GitVersion.Core/Helpers/StringFormatWith.cs @@ -92,7 +92,7 @@ private static string EvaluateMember(T source, string member, string? format, return fallback ?? string.Empty; } - if (format is not null && ValueFormatter.TryFormat( + if (format is not null && ValueFormatter.Default.TryFormat( value, InputSanitizer.SanitizeFormat(format), out var formatted)) From 625c32f2ae5cc17af9ea3b65307f5dd58a149a53 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Sun, 20 Jul 2025 00:42:03 +0100 Subject: [PATCH 4/4] Draft documentation proposal. --- docs/input/docs/reference/configuration.md | 3 + .../input/docs/reference/custom-formatting.md | 252 ++++++++++++++++++ docs/input/docs/reference/variables.md | 4 + docs/input/docs/usage/cli/arguments.md | 1 + docs/input/docs/usage/msbuild.md | 2 + 5 files changed, 262 insertions(+) create mode 100644 docs/input/docs/reference/custom-formatting.md diff --git a/docs/input/docs/reference/configuration.md b/docs/input/docs/reference/configuration.md index 78f90204b8..c1355e20f3 100644 --- a/docs/input/docs/reference/configuration.md +++ b/docs/input/docs/reference/configuration.md @@ -477,6 +477,9 @@ while still updating the `AssemblyFileVersion` and `AssemblyInformationVersion` attributes. Valid values: `MajorMinorPatchTag`, `MajorMinorPatch`, `MajorMinor`, `Major`, `None`. +For information on using format strings in these properties, see +[Format Strings](/docs/reference/custom-formatting). + ### assembly-file-versioning-scheme When updating assembly info, `assembly-file-versioning-scheme` tells GitVersion diff --git a/docs/input/docs/reference/custom-formatting.md b/docs/input/docs/reference/custom-formatting.md new file mode 100644 index 0000000000..055ffaa7d0 --- /dev/null +++ b/docs/input/docs/reference/custom-formatting.md @@ -0,0 +1,252 @@ +--- +title: Format Strings +description: Using C# format strings in GitVersion configuration +--- + +GitVersion supports C# format strings in configuration, allowing you to apply standard .NET formatting and custom transformations to version properties. This enhancement provides more flexibility and control over how version information is displayed and used throughout your build process. + +## Overview + +The custom formatter functionality introduces several new formatters that can be used in GitVersion configuration files and templates: + +- **FormattableFormatter**: Supports standard .NET format strings for numeric values, dates, and implements `IFormattable` +- **NumericFormatter**: Handles numeric formatting with culture-aware output +- **DateTimeFormatter**: Provides date and time formatting with standard and custom format specifiers +- **String Case Formatters**: Provides text case transformations with custom format specifiers + +## Standard .NET Format Strings + +### Numeric Formatting + +You can now use standard .NET numeric format strings with version components: + +```yaml +# GitVersion.yml +template: "{Major}.{Minor}.{Patch:F2}-{PreReleaseLabel}" +``` + +**Supported Numeric Formats:** + +- `F` or `f` (Fixed-point): `{Patch:F2}` → `"1.23"` +- `N` or `n` (Number): `{BuildMetadata:N0}` → `"1,234"` +- `C` or `c` (Currency): `{Major:C}` → `"¤1.00"` +- `P` or `p` (Percent): `{CommitsSinceVersionSource:P}` → `"12,345.60 %"` +- `D` or `d` (Decimal): `{Major:D4}` → `"0001"` +- `X` or `x` (Hexadecimal): `{Patch:X}` → `"FF"` + +### Date and Time Formatting + +When working with date-related properties like `CommitDate`: + +```yaml +template: "Build-{SemVer}-{CommitDate:yyyy-MM-dd}" +``` + +**Common Date Format Specifiers:** + +- `yyyy-MM-dd` → `"2024-03-15"` +- `HH:mm:ss` → `"14:30:22"` +- `MMM dd, yyyy` → `"Mar 15, 2024"` +- `yyyy-MM-dd'T'HH:mm:ss'Z'` → `"2024-03-15T14:30:22Z"` + +## Custom String Case Formatters + +GitVersion introduces custom format specifiers for string case transformations that can be used in templates: + +### Available Case Formats + +| Format | Description | Example Input | Example Output | +|--------|-------------|---------------|----------------| +| `u` | **Uppercase** - Converts entire string to uppercase | `feature-branch` | `FEATURE-BRANCH` | +| `l` | **Lowercase** - Converts entire string to lowercase | `Feature-Branch` | `feature-branch` | +| `t` | **Title Case** - Capitalizes first letter of each word | `feature-branch` | `Feature-Branch` | +| `s` | **Sentence Case** - Capitalizes only the first letter | `feature-branch` | `Feature-branch` | +| `c` | **PascalCase** - Removes separators and capitalizes each word | `feature-branch` | `FeatureBranch` | + +### Usage Examples + +```yaml +# GitVersion.yml configuration +branches: + feature: + label: "{BranchName:c}" # Converts to PascalCase + +template: "{Major}.{Minor}.{Patch}-{PreReleaseLabel:l}.{CommitsSinceVersionSource:0000}" +``` + +**Template Usage:** + +```yaml +# Using format strings in templates +assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}" +template: "{SemVer}-{BranchName:l}" +``` + +## Examples + +Based on actual test cases from the implementation: + +### Zero-Padded Numeric Formatting + +```yaml +# Zero-padded commit count +assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}" +# Result: "1.2.3-0042" +``` + +### String Case Transformations + +```yaml +branches: + feature: + label: "{BranchName:c}" # PascalCase: "feature-branch" → "FeatureBranch" + hotfix: + label: "hotfix-{BranchName:l}" # Lowercase: "HOTFIX-BRANCH" → "hotfix-branch" +``` + +### Date and Time Formatting + +```yaml +template: "{SemVer}-build-{CommitDate:yyyy-MM-dd}" +# Result: "1.2.3-build-2021-01-01" +``` + +### Numeric Formatting + +```yaml +# Currency format (uses InvariantCulture) +template: "Cost-{Major:C}" # Result: "Cost-¤1.00" + +# Percentage format +template: "Progress-{Minor:P}" # Result: "Progress-200.00 %" + +# Thousands separator +template: "Build-{CommitsSinceVersionSource:N0}" # Result: "Build-1,234" +``` + +## Configuration Integration + +The format strings are used in GitVersion configuration files through various formatting properties: + +### Assembly Version Formatting + +```yaml +# GitVersion.yml +assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}" +assembly-versioning-format: "{Major}.{Minor}.{Patch}.{env:BUILD_NUMBER}" +assembly-file-versioning-format: "{MajorMinorPatch}.{CommitsSinceVersionSource}" +``` + +### Template-Based Configuration + +```yaml +# Global template for consistent formatting across all variables +template: "{SemVer}-{BranchName:l}-{ShortSha}" + +branches: + main: + label: "" + feature: + label: "{BranchName:c}.{CommitsSinceVersionSource}" + increment: Minor + release: + label: "rc-{CommitsSinceVersionSource:000}" + increment: None +``` + +### Environment Variable Integration + +```yaml +# Using environment variables with fallbacks +template: "{Major}.{Minor}.{Patch}-{env:RELEASE_STAGE ?? 'dev'}" +assembly-informational-format: "{SemVer}+{env:BUILD_ID ?? 'local'}" +``` + +### Real-World Integration Examples + +Based on the actual test implementation: + +```yaml +# Example from VariableProviderTests.cs +assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}" +# Result: "1.2.3-0042" when CommitsSinceVersionSource = 42 + +# Branch-specific formatting +branches: + feature: + label: "{BranchName:c}" # PascalCase conversion + hotfix: + label: "hotfix.{CommitsSinceVersionSource:00}" +``` + +## Invariant Culture Formatting + +The formatting system uses `CultureInfo.InvariantCulture` by default through the chained `TryFormat` overload implementation. This provides: + +- **Consistent results** across all environments and systems +- **Predictable numeric formatting** with period (.) as decimal separator and comma (,) as thousands separator +- **Standard date formatting** using English month names and formats +- **No localization variations** regardless of system locale + +```csharp +// All environments produce the same output: +// {CommitsSinceVersionSource:N0} → "1,234" +// {CommitDate:MMM dd, yyyy} → "Mar 15, 2024" +// {Major:C} → "¤1.00" (generic currency symbol) +``` + +This ensures that version strings generated by GitVersion are consistent across different build environments, developer machines, and CI/CD systems. + +## Verified Examples + +The following examples are verified by actual unit tests in the GitVersion codebase: + +### Zero-Padded Numeric Formatting + +```yaml +assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}" +``` + +**Test**: `VariableProviderTests.Format_Allows_CSharp_FormatStrings()` +**Input**: `CommitsSinceVersionSource = 42` +**Output**: `"1.2.3-0042"` + +### String Case Transformations + +```csharp +// From StringFormatterTests.cs +[TestCase("hello world", "c", "HelloWorld")] // PascalCase +[TestCase("hello", "u", "HELLO")] // Uppercase +[TestCase("HELLO", "l", "hello")] // Lowercase +[TestCase("hello world", "t", "Hello World")] // Title Case +[TestCase("hELLO", "s", "Hello")] // Sentence Case +``` + +### Numeric Format Specifiers + +```csharp +// From NumericFormatterTests.cs +[TestCase("1234.5678", "n", "1,234.57")] // Number format +[TestCase("1234.5678", "f2", "1234.57")] // Fixed-point format +[TestCase("1234.5678", "f0", "1235")] // No decimals +``` + +### Date Formatting + +```csharp +// From DateFormatterTests.cs +[TestCase("2021-01-01", "yyyy-MM-dd", "2021-01-01")] +[TestCase("2021-01-01T12:00:00Z", "yyyy-MM-ddTHH:mm:ssZ", "2021-01-01T12:00:00Z")] +``` + +### Currency and Percentage (InvariantCulture) + +```csharp +// From FormattableFormatterTests.cs +[TestCase(123.456, "C", "¤123.46")] // Generic currency symbol +[TestCase(123.456, "P", "12,345.60 %")] // Percentage format +[TestCase(1234567890, "N0", "1,234,567,890")] // Thousands separators +``` + +[reference-configuration]: /docs/reference/configuration +[variables]: /docs/reference/variables \ No newline at end of file diff --git a/docs/input/docs/reference/variables.md b/docs/input/docs/reference/variables.md index 8f963405fd..158891797a 100644 --- a/docs/input/docs/reference/variables.md +++ b/docs/input/docs/reference/variables.md @@ -74,4 +74,8 @@ within a [supported build server][build-servers]), the above version variables may be exposed automatically as **environment variables** in the format `GitVersion_FullSemVer`. +## Formatting Variables + +GitVersion variables can be formatted using C# format strings. See [Format Strings](/docs/reference/custom-formatting) for details. + [build-servers]: ./build-servers/ diff --git a/docs/input/docs/usage/cli/arguments.md b/docs/input/docs/usage/cli/arguments.md index b503a1f899..a406ca76a0 100644 --- a/docs/input/docs/usage/cli/arguments.md +++ b/docs/input/docs/usage/cli/arguments.md @@ -38,6 +38,7 @@ GitVersion [path] - will output `1.2.3+beta.4` /format Used in conjunction with /output json, will output a format containing version variables. + Supports C# format strings - see [Format Strings](/docs/reference/custom-formatting) for details. E.g. /output json /format {SemVer} - will output `1.2.3+beta.4` /output json /format {Major}.{Minor} - will output `1.2` /l Path to logfile. diff --git a/docs/input/docs/usage/msbuild.md b/docs/input/docs/usage/msbuild.md index 5b3c258173..5cacea82fd 100644 --- a/docs/input/docs/usage/msbuild.md +++ b/docs/input/docs/usage/msbuild.md @@ -98,6 +98,8 @@ Now, when you build: appended to it. * `AssemblyInformationalVersion` will be set to the `InformationalVersion` variable. +Assembly version formatting can use C# format strings. See [Format Strings](/docs/reference/custom-formatting) for available options. + #### Other injected Variables All other [variables](/docs/reference/variables) will be injected into an