From 50e71fe0025e2dacf61bb966740920fd78a0df03 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 15 Jun 2026 09:03:46 +1000 Subject: [PATCH 01/16] feat: add improved regex parsing for labels --- src/GitVersion.Core/Core/RegexPatterns.cs | 22 +--- .../Formatting/StringFormatWithExtension.cs | 124 ++++++++++++------ 2 files changed, 84 insertions(+), 62 deletions(-) diff --git a/src/GitVersion.Core/Core/RegexPatterns.cs b/src/GitVersion.Core/Core/RegexPatterns.cs index ff33947144..79dc6da59f 100644 --- a/src/GitVersion.Core/Core/RegexPatterns.cs +++ b/src/GitVersion.Core/Core/RegexPatterns.cs @@ -17,27 +17,7 @@ internal static partial class RegexPatterns private const string ObscurePasswordRegexPattern = "(https?://)(.+)(:.+@)"; [StringSyntax(StringSyntaxAttribute.Regex)] - private 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 - \} - """; + private const string ExpandTokensRegexPattern = @"\{([^{}]+)\}"; /// /// Allow alphanumeric, underscore, colon (for custom format specification), hyphen, and dot diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index df34cb3f2e..d0d18c7b23 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Xml.Linq; namespace GitVersion.Formatting; @@ -40,7 +41,7 @@ public string FormatWith(object source, IEnvironment environment) { ArgumentNullException.ThrowIfNull(source); - return template.FormatWith((member, format, fallback) => EvaluateMemberFromObject(source, member, format, fallback), environment); + return template.FormatWith((member, format) => EvaluateMemberFromObject(source, member, format), environment); } /// @@ -68,62 +69,64 @@ public string FormatWith(IDictionary source, IEnvironment enviro { ArgumentNullException.ThrowIfNull(source); - return template.FormatWith((member, format, fallback) => EvaluateMemberFromDictionary(source, member, format, fallback), environment); + return template.FormatWith((member, format) => EvaluateMemberFromDictionary(source, member, format), environment); } private string FormatWith(EvaluateMemberDelegate memberEvaluator, IEnvironment environment) { ArgumentNullException.ThrowIfNull(template); - var result = new StringBuilder(); - var lastIndex = 0; - - foreach (var match in RegexPatterns.ExpandTokensRegex.Matches(template).Cast()) - { - var replacement = EvaluateMatch(match, memberEvaluator, 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(); + return RegexPatterns.ExpandTokensRegex.Replace(template, match => EvaluateMatch(match.Groups[1].Value, memberEvaluator, environment)); } } - private static string EvaluateMatch(Match match, EvaluateMemberDelegate memberEvaluator, IEnvironment environment) + private static string EvaluateMatch(string input, EvaluateMemberDelegate memberEvaluator, 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); + ArgumentNullException.ThrowIfNull(input); - if (match.Groups["member"].Success) + foreach (var token in ParseFormatTokens(input)) { - var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; - return memberEvaluator(match.Groups["member"].Value, format, fallback); - } + if (token.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + { + var safeName = InputSanitizer.SanitizeEnvVarName(token[4..]); + var value = environment.GetEnvironmentVariable(safeName); - throw new ArgumentException($"Invalid token format: '{match.Value}'"); - } + if (!string.IsNullOrEmpty(value)) + { + return value; + } + } + else if (token.StartsWith('"') && token.EndsWith('"')) + { + return token.Trim('"'); + } + else + { + var formattedParts = token.Split(':', 2); + var member = formattedParts.First(); + var format = formattedParts.Skip(1).FirstOrDefault(); - 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"); + var value = memberEvaluator(member, format); + + if (!string.IsNullOrEmpty(value)) + { + return value; + } + } + } + + throw new ArgumentException($"Invalid token string or no available values to parse: '{input}'"); } - private static string EvaluateMemberFromObject(object source, string member, string? format, string? fallback) + private static string? EvaluateMemberFromObject(object source, string member, string? format) { var safeMember = InputSanitizer.SanitizeMemberName(member); - var memberPath = MemberResolver.ResolveMemberPath(source!.GetType(), safeMember); + 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; + return null; if (format is not null && ValueFormatter.Default.TryFormat( value, @@ -133,24 +136,63 @@ private static string EvaluateMemberFromObject(object source, string member, str return formatted; } - return value.ToString() ?? fallback ?? string.Empty; + return value.ToString(); } - private static string EvaluateMemberFromDictionary(IDictionary source, string member, string? format, string? fallback) + private static string? EvaluateMemberFromDictionary(IDictionary source, string member, string? format) { var safeMember = InputSanitizer.SanitizeMemberName(member); if (!source.TryGetValue(safeMember, out var value)) - return fallback ?? string.Empty; + return null; if (value is null) - return fallback ?? string.Empty; + return null; if (format is not null && ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(format), out var formatted)) return formatted; - return value.ToString() ?? fallback ?? string.Empty; + return value.ToString(); } - private delegate string EvaluateMemberDelegate(string member, string? format, string? fallback); + private static IEnumerable ParseFormatTokens(string value) + { + var tokens = new List(); + var current = new StringBuilder(); + + var inQuotes = false; + var index = 0; + + while (index < value.Length) + { + if (value[index] == '"') + { + inQuotes = !inQuotes; + } + + if (!inQuotes && index + 1 < value.Length && value[index] == '?' && value[index + 1] == '?') + { + tokens.Add(current.ToString().Trim()); + + current.Clear(); + index += 2; + } + else + { + current.Append(value[index]); + index++; + } + } + + if (current.Length > 0) + { + tokens.Add(current.ToString().Trim()); + } + + return tokens; + } + + private static string UnescapeLiteral(string value) => value.Replace("\\\"", "\""); + + private delegate string? EvaluateMemberDelegate(string member, string? format); } From 3069a5f81f8da5d087298d9f55c787699819d046 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Wed, 17 Jun 2026 09:36:51 +1000 Subject: [PATCH 02/16] feat: parse chain of tokens for labels --- .../Extensions/ConfigurationExtensions.cs | 3 +- .../Formatting/StringFormatWithExtension.cs | 121 ++++++++++-------- 2 files changed, 73 insertions(+), 51 deletions(-) diff --git a/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs b/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs index 49880d8e06..0307dc1197 100644 --- a/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs +++ b/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs @@ -154,9 +154,10 @@ private static Dictionary BuildLabelPlaceholders(string? regular if (!match.Success) return placeholders; - foreach (var groupName in regex.GetGroupNames()) + foreach (var groupName in regex.GetGroupNames().Skip(1)) { var groupValue = match.Groups[groupName].Value; + placeholders[groupName] = groupValue.RegexReplace(RegexPatterns.SanitizeNameRegexPattern, "-"); } diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index d0d18c7b23..56fa5ecbb6 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -41,7 +41,7 @@ public string FormatWith(object source, IEnvironment environment) { ArgumentNullException.ThrowIfNull(source); - return template.FormatWith((member, format) => EvaluateMemberFromObject(source, member, format), environment); + return template.FormatWith(member => EvaluateMemberFromObject(source, member), environment); } /// @@ -69,10 +69,10 @@ public string FormatWith(IDictionary source, IEnvironment enviro { ArgumentNullException.ThrowIfNull(source); - return template.FormatWith((member, format) => EvaluateMemberFromDictionary(source, member, format), environment); + return template.FormatWith(member => EvaluateMemberFromDictionary(source, member), environment); } - private string FormatWith(EvaluateMemberDelegate memberEvaluator, IEnvironment environment) + private string FormatWith(Func memberEvaluator, IEnvironment environment) { ArgumentNullException.ThrowIfNull(template); @@ -80,84 +80,64 @@ private string FormatWith(EvaluateMemberDelegate memberEvaluator, IEnvironment e } } - private static string EvaluateMatch(string input, EvaluateMemberDelegate memberEvaluator, IEnvironment environment) + private static string EvaluateMatch(string input, Func memberEvaluator, IEnvironment environment) { ArgumentNullException.ThrowIfNull(input); - foreach (var token in ParseFormatTokens(input)) + foreach (var token in ParseTokens(input)) { - if (token.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + if (token.Type == TokenType.Literal) { - var safeName = InputSanitizer.SanitizeEnvVarName(token[4..]); - var value = environment.GetEnvironmentVariable(safeName); - - if (!string.IsNullOrEmpty(value)) - { - return value; - } + return token.Name; } - else if (token.StartsWith('"') && token.EndsWith('"')) - { - return token.Trim('"'); - } - else - { - var formattedParts = token.Split(':', 2); - var member = formattedParts.First(); - var format = formattedParts.Skip(1).FirstOrDefault(); - var value = memberEvaluator(member, format); + var value = token.Type == TokenType.EnvironmentVariable + ? environment.GetEnvironmentVariable(token.Name) + : memberEvaluator(token.Name); - if (!string.IsNullOrEmpty(value)) + if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(token.Format)) + { + if (ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) { - return value; + return formatted; } + + return value; + } + + if (!string.IsNullOrEmpty(value)) + { + return value; } } throw new ArgumentException($"Invalid token string or no available values to parse: '{input}'"); } - private static string? EvaluateMemberFromObject(object source, string member, string? format) + private static string? EvaluateMemberFromObject(object source, string member) { 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 null; - if (format is not null && ValueFormatter.Default.TryFormat( - value, - InputSanitizer.SanitizeFormat(format), - out var formatted)) - { - return formatted; - } + var value = getter(source); - return value.ToString(); + return value?.ToString(); } - private static string? EvaluateMemberFromDictionary(IDictionary source, string member, string? format) + private static string? EvaluateMemberFromDictionary(IDictionary source, string member) { var safeMember = InputSanitizer.SanitizeMemberName(member); if (!source.TryGetValue(safeMember, out var value)) return null; - if (value is null) - return null; - - if (format is not null && ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(format), out var formatted)) - return formatted; - return value.ToString(); } - private static IEnumerable ParseFormatTokens(string value) + private static IEnumerable ParseTokens(string value) { - var tokens = new List(); + var tokens = new List(); var current = new StringBuilder(); var inQuotes = false; @@ -172,7 +152,7 @@ private static IEnumerable ParseFormatTokens(string value) if (!inQuotes && index + 1 < value.Length && value[index] == '?' && value[index + 1] == '?') { - tokens.Add(current.ToString().Trim()); + tokens.Add(ParseToken(current.ToString())); current.Clear(); index += 2; @@ -186,13 +166,54 @@ private static IEnumerable ParseFormatTokens(string value) if (current.Length > 0) { - tokens.Add(current.ToString().Trim()); + tokens.Add(ParseToken(current.ToString())); } return tokens; } + private static Token ParseToken(string token) + { + token = token.Trim(); + + if (token.StartsWith('"') && token.EndsWith('"')) + { + return new Token(token.Trim('"'), TokenType.Literal); + } + + if (token.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + { + var variable = token[4..]; + + var (name, format) = ParseNameAndFormat(variable); + var safeName = InputSanitizer.SanitizeEnvVarName(name); + + return new Token(safeName, TokenType.EnvironmentVariable, format); + } + + var (member, memberFormat) = ParseNameAndFormat(token); + + return new Token(member, TokenType.Proeprty, memberFormat); + } + + private static (string Name, string? Format) ParseNameAndFormat(string value) + { + if (value.Split(':', 2) is [var name, var format]) + { + return (name, format); + } + + return (value, null); + } + private static string UnescapeLiteral(string value) => value.Replace("\\\"", "\""); - private delegate string? EvaluateMemberDelegate(string member, string? format); + private enum TokenType + { + Literal, + Proeprty, + EnvironmentVariable + } + + private record Token(string Name, TokenType Type, string? Format = null); } From 58ba4f2262c2c01bcf0ccb1313d1c48d2333bfaf Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 22 Jun 2026 20:52:23 +1000 Subject: [PATCH 03/16] feat: add label tokenizer and tests --- .../StringFormatWithExtensionTests.cs | 81 +++++++- .../Formatting/LabelTokenizerTests.cs | 69 +++++++ src/GitVersion.Core/Formatting/LabelToken.cs | 17 ++ .../Formatting/LabelTokenType.cs | 8 + .../Formatting/LabelTokenizer.cs | 173 ++++++++++++++++++ .../Formatting/MemberResolver.cs | 2 +- .../Formatting/StringFormatWithExtension.cs | 108 +++-------- 7 files changed, 361 insertions(+), 97 deletions(-) create mode 100644 src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs create mode 100644 src/GitVersion.Core/Formatting/LabelToken.cs create mode 100644 src/GitVersion.Core/Formatting/LabelTokenType.cs create mode 100644 src/GitVersion.Core/Formatting/LabelTokenizer.cs diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 3e67be55a7..57afd22220 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -79,7 +79,9 @@ public void FormatWithUnsetEnvVarToken_WithoutFallback() this.environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null); var propertyObject = new { }; const string target = "{env:GIT_VERSION_UNSET_TEST_VAR}"; - Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); + const string expected = ""; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); } [Test] @@ -98,9 +100,8 @@ public void FormatWithMultipleEnvVars() public void FormatWithMultipleEnvChars() { var propertyObject = new { }; - //Test the greediness of the regex in matching env: char const string target = "{env:env:GIT_VERSION_TEST_VAR_1} and {env:DUMMY_VAR ?? fallback}"; - const string expected = "{env:env:GIT_VERSION_TEST_VAR_1} and fallback"; + const string expected = " and fallback"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); } @@ -109,10 +110,8 @@ public void FormatWithMultipleEnvChars() public void FormatWithMultipleFallbackChars() { var propertyObject = new { }; - //Test the greediness of the regex in matching env: and ?? chars - const string target = "{env:env:GIT_VERSION_TEST_VAR_1} and {env:DUMMY_VAR ??? fallback}"; - var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo(target)); + const string target = " and {env:DUMMY_VAR ??? fallback}"; + Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); } [Test] @@ -120,14 +119,14 @@ public void FormatWithSingleFallbackChar() { this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val"); var propertyObject = new { }; - //Test the sanity of the regex when there is a grammar mismatch const string target = "{en:DUMMY_ENV_VAR} and {env:DUMMY_ENV_VAR??fallback}"; + const string expected = " and Dummy-Val"; var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo(target)); + Assert.That(actual, Is.EqualTo(expected)); } [Test] - public void FormatWIthNullPropagationWithMultipleSpaces() + public void FormatWithNullPropagationWithMultipleSpaces() { var propertyObject = new { SomeProperty = "Some Value" }; const string target = "{SomeProperty} and {env:DUMMY_ENV_VAR ?? fallback}"; @@ -136,6 +135,68 @@ public void FormatWIthNullPropagationWithMultipleSpaces() Assert.That(actual, Is.EqualTo(expected)); } + [Test] + public void FormatWithMissingPropertyAndEnvFallback() + { + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val"); + var propertyObject = new { }; + const string target = "{SomeProperty ?? env:DUMMY_ENV_VAR}"; + const string expected = "Dummy-Val"; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void FormatWithMissingEnvAndPropertyFallback() + { + var propertyObject = new { SomeProperty = "Some Value" }; + const string target = "{env:DUMMY_ENV_VAR ?? SomeProperty}"; + const string expected = "Some Value"; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void FormatWithMultiplePropertiesAndFallback() + { + var propertyObject = new { }; + const string target = "{SomeProperty ?? SomeOtherProperty ?? MissingPropTreatedAsLiteral}"; + const string expected = "MissingPropTreatedAsLiteral"; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void FormatWithMultiplePropertiesAndQuotedFallback() + { + var propertyObject = new { }; + const string target = "{SomeProperty ?? SomeOtherProperty ?? \"fallback\"}"; + const string expected = "fallback"; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void FormatWithMultipleAvailablePropertiesAndFallback() + { + var propertyObject = new { SomeOtherProperty = "Some-Value" }; + const string target = "{SomeProperty ?? SomeOtherProperty ?? fallback}"; + const string expected = "Some-Value"; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void FormatWithPropertyAndEnvAndFormatters() + { + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val"); + var propertyObject = new { SomeProperty = "Some-Value" }; + const string target = "{SomeProperty:l} and {env:DUMMY_ENV_VAR:l}"; + const string expected = "some-value and dummy-val"; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + [Test] public void FormatEnvVar_WithFallback_QuotedAndEmpty() { diff --git a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs new file mode 100644 index 0000000000..96658b1fef --- /dev/null +++ b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs @@ -0,0 +1,69 @@ +using GitVersion.Formatting; + +namespace GitVersion.Tests.Formatting; + +[TestFixture] +public class LabelTokenizerTests +{ + [TestCase("Pattern", "Pattern")] + [TestCase("Pattern ", "Pattern")] + [TestCase(" Pattern", "Pattern")] + [TestCase(" Pattern ", "Pattern")] + [TestCase("Pat\\\"tern", "Pat\"tern")] + [TestCase("\"Pattern\"", "Pattern")] + [TestCase("\"Pat?tern\"", "Pat?tern")] + [TestCase("\"Pat tern\"", "Pat tern")] + [TestCase("\" Pattern\"", " Pattern")] + [TestCase("\"Pattern \"", "Pattern ")] + [TestCase("\"Pat\\\"tern\"", "Pat\"tern")] + public void ParseTokens_ValidLiterals_ReturnsValid(string input, params string[] expected) + { + var tokenizer = new LabelTokenizer(input); + var tokens = tokenizer.ParseTokens() + .Select(x => x.Name) + .ToArray(); + + tokens.ShouldBeEquivalentTo(expected); + } + + [TestCase("Pat?tern")] + [TestCase("\"Pattern")] + [TestCase("Pattern\"")] + [TestCase("Pat\"tern")] + public void ParseTokens_InvalidLiterals_Throws(string input) + { + var tokenizer = new LabelTokenizer(input); + + Assert.Throws(() => tokenizer.ParseTokens()); + } + + [TestCase("Prop ?? literal", "Prop", "literal")] + [TestCase("Prop??literal", "Prop", "literal")] + [TestCase("Prop ?? literal ?? fallback", "Prop", "literal", "fallback")] + [TestCase("Prop ??literal?? fallback", "Prop", "literal", "fallback")] + [TestCase("Prop ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] + [TestCase("Prop:format ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] + [TestCase("env:Prop ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] + [TestCase("env:Prop:format ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] + public void ParseTokens_ValidIdentifiers_ReturnsValid(string input, params string[] expected) + { + var tokenizer = new LabelTokenizer(input); + var tokens = tokenizer.ParseTokens() + .Select(x => x.Name) + .ToArray(); + + tokens.ShouldBeEquivalentTo(expected); + } + + [TestCase("Prop ??? literal")] + [TestCase("Prop literal")] + [TestCase("Prop ?? literal ?? ? fallback")] + [TestCase("Prop ? fallback")] + [TestCase("Prop ?? fall?back")] + public void ParseTokens_MalformedIdentifiers_Throws(string input) + { + var tokenizer = new LabelTokenizer(input); + + Assert.Throws(() => tokenizer.ParseTokens()); + } +} diff --git a/src/GitVersion.Core/Formatting/LabelToken.cs b/src/GitVersion.Core/Formatting/LabelToken.cs new file mode 100644 index 0000000000..62f2b61f02 --- /dev/null +++ b/src/GitVersion.Core/Formatting/LabelToken.cs @@ -0,0 +1,17 @@ +namespace GitVersion.Formatting; + +internal class LabelToken +{ + public LabelToken(string name, LabelTokenType type, string? format = null) + { + Name = name; + Type = type; + Format = format; + } + + public string Name { get; } + + public LabelTokenType Type { get; } + + public string? Format { get; } +} diff --git a/src/GitVersion.Core/Formatting/LabelTokenType.cs b/src/GitVersion.Core/Formatting/LabelTokenType.cs new file mode 100644 index 0000000000..375ab906e9 --- /dev/null +++ b/src/GitVersion.Core/Formatting/LabelTokenType.cs @@ -0,0 +1,8 @@ +namespace GitVersion.Formatting; + +internal enum LabelTokenType +{ + Literal, + Property, + Environment +} diff --git a/src/GitVersion.Core/Formatting/LabelTokenizer.cs b/src/GitVersion.Core/Formatting/LabelTokenizer.cs new file mode 100644 index 0000000000..39c19c25c8 --- /dev/null +++ b/src/GitVersion.Core/Formatting/LabelTokenizer.cs @@ -0,0 +1,173 @@ +using System.Reflection.Metadata.Ecma335; + +namespace GitVersion.Formatting; + +internal class LabelTokenizer(string input) +{ + private int index; + + public IEnumerable ParseTokens() + { + var tokens = new List(); + + SkipWhitespace(); + + while (index < input.Length) + { + var identifier = ParseIdentifier(); + + SkipWhitespace(); + + if (string.IsNullOrEmpty(identifier)) + { + throw new FormatException("Invalid format sequence, expected identifier"); + } + + tokens.Add(ParseToken(identifier)); + + ParseSeparator(); + SkipWhitespace(); + } + + return tokens; + } + + private LabelToken ParseToken(string identifier) + { + if (identifier.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + { + var name = identifier[4..]; + var (environmentName, environemntFormat) = ParseKeyAndFormat(name); + + return new LabelToken(environmentName, LabelTokenType.Environment, environemntFormat); + } + + if (identifier.StartsWith('"') && identifier.EndsWith('"')) + { + return new LabelToken(identifier[1..^1], LabelTokenType.Literal); + } + + var (propertyName, propertyFormat) = ParseKeyAndFormat(identifier); + + return new LabelToken(propertyName, LabelTokenType.Property, propertyFormat); + } + + private (string Key, string? Format) ParseKeyAndFormat(string identifier) + { + var parts = identifier.Split(':'); + + if (parts.Length > 2) + { + throw new FormatException($"Invalid format string: {identifier}"); + } + + if (parts is [var key, var format]) + { + return (key, format); + } + + return (identifier, null); + } + + private string ParseIdentifier() + { + var value = new StringBuilder(); + var inQuotes = ParseQuote(); + + if (inQuotes) + { + value.Append('"'); + } + + while (index < input.Length) + { + var c = input[index]; + + if (!inQuotes && IsQuote()) + { + throw new FormatException("Literal value was not correctly quoted"); + } + + if (ParseQuote()) + { + value.Append('"'); + + return value.ToString(); + } + + if (!inQuotes && char.IsWhiteSpace(c)) + { + return value.ToString(); + } + + if (!inQuotes && c == '?') + { + return value.ToString(); + } + + if (IsEscapeQuote()) + { + value.Append('"'); + index += 2; + } + else + { + value.Append(c); + + index++; + } + } + + if (inQuotes) + { + throw new FormatException("Literal value is missing closing quote"); + } + + return value.ToString(); + } + + private void ParseSeparator() + { + var seen = 0; + + while (this.index < input.Length && seen < 2) + { + if (input[index] != '?') + { + throw new FormatException("Expected '??' separator"); + } + + seen++; + index++; + } + } + + private void SkipWhitespace() + { + while (index < input.Length) + { + if (!char.IsWhiteSpace(input[index])) + { + break; + } + + index++; + } + } + + private bool ParseQuote() + { + if (IsQuote()) + { + index++; + + return true; + } + + return false; + } + + private bool IsQuote() => index < input.Length && input[index] == '"'; + + private bool IsEscapeQuote() => this.index + 1 < input.Length && input[index] == '\\' && input[this.index + 1] == '"'; +} diff --git a/src/GitVersion.Core/Formatting/MemberResolver.cs b/src/GitVersion.Core/Formatting/MemberResolver.cs index f1fde1bc55..88ce83f9c1 100644 --- a/src/GitVersion.Core/Formatting/MemberResolver.cs +++ b/src/GitVersion.Core/Formatting/MemberResolver.cs @@ -15,7 +15,7 @@ public MemberInfo[] ResolveMemberPath(Type type, string memberExpression) { var recursivePath = FindMemberRecursive(type, memberName, []); return recursivePath == null - ? throw new ArgumentException($"'{memberName}' is not a property or field on type '{type.Name}'") + ? [] : [.. recursivePath]; } diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index 56fa5ecbb6..a2e5d00631 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -84,14 +84,17 @@ private static string EvaluateMatch(string input, Func memberEv { ArgumentNullException.ThrowIfNull(input); - foreach (var token in ParseTokens(input)) + var tokenizer = new LabelTokenizer(input); + var tokens = tokenizer.ParseTokens().ToArray(); + + foreach (var token in tokens) { - if (token.Type == TokenType.Literal) + if (token.Type == LabelTokenType.Literal) { return token.Name; } - var value = token.Type == TokenType.EnvironmentVariable + var value = token.Type == LabelTokenType.Environment ? environment.GetEnvironmentVariable(token.Name) : memberEvaluator(token.Name); @@ -111,13 +114,26 @@ private static string EvaluateMatch(string input, Func memberEv } } - throw new ArgumentException($"Invalid token string or no available values to parse: '{input}'"); + var lastToken = tokens.LastOrDefault(); + + if (tokens.Length > 1 && lastToken?.Type == LabelTokenType.Property) + { + return lastToken.Name; + } + + return string.Empty; } private static string? EvaluateMemberFromObject(object source, string member) { var safeMember = InputSanitizer.SanitizeMemberName(member); var memberPath = MemberResolver.ResolveMemberPath(source.GetType(), safeMember); + + if (memberPath.Length == 0) + { + return null; + } + var getter = ExpressionCompiler.CompileGetter(source.GetType(), memberPath); var value = getter(source); @@ -132,88 +148,8 @@ private static string EvaluateMatch(string input, Func memberEv if (!source.TryGetValue(safeMember, out var value)) return null; - return value.ToString(); - } - - private static IEnumerable ParseTokens(string value) - { - var tokens = new List(); - var current = new StringBuilder(); - - var inQuotes = false; - var index = 0; - - while (index < value.Length) - { - if (value[index] == '"') - { - inQuotes = !inQuotes; - } - - if (!inQuotes && index + 1 < value.Length && value[index] == '?' && value[index + 1] == '?') - { - tokens.Add(ParseToken(current.ToString())); - - current.Clear(); - index += 2; - } - else - { - current.Append(value[index]); - index++; - } - } - - if (current.Length > 0) - { - tokens.Add(ParseToken(current.ToString())); - } - - return tokens; - } - - private static Token ParseToken(string token) - { - token = token.Trim(); - - if (token.StartsWith('"') && token.EndsWith('"')) - { - return new Token(token.Trim('"'), TokenType.Literal); - } - - if (token.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) - { - var variable = token[4..]; - - var (name, format) = ParseNameAndFormat(variable); - var safeName = InputSanitizer.SanitizeEnvVarName(name); - - return new Token(safeName, TokenType.EnvironmentVariable, format); - } - - var (member, memberFormat) = ParseNameAndFormat(token); - - return new Token(member, TokenType.Proeprty, memberFormat); - } - - private static (string Name, string? Format) ParseNameAndFormat(string value) - { - if (value.Split(':', 2) is [var name, var format]) - { - return (name, format); - } - - return (value, null); - } - - private static string UnescapeLiteral(string value) => value.Replace("\\\"", "\""); - - private enum TokenType - { - Literal, - Proeprty, - EnvironmentVariable + return value?.ToString(); } - private record Token(string Name, TokenType Type, string? Format = null); + private record struct StringValue(string? Value, bool Exists); } From e0196f9ac56e00ff3a1a9351685fc1b33b6670e5 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 22 Jun 2026 20:56:20 +1000 Subject: [PATCH 04/16] chore: remove unused format type --- src/GitVersion.Core/Formatting/StringFormatWithExtension.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index a2e5d00631..6533d09b34 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -150,6 +150,4 @@ private static string EvaluateMatch(string input, Func memberEv return value?.ToString(); } - - private record struct StringValue(string? Value, bool Exists); } From ed58dc363f16554353072b283f4114c3d39a24c0 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 22 Jun 2026 21:10:59 +1000 Subject: [PATCH 05/16] fix: address sonarqube issues with label parsing --- .../StringFormatWithExtensionTests.cs | 14 ++++---- .../Formatting/LabelTokenizerTests.cs | 35 +++++++------------ .../Formatting/LabelTokenizer.cs | 10 ++---- 3 files changed, 22 insertions(+), 37 deletions(-) diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 57afd22220..760a9f1fb9 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -117,10 +117,10 @@ public void FormatWithMultipleFallbackChars() [Test] public void FormatWithSingleFallbackChar() { - this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val"); + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal"); var propertyObject = new { }; const string target = "{en:DUMMY_ENV_VAR} and {env:DUMMY_ENV_VAR??fallback}"; - const string expected = " and Dummy-Val"; + const string expected = " and DummyVal"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); } @@ -138,10 +138,10 @@ public void FormatWithNullPropagationWithMultipleSpaces() [Test] public void FormatWithMissingPropertyAndEnvFallback() { - this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val"); + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Value"); var propertyObject = new { }; const string target = "{SomeProperty ?? env:DUMMY_ENV_VAR}"; - const string expected = "Dummy-Val"; + const string expected = "Dummy-Value"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); } @@ -189,10 +189,10 @@ public void FormatWithMultipleAvailablePropertiesAndFallback() [Test] public void FormatWithPropertyAndEnvAndFormatters() { - this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val"); - var propertyObject = new { SomeProperty = "Some-Value" }; + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal"); + var propertyObject = new { SomeProperty = "SomeValue" }; const string target = "{SomeProperty:l} and {env:DUMMY_ENV_VAR:l}"; - const string expected = "some-value and dummy-val"; + const string expected = "somevalue and dummyval"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); } diff --git a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs index 96658b1fef..f135d439d0 100644 --- a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs @@ -16,26 +16,13 @@ public class LabelTokenizerTests [TestCase("\" Pattern\"", " Pattern")] [TestCase("\"Pattern \"", "Pattern ")] [TestCase("\"Pat\\\"tern\"", "Pat\"tern")] - public void ParseTokens_ValidLiterals_ReturnsValid(string input, params string[] expected) - { - var tokenizer = new LabelTokenizer(input); - var tokens = tokenizer.ParseTokens() - .Select(x => x.Name) - .ToArray(); - - tokens.ShouldBeEquivalentTo(expected); - } + public void ParseTokens_ValidLiterals_ReturnsValid(string input, params string[] expected) => AssertTokens(input, expected); [TestCase("Pat?tern")] [TestCase("\"Pattern")] [TestCase("Pattern\"")] [TestCase("Pat\"tern")] - public void ParseTokens_InvalidLiterals_Throws(string input) - { - var tokenizer = new LabelTokenizer(input); - - Assert.Throws(() => tokenizer.ParseTokens()); - } + public void ParseTokens_InvalidLiterals_Throws(string input) => AssertThrows(input); [TestCase("Prop ?? literal", "Prop", "literal")] [TestCase("Prop??literal", "Prop", "literal")] @@ -45,7 +32,16 @@ public void ParseTokens_InvalidLiterals_Throws(string input) [TestCase("Prop:format ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] [TestCase("env:Prop ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] [TestCase("env:Prop:format ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] - public void ParseTokens_ValidIdentifiers_ReturnsValid(string input, params string[] expected) + public void ParseTokens_ValidIdentifiers_ReturnsValid(string input, params string[] expected) => AssertTokens(input, expected); + + [TestCase("Prop ??? literal")] + [TestCase("Prop literal")] + [TestCase("Prop ?? literal ?? ? fallback")] + [TestCase("Prop ? fallback")] + [TestCase("Prop ?? fall?back")] + public void ParseTokens_MalformedIdentifiers_Throws(string input) => AssertThrows(input); + + private void AssertTokens(string input, string[] expected) { var tokenizer = new LabelTokenizer(input); var tokens = tokenizer.ParseTokens() @@ -55,12 +51,7 @@ public void ParseTokens_ValidIdentifiers_ReturnsValid(string input, params strin tokens.ShouldBeEquivalentTo(expected); } - [TestCase("Prop ??? literal")] - [TestCase("Prop literal")] - [TestCase("Prop ?? literal ?? ? fallback")] - [TestCase("Prop ? fallback")] - [TestCase("Prop ?? fall?back")] - public void ParseTokens_MalformedIdentifiers_Throws(string input) + private void AssertThrows(string input) { var tokenizer = new LabelTokenizer(input); diff --git a/src/GitVersion.Core/Formatting/LabelTokenizer.cs b/src/GitVersion.Core/Formatting/LabelTokenizer.cs index 39c19c25c8..6130e157f2 100644 --- a/src/GitVersion.Core/Formatting/LabelTokenizer.cs +++ b/src/GitVersion.Core/Formatting/LabelTokenizer.cs @@ -52,7 +52,7 @@ private LabelToken ParseToken(string identifier) return new LabelToken(propertyName, LabelTokenType.Property, propertyFormat); } - private (string Key, string? Format) ParseKeyAndFormat(string identifier) + private static (string Key, string? Format) ParseKeyAndFormat(string identifier) { var parts = identifier.Split(':'); @@ -95,12 +95,7 @@ private string ParseIdentifier() return value.ToString(); } - if (!inQuotes && char.IsWhiteSpace(c)) - { - return value.ToString(); - } - - if (!inQuotes && c == '?') + if (!inQuotes && (char.IsWhiteSpace(c) || c == '?')) { return value.ToString(); } @@ -113,7 +108,6 @@ private string ParseIdentifier() else { value.Append(c); - index++; } } From ab6399e55074150261510ee731be9d00b6121e9f Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 22 Jun 2026 21:16:25 +1000 Subject: [PATCH 06/16] fix: address test sonarqube issues --- .../Extensions/StringFormatWithExtensionTests.cs | 8 ++++---- .../Formatting/LabelTokenizerTests.cs | 4 ++-- src/GitVersion.Core/Formatting/LabelTokenizer.cs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 760a9f1fb9..e670627dea 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -33,9 +33,9 @@ public void FormatWithSingleSimpleToken() [Test] public void FormatWithMultipleTokensAndVerbatimText() { - var propertyObject = new { SomeProperty = "SomeValue", AnotherProperty = "Other Value" }; + var propertyObject = new { SomeProperty = "AValue", AnotherProperty = "Other Value" }; const string target = "{SomeProperty} some text {AnotherProperty}"; - const string expected = "SomeValue some text Other Value"; + const string expected = "AValue some text Other Value"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); } @@ -190,9 +190,9 @@ public void FormatWithMultipleAvailablePropertiesAndFallback() public void FormatWithPropertyAndEnvAndFormatters() { this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal"); - var propertyObject = new { SomeProperty = "SomeValue" }; + var propertyObject = new { SomeProperty = "TheValue" }; const string target = "{SomeProperty:l} and {env:DUMMY_ENV_VAR:l}"; - const string expected = "somevalue and dummyval"; + const string expected = "thevalue and dummyval"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); } diff --git a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs index f135d439d0..a3817d14f9 100644 --- a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs @@ -41,7 +41,7 @@ public class LabelTokenizerTests [TestCase("Prop ?? fall?back")] public void ParseTokens_MalformedIdentifiers_Throws(string input) => AssertThrows(input); - private void AssertTokens(string input, string[] expected) + private static void AssertTokens(string input, string[] expected) { var tokenizer = new LabelTokenizer(input); var tokens = tokenizer.ParseTokens() @@ -51,7 +51,7 @@ private void AssertTokens(string input, string[] expected) tokens.ShouldBeEquivalentTo(expected); } - private void AssertThrows(string input) + private static void AssertThrows(string input) { var tokenizer = new LabelTokenizer(input); diff --git a/src/GitVersion.Core/Formatting/LabelTokenizer.cs b/src/GitVersion.Core/Formatting/LabelTokenizer.cs index 6130e157f2..b706580c3f 100644 --- a/src/GitVersion.Core/Formatting/LabelTokenizer.cs +++ b/src/GitVersion.Core/Formatting/LabelTokenizer.cs @@ -32,7 +32,7 @@ public IEnumerable ParseTokens() return tokens; } - private LabelToken ParseToken(string identifier) + private static LabelToken ParseToken(string identifier) { if (identifier.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) { From d659bda4b2ddc5ec4de6a7e5b5065d13b92f525b Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Wed, 24 Jun 2026 08:34:05 +1000 Subject: [PATCH 07/16] fix: fix failing test --- .../Configuration/ConfigurationExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs index 8eac64ec19..d8e3e623ba 100644 --- a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs @@ -127,7 +127,7 @@ public void EnsureGetBranchSpecificLabelWorksWithoutEnvironmentWhenNoEnvPlacehol } [Test] - public void EnsureGetBranchSpecificLabelThrowsWhenThrowIfNotFoundAndEnvVarMissing() + public void EnsureGetBranchSpecificLabelReturnsEmptyWhenEnvVarMissing() { var environment = new TestEnvironment(); // Do not set MISSING_VAR From a47ceaf8968f3f44495f6cff528b9e9603c6af4f Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Wed, 24 Jun 2026 09:12:35 +1000 Subject: [PATCH 08/16] fix: update failing test --- .../Configuration/ConfigurationExtensionsTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs index d8e3e623ba..76f22f20dc 100644 --- a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs @@ -140,7 +140,7 @@ public void EnsureGetBranchSpecificLabelReturnsEmptyWhenEnvVarMissing() .Build(); var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName(BranchName)); - Should.Throw(() => - effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(BranchName), null, environment)); + var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(BranchName), null, environment); + actual.ShouldBe("pr-"); } } From b5ef4107aa1a343ceb7d71d2193f1c0495320e59 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Wed, 24 Jun 2026 09:12:55 +1000 Subject: [PATCH 09/16] feat: add documentation for advanced parsing --- docs/input/docs/reference/mdsource/configuration.source.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/input/docs/reference/mdsource/configuration.source.md b/docs/input/docs/reference/mdsource/configuration.source.md index 4d3b387b73..4ae2438ff8 100644 --- a/docs/input/docs/reference/mdsource/configuration.source.md +++ b/docs/input/docs/reference/mdsource/configuration.source.md @@ -504,7 +504,7 @@ of `alpha.foo` with `label: 'alpha.{BranchName}'` and `regex: '^features?[\/-](? Another example: branch `features/sc-12345/some-description` would become a pre-release label of `sc-12345` with `label: '{StoryNo}'` and `regex: '^features?[\/-](?sc-\d+)[-/].+'`. -You can also use environment variable placeholders with the `{env:VARIABLE_NAME}` syntax. Environment variable placeholders can also be combined with regex placeholders, for example `{BranchName}-{env:VARIABLE_NAME}`, and support fallback values using the `{env:VARIABLE_NAME ?? "fallback"}` syntax. +You can also use environment variable placeholders with the `{env:VARIABLE_NAME}` syntax. Environment variable placeholders can also be combined with regex placeholders, for example `{BranchName}-{env:VARIABLE_NAME}`, and support fallback values using the `{env:VARIABLE_NAME ?? "fallback"}` syntax. These can all be combined with cascading fallbacks using environment variables and placeholders like this: `{env:VARIABLE_NAME ?? BranchName ?? "fallback"}` **Note:** To clear a default use an empty string: `label: ''` From 94ac0d8ced1819ebaaeea97bb30a2296f94d4095 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Thu, 25 Jun 2026 07:58:11 +1000 Subject: [PATCH 10/16] fix: change label token to be record --- src/GitVersion.Core/Formatting/LabelToken.cs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/GitVersion.Core/Formatting/LabelToken.cs b/src/GitVersion.Core/Formatting/LabelToken.cs index 62f2b61f02..1c2fac7b24 100644 --- a/src/GitVersion.Core/Formatting/LabelToken.cs +++ b/src/GitVersion.Core/Formatting/LabelToken.cs @@ -1,17 +1,3 @@ namespace GitVersion.Formatting; -internal class LabelToken -{ - public LabelToken(string name, LabelTokenType type, string? format = null) - { - Name = name; - Type = type; - Format = format; - } - - public string Name { get; } - - public LabelTokenType Type { get; } - - public string? Format { get; } -} +internal record LabelToken(string Name, LabelTokenType Type, string? Format = null); From 8847f7a25b5e9d9b4ee52260afd9020a7c293a6d Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Thu, 25 Jun 2026 07:59:00 +1000 Subject: [PATCH 11/16] docs: add breaking change docs for env and placeholder behavior --- BREAKING_CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index e1ab2c4975..d227f0e5a9 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -1,3 +1,6 @@ +## v6.8.0 +* When using `{env:VAR_NAME}` or `{BranchName}` syntax in labels or version formatting, a missing environment variable or placeholder no longer throws and instead returns a blank value. + ## v6.2.0 * The configuration property `label-number-pattern` was removed. The functionality can be still used by changing the label and the branch name regular expression for pull-request branches. From 7ed30b00bf9b6017ffe33a23e0c65288c55fb4ad Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Thu, 25 Jun 2026 08:58:47 +1000 Subject: [PATCH 12/16] fix: tighten parsing of label formats --- .../Formatting/LabelTokenizerTests.cs | 2 + .../Extensions/ConfigurationExtensions.cs | 5 ++- .../Formatting/LabelTokenizer.cs | 45 +++++++++++-------- .../Formatting/StringFormatWithExtension.cs | 9 ++-- .../VersionCalculation/VariableProvider.cs | 2 +- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs index a3817d14f9..190a76ed58 100644 --- a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs @@ -39,6 +39,8 @@ public class LabelTokenizerTests [TestCase("Prop ?? literal ?? ? fallback")] [TestCase("Prop ? fallback")] [TestCase("Prop ?? fall?back")] + [TestCase("Prop ?? fallback ??")] + [TestCase("Prop ?? fallback ?? ")] public void ParseTokens_MalformedIdentifiers_Throws(string input) => AssertThrows(input); private static void AssertTokens(string input, string[] expected) diff --git a/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs b/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs index 0307dc1197..0cd0c6a66a 100644 --- a/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs +++ b/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs @@ -154,7 +154,10 @@ private static Dictionary BuildLabelPlaceholders(string? regular if (!match.Success) return placeholders; - foreach (var groupName in regex.GetGroupNames().Skip(1)) + var namedGroups = regex.GetGroupNames() + .Where(name => !int.TryParse(name, out _)); + + foreach (var groupName in namedGroups) { var groupValue = match.Groups[groupName].Value; diff --git a/src/GitVersion.Core/Formatting/LabelTokenizer.cs b/src/GitVersion.Core/Formatting/LabelTokenizer.cs index b706580c3f..4b41682a4b 100644 --- a/src/GitVersion.Core/Formatting/LabelTokenizer.cs +++ b/src/GitVersion.Core/Formatting/LabelTokenizer.cs @@ -1,5 +1,3 @@ -using System.Reflection.Metadata.Ecma335; - namespace GitVersion.Formatting; internal class LabelTokenizer(string input) @@ -9,10 +7,11 @@ internal class LabelTokenizer(string input) public IEnumerable ParseTokens() { var tokens = new List(); + var separatedParsed = false; SkipWhitespace(); - while (index < input.Length) + while (this.index < input.Length) { var identifier = ParseIdentifier(); @@ -25,10 +24,16 @@ public IEnumerable ParseTokens() tokens.Add(ParseToken(identifier)); - ParseSeparator(); + separatedParsed = ParseSeparator(); + SkipWhitespace(); } + if (separatedParsed) + { + throw new FormatException("Invalid format sequence, expected identifier after '??' separator"); + } + return tokens; } @@ -37,9 +42,9 @@ private static LabelToken ParseToken(string identifier) if (identifier.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) { var name = identifier[4..]; - var (environmentName, environemntFormat) = ParseKeyAndFormat(name); + var (environmentName, environmentFormat) = ParseKeyAndFormat(name); - return new LabelToken(environmentName, LabelTokenType.Environment, environemntFormat); + return new LabelToken(environmentName, LabelTokenType.Environment, environmentFormat); } if (identifier.StartsWith('"') && identifier.EndsWith('"')) @@ -79,9 +84,9 @@ private string ParseIdentifier() value.Append('"'); } - while (index < input.Length) + while (this.index < input.Length) { - var c = input[index]; + var c = input[this.index]; if (!inQuotes && IsQuote()) { @@ -103,12 +108,12 @@ private string ParseIdentifier() if (IsEscapeQuote()) { value.Append('"'); - index += 2; + this.index += 2; } else { value.Append(c); - index++; + this.index++; } } @@ -120,32 +125,34 @@ private string ParseIdentifier() return value.ToString(); } - private void ParseSeparator() + private bool ParseSeparator() { var seen = 0; while (this.index < input.Length && seen < 2) { - if (input[index] != '?') + if (input[this.index] != '?') { throw new FormatException("Expected '??' separator"); } seen++; - index++; + this.index++; } + + return seen == 2; } private void SkipWhitespace() { - while (index < input.Length) + while (this.index < input.Length) { - if (!char.IsWhiteSpace(input[index])) + if (!char.IsWhiteSpace(input[this.index])) { break; } - index++; + this.index++; } } @@ -153,7 +160,7 @@ private bool ParseQuote() { if (IsQuote()) { - index++; + this.index++; return true; } @@ -161,7 +168,7 @@ private bool ParseQuote() return false; } - private bool IsQuote() => index < input.Length && input[index] == '"'; + private bool IsQuote() => this.index < input.Length && input[this.index] == '"'; - private bool IsEscapeQuote() => this.index + 1 < input.Length && input[index] == '\\' && input[this.index + 1] == '"'; + private bool IsEscapeQuote() => this.index + 1 < input.Length && input[this.index] == '\\' && input[this.index + 1] == '"'; } diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index 6533d09b34..d33e83945b 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -1,6 +1,3 @@ -using System.Text.RegularExpressions; -using System.Xml.Linq; - namespace GitVersion.Formatting; internal static class StringFormatWithExtension @@ -95,10 +92,10 @@ private static string EvaluateMatch(string input, Func memberEv } var value = token.Type == LabelTokenType.Environment - ? environment.GetEnvironmentVariable(token.Name) + ? environment.GetEnvironmentVariable(InputSanitizer.SanitizeEnvVarName(token.Name)) : memberEvaluator(token.Name); - if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(token.Format)) + if (value is not null && !string.IsNullOrEmpty(token.Format)) { if (ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) { @@ -108,7 +105,7 @@ private static string EvaluateMatch(string input, Func memberEv return value; } - if (!string.IsNullOrEmpty(value)) + if (value is not null) { return value; } diff --git a/src/GitVersion.Core/VersionCalculation/VariableProvider.cs b/src/GitVersion.Core/VersionCalculation/VariableProvider.cs index 656b6b0f12..33e8880086 100644 --- a/src/GitVersion.Core/VersionCalculation/VariableProvider.cs +++ b/src/GitVersion.Core/VersionCalculation/VariableProvider.cs @@ -84,7 +84,7 @@ public GitVersionVariables GetVariablesFor( formattedString = formatString.FormatWith(source, this.environment) .RegexReplace(RegexPatterns.Output.SanitizeAssemblyInfoRegexPattern, "-"); } - catch (ArgumentException exception) + catch (Exception exception) when (exception is ArgumentException or FormatException) { throw new WarningException($"Unable to format {formatVarName}. Check your format string: {exception.Message}"); } From 8a0f8d5c348fb5b1f9ae82bd1aba0f56be05fc36 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Thu, 25 Jun 2026 08:59:23 +1000 Subject: [PATCH 13/16] docs: improve fallback documentation --- docs/input/docs/reference/mdsource/configuration.source.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/input/docs/reference/mdsource/configuration.source.md b/docs/input/docs/reference/mdsource/configuration.source.md index 4ae2438ff8..a4d6916e41 100644 --- a/docs/input/docs/reference/mdsource/configuration.source.md +++ b/docs/input/docs/reference/mdsource/configuration.source.md @@ -504,7 +504,7 @@ of `alpha.foo` with `label: 'alpha.{BranchName}'` and `regex: '^features?[\/-](? Another example: branch `features/sc-12345/some-description` would become a pre-release label of `sc-12345` with `label: '{StoryNo}'` and `regex: '^features?[\/-](?sc-\d+)[-/].+'`. -You can also use environment variable placeholders with the `{env:VARIABLE_NAME}` syntax. Environment variable placeholders can also be combined with regex placeholders, for example `{BranchName}-{env:VARIABLE_NAME}`, and support fallback values using the `{env:VARIABLE_NAME ?? "fallback"}` syntax. These can all be combined with cascading fallbacks using environment variables and placeholders like this: `{env:VARIABLE_NAME ?? BranchName ?? "fallback"}` +You can also use environment variable placeholders with the `{env:VARIABLE_NAME}` syntax. Environment variable placeholders can also be combined with regex placeholders, for example `{BranchName}-{env:VARIABLE_NAME}`, and support fallback values using the `{env:VARIABLE_NAME ?? "fallback"}` syntax. These can all be combined with cascading fallbacks using environment variables and placeholders like this: `{env:VARIABLE_NAME ?? BranchName ?? "fallback"}`. If no value is found and no fallback is provided, the placeholder expands to an empty string. **Note:** To clear a default use an empty string: `label: ''` From c749ad78df5be83ebfe2f635bce29b9af138e91d Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 29 Jun 2026 10:23:34 +1000 Subject: [PATCH 14/16] fix: throw on missing properties and use integer fallbacks --- BREAKING_CHANGES.md | 3 - .../mdsource/configuration.source.md | 2 +- .../ConfigurationExtensionsTests.cs | 6 +- .../StringFormatWithExtensionTests.cs | 86 +++++++++++++------ .../Formatting/LabelTokenizerTests.cs | 19 ++-- .../Formatting/LabelTokenizer.cs | 5 ++ .../Formatting/MemberResolver.cs | 2 +- .../Formatting/StringFormatWithExtension.cs | 52 ++++++----- 8 files changed, 111 insertions(+), 64 deletions(-) diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index d227f0e5a9..e1ab2c4975 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -1,6 +1,3 @@ -## v6.8.0 -* When using `{env:VAR_NAME}` or `{BranchName}` syntax in labels or version formatting, a missing environment variable or placeholder no longer throws and instead returns a blank value. - ## v6.2.0 * The configuration property `label-number-pattern` was removed. The functionality can be still used by changing the label and the branch name regular expression for pull-request branches. diff --git a/docs/input/docs/reference/mdsource/configuration.source.md b/docs/input/docs/reference/mdsource/configuration.source.md index a4d6916e41..5d814f8a7e 100644 --- a/docs/input/docs/reference/mdsource/configuration.source.md +++ b/docs/input/docs/reference/mdsource/configuration.source.md @@ -504,7 +504,7 @@ of `alpha.foo` with `label: 'alpha.{BranchName}'` and `regex: '^features?[\/-](? Another example: branch `features/sc-12345/some-description` would become a pre-release label of `sc-12345` with `label: '{StoryNo}'` and `regex: '^features?[\/-](?sc-\d+)[-/].+'`. -You can also use environment variable placeholders with the `{env:VARIABLE_NAME}` syntax. Environment variable placeholders can also be combined with regex placeholders, for example `{BranchName}-{env:VARIABLE_NAME}`, and support fallback values using the `{env:VARIABLE_NAME ?? "fallback"}` syntax. These can all be combined with cascading fallbacks using environment variables and placeholders like this: `{env:VARIABLE_NAME ?? BranchName ?? "fallback"}`. If no value is found and no fallback is provided, the placeholder expands to an empty string. +You can also use environment variable placeholders with the `{env:VARIABLE_NAME}` syntax. Environment variable placeholders can also be combined with regex placeholders, for example `{BranchName}-{env:VARIABLE_NAME}`, and support fallback values using the `{env:VARIABLE_NAME ?? "fallback"}` syntax. These can be combined with cascading fallbacks using environment variables and placeholders like this: `{env:VARIABLE_NAME ?? BranchName ?? "fallback"}`. **Note:** To clear a default use an empty string: `label: ''` diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs index 76f22f20dc..2c6b2fef35 100644 --- a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs @@ -127,7 +127,7 @@ public void EnsureGetBranchSpecificLabelWorksWithoutEnvironmentWhenNoEnvPlacehol } [Test] - public void EnsureGetBranchSpecificLabelReturnsEmptyWhenEnvVarMissing() + public void EnsureGetBranchSpecificLabelThrowsWhenEnvVarMissing() { var environment = new TestEnvironment(); // Do not set MISSING_VAR @@ -140,7 +140,7 @@ public void EnsureGetBranchSpecificLabelReturnsEmptyWhenEnvVarMissing() .Build(); var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName(BranchName)); - var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(BranchName), null, environment); - actual.ShouldBe("pr-"); + Should.Throw(() => + effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(BranchName), null, environment)); } } diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index e670627dea..6f3cc6f9f8 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -56,7 +56,7 @@ public void FormatWithEnvVarTokenWithFallback() { this.environment.SetEnvironmentVariable("GIT_VERSION_TEST_VAR", "Env Var Value"); var propertyObject = new { }; - const string target = "{env:GIT_VERSION_TEST_VAR ?? fallback}"; + const string target = "{env:GIT_VERSION_TEST_VAR ?? \"fallback\"}"; const string expected = "Env Var Value"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); @@ -67,7 +67,7 @@ public void FormatWithUnsetEnvVarToken_WithFallback() { this.environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null); var propertyObject = new { }; - const string target = "{env:GIT_VERSION_UNSET_TEST_VAR ?? fallback}"; + const string target = "{env:GIT_VERSION_UNSET_TEST_VAR ?? \"fallback\"}"; const string expected = "fallback"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); @@ -79,9 +79,7 @@ public void FormatWithUnsetEnvVarToken_WithoutFallback() this.environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null); var propertyObject = new { }; const string target = "{env:GIT_VERSION_UNSET_TEST_VAR}"; - const string expected = ""; - var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo(expected)); + Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); } [Test] @@ -100,17 +98,15 @@ public void FormatWithMultipleEnvVars() public void FormatWithMultipleEnvChars() { var propertyObject = new { }; - const string target = "{env:env:GIT_VERSION_TEST_VAR_1} and {env:DUMMY_VAR ?? fallback}"; - const string expected = " and fallback"; - var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo(expected)); + const string target = "{env:env:GIT_VERSION_TEST_VAR_1} and {env:DUMMY_VAR ?? \"fallback\"}"; + Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); } [Test] public void FormatWithMultipleFallbackChars() { var propertyObject = new { }; - const string target = " and {env:DUMMY_VAR ??? fallback}"; + const string target = " and {env:DUMMY_VAR ??? \"fallback\"}"; Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); } @@ -119,17 +115,15 @@ public void FormatWithSingleFallbackChar() { this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal"); var propertyObject = new { }; - const string target = "{en:DUMMY_ENV_VAR} and {env:DUMMY_ENV_VAR??fallback}"; - const string expected = " and DummyVal"; - var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo(expected)); + const string target = "{en:DUMMY_ENV_VAR} and {env:DUMMY_ENV_VAR??\"fallback\"}"; + Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); } [Test] public void FormatWithNullPropagationWithMultipleSpaces() { var propertyObject = new { SomeProperty = "Some Value" }; - const string target = "{SomeProperty} and {env:DUMMY_ENV_VAR ?? fallback}"; + const string target = "{SomeProperty} and {env:DUMMY_ENV_VAR ?? \"fallback\"}"; const string expected = "Some Value and fallback"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); @@ -157,13 +151,11 @@ public void FormatWithMissingEnvAndPropertyFallback() } [Test] - public void FormatWithMultiplePropertiesAndFallback() + public void FormatWithMultiplePropertiesAndNoFallback() { var propertyObject = new { }; - const string target = "{SomeProperty ?? SomeOtherProperty ?? MissingPropTreatedAsLiteral}"; - const string expected = "MissingPropTreatedAsLiteral"; - var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo(expected)); + const string target = "{SomeProperty ?? SomeOtherProperty ?? MissingProp}"; + Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); } [Test] @@ -180,12 +172,22 @@ public void FormatWithMultiplePropertiesAndQuotedFallback() public void FormatWithMultipleAvailablePropertiesAndFallback() { var propertyObject = new { SomeOtherProperty = "Some-Value" }; - const string target = "{SomeProperty ?? SomeOtherProperty ?? fallback}"; + const string target = "{SomeProperty ?? SomeOtherProperty ?? \"fallback\"}"; const string expected = "Some-Value"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo(expected)); } + [Test] + public void FormatWithMissingPropertiesAndIntegerFallback() + { + var propertyObject = new { }; + const string target = "{SomeProperty ?? SomeOtherProperty ?? 47}"; + const string expected = "47"; + var actual = target.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + [Test] public void FormatWithPropertyAndEnvAndFormatters() { @@ -197,6 +199,38 @@ public void FormatWithPropertyAndEnvAndFormatters() Assert.That(actual, Is.EqualTo(expected)); } + [TestCase("{env:VARIABLE ?? \"\"}", null, null, "")] + [TestCase("{env:MISSING ?? env:VARIABLE}", "Var", null, "Var")] + [TestCase("{Property ?? \"\"}", null, null, "")] + [TestCase("{Property ?? 47}", null, null, "47")] + [TestCase("{Property ?? env:VARIABLE}", null, "Branch", "Branch")] + [TestCase("{Property ?? env:VARIABLE ?? \"\"}", null, null, "")] + [TestCase("{Property ?? env:VARIABLE ?? 42}", null, null, "42")] + public void FormatWith_EnvVarAndPropertyAndFallback_DoesNotThrow(string input, string? envVar, string? property, string expected) + { + if (envVar != null) + { + this.environment.SetEnvironmentVariable("VARIABLE", envVar); + } + + object propertyObject = property != null + ? new { Property = property } + : new { }; + + var actual = input.FormatWith(propertyObject, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase("{env:VARIABLE}")] + [TestCase("{Property}")] + [TestCase("{Property ?? env:VARIABLE}")] + [TestCase("{Property ?? Property}")] + public void FormatWith_MissingEnvVarOrPropertyAndNoFallback_Throws(string input) + { + object propertyObject = new { }; + Assert.Throws(() => input.FormatWith(propertyObject, this.environment)); + } + [Test] public void FormatEnvVar_WithFallback_QuotedAndEmpty() { @@ -247,7 +281,7 @@ public void FormatProperty_NullInteger() public void FormatProperty_String_WithFallback() { var propertyObject = new { Property = "Value" }; - const string target = "{Property ?? fallback}"; + const string target = "{Property ?? \"fallback\"}"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo("Value")); } @@ -256,7 +290,7 @@ public void FormatProperty_String_WithFallback() public void FormatProperty_Integer_WithFallback() { var propertyObject = new { Property = 42 }; - const string target = "{Property ?? fallback}"; + const string target = "{Property ?? \"fallback\"}"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo("42")); } @@ -265,7 +299,7 @@ public void FormatProperty_Integer_WithFallback() public void FormatProperty_NullObject_WithFallback() { var propertyObject = new { Property = (object?)null }; - const string target = "{Property ?? fallback}"; + const string target = "{Property ?? \"fallback\"}"; var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo("fallback")); } @@ -274,9 +308,9 @@ public void FormatProperty_NullObject_WithFallback() public void FormatProperty_NullInteger_WithFallback() { var propertyObject = new { Property = (int?)null }; - const string target = "{Property ?? fallback}"; + const string target = "{Property ?? 43}"; var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo("fallback")); + Assert.That(actual, Is.EqualTo("43")); } [Test] diff --git a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs index 190a76ed58..0588636dc9 100644 --- a/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs @@ -24,14 +24,17 @@ public class LabelTokenizerTests [TestCase("Pat\"tern")] public void ParseTokens_InvalidLiterals_Throws(string input) => AssertThrows(input); - [TestCase("Prop ?? literal", "Prop", "literal")] - [TestCase("Prop??literal", "Prop", "literal")] - [TestCase("Prop ?? literal ?? fallback", "Prop", "literal", "fallback")] - [TestCase("Prop ??literal?? fallback", "Prop", "literal", "fallback")] - [TestCase("Prop ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] - [TestCase("Prop:format ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] - [TestCase("env:Prop ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] - [TestCase("env:Prop:format ?? \"literal\" ?? fallback", "Prop", "literal", "fallback")] + [TestCase("Prop1 ?? Prop2", "Prop1", "Prop2")] + [TestCase("Prop1??Prop2", "Prop1", "Prop2")] + [TestCase("Prop1??Prop2??42", "Prop1", "Prop2", "42")] + [TestCase("Prop1??Prop2??\"42\"", "Prop1", "Prop2", "42")] + [TestCase("Prop1 ?? Prop2 ?? \"fallback\"", "Prop1", "Prop2", "fallback")] + [TestCase("Prop1 ??Prop2?? \"fallback\"", "Prop1", "Prop2", "fallback")] + [TestCase("Prop1 ?? Prop2 ?? 42", "Prop1", "Prop2", "42")] + [TestCase("Prop1:format ?? Prop2 ?? \"fallback\"", "Prop1", "Prop2", "fallback")] + [TestCase("env:Env1 ?? Prop2 ?? \"fallback\"", "Env1", "Prop2", "fallback")] + [TestCase("env:Env1:format ?? \"literal\" ?? \"fallback\"", "Env1", "literal", "fallback")] + [TestCase("env:Env1 ?? 42", "Env1", "42")] public void ParseTokens_ValidIdentifiers_ReturnsValid(string input, params string[] expected) => AssertTokens(input, expected); [TestCase("Prop ??? literal")] diff --git a/src/GitVersion.Core/Formatting/LabelTokenizer.cs b/src/GitVersion.Core/Formatting/LabelTokenizer.cs index 4b41682a4b..049f586e87 100644 --- a/src/GitVersion.Core/Formatting/LabelTokenizer.cs +++ b/src/GitVersion.Core/Formatting/LabelTokenizer.cs @@ -52,6 +52,11 @@ private static LabelToken ParseToken(string identifier) return new LabelToken(identifier[1..^1], LabelTokenType.Literal); } + if (int.TryParse(identifier, out _)) + { + return new LabelToken(identifier, LabelTokenType.Literal); + } + var (propertyName, propertyFormat) = ParseKeyAndFormat(identifier); return new LabelToken(propertyName, LabelTokenType.Property, propertyFormat); diff --git a/src/GitVersion.Core/Formatting/MemberResolver.cs b/src/GitVersion.Core/Formatting/MemberResolver.cs index 88ce83f9c1..f1fde1bc55 100644 --- a/src/GitVersion.Core/Formatting/MemberResolver.cs +++ b/src/GitVersion.Core/Formatting/MemberResolver.cs @@ -15,7 +15,7 @@ public MemberInfo[] ResolveMemberPath(Type type, string memberExpression) { var recursivePath = FindMemberRecursive(type, memberName, []); return recursivePath == null - ? [] + ? throw new ArgumentException($"'{memberName}' is not a property or field on type '{type.Name}'") : [.. recursivePath]; } diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index d33e83945b..7fa1dbddd9 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -84,6 +84,8 @@ private static string EvaluateMatch(string input, Func memberEv var tokenizer = new LabelTokenizer(input); var tokens = tokenizer.ParseTokens().ToArray(); + Exception? lastException = null; + foreach (var token in tokens) { if (token.Type == LabelTokenType.Literal) @@ -91,31 +93,36 @@ private static string EvaluateMatch(string input, Func memberEv return token.Name; } - var value = token.Type == LabelTokenType.Environment - ? environment.GetEnvironmentVariable(InputSanitizer.SanitizeEnvVarName(token.Name)) - : memberEvaluator(token.Name); - - if (value is not null && !string.IsNullOrEmpty(token.Format)) + try { - if (ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) + var value = token.Type == LabelTokenType.Environment + ? EvaluateEnvVar(token.Name, environment) + : memberEvaluator(token.Name); + + if (value is not null && !string.IsNullOrEmpty(token.Format)) { - return formatted; + if (ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) + { + return formatted; + } + + return value; } - return value; + if (value is not null) + { + return value; + } } - - if (value is not null) + catch (Exception e) { - return value; + lastException = e; } } - var lastToken = tokens.LastOrDefault(); - - if (tokens.Length > 1 && lastToken?.Type == LabelTokenType.Property) + if (lastException != null) { - return lastToken.Name; + throw lastException; } return string.Empty; @@ -125,12 +132,6 @@ private static string EvaluateMatch(string input, Func memberEv { var safeMember = InputSanitizer.SanitizeMemberName(member); var memberPath = MemberResolver.ResolveMemberPath(source.GetType(), safeMember); - - if (memberPath.Length == 0) - { - return null; - } - var getter = ExpressionCompiler.CompileGetter(source.GetType(), memberPath); var value = getter(source); @@ -143,8 +144,15 @@ private static string EvaluateMatch(string input, Func memberEv var safeMember = InputSanitizer.SanitizeMemberName(member); if (!source.TryGetValue(safeMember, out var value)) - return null; + throw new ArgumentException($"'{safeMember}' is not a valid placeholder"); return value?.ToString(); } + + private static string EvaluateEnvVar(string name, IEnvironment environment) + { + var safeName = InputSanitizer.SanitizeEnvVarName(name); + + return environment.GetEnvironmentVariable(name) ?? throw new ArgumentException($"Environment variable {safeName} not found and no fallback provided"); + } } From 65869b3bab99bc46b06d6c47d21dbf81c76a0a55 Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 29 Jun 2026 12:36:56 +1000 Subject: [PATCH 15/16] fix: fix sonarqube issues --- global.json | 3 --- .../Extensions/StringFormatWithExtensionTests.cs | 4 ++-- .../Formatting/StringFormatWithExtension.cs | 15 ++++++--------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/global.json b/global.json index 2924e58a3b..0ce2ae3feb 100644 --- a/global.json +++ b/global.json @@ -4,9 +4,6 @@ "new-cli", "src" ], - "sdk": { - "version": "10.0.301" - }, "test": { "runner": "Microsoft.Testing.Platform" } diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 6f3cc6f9f8..38c16cdff1 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -317,9 +317,9 @@ public void FormatProperty_NullInteger_WithFallback() public void FormatProperty_NullObject_WithFallback_Quoted() { var propertyObject = new { Property = (object?)null }; - const string target = "{Property ?? \"fallback\"}"; + const string target = "{Property ?? \"literal\"}"; var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo("fallback")); + Assert.That(actual, Is.EqualTo("literal")); } [Test] diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index 7fa1dbddd9..90a178b3a3 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -99,20 +99,17 @@ private static string EvaluateMatch(string input, Func memberEv ? EvaluateEnvVar(token.Name, environment) : memberEvaluator(token.Name); - if (value is not null && !string.IsNullOrEmpty(token.Format)) + if (value is null) { - if (ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) - { - return formatted; - } - - return value; + continue; } - if (value is not null) + if (!string.IsNullOrEmpty(token.Format) && ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) { - return value; + return formatted; } + + return value; } catch (Exception e) { From 431c7934ac75de148b49f992171ae9f63c34c18d Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 29 Jun 2026 12:37:33 +1000 Subject: [PATCH 16/16] fix: revert global.json changes --- global.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/global.json b/global.json index 0ce2ae3feb..2924e58a3b 100644 --- a/global.json +++ b/global.json @@ -4,6 +4,9 @@ "new-cli", "src" ], + "sdk": { + "version": "10.0.301" + }, "test": { "runner": "Microsoft.Testing.Platform" }