diff --git a/docs/input/docs/reference/mdsource/configuration.source.md b/docs/input/docs/reference/mdsource/configuration.source.md index 4d3b387b73..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. +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 8eac64ec19..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 EnsureGetBranchSpecificLabelThrowsWhenThrowIfNotFoundAndEnvVarMissing() + public void EnsureGetBranchSpecificLabelThrowsWhenEnvVarMissing() { var environment = new TestEnvironment(); // Do not set MISSING_VAR diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 3e67be55a7..38c16cdff1 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)); } @@ -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)); @@ -98,44 +98,139 @@ 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"; - 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 { }; - //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] public void FormatWithSingleFallbackChar() { - this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val"); + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal"); 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}"; - var actual = target.FormatWith(propertyObject, this.environment); - Assert.That(actual, Is.EqualTo(target)); + const string target = "{en:DUMMY_ENV_VAR} and {env:DUMMY_ENV_VAR??\"fallback\"}"; + Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); } [Test] - public void FormatWIthNullPropagationWithMultipleSpaces() + 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)); } + [Test] + public void FormatWithMissingPropertyAndEnvFallback() + { + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Value"); + var propertyObject = new { }; + const string target = "{SomeProperty ?? env:DUMMY_ENV_VAR}"; + const string expected = "Dummy-Value"; + 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 FormatWithMultiplePropertiesAndNoFallback() + { + var propertyObject = new { }; + const string target = "{SomeProperty ?? SomeOtherProperty ?? MissingProp}"; + Assert.Throws(() => target.FormatWith(propertyObject, this.environment)); + } + + [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 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() + { + this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal"); + var propertyObject = new { SomeProperty = "TheValue" }; + const string target = "{SomeProperty:l} and {env:DUMMY_ENV_VAR:l}"; + const string expected = "thevalue and dummyval"; + var actual = target.FormatWith(propertyObject, this.environment); + 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() { @@ -186,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")); } @@ -195,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")); } @@ -204,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")); } @@ -213,18 +308,18 @@ 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] 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.Tests/Formatting/LabelTokenizerTests.cs b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs new file mode 100644 index 0000000000..0588636dc9 --- /dev/null +++ b/src/GitVersion.Core.Tests/Formatting/LabelTokenizerTests.cs @@ -0,0 +1,65 @@ +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) => AssertTokens(input, expected); + + [TestCase("Pat?tern")] + [TestCase("\"Pattern")] + [TestCase("Pattern\"")] + [TestCase("Pat\"tern")] + public void ParseTokens_InvalidLiterals_Throws(string input) => AssertThrows(input); + + [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")] + [TestCase("Prop literal")] + [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) + { + var tokenizer = new LabelTokenizer(input); + var tokens = tokenizer.ParseTokens() + .Select(x => x.Name) + .ToArray(); + + tokens.ShouldBeEquivalentTo(expected); + } + + private static void AssertThrows(string input) + { + var tokenizer = new LabelTokenizer(input); + + Assert.Throws(() => tokenizer.ParseTokens()); + } +} 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/Extensions/ConfigurationExtensions.cs b/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs index 49880d8e06..0cd0c6a66a 100644 --- a/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs +++ b/src/GitVersion.Core/Extensions/ConfigurationExtensions.cs @@ -154,9 +154,13 @@ private static Dictionary BuildLabelPlaceholders(string? regular if (!match.Success) return placeholders; - foreach (var groupName in regex.GetGroupNames()) + var namedGroups = regex.GetGroupNames() + .Where(name => !int.TryParse(name, out _)); + + foreach (var groupName in namedGroups) { var groupValue = match.Groups[groupName].Value; + placeholders[groupName] = groupValue.RegexReplace(RegexPatterns.SanitizeNameRegexPattern, "-"); } diff --git a/src/GitVersion.Core/Formatting/LabelToken.cs b/src/GitVersion.Core/Formatting/LabelToken.cs new file mode 100644 index 0000000000..1c2fac7b24 --- /dev/null +++ b/src/GitVersion.Core/Formatting/LabelToken.cs @@ -0,0 +1,3 @@ +namespace GitVersion.Formatting; + +internal record LabelToken(string Name, LabelTokenType Type, string? Format = null); 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..049f586e87 --- /dev/null +++ b/src/GitVersion.Core/Formatting/LabelTokenizer.cs @@ -0,0 +1,179 @@ +namespace GitVersion.Formatting; + +internal class LabelTokenizer(string input) +{ + private int index; + + public IEnumerable ParseTokens() + { + var tokens = new List(); + var separatedParsed = false; + + SkipWhitespace(); + + while (this.index < input.Length) + { + var identifier = ParseIdentifier(); + + SkipWhitespace(); + + if (string.IsNullOrEmpty(identifier)) + { + throw new FormatException("Invalid format sequence, expected identifier"); + } + + tokens.Add(ParseToken(identifier)); + + separatedParsed = ParseSeparator(); + + SkipWhitespace(); + } + + if (separatedParsed) + { + throw new FormatException("Invalid format sequence, expected identifier after '??' separator"); + } + + return tokens; + } + + private static LabelToken ParseToken(string identifier) + { + if (identifier.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + { + var name = identifier[4..]; + var (environmentName, environmentFormat) = ParseKeyAndFormat(name); + + return new LabelToken(environmentName, LabelTokenType.Environment, environmentFormat); + } + + if (identifier.StartsWith('"') && identifier.EndsWith('"')) + { + 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); + } + + private static (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 (this.index < input.Length) + { + var c = input[this.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) || c == '?')) + { + return value.ToString(); + } + + if (IsEscapeQuote()) + { + value.Append('"'); + this.index += 2; + } + else + { + value.Append(c); + this.index++; + } + } + + if (inQuotes) + { + throw new FormatException("Literal value is missing closing quote"); + } + + return value.ToString(); + } + + private bool ParseSeparator() + { + var seen = 0; + + while (this.index < input.Length && seen < 2) + { + if (input[this.index] != '?') + { + throw new FormatException("Expected '??' separator"); + } + + seen++; + this.index++; + } + + return seen == 2; + } + + private void SkipWhitespace() + { + while (this.index < input.Length) + { + if (!char.IsWhiteSpace(input[this.index])) + { + break; + } + + this.index++; + } + } + + private bool ParseQuote() + { + if (IsQuote()) + { + this.index++; + + return true; + } + + return false; + } + + private bool IsQuote() => this.index < input.Length && input[this.index] == '"'; + + 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 df34cb3f2e..90a178b3a3 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -1,5 +1,3 @@ -using System.Text.RegularExpressions; - namespace GitVersion.Formatting; internal static class StringFormatWithExtension @@ -40,7 +38,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); } /// @@ -68,89 +66,90 @@ 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; - - 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, Func memberEvaluator, IEnvironment environment) { - var fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null; + ArgumentNullException.ThrowIfNull(input); + + var tokenizer = new LabelTokenizer(input); + var tokens = tokenizer.ParseTokens().ToArray(); - if (match.Groups["envvar"].Success) - return EvaluateEnvVar(match.Groups["envvar"].Value, fallback, environment); + Exception? lastException = null; - if (match.Groups["member"].Success) + foreach (var token in tokens) { - var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; - return memberEvaluator(match.Groups["member"].Value, format, fallback); + if (token.Type == LabelTokenType.Literal) + { + return token.Name; + } + + try + { + var value = token.Type == LabelTokenType.Environment + ? EvaluateEnvVar(token.Name, environment) + : memberEvaluator(token.Name); + + if (value is null) + { + continue; + } + + if (!string.IsNullOrEmpty(token.Format) && ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(token.Format), out var formatted)) + { + return formatted; + } + + return value; + } + catch (Exception e) + { + lastException = e; + } } - throw new ArgumentException($"Invalid token format: '{match.Value}'"); - } + if (lastException != null) + { + throw lastException; + } - 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"); + return string.Empty; } - private static string EvaluateMemberFromObject(object source, string member, string? format, string? fallback) + private static string? EvaluateMemberFromObject(object source, string member) { 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; - if (format is not null && ValueFormatter.Default.TryFormat( - value, - InputSanitizer.SanitizeFormat(format), - out var formatted)) - { - return formatted; - } + var value = getter(source); - 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) { var safeMember = InputSanitizer.SanitizeMemberName(member); if (!source.TryGetValue(safeMember, out var value)) - return fallback ?? string.Empty; + throw new ArgumentException($"'{safeMember}' is not a valid placeholder"); - if (value is null) - return fallback ?? string.Empty; + return value?.ToString(); + } - if (format is not null && ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(format), out var formatted)) - return formatted; + private static string EvaluateEnvVar(string name, IEnvironment environment) + { + var safeName = InputSanitizer.SanitizeEnvVarName(name); - return value.ToString() ?? fallback ?? string.Empty; + return environment.GetEnvironmentVariable(name) ?? throw new ArgumentException($"Environment variable {safeName} not found and no fallback provided"); } - - private delegate string EvaluateMemberDelegate(string member, string? format, string? fallback); } 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}"); }