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/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 341c9096f0..7a86fc3fbc 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 => EvaluateMemberFromObject(source, member), environment); } /// @@ -69,89 +70,151 @@ 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 => EvaluateMemberFromDictionary(source, member), environment); } - private string FormatWith(EvaluateMemberDelegate memberEvaluator, IEnvironment environment) + private string FormatWith(Func memberEvaluator, IEnvironment environment) { ArgumentNullException.ThrowIfNull(template); - var result = new StringBuilder(); - var lastIndex = 0; + return RegexPatterns.ExpandTokensRegex.Replace(template, match => EvaluateMatch(match.Groups[1].Value, memberEvaluator, environment)); + } + } + + private static string EvaluateMatch(string input, Func memberEvaluator, IEnvironment environment) + { + ArgumentNullException.ThrowIfNull(input); - foreach (var match in RegexPatterns.ExpandTokensRegex.Matches(template).Cast()) + foreach (var token in ParseTokens(input)) + { + if (token.Type == TokenType.Literal) { - var replacement = EvaluateMatch(match, memberEvaluator, environment); - result.Append(template, lastIndex, match.Index - lastIndex); - result.Append(replacement); - lastIndex = match.Index + match.Length; + return token.Name; } - result.Append(template, lastIndex, template.Length - lastIndex); - return result.ToString(); - } - } + var value = token.Type == TokenType.EnvironmentVariable + ? environment.GetEnvironmentVariable(token.Name) + : memberEvaluator(token.Name); - private static string EvaluateMatch(Match match, EvaluateMemberDelegate memberEvaluator, IEnvironment environment) - { - var fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null; + if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(token.Format)) + { + if (ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) + { + return formatted; + } - if (match.Groups["envvar"].Success) - return EvaluateEnvVar(match.Groups["envvar"].Value, fallback, environment); + return value; + } - if (match.Groups["member"].Success) - { - var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; - return memberEvaluator(match.Groups["member"].Value, format, fallback); + if (!string.IsNullOrEmpty(value)) + { + return value; + } } - throw new ArgumentException($"Invalid token format: '{match.Value}'"); + throw new ArgumentException($"Invalid token string or no available values to parse: '{input}'"); } - private static string EvaluateEnvVar(string name, string? fallback, IEnvironment env) + private static string? EvaluateMemberFromObject(object source, string member) { - var safeName = InputSanitizer.SanitizeEnvVarName(name); - return env.GetEnvironmentVariable(safeName) - ?? fallback - ?? throw new ArgumentException($"Environment variable {safeName} not found and no fallback provided"); + var safeMember = InputSanitizer.SanitizeMemberName(member); + var memberPath = MemberResolver.ResolveMemberPath(source.GetType(), safeMember); + var getter = ExpressionCompiler.CompileGetter(source.GetType(), memberPath); + + var value = getter(source); + + return value?.ToString(); } - private static string EvaluateMemberFromObject(object source, string member, string? format, string? fallback) + private static string? EvaluateMemberFromDictionary(IDictionary 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 fallback ?? string.Empty; + 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(); - if (format is not null && ValueFormatter.Default.TryFormat( - value, - InputSanitizer.SanitizeFormat(format), - out var formatted)) + var inQuotes = false; + var index = 0; + + while (index < value.Length) { - return formatted; + 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 value.ToString() ?? fallback ?? string.Empty; + return tokens; } - private static string EvaluateMemberFromDictionary(IDictionary source, string member, string? format, string? fallback) + private static Token ParseToken(string token) { - var safeMember = InputSanitizer.SanitizeMemberName(member); + token = token.Trim(); - if (!source.TryGetValue(safeMember, out var value)) - return fallback ?? string.Empty; + if (token.StartsWith('"') && token.EndsWith('"')) + { + return new Token(token.Trim('"'), TokenType.Literal); + } - if (value is null) - return fallback ?? string.Empty; + if (token.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + { + var variable = token[4..]; - if (format is not null && ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(format), out var formatted)) - return formatted; + var (name, format) = ParseNameAndFormat(variable); + var safeName = InputSanitizer.SanitizeEnvVarName(name); - return value.ToString() ?? fallback ?? string.Empty; + 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 } - private delegate string EvaluateMemberDelegate(string member, string? format, string? fallback); + private record Token(string Name, TokenType Type, string? Format = null); }