Skip to content

Commit ac9485d

Browse files
committed
Fix legacy composite format syntax handling (Issue #4654)
This commit fixes the bug where legacy .NET composite format syntax like {CommitsSinceVersionSource:0000;;''} was not working correctly. The issue was that the formatter would output literal text instead of properly formatted values. For example, it would output: "6.13.54-gv6-CommitsSinceVersionSource-0000-----" instead of: "6.13.54-gv60002" Changes: 1. Updated RegexPatterns.cs to allow semicolons and quotes in format strings by changing the format pattern from [A-Za-z0-9\.\-,]+ to [A-Za-z0-9\.\-,;'"]+ 2. Added LegacyCompositeFormatter.cs to properly handle the three-section composite format syntax (positive;negative;zero) with support for: - Zero-suppression using empty string ('') - Proper handling of negative values (no double negative) - Numeric formatting in all sections 3. Updated ValueFormatter.cs to register LegacyCompositeFormatter with priority 1 and allow null values to be processed by formatters 4. Updated StringFormatWithExtension.cs to not return early for null values when legacy syntax is detected 5. Added Issue4654Tests.cs to verify the fix works correctly The fix ensures backward compatibility with legacy .NET composite format syntax while maintaining support for modern format strings.
1 parent 1942455 commit ac9485d

File tree

5 files changed

+280
-7
lines changed

5 files changed

+280
-7
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System.Globalization;
2+
using GitVersion.Core.Tests.Helpers;
3+
using GitVersion.Formatting;
4+
5+
namespace GitVersion.Core.Tests.Issues;
6+
7+
[TestFixture]
8+
public class Issue4654Tests
9+
{
10+
private const string TestVersion = "6.13.54";
11+
private const string TestVersionWithPreRelease = "6.13.54-gv60002";
12+
private const string TestPreReleaseLabel = "gv6";
13+
private const string TestPreReleaseLabelWithDash = "-gv6";
14+
15+
[Test]
16+
[Category("Issue4654")]
17+
public void Issue4654_ExactReproduction_ShouldFormatCorrectly()
18+
{
19+
var semanticVersion = new SemanticVersion
20+
{
21+
Major = 6,
22+
Minor = 13,
23+
Patch = 54,
24+
PreReleaseTag = new SemanticVersionPreReleaseTag(TestPreReleaseLabel, 1, true),
25+
BuildMetaData = new SemanticVersionBuildMetaData()
26+
{
27+
Branch = "feature/gv6",
28+
VersionSourceSha = "21d7e26e6ff58374abd3daf2177be4b7a9c49040",
29+
Sha = "489a0c0ab425214def918e36399f3cc3c9a9c42d",
30+
ShortSha = "489a0c0",
31+
CommitsSinceVersionSource = 2,
32+
CommitDate = DateTimeOffset.Parse("2025-08-12", CultureInfo.InvariantCulture),
33+
UncommittedChanges = 0
34+
}
35+
};
36+
37+
var extendedVersion = new
38+
{
39+
semanticVersion.Major,
40+
semanticVersion.Minor,
41+
semanticVersion.Patch,
42+
semanticVersion.BuildMetaData.CommitsSinceVersionSource,
43+
MajorMinorPatch = $"{semanticVersion.Major}.{semanticVersion.Minor}.{semanticVersion.Patch}",
44+
PreReleaseLabel = semanticVersion.PreReleaseTag.Name,
45+
PreReleaseLabelWithDash = string.IsNullOrEmpty(semanticVersion.PreReleaseTag.Name)
46+
? ""
47+
: $"-{semanticVersion.PreReleaseTag.Name}",
48+
AssemblySemFileVer = TestVersion + ".0",
49+
AssemblySemVer = TestVersion + ".0",
50+
BranchName = "feature/gv6",
51+
EscapedBranchName = "feature-gv6",
52+
FullSemVer = "6.13.54-gv6.1+2",
53+
SemVer = "6.13.54-gv6.1"
54+
};
55+
56+
const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}";
57+
const string expected = TestVersionWithPreRelease;
58+
59+
var actual = template.FormatWith(extendedVersion, new TestEnvironment());
60+
61+
actual.ShouldBe(expected, "The legacy ;;'' syntax should format CommitsSinceVersionSource as 0002, not as literal text");
62+
}
63+
64+
[Test]
65+
[Category("Issue4654")]
66+
public void Issue4654_WithoutLegacySyntax_ShouldStillWork()
67+
{
68+
var testData = new
69+
{
70+
MajorMinorPatch = TestVersion,
71+
PreReleaseLabelWithDash = TestPreReleaseLabelWithDash,
72+
CommitsSinceVersionSource = 2
73+
};
74+
75+
const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000}";
76+
const string expected = TestVersionWithPreRelease;
77+
78+
var actual = template.FormatWith(testData, new TestEnvironment());
79+
80+
actual.ShouldBe(expected, "New format syntax should work correctly");
81+
}
82+
83+
[Test]
84+
[Category("Issue4654")]
85+
public void Issue4654_ZeroValueWithLegacySyntax_ShouldUseEmptyFallback()
86+
{
87+
var mainBranchData = new
88+
{
89+
MajorMinorPatch = TestVersion,
90+
PreReleaseLabelWithDash = "",
91+
CommitsSinceVersionSource = 0
92+
};
93+
94+
const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}";
95+
const string expected = TestVersion;
96+
97+
var actual = template.FormatWith(mainBranchData, new TestEnvironment());
98+
99+
actual.ShouldBe(expected, "Zero values should use the third section (empty string) in legacy ;;'' syntax");
100+
}
101+
}

src/GitVersion.Core/Core/RegexPatterns.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ internal static partial class Common
9393
| # OR
9494
(?<member>[A-Za-z_][A-Za-z0-9_]*) # member/property name
9595
(?: # Optional format specifier
96-
:(?<format>[A-Za-z0-9\.\-,]+) # Colon followed by format string (no spaces, ?, or }), format cannot contain colon
96+
:(?<format>[A-Za-z0-9\.\-,;'"]+) # Colon followed by format string (including semicolons and quotes for legacy composite format)
9797
)? # Format is optional
9898
) # End group for env or member
9999
(?: # Optional fallback group
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
using System.Globalization;
2+
3+
namespace GitVersion.Formatting;
4+
5+
internal class LegacyCompositeFormatter : IValueFormatter
6+
{
7+
public int Priority => 1;
8+
9+
public bool TryFormat(object? value, string format, out string result) =>
10+
TryFormat(value, format, CultureInfo.InvariantCulture, out result);
11+
12+
public bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result)
13+
{
14+
result = string.Empty;
15+
16+
if (!HasLegacySyntax(format))
17+
return false;
18+
19+
var sections = ParseSections(format);
20+
var index = GetSectionIndex(value, sections.Length);
21+
22+
if (index >= sections.Length)
23+
return true;
24+
25+
var section = sections[index];
26+
27+
// Use absolute value for negative numbers to prevent double negatives
28+
var valueToFormat = (index == 1 && value != null && IsNumeric(value) && Convert.ToDouble(value) < 0)
29+
? Math.Abs(Convert.ToDouble(value))
30+
: value;
31+
32+
result = IsQuotedLiteral(section)
33+
? UnquoteString(section)
34+
: FormatWithSection(valueToFormat, section, cultureInfo, sections, index);
35+
36+
return true;
37+
}
38+
39+
private static bool HasLegacySyntax(string format) =>
40+
!string.IsNullOrEmpty(format) && format.Contains(';') && !format.Contains("??");
41+
42+
private static string[] ParseSections(string format)
43+
{
44+
var sections = new List<string>();
45+
var current = new StringBuilder();
46+
var inQuotes = false;
47+
var quoteChar = '\0';
48+
49+
foreach (var c in format)
50+
{
51+
if (!inQuotes && (c == '\'' || c == '"'))
52+
{
53+
inQuotes = true;
54+
quoteChar = c;
55+
}
56+
else if (inQuotes && c == quoteChar)
57+
{
58+
inQuotes = false;
59+
}
60+
else if (!inQuotes && c == ';')
61+
{
62+
sections.Add(current.ToString());
63+
current.Clear();
64+
continue;
65+
}
66+
67+
current.Append(c);
68+
}
69+
70+
sections.Add(current.ToString());
71+
return [.. sections];
72+
}
73+
74+
private static int GetSectionIndex(object? value, int sectionCount)
75+
{
76+
if (sectionCount == 1) return 0;
77+
if (value == null) return sectionCount >= 3 ? 2 : 0;
78+
79+
if (!IsNumeric(value)) return 0;
80+
81+
var num = Convert.ToDouble(value);
82+
return num switch
83+
{
84+
> 0 => 0,
85+
< 0 when sectionCount >= 2 => 1,
86+
0 when sectionCount >= 3 => 2,
87+
_ => 0
88+
};
89+
}
90+
91+
private static bool IsNumeric(object value) =>
92+
value is byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal;
93+
94+
private static bool IsQuotedLiteral(string section)
95+
{
96+
if (string.IsNullOrEmpty(section)) return true;
97+
var trimmed = section.Trim();
98+
return (trimmed.StartsWith('\'') && trimmed.EndsWith('\'')) ||
99+
(trimmed.StartsWith('"') && trimmed.EndsWith('"'));
100+
}
101+
102+
private static string UnquoteString(string section)
103+
{
104+
if (string.IsNullOrEmpty(section)) return string.Empty;
105+
var trimmed = section.Trim();
106+
107+
// Handle empty quoted strings like '' and ""
108+
if (trimmed == "''" || trimmed == "\"\"")
109+
return string.Empty;
110+
111+
return IsQuoted(trimmed) && trimmed.Length > 2
112+
? trimmed[1..^1]
113+
: trimmed;
114+
115+
static bool IsQuoted(string s) =>
116+
(s.StartsWith('\'') && s.EndsWith('\'')) || (s.StartsWith('"') && s.EndsWith('"'));
117+
}
118+
119+
private static string FormatWithSection(object? value, string section, IFormatProvider formatProvider, string[]? sections = null, int index = 0)
120+
{
121+
if (string.IsNullOrEmpty(section)) return string.Empty;
122+
if (IsQuotedLiteral(section)) return UnquoteString(section);
123+
124+
try
125+
{
126+
return value switch
127+
{
128+
IFormattable formattable => formattable.ToString(section, formatProvider),
129+
not null when IsValidFormatString(section) =>
130+
string.Format(formatProvider, "{0:" + section + "}", value),
131+
not null when index > 0 && sections != null && sections.Length > 0 && IsValidFormatString(sections[0]) =>
132+
// For invalid formats in non-first sections, use first section format
133+
string.Format(formatProvider, "{0:" + sections[0] + "}", value),
134+
not null => value.ToString() ?? string.Empty,
135+
_ => section // Only for null values without valid format
136+
};
137+
}
138+
catch (FormatException)
139+
{
140+
// On format exception, try first section format or return value string
141+
if (index > 0 && sections != null && sections.Length > 0 && IsValidFormatString(sections[0]))
142+
{
143+
try
144+
{
145+
return string.Format(formatProvider, "{0:" + sections[0] + "}", value);
146+
}
147+
catch
148+
{
149+
return value?.ToString() ?? section;
150+
}
151+
}
152+
return value?.ToString() ?? section;
153+
}
154+
}
155+
156+
private static bool IsValidFormatString(string format)
157+
{
158+
if (string.IsNullOrEmpty(format)) return false;
159+
160+
var firstChar = char.ToUpperInvariant(format[0]);
161+
return "CDEFGNPXR".Contains(firstChar) ||
162+
format.All(c => "0123456789.,#".Contains(c));
163+
}
164+
}

src/GitVersion.Core/Formatting/StringFormatWithExtension.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ private static string EvaluateMember<T>(T source, string member, string? format,
8484
var getter = ExpressionCompiler.CompileGetter(source.GetType(), memberPath);
8585
var value = getter(source);
8686

87-
if (value is null)
87+
// Only return early for null if format doesn't use legacy syntax
88+
if (value is null && !HasLegacySyntax(format))
8889
return fallback ?? string.Empty;
8990

9091
if (format is not null && ValueFormatter.Default.TryFormat(
@@ -95,6 +96,9 @@ private static string EvaluateMember<T>(T source, string member, string? format,
9596
return formatted;
9697
}
9798

98-
return value.ToString() ?? fallback ?? string.Empty;
99+
return value?.ToString() ?? fallback ?? string.Empty;
99100
}
101+
102+
private static bool HasLegacySyntax(string? format) =>
103+
!string.IsNullOrEmpty(format) && format.Contains(';') && !format.Contains("??");
100104
}

src/GitVersion.Core/Formatting/ValueFormatter.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ internal class ValueFormatter : InvariantFormatter, IValueFormatterCombiner
1313
internal ValueFormatter()
1414
=> formatters =
1515
[
16+
new LegacyCompositeFormatter(),
1617
new StringFormatter(),
1718
new FormattableFormatter(),
1819
new NumericFormatter(),
@@ -22,17 +23,20 @@ internal ValueFormatter()
2223
public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result)
2324
{
2425
result = string.Empty;
25-
if (value is null)
26-
{
27-
return false;
28-
}
2926

27+
// Allow formatters to handle null values (e.g., legacy composite formatter for zero sections)
3028
foreach (var formatter in formatters.OrderBy(f => f.Priority))
3129
{
3230
if (formatter.TryFormat(value, format, out result))
3331
return true;
3432
}
3533

34+
// Only return false if no formatter could handle it
35+
if (value is null)
36+
{
37+
return false;
38+
}
39+
3640
return false;
3741
}
3842

0 commit comments

Comments
 (0)