Skip to content

Commit a9addbc

Browse files
feat: add improved parsing for labels and support for multiple fallbacks (#4977)
* feat: add improved regex parsing for labels * feat: parse chain of tokens for labels * feat: add label tokenizer and tests * chore: remove unused format type * fix: address sonarqube issues with label parsing * fix: address test sonarqube issues * fix: fix failing test * fix: update failing test * feat: add documentation for advanced parsing * fix: change label token to be record * docs: add breaking change docs for env and placeholder behavior * fix: tighten parsing of label formats * docs: improve fallback documentation * fix: throw on missing properties and use integer fallbacks * fix: fix sonarqube issues * fix: revert global.json changes
1 parent 0a4a0f8 commit a9addbc

11 files changed

Lines changed: 441 additions & 108 deletions

File tree

docs/input/docs/reference/mdsource/configuration.source.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ of `alpha.foo` with `label: 'alpha.{BranchName}'` and `regex: '^features?[\/-](?
504504

505505
Another example: branch `features/sc-12345/some-description` would become a pre-release label of `sc-12345` with `label: '{StoryNo}'` and `regex: '^features?[\/-](?<StoryNo>sc-\d+)[-/].+'`.
506506

507-
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.
507+
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"}`.
508508

509509
**Note:** To clear a default use an empty string: `label: ''`
510510

src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ public void EnsureGetBranchSpecificLabelWorksWithoutEnvironmentWhenNoEnvPlacehol
127127
}
128128

129129
[Test]
130-
public void EnsureGetBranchSpecificLabelThrowsWhenThrowIfNotFoundAndEnvVarMissing()
130+
public void EnsureGetBranchSpecificLabelThrowsWhenEnvVarMissing()
131131
{
132132
var environment = new TestEnvironment();
133133
// Do not set MISSING_VAR

src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs

Lines changed: 122 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ public void FormatWithSingleSimpleToken()
3333
[Test]
3434
public void FormatWithMultipleTokensAndVerbatimText()
3535
{
36-
var propertyObject = new { SomeProperty = "SomeValue", AnotherProperty = "Other Value" };
36+
var propertyObject = new { SomeProperty = "AValue", AnotherProperty = "Other Value" };
3737
const string target = "{SomeProperty} some text {AnotherProperty}";
38-
const string expected = "SomeValue some text Other Value";
38+
const string expected = "AValue some text Other Value";
3939
var actual = target.FormatWith(propertyObject, this.environment);
4040
Assert.That(actual, Is.EqualTo(expected));
4141
}
@@ -56,7 +56,7 @@ public void FormatWithEnvVarTokenWithFallback()
5656
{
5757
this.environment.SetEnvironmentVariable("GIT_VERSION_TEST_VAR", "Env Var Value");
5858
var propertyObject = new { };
59-
const string target = "{env:GIT_VERSION_TEST_VAR ?? fallback}";
59+
const string target = "{env:GIT_VERSION_TEST_VAR ?? \"fallback\"}";
6060
const string expected = "Env Var Value";
6161
var actual = target.FormatWith(propertyObject, this.environment);
6262
Assert.That(actual, Is.EqualTo(expected));
@@ -67,7 +67,7 @@ public void FormatWithUnsetEnvVarToken_WithFallback()
6767
{
6868
this.environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null);
6969
var propertyObject = new { };
70-
const string target = "{env:GIT_VERSION_UNSET_TEST_VAR ?? fallback}";
70+
const string target = "{env:GIT_VERSION_UNSET_TEST_VAR ?? \"fallback\"}";
7171
const string expected = "fallback";
7272
var actual = target.FormatWith(propertyObject, this.environment);
7373
Assert.That(actual, Is.EqualTo(expected));
@@ -98,44 +98,139 @@ public void FormatWithMultipleEnvVars()
9898
public void FormatWithMultipleEnvChars()
9999
{
100100
var propertyObject = new { };
101-
//Test the greediness of the regex in matching env: char
102-
const string target = "{env:env:GIT_VERSION_TEST_VAR_1} and {env:DUMMY_VAR ?? fallback}";
103-
const string expected = "{env:env:GIT_VERSION_TEST_VAR_1} and fallback";
104-
var actual = target.FormatWith(propertyObject, this.environment);
105-
Assert.That(actual, Is.EqualTo(expected));
101+
const string target = "{env:env:GIT_VERSION_TEST_VAR_1} and {env:DUMMY_VAR ?? \"fallback\"}";
102+
Assert.Throws<ArgumentException>(() => target.FormatWith(propertyObject, this.environment));
106103
}
107104

108105
[Test]
109106
public void FormatWithMultipleFallbackChars()
110107
{
111108
var propertyObject = new { };
112-
//Test the greediness of the regex in matching env: and ?? chars
113-
const string target = "{env:env:GIT_VERSION_TEST_VAR_1} and {env:DUMMY_VAR ??? fallback}";
114-
var actual = target.FormatWith(propertyObject, this.environment);
115-
Assert.That(actual, Is.EqualTo(target));
109+
const string target = " and {env:DUMMY_VAR ??? \"fallback\"}";
110+
Assert.Throws<FormatException>(() => target.FormatWith(propertyObject, this.environment));
116111
}
117112

118113
[Test]
119114
public void FormatWithSingleFallbackChar()
120115
{
121-
this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Val");
116+
this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal");
122117
var propertyObject = new { };
123-
//Test the sanity of the regex when there is a grammar mismatch
124-
const string target = "{en:DUMMY_ENV_VAR} and {env:DUMMY_ENV_VAR??fallback}";
125-
var actual = target.FormatWith(propertyObject, this.environment);
126-
Assert.That(actual, Is.EqualTo(target));
118+
const string target = "{en:DUMMY_ENV_VAR} and {env:DUMMY_ENV_VAR??\"fallback\"}";
119+
Assert.Throws<ArgumentException>(() => target.FormatWith(propertyObject, this.environment));
127120
}
128121

129122
[Test]
130-
public void FormatWIthNullPropagationWithMultipleSpaces()
123+
public void FormatWithNullPropagationWithMultipleSpaces()
131124
{
132125
var propertyObject = new { SomeProperty = "Some Value" };
133-
const string target = "{SomeProperty} and {env:DUMMY_ENV_VAR ?? fallback}";
126+
const string target = "{SomeProperty} and {env:DUMMY_ENV_VAR ?? \"fallback\"}";
134127
const string expected = "Some Value and fallback";
135128
var actual = target.FormatWith(propertyObject, this.environment);
136129
Assert.That(actual, Is.EqualTo(expected));
137130
}
138131

132+
[Test]
133+
public void FormatWithMissingPropertyAndEnvFallback()
134+
{
135+
this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "Dummy-Value");
136+
var propertyObject = new { };
137+
const string target = "{SomeProperty ?? env:DUMMY_ENV_VAR}";
138+
const string expected = "Dummy-Value";
139+
var actual = target.FormatWith(propertyObject, this.environment);
140+
Assert.That(actual, Is.EqualTo(expected));
141+
}
142+
143+
[Test]
144+
public void FormatWithMissingEnvAndPropertyFallback()
145+
{
146+
var propertyObject = new { SomeProperty = "Some Value" };
147+
const string target = "{env:DUMMY_ENV_VAR ?? SomeProperty}";
148+
const string expected = "Some Value";
149+
var actual = target.FormatWith(propertyObject, this.environment);
150+
Assert.That(actual, Is.EqualTo(expected));
151+
}
152+
153+
[Test]
154+
public void FormatWithMultiplePropertiesAndNoFallback()
155+
{
156+
var propertyObject = new { };
157+
const string target = "{SomeProperty ?? SomeOtherProperty ?? MissingProp}";
158+
Assert.Throws<ArgumentException>(() => target.FormatWith(propertyObject, this.environment));
159+
}
160+
161+
[Test]
162+
public void FormatWithMultiplePropertiesAndQuotedFallback()
163+
{
164+
var propertyObject = new { };
165+
const string target = "{SomeProperty ?? SomeOtherProperty ?? \"fallback\"}";
166+
const string expected = "fallback";
167+
var actual = target.FormatWith(propertyObject, this.environment);
168+
Assert.That(actual, Is.EqualTo(expected));
169+
}
170+
171+
[Test]
172+
public void FormatWithMultipleAvailablePropertiesAndFallback()
173+
{
174+
var propertyObject = new { SomeOtherProperty = "Some-Value" };
175+
const string target = "{SomeProperty ?? SomeOtherProperty ?? \"fallback\"}";
176+
const string expected = "Some-Value";
177+
var actual = target.FormatWith(propertyObject, this.environment);
178+
Assert.That(actual, Is.EqualTo(expected));
179+
}
180+
181+
[Test]
182+
public void FormatWithMissingPropertiesAndIntegerFallback()
183+
{
184+
var propertyObject = new { };
185+
const string target = "{SomeProperty ?? SomeOtherProperty ?? 47}";
186+
const string expected = "47";
187+
var actual = target.FormatWith(propertyObject, this.environment);
188+
Assert.That(actual, Is.EqualTo(expected));
189+
}
190+
191+
[Test]
192+
public void FormatWithPropertyAndEnvAndFormatters()
193+
{
194+
this.environment.SetEnvironmentVariable("DUMMY_ENV_VAR", "DummyVal");
195+
var propertyObject = new { SomeProperty = "TheValue" };
196+
const string target = "{SomeProperty:l} and {env:DUMMY_ENV_VAR:l}";
197+
const string expected = "thevalue and dummyval";
198+
var actual = target.FormatWith(propertyObject, this.environment);
199+
Assert.That(actual, Is.EqualTo(expected));
200+
}
201+
202+
[TestCase("{env:VARIABLE ?? \"\"}", null, null, "")]
203+
[TestCase("{env:MISSING ?? env:VARIABLE}", "Var", null, "Var")]
204+
[TestCase("{Property ?? \"\"}", null, null, "")]
205+
[TestCase("{Property ?? 47}", null, null, "47")]
206+
[TestCase("{Property ?? env:VARIABLE}", null, "Branch", "Branch")]
207+
[TestCase("{Property ?? env:VARIABLE ?? \"\"}", null, null, "")]
208+
[TestCase("{Property ?? env:VARIABLE ?? 42}", null, null, "42")]
209+
public void FormatWith_EnvVarAndPropertyAndFallback_DoesNotThrow(string input, string? envVar, string? property, string expected)
210+
{
211+
if (envVar != null)
212+
{
213+
this.environment.SetEnvironmentVariable("VARIABLE", envVar);
214+
}
215+
216+
object propertyObject = property != null
217+
? new { Property = property }
218+
: new { };
219+
220+
var actual = input.FormatWith(propertyObject, this.environment);
221+
Assert.That(actual, Is.EqualTo(expected));
222+
}
223+
224+
[TestCase("{env:VARIABLE}")]
225+
[TestCase("{Property}")]
226+
[TestCase("{Property ?? env:VARIABLE}")]
227+
[TestCase("{Property ?? Property}")]
228+
public void FormatWith_MissingEnvVarOrPropertyAndNoFallback_Throws(string input)
229+
{
230+
object propertyObject = new { };
231+
Assert.Throws<ArgumentException>(() => input.FormatWith(propertyObject, this.environment));
232+
}
233+
139234
[Test]
140235
public void FormatEnvVar_WithFallback_QuotedAndEmpty()
141236
{
@@ -186,7 +281,7 @@ public void FormatProperty_NullInteger()
186281
public void FormatProperty_String_WithFallback()
187282
{
188283
var propertyObject = new { Property = "Value" };
189-
const string target = "{Property ?? fallback}";
284+
const string target = "{Property ?? \"fallback\"}";
190285
var actual = target.FormatWith(propertyObject, this.environment);
191286
Assert.That(actual, Is.EqualTo("Value"));
192287
}
@@ -195,7 +290,7 @@ public void FormatProperty_String_WithFallback()
195290
public void FormatProperty_Integer_WithFallback()
196291
{
197292
var propertyObject = new { Property = 42 };
198-
const string target = "{Property ?? fallback}";
293+
const string target = "{Property ?? \"fallback\"}";
199294
var actual = target.FormatWith(propertyObject, this.environment);
200295
Assert.That(actual, Is.EqualTo("42"));
201296
}
@@ -204,7 +299,7 @@ public void FormatProperty_Integer_WithFallback()
204299
public void FormatProperty_NullObject_WithFallback()
205300
{
206301
var propertyObject = new { Property = (object?)null };
207-
const string target = "{Property ?? fallback}";
302+
const string target = "{Property ?? \"fallback\"}";
208303
var actual = target.FormatWith(propertyObject, this.environment);
209304
Assert.That(actual, Is.EqualTo("fallback"));
210305
}
@@ -213,18 +308,18 @@ public void FormatProperty_NullObject_WithFallback()
213308
public void FormatProperty_NullInteger_WithFallback()
214309
{
215310
var propertyObject = new { Property = (int?)null };
216-
const string target = "{Property ?? fallback}";
311+
const string target = "{Property ?? 43}";
217312
var actual = target.FormatWith(propertyObject, this.environment);
218-
Assert.That(actual, Is.EqualTo("fallback"));
313+
Assert.That(actual, Is.EqualTo("43"));
219314
}
220315

221316
[Test]
222317
public void FormatProperty_NullObject_WithFallback_Quoted()
223318
{
224319
var propertyObject = new { Property = (object?)null };
225-
const string target = "{Property ?? \"fallback\"}";
320+
const string target = "{Property ?? \"literal\"}";
226321
var actual = target.FormatWith(propertyObject, this.environment);
227-
Assert.That(actual, Is.EqualTo("fallback"));
322+
Assert.That(actual, Is.EqualTo("literal"));
228323
}
229324

230325
[Test]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using GitVersion.Formatting;
2+
3+
namespace GitVersion.Tests.Formatting;
4+
5+
[TestFixture]
6+
public class LabelTokenizerTests
7+
{
8+
[TestCase("Pattern", "Pattern")]
9+
[TestCase("Pattern ", "Pattern")]
10+
[TestCase(" Pattern", "Pattern")]
11+
[TestCase(" Pattern ", "Pattern")]
12+
[TestCase("Pat\\\"tern", "Pat\"tern")]
13+
[TestCase("\"Pattern\"", "Pattern")]
14+
[TestCase("\"Pat?tern\"", "Pat?tern")]
15+
[TestCase("\"Pat tern\"", "Pat tern")]
16+
[TestCase("\" Pattern\"", " Pattern")]
17+
[TestCase("\"Pattern \"", "Pattern ")]
18+
[TestCase("\"Pat\\\"tern\"", "Pat\"tern")]
19+
public void ParseTokens_ValidLiterals_ReturnsValid(string input, params string[] expected) => AssertTokens(input, expected);
20+
21+
[TestCase("Pat?tern")]
22+
[TestCase("\"Pattern")]
23+
[TestCase("Pattern\"")]
24+
[TestCase("Pat\"tern")]
25+
public void ParseTokens_InvalidLiterals_Throws(string input) => AssertThrows(input);
26+
27+
[TestCase("Prop1 ?? Prop2", "Prop1", "Prop2")]
28+
[TestCase("Prop1??Prop2", "Prop1", "Prop2")]
29+
[TestCase("Prop1??Prop2??42", "Prop1", "Prop2", "42")]
30+
[TestCase("Prop1??Prop2??\"42\"", "Prop1", "Prop2", "42")]
31+
[TestCase("Prop1 ?? Prop2 ?? \"fallback\"", "Prop1", "Prop2", "fallback")]
32+
[TestCase("Prop1 ??Prop2?? \"fallback\"", "Prop1", "Prop2", "fallback")]
33+
[TestCase("Prop1 ?? Prop2 ?? 42", "Prop1", "Prop2", "42")]
34+
[TestCase("Prop1:format ?? Prop2 ?? \"fallback\"", "Prop1", "Prop2", "fallback")]
35+
[TestCase("env:Env1 ?? Prop2 ?? \"fallback\"", "Env1", "Prop2", "fallback")]
36+
[TestCase("env:Env1:format ?? \"literal\" ?? \"fallback\"", "Env1", "literal", "fallback")]
37+
[TestCase("env:Env1 ?? 42", "Env1", "42")]
38+
public void ParseTokens_ValidIdentifiers_ReturnsValid(string input, params string[] expected) => AssertTokens(input, expected);
39+
40+
[TestCase("Prop ??? literal")]
41+
[TestCase("Prop literal")]
42+
[TestCase("Prop ?? literal ?? ? fallback")]
43+
[TestCase("Prop ? fallback")]
44+
[TestCase("Prop ?? fall?back")]
45+
[TestCase("Prop ?? fallback ??")]
46+
[TestCase("Prop ?? fallback ?? ")]
47+
public void ParseTokens_MalformedIdentifiers_Throws(string input) => AssertThrows(input);
48+
49+
private static void AssertTokens(string input, string[] expected)
50+
{
51+
var tokenizer = new LabelTokenizer(input);
52+
var tokens = tokenizer.ParseTokens()
53+
.Select(x => x.Name)
54+
.ToArray();
55+
56+
tokens.ShouldBeEquivalentTo(expected);
57+
}
58+
59+
private static void AssertThrows(string input)
60+
{
61+
var tokenizer = new LabelTokenizer(input);
62+
63+
Assert.Throws<FormatException>(() => tokenizer.ParseTokens());
64+
}
65+
}

src/GitVersion.Core/Core/RegexPatterns.cs

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,7 @@ internal static partial class RegexPatterns
1717
private const string ObscurePasswordRegexPattern = "(https?://)(.+)(:.+@)";
1818

1919
[StringSyntax(StringSyntaxAttribute.Regex)]
20-
private const string ExpandTokensRegexPattern =
21-
"""
22-
\{ # Opening brace
23-
(?: # Start of either env or member expression
24-
env:(?!env:)(?<envvar>[A-Za-z_][A-Za-z0-9_]*) # Only a single env: prefix, not followed by another env:
25-
| # OR
26-
(?<member>[A-Za-z_][A-Za-z0-9_]*) # member/property name
27-
(?: # Optional format specifier
28-
:(?<format>[A-Za-z0-9\.\-,]+) # Colon followed by format string (no spaces, ?, or }), format cannot contain colon
29-
)? # Format is optional
30-
) # End group for env or member
31-
(?: # Optional fallback group
32-
\s*\?\?\s+ # '??' operator with optional whitespace: exactly two question marks for fallback
33-
(?: # Fallback value alternatives:
34-
(?<fallback>\w+) # A single word fallback
35-
| # OR
36-
"(?<fallback>[^"]*)" # A quoted string fallback
37-
)
38-
)? # Fallback is optional
39-
\}
40-
""";
20+
private const string ExpandTokensRegexPattern = @"\{([^{}]+)\}";
4121

4222
/// <summary>
4323
/// Allow alphanumeric, underscore, colon (for custom format specification), hyphen, and dot

src/GitVersion.Core/Extensions/ConfigurationExtensions.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,13 @@ private static Dictionary<string, object> BuildLabelPlaceholders(string? regular
154154
if (!match.Success)
155155
return placeholders;
156156

157-
foreach (var groupName in regex.GetGroupNames())
157+
var namedGroups = regex.GetGroupNames()
158+
.Where(name => !int.TryParse(name, out _));
159+
160+
foreach (var groupName in namedGroups)
158161
{
159162
var groupValue = match.Groups[groupName].Value;
163+
160164
placeholders[groupName] = groupValue.RegexReplace(RegexPatterns.SanitizeNameRegexPattern, "-");
161165
}
162166

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace GitVersion.Formatting;
2+
3+
internal record LabelToken(string Name, LabelTokenType Type, string? Format = null);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace GitVersion.Formatting;
2+
3+
internal enum LabelTokenType
4+
{
5+
Literal,
6+
Property,
7+
Environment
8+
}

0 commit comments

Comments
 (0)