From 4d52d8215adcd47ab2867a2f4d4e70f9cd4e49bc Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Mon, 15 Jun 2026 09:03:46 +1000 Subject: [PATCH 1/2] 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 3b6ae834dd..b2b85dd02c 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 341c9096f0..70830318e8 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; using GitVersion.Core; namespace GitVersion.Formatting; @@ -41,7 +42,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); } /// @@ -69,62 +70,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, @@ -134,24 +137,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 669b4f8f440912a5c170fa44a9d133a7c7bbac0e Mon Sep 17 00:00:00 2001 From: Robert Coltheart Date: Wed, 17 Jun 2026 09:36:51 +1000 Subject: [PATCH 2/2] 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 e79fcca522..def31e80cf 100644 --- a/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs +++ b/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs @@ -155,9 +155,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 70830318e8..7a86fc3fbc 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -42,7 +42,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); } /// @@ -70,10 +70,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); @@ -81,84 +81,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; @@ -173,7 +153,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; @@ -187,13 +167,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); }