From 96d8ba66789f8d3f0fd4920867260dfaa5526bf0 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Fri, 27 Jun 2025 09:06:43 +0100 Subject: [PATCH 01/10] 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 532fdbcab1628eff4beb85cb086fe9f9e6345e51 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Tue, 15 Jul 2025 19:19:20 +0100 Subject: [PATCH 02/10] 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 11a97373ab3830991590115736c7ffeb22f7cc67 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Sat, 19 Jul 2025 22:05:18 +0100 Subject: [PATCH 03/10] 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 61737d8507..065fa33110 100644 --- a/new-cli/GitVersion.Common/GitVersion.Common.csproj +++ b/new-cli/GitVersion.Common/GitVersion.Common.csproj @@ -12,6 +12,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..77aab62b05 --- /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 146e22989466a9cea3e9aa554f7a7ad11065fa77 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Sun, 20 Jul 2025 00:42:03 +0100 Subject: [PATCH 04/10] Draft documentation proposal. --- docs/input/docs/reference/configuration.md | 7 +- .../input/docs/reference/custom-formatting.md | 252 ++++++++++++++++++ .../mdsource/configuration.source.md | 7 +- docs/input/docs/reference/variables.md | 4 + docs/input/docs/usage/cli/arguments.md | 1 + docs/input/docs/usage/msbuild.md | 2 + 6 files changed, 269 insertions(+), 4 deletions(-) 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..9c4dd1a8f6 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 @@ -632,7 +635,7 @@ ignore: - ^docs\/ ``` ##### *Monorepo* -This ignore config can be used to filter only those commits that belong to a specific project in a monorepo. +This ignore config can be used to filter only those commits that belong to a specific project in a monorepo. As an example, consider a monorepo consisting of subdirectories for `ProjectA`, `ProjectB` and a shared `LibraryC`. For GitVersion to consider only commits that are part of `projectA` and shared library `LibraryC`, a regex that matches all paths except those starting with `ProjectA` or `LibraryC` can be used. Either one of the following configs would filter out `ProjectB`. * Specific match on `/ProjectB/*`: ```yaml @@ -655,7 +658,7 @@ A commit having changes only in `/ProjectB/*` path would be ignored. A commit ha * `/ProductA/*` and `/ProductB/*` and `/LibraryC/*` ::: -Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`. +Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`. ::: ::: {.alert .alert-warning} 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/mdsource/configuration.source.md b/docs/input/docs/reference/mdsource/configuration.source.md index 1b29b89523..f08c69c816 100644 --- a/docs/input/docs/reference/mdsource/configuration.source.md +++ b/docs/input/docs/reference/mdsource/configuration.source.md @@ -78,6 +78,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 @@ -233,7 +236,7 @@ ignore: - ^docs\/ ``` ##### *Monorepo* -This ignore config can be used to filter only those commits that belong to a specific project in a monorepo. +This ignore config can be used to filter only those commits that belong to a specific project in a monorepo. As an example, consider a monorepo consisting of subdirectories for `ProjectA`, `ProjectB` and a shared `LibraryC`. For GitVersion to consider only commits that are part of `projectA` and shared library `LibraryC`, a regex that matches all paths except those starting with `ProjectA` or `LibraryC` can be used. Either one of the following configs would filter out `ProjectB`. * Specific match on `/ProjectB/*`: ```yaml @@ -256,7 +259,7 @@ A commit having changes only in `/ProjectB/*` path would be ignored. A commit ha * `/ProductA/*` and `/ProductB/*` and `/LibraryC/*` ::: -Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`. +Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`. ::: ::: {.alert .alert-warning} 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 From 61163ef625fb53c3f7b59892123dfac6b90050f9 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Sat, 26 Jul 2025 19:28:38 +0100 Subject: [PATCH 05/10] Add ZeroFormatting tests to cover zero-suppression syntax --- .../Formatting/FormattableFormatterTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs index e42422c7ad..8b0f75f048 100644 --- a/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs @@ -39,4 +39,16 @@ public void TryFormat_UnsupportedFormat_ReturnsFalse(object input, string format result.ShouldBeFalse(); formatted.ShouldBe(expected); } + + [TestCase(0, "0000", "0000")] + [TestCase(1, "0000", "0001")] + [TestCase(0, "-0000;;''", "")] + [TestCase(1, "-0000;;''", "-0001")] + public void ZeroFormatting(int value, string format, string expected) + { + var sut = new FormattableFormatter(); + var result = sut.TryFormat(value, format, out var formatted); + result.ShouldBe(true); + formatted.ShouldBe(expected); + } } From 9e97bfe0a8c090a1c842e264d5f5eb9fe2e63fdc Mon Sep 17 00:00:00 2001 From: 9swampy Date: Wed, 30 Jul 2025 22:04:14 +0100 Subject: [PATCH 06/10] Align tests with moved InputSanitizer.cs and StringFormatWithExtension.cs --- .../StringFormatWithExtensionTests.cs | 2 +- .../{Helpers => Formatting}/EdgeCaseTests.cs | 2 +- .../InputSanitizerTests.cs | 2 +- .../SanitizeEnvVarNameTests.cs | 2 +- .../SanitizeMemberNameTests.cs | 2 +- .../IInputSanitizer.cs | 2 +- .../{Helpers => Formatting}/InputSanitizer.cs | 20 +------------------ .../StringFormatWithExtension.cs} | 7 +------ .../VersionCalculation/VariableProvider.cs | 2 +- .../OutputGenerator/OutputGenerator.cs | 1 + 10 files changed, 10 insertions(+), 32 deletions(-) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/EdgeCaseTests.cs (98%) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/InputSanitizerTests.cs (99%) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/SanitizeEnvVarNameTests.cs (98%) rename src/GitVersion.Core.Tests/{Helpers => Formatting}/SanitizeMemberNameTests.cs (98%) rename src/GitVersion.Core/{Helpers => Formatting}/IInputSanitizer.cs (85%) rename src/GitVersion.Core/{Helpers => Formatting}/InputSanitizer.cs (89%) rename src/GitVersion.Core/{Helpers/StringFormatWith.cs => Formatting/StringFormatWithExtension.cs} (97%) diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 144b69baf0..4de05822ac 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -1,5 +1,5 @@ using GitVersion.Core.Tests.Helpers; -using GitVersion.Helpers; +using GitVersion.Formatting; namespace GitVersion.Core.Tests; diff --git a/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs b/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs similarity index 98% rename from src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs rename to src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs index 9aacdb7e9d..864e654858 100644 --- a/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs @@ -1,5 +1,5 @@ using GitVersion.Core.Tests.Extensions; -using GitVersion.Helpers; +using GitVersion.Formatting; namespace GitVersion.Tests.Helpers; diff --git a/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs b/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs similarity index 99% rename from src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs rename to src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs index c0899c13ad..176c46af38 100644 --- a/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs @@ -1,5 +1,5 @@ using GitVersion.Core.Tests.Extensions; -using GitVersion.Helpers; +using GitVersion.Formatting; namespace GitVersion.Tests.Helpers; diff --git a/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs b/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs similarity index 98% rename from src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs rename to src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs index d66ce416a1..8e2c35c5b7 100644 --- a/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs @@ -1,5 +1,5 @@ using GitVersion.Core.Tests.Extensions; -using GitVersion.Helpers; +using GitVersion.Formatting; namespace GitVersion.Tests.Helpers; diff --git a/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs b/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs similarity index 98% rename from src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs rename to src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs index 2fc4d07b48..55fcc07811 100644 --- a/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs @@ -1,5 +1,5 @@ using GitVersion.Core.Tests.Extensions; -using GitVersion.Helpers; +using GitVersion.Formatting; namespace GitVersion.Tests.Helpers; diff --git a/src/GitVersion.Core/Helpers/IInputSanitizer.cs b/src/GitVersion.Core/Formatting/IInputSanitizer.cs similarity index 85% rename from src/GitVersion.Core/Helpers/IInputSanitizer.cs rename to src/GitVersion.Core/Formatting/IInputSanitizer.cs index f130457a94..ead3dcedcc 100644 --- a/src/GitVersion.Core/Helpers/IInputSanitizer.cs +++ b/src/GitVersion.Core/Formatting/IInputSanitizer.cs @@ -1,4 +1,4 @@ -namespace GitVersion.Helpers +namespace GitVersion.Formatting { internal interface IInputSanitizer { diff --git a/src/GitVersion.Core/Helpers/InputSanitizer.cs b/src/GitVersion.Core/Formatting/InputSanitizer.cs similarity index 89% rename from src/GitVersion.Core/Helpers/InputSanitizer.cs rename to src/GitVersion.Core/Formatting/InputSanitizer.cs index 7a33484a9f..a676ec5540 100644 --- a/src/GitVersion.Core/Helpers/InputSanitizer.cs +++ b/src/GitVersion.Core/Formatting/InputSanitizer.cs @@ -1,25 +1,19 @@ using GitVersion.Core; -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; 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; } @@ -27,19 +21,13 @@ public string SanitizeFormat(string 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; } @@ -47,19 +35,13 @@ public string SanitizeEnvVarName(string 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/StringFormatWith.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs similarity index 97% rename from src/GitVersion.Core/Helpers/StringFormatWith.cs rename to src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index 8f9e0aa9a5..4a97738d79 100644 --- a/src/GitVersion.Core/Helpers/StringFormatWith.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -1,8 +1,7 @@ using System.Text.RegularExpressions; using GitVersion.Core; -using GitVersion.Formatting; -namespace GitVersion.Helpers; +namespace GitVersion.Formatting; internal static class StringFormatWithExtension { @@ -59,9 +58,7 @@ private static string EvaluateMatch(Match match, T source, IEnvironment envir 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) { @@ -88,9 +85,7 @@ private static string EvaluateMember(T source, string member, string? format, var value = getter(source); if (value is null) - { return fallback ?? string.Empty; - } if (format is not null && ValueFormatter.Default.TryFormat( value, diff --git a/src/GitVersion.Core/VersionCalculation/VariableProvider.cs b/src/GitVersion.Core/VersionCalculation/VariableProvider.cs index 0e677163a2..424c91711b 100644 --- a/src/GitVersion.Core/VersionCalculation/VariableProvider.cs +++ b/src/GitVersion.Core/VersionCalculation/VariableProvider.cs @@ -1,7 +1,7 @@ using GitVersion.Configuration; using GitVersion.Core; using GitVersion.Extensions; -using GitVersion.Helpers; +using GitVersion.Formatting; using GitVersion.OutputVariables; namespace GitVersion.VersionCalculation; diff --git a/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs b/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs index ddf3f23bbb..971e165c70 100644 --- a/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs +++ b/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using GitVersion.Agents; using GitVersion.Extensions; +using GitVersion.Formatting; using GitVersion.Helpers; using GitVersion.Logging; using GitVersion.OutputVariables; From a147b3788fdf2d7368cfc81ad40846d9f930f7e7 Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Thu, 31 Jul 2025 07:49:17 +0200 Subject: [PATCH 07/10] Renames and updates PascalCase extension method Renames `PascalCase` to `ToPascalCase` and moves `TextInfo` parameter to improve clarity and usability. Refactors related calls to align with the updated method signature. --- src/GitVersion.Core/Extensions/StringExtensions.cs | 9 +++++++-- src/GitVersion.Core/Formatting/StringFormatter.cs | 12 ++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/GitVersion.Core/Extensions/StringExtensions.cs b/src/GitVersion.Core/Extensions/StringExtensions.cs index ad86f6833e..cb36b0833f 100644 --- a/src/GitVersion.Core/Extensions/StringExtensions.cs +++ b/src/GitVersion.Core/Extensions/StringExtensions.cs @@ -32,8 +32,13 @@ 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, CultureInfo cultureInfo) + internal static string ToPascalCase(this TextInfo textInfo, string input) { + if (string.IsNullOrEmpty(input)) + { + return input; + } + var sb = new StringBuilder(input.Length); var capitalizeNext = true; @@ -45,7 +50,7 @@ internal static string PascalCase(this string input, CultureInfo cultureInfo) continue; } - sb.Append(capitalizeNext ? cultureInfo.TextInfo.ToUpper(c) : cultureInfo.TextInfo.ToLower(c)); + sb.Append(capitalizeNext ? textInfo.ToUpper(c) : textInfo.ToLower(c)); capitalizeNext = false; } diff --git a/src/GitVersion.Core/Formatting/StringFormatter.cs b/src/GitVersion.Core/Formatting/StringFormatter.cs index 53022d074e..e2877ef573 100644 --- a/src/GitVersion.Core/Formatting/StringFormatter.cs +++ b/src/GitVersion.Core/Formatting/StringFormatter.cs @@ -33,16 +33,12 @@ public override bool TryFormat(object? value, string format, CultureInfo culture result = cultureInfo.TextInfo.ToTitleCase(cultureInfo.TextInfo.ToLower(stringValue)); return true; case "s": - if (stringValue.Length == 1) - result = cultureInfo.TextInfo.ToUpper(stringValue); - else - { - result = cultureInfo.TextInfo.ToUpper(stringValue[0]) + cultureInfo.TextInfo.ToLower(stringValue[1..]); - } - + result = stringValue.Length == 1 + ? cultureInfo.TextInfo.ToUpper(stringValue) + : cultureInfo.TextInfo.ToUpper(stringValue[0]) + cultureInfo.TextInfo.ToLower(stringValue[1..]); return true; case "c": - result = stringValue.PascalCase(cultureInfo); + result = cultureInfo.TextInfo.ToPascalCase(stringValue); return true; default: result = string.Empty; From 7d8bc6299a04eed0c2858b927bad318d448918b0 Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Thu, 31 Jul 2025 08:08:37 +0200 Subject: [PATCH 08/10] Fix table alignment and remove trailing whitespaces --- .../input/docs/reference/custom-formatting.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/input/docs/reference/custom-formatting.md b/docs/input/docs/reference/custom-formatting.md index 055ffaa7d0..621bd844de 100644 --- a/docs/input/docs/reference/custom-formatting.md +++ b/docs/input/docs/reference/custom-formatting.md @@ -55,13 +55,13 @@ GitVersion introduces custom format specifiers for string case transformations t ### 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` | +| 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 @@ -70,7 +70,7 @@ GitVersion introduces custom format specifiers for string case transformations t branches: feature: label: "{BranchName:c}" # Converts to PascalCase - + template: "{Major}.{Minor}.{Patch}-{PreReleaseLabel:l}.{CommitsSinceVersionSource:0000}" ``` @@ -191,7 +191,7 @@ The formatting system uses `CultureInfo.InvariantCulture` by default through the ```csharp // All environments produce the same output: // {CommitsSinceVersionSource:N0} → "1,234" -// {CommitDate:MMM dd, yyyy} → "Mar 15, 2024" +// {CommitDate:MMM dd, yyyy} → "Mar 15, 2024" // {Major:C} → "¤1.00" (generic currency symbol) ``` @@ -207,8 +207,8 @@ The following examples are verified by actual unit tests in the GitVersion codeb assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}" ``` -**Test**: `VariableProviderTests.Format_Allows_CSharp_FormatStrings()` -**Input**: `CommitsSinceVersionSource = 42` +**Test**: `VariableProviderTests.Format_Allows_CSharp_FormatStrings()` +**Input**: `CommitsSinceVersionSource = 42` **Output**: `"1.2.3-0042"` ### String Case Transformations @@ -216,7 +216,7 @@ assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSour ```csharp // From StringFormatterTests.cs [TestCase("hello world", "c", "HelloWorld")] // PascalCase -[TestCase("hello", "u", "HELLO")] // Uppercase +[TestCase("hello", "u", "HELLO")] // Uppercase [TestCase("HELLO", "l", "hello")] // Lowercase [TestCase("hello world", "t", "Hello World")] // Title Case [TestCase("hELLO", "s", "Hello")] // Sentence Case @@ -225,7 +225,7 @@ assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSour ### Numeric Format Specifiers ```csharp -// From NumericFormatterTests.cs +// 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 @@ -249,4 +249,4 @@ assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSour ``` [reference-configuration]: /docs/reference/configuration -[variables]: /docs/reference/variables \ No newline at end of file +[variables]: /docs/reference/variables From 1e890a39458c3098fbcefba1672f097319751d0c Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Thu, 31 Jul 2025 12:37:55 +0200 Subject: [PATCH 09/10] Moves formatting tests to correct namespace --- src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs | 2 +- src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs | 2 +- .../Formatting/FormattableFormatterTests.cs | 2 +- src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs | 2 +- src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs | 2 +- src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs | 2 +- src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs | 2 +- src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs | 2 +- src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs index 90180a5277..3e5215f4f3 100644 --- a/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs @@ -1,6 +1,6 @@ using GitVersion.Formatting; -namespace GitVersion.Core.Tests.Formatting; +namespace GitVersion.Tests.Formatting; [TestFixture] public class DateFormatterTests diff --git a/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs b/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs index 864e654858..5d8e0e49b3 100644 --- a/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs @@ -1,7 +1,7 @@ using GitVersion.Core.Tests.Extensions; using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Tests.Formatting; public partial class InputSanitizerTests { diff --git a/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs index 8b0f75f048..1404c18b20 100644 --- a/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs @@ -1,7 +1,7 @@ using System.Globalization; using GitVersion.Formatting; -namespace GitVersion.Core.Tests.Formatting; +namespace GitVersion.Tests.Formatting; [TestFixture] public class FormattableFormatterTests diff --git a/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs b/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs index 176c46af38..73bf35dbe6 100644 --- a/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs @@ -1,7 +1,7 @@ using GitVersion.Core.Tests.Extensions; using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Tests.Formatting; [TestFixture] public partial class InputSanitizerTests diff --git a/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs index af336615f5..a7696607a1 100644 --- a/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs @@ -1,6 +1,6 @@ using GitVersion.Formatting; -namespace GitVersion.Core.Tests.Formatting; +namespace GitVersion.Tests.Formatting; [TestFixture] public class NumericFormatterTests diff --git a/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs b/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs index 8e2c35c5b7..53f77a0a26 100644 --- a/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs @@ -1,7 +1,7 @@ using GitVersion.Core.Tests.Extensions; using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Tests.Formatting; public partial class InputSanitizerTests { diff --git a/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs b/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs index 55fcc07811..7f7aa87c1f 100644 --- a/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs @@ -1,7 +1,7 @@ using GitVersion.Core.Tests.Extensions; using GitVersion.Formatting; -namespace GitVersion.Tests.Helpers; +namespace GitVersion.Tests.Formatting; public partial class InputSanitizerTests { diff --git a/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs index 7c9024fe79..b9f3c0a27d 100644 --- a/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs @@ -1,6 +1,6 @@ using GitVersion.Formatting; -namespace GitVersion.Core.Tests.Formatting; +namespace GitVersion.Tests.Formatting; [TestFixture] public class StringFormatterTests diff --git a/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs index 77aab62b05..ed1ffb0adc 100644 --- a/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs @@ -1,7 +1,7 @@ using System.Globalization; using GitVersion.Formatting; -namespace GitVersion.Core.Tests.Formatting; +namespace GitVersion.Tests.Formatting; [TestFixture] public class ValueFormatterTests From cc37e2b5859e6a606b98d15b6218bbd2e030e1f9 Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Thu, 31 Jul 2025 12:43:32 +0200 Subject: [PATCH 10/10] address PR comments --- src/GitVersion.Core/Formatting/StringFormatWithExtension.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index 4a97738d79..0f061c4f0b 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -5,11 +5,11 @@ namespace GitVersion.Formatting; internal static class StringFormatWithExtension { - internal static IExpressionCompiler ExpressionCompiler { get; set; } = new ExpressionCompiler(); + private static readonly IExpressionCompiler ExpressionCompiler = new ExpressionCompiler(); - internal static IInputSanitizer InputSanitizer { get; set; } = new InputSanitizer(); + private static readonly IInputSanitizer InputSanitizer = new InputSanitizer(); - internal static IMemberResolver MemberResolver { get; set; } = new MemberResolver(); + private static readonly IMemberResolver MemberResolver = new MemberResolver(); /// /// Formats the , replacing each expression wrapped in curly braces