Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 1 addition & 21 deletions src/GitVersion.Core/Core/RegexPatterns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:)(?<envvar>[A-Za-z_][A-Za-z0-9_]*) # Only a single env: prefix, not followed by another env:
| # OR
(?<member>[A-Za-z_][A-Za-z0-9_]*) # member/property name
(?: # Optional format specifier
:(?<format>[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:
(?<fallback>\w+) # A single word fallback
| # OR
"(?<fallback>[^"]*)" # A quoted string fallback
)
)? # Fallback is optional
\}
""";
private const string ExpandTokensRegexPattern = @"\{([^{}]+)\}";

/// <summary>
/// Allow alphanumeric, underscore, colon (for custom format specification), hyphen, and dot
Expand Down
3 changes: 2 additions & 1 deletion src/GitVersion.Core/Extensions/ConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ private static Dictionary<string, object> 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, "-");
}

Expand Down
165 changes: 114 additions & 51 deletions src/GitVersion.Core/Formatting/StringFormatWithExtension.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Xml.Linq;
using GitVersion.Core;

namespace GitVersion.Formatting;
Expand Down Expand Up @@ -41,7 +42,7 @@
{
ArgumentNullException.ThrowIfNull(source);

return template.FormatWith((member, format, fallback) => EvaluateMemberFromObject(source, member, format, fallback), environment);
return template.FormatWith(member => EvaluateMemberFromObject(source, member), environment);
}

/// <summary>
Expand Down Expand Up @@ -69,89 +70,151 @@
{
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<string, string?> 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<string, string?> memberEvaluator, IEnvironment environment)
{
ArgumentNullException.ThrowIfNull(input);

foreach (var match in RegexPatterns.ExpandTokensRegex.Matches(template).Cast<Match>())
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<string, 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 fallback ?? string.Empty;
if (!source.TryGetValue(safeMember, out var value))
return null;

return value.ToString();
}

private static IEnumerable<Token> ParseTokens(string value)
{
var tokens = new List<Token>();
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<string, object> 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);

Check warning on line 219 in src/GitVersion.Core/Formatting/StringFormatWithExtension.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Private record classes which are not derived in the current assembly should be marked as 'sealed'.

See more on https://sonarcloud.io/project/issues?id=GitTools_GitVersion&issues=AZ7SzkPmHrNUCHtbcOl_&open=AZ7SzkPmHrNUCHtbcOl_&pullRequest=4977
}
Loading