Skip to content

Commit efb857e

Browse files
committed
feat: accept a fallback value in all formatted expressions
1 parent ff34946 commit efb857e

File tree

3 files changed

+178
-87
lines changed

3 files changed

+178
-87
lines changed

docs/input/docs/configuration.md

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -87,33 +87,34 @@ skip updating the `AssemblyFileVersion` while still updating the
8787

8888
### assembly-file-versioning-format
8989

90-
Set this to any of the available [variables](./more-info/variables) in
91-
combination (but not necessary) with a process scoped environment variable. It
92-
overwrites the value of `assembly-file-versioning-scheme`. To reference an
93-
environment variable, use `env:` Example Syntax #1:
90+
Specifies the format of `AssemblyFileVersion` and
91+
overwrites the value of `assembly-file-versioning-scheme`.
9492

95-
`'{Major}.{Minor}.{Patch}.{env:JENKINS_BUILD_NUMBER ?? fallback_string}'`.
93+
Expressions in curly braces reference one of the [variables](./more-info/variables)
94+
or a process-scoped environment variable (when prefixed with `env:`). For example,
9695

97-
Uses `JENKINS_BUILD_NUMBER` if available in the environment otherwise the
98-
`fallback_string` Example Syntax #2:
96+
```yaml
97+
# use a variable if non-null or a fallback value otherwise
98+
assembly-file-versioning-format: '{Major}.{Minor}.{Patch}.{WeightedPreReleaseNumber ?? 0}'
9999
100-
`'{Major}.{Minor}.{Patch}.{env:JENKINS_BUILD_NUMBER}'`.
100+
# use an environment variable or raise an error if not available
101+
assembly-file-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_NUMBER}'
101102
102-
Uses `JENKINS_BUILD_NUMBER` if available in the environment otherwise the
103-
parsing fails. String interpolation is supported as in
104-
`assembly-informational-format`
103+
# use an environment variable if available or a fallback value otherwise
104+
assembly-file-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_NUMBER ?? 42}'
105+
```
105106

106107
### assembly-versioning-format
107108

108-
Follows the same semantics as `assembly-file-versioning-format` and overwrites
109-
the value of `assembly-versioning-scheme`.
109+
Specifies the format of `AssemblyVersion` and
110+
overwrites the value of `assembly-versioning-scheme`.
111+
Follows the same formatting semantics as `assembly-file-versioning-format`.
110112

111113
### assembly-informational-format
112114

113-
Set this to any of the available [variables](./more-info/variables) to change the
114-
value of the `AssemblyInformationalVersion` attribute. Default set to
115-
`{InformationalVersion}`. It also supports string interpolation
116-
(`{MajorMinorPatch}+{BranchName}`)
115+
Specifies the format of `AssemblyInformationalVersion`.
116+
Follows the same formatting semantics as `assembly-file-versioning-format`.
117+
The default value is `{InformationalVersion}`.
117118

118119
### mode
119120

src/GitVersionCore.Tests/StringFormatWithExtensionTests.cs

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using GitVersion;
23
using GitVersion.Helpers;
34
using GitVersionCore.Tests.Helpers;
@@ -6,7 +7,6 @@
67
namespace GitVersionCore.Tests
78
{
89
[TestFixture]
9-
1010
public class StringFormatWithExtensionTests
1111
{
1212
private IEnvironment environment;
@@ -70,7 +70,7 @@ public void FormatWithEnvVarTokenWithFallback()
7070
}
7171

7272
[Test]
73-
public void FormatWithUnsetEnvVarTokenWithFallback()
73+
public void FormatWithUnsetEnvVarToken_WithFallback()
7474
{
7575
environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null);
7676
var propertyObject = new { };
@@ -80,6 +80,15 @@ public void FormatWithUnsetEnvVarTokenWithFallback()
8080
Assert.AreEqual(expected, actual);
8181
}
8282

83+
[Test]
84+
public void FormatWithUnsetEnvVarToken_WithoutFallback()
85+
{
86+
environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null);
87+
var propertyObject = new { };
88+
var target = "{env:GIT_VERSION_UNSET_TEST_VAR}";
89+
Assert.Throws<ArgumentException>(() => target.FormatWith(propertyObject, environment));
90+
}
91+
8392
[Test]
8493
public void FormatWithMultipleEnvVars()
8594
{
@@ -133,5 +142,114 @@ public void FormatWIthNullPropagationWithMultipleSpaces()
133142
var actual = target.FormatWith(propertyObject, environment);
134143
Assert.AreEqual(expected, actual);
135144
}
145+
146+
[Test]
147+
public void FormatEnvVar_WithFallback_QuotedAndEmpty()
148+
{
149+
environment.SetEnvironmentVariable("ENV_VAR", null);
150+
var propertyObject = new { };
151+
var target = "{env:ENV_VAR ?? \"\"}";
152+
var actual = target.FormatWith(propertyObject, environment);
153+
Assert.That(actual, Is.EqualTo(""));
154+
}
155+
156+
[Test]
157+
public void FormatProperty_String()
158+
{
159+
var propertyObject = new { Property = "Value" };
160+
var target = "{Property}";
161+
var actual = target.FormatWith(propertyObject, environment);
162+
Assert.That(actual, Is.EqualTo("Value"));
163+
}
164+
165+
[Test]
166+
public void FormatProperty_Integer()
167+
{
168+
var propertyObject = new { Property = 42 };
169+
var target = "{Property}";
170+
var actual = target.FormatWith(propertyObject, environment);
171+
Assert.That(actual, Is.EqualTo("42"));
172+
}
173+
174+
[Test]
175+
public void FormatProperty_NullObject()
176+
{
177+
var propertyObject = new { Property = (object)null };
178+
var target = "{Property}";
179+
var actual = target.FormatWith(propertyObject, environment);
180+
Assert.That(actual, Is.EqualTo(""));
181+
}
182+
183+
[Test]
184+
public void FormatProperty_NullInteger()
185+
{
186+
var propertyObject = new { Property = (int?)null };
187+
var target = "{Property}";
188+
var actual = target.FormatWith(propertyObject, environment);
189+
Assert.That(actual, Is.EqualTo(""));
190+
}
191+
192+
[Test]
193+
public void FormatProperty_String_WithFallback()
194+
{
195+
var propertyObject = new { Property = "Value" };
196+
var target = "{Property ?? fallback}";
197+
var actual = target.FormatWith(propertyObject, environment);
198+
Assert.That(actual, Is.EqualTo("Value"));
199+
}
200+
201+
[Test]
202+
public void FormatProperty_Integer_WithFallback()
203+
{
204+
var propertyObject = new { Property = 42 };
205+
var target = "{Property ?? fallback}";
206+
var actual = target.FormatWith(propertyObject, environment);
207+
Assert.That(actual, Is.EqualTo("42"));
208+
}
209+
210+
[Test]
211+
public void FormatProperty_NullObject_WithFallback()
212+
{
213+
var propertyObject = new { Property = (object)null };
214+
var target = "{Property ?? fallback}";
215+
var actual = target.FormatWith(propertyObject, environment);
216+
Assert.That(actual, Is.EqualTo("fallback"));
217+
}
218+
219+
[Test]
220+
public void FormatProperty_NullInteger_WithFallback()
221+
{
222+
var propertyObject = new { Property = (int?)null };
223+
var target = "{Property ?? fallback}";
224+
var actual = target.FormatWith(propertyObject, environment);
225+
Assert.That(actual, Is.EqualTo("fallback"));
226+
}
227+
228+
[Test]
229+
public void FormatProperty_NullObject_WithFallback_Quoted()
230+
{
231+
var propertyObject = new { Property = (object)null };
232+
var target = "{Property ?? \"fallback\"}";
233+
var actual = target.FormatWith(propertyObject, environment);
234+
Assert.That(actual, Is.EqualTo("fallback"));
235+
}
236+
237+
[Test]
238+
public void FormatProperty_NullObject_WithFallback_QuotedAndPadded()
239+
{
240+
var propertyObject = new { Property = (object)null };
241+
var target = "{Property ?? \" fallback \"}";
242+
var actual = target.FormatWith(propertyObject, environment);
243+
Assert.That(actual, Is.EqualTo(" fallback "));
244+
}
245+
246+
[Test]
247+
public void FormatProperty_NullObject_WithFallback_QuotedAndEmpty()
248+
{
249+
var propertyObject = new { Property = (object)null };
250+
var target = "{Property ?? \"\"}";
251+
var actual = target.FormatWith(propertyObject, environment);
252+
Assert.That(actual, Is.EqualTo(""));
253+
}
136254
}
137255
}
Lines changed: 40 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,80 @@
11
using System;
22
using System.Linq;
33
using System.Linq.Expressions;
4-
using System.Reflection;
54
using System.Text.RegularExpressions;
65

76
namespace GitVersion.Helpers
87
{
98
internal static class StringFormatWithExtension
109
{
11-
private static readonly Regex TokensRegex = new Regex(@"{(?<env>env:)??\w+(\s+(\?\?)??\s+\w+)??}", RegexOptions.Compiled);
10+
// This regex matches an expression to replace.
11+
// - env:ENV name OR a member name
12+
// - optional fallback value after " ?? "
13+
// - the fallback value should be a quoted string, but simple unquoted text is allowed for back compat
14+
private static readonly Regex TokensRegex = new Regex(@"{((env:(?<envvar>\w+))|(?<member>\w+))(\s+(\?\?)??\s+((?<fallback>\w+)|""(?<fallback>.*)""))??}", RegexOptions.Compiled);
1215

1316
/// <summary>
14-
/// Formats a string template with the given source object.
15-
/// Expression like {Id} are replaced with the corresponding
16-
/// property value in the <paramref name="source" />.
17-
/// Supports property access expressions.
18-
/// </summary>
19-
/// <param name="template" this="true">The template to be replaced with values from the source object. The template can contain expressions wrapped in curly braces, that point to properties or fields on the source object to be used as a substitute, e.g '{Foo.Bar.CurrencySymbol} foo {Foo.Bar.Price}'.</param>
20-
/// <param name="source">The source object to apply to format</param>
17+
/// Formats the <paramref name="template"/>, replacing each expression wrapped in curly braces
18+
/// with the corresponding property from the <paramref name="source"/> or <paramref name="environment"/>.
19+
/// </summary>
20+
/// <param name="template" this="true">The source template, which may contain expressions to be replaced, e.g '{Foo.Bar.CurrencySymbol} foo {Foo.Bar.Price}'</param>
21+
/// <param name="source">The source object to apply to the <paramref name="template"/></param>
2122
/// <param name="environment"></param>
23+
/// <exception cref="ArgumentNullException">The <paramref name="template"/> is null.</exception>
24+
/// <exception cref="ArgumentException">An environment variable was null and no fallback was provided.</exception>
25+
/// <remarks>
26+
/// An expression containing "." is treated as a property or field access on the <paramref name="source"/>.
27+
/// An expression starting with "env:" is replaced with the value of the corresponding variable from the <paramref name="environment"/>.
28+
/// Each expression may specify a single hardcoded fallback value using the {Prop ?? "fallback"} syntax, which applies if the expression evaluates to null.
29+
/// </remarks>
30+
/// <example>
31+
/// // replace an expression with a property value
32+
/// "Hello {Name}".FormatWith(new { Name = "Fred" }, env);
33+
/// "Hello {Name ?? \"Fred\"}".FormatWith(new { Name = GetNameOrNull() }, env);
34+
/// // replace an expression with an environment variable
35+
/// "{env:BUILD_NUMBER}".FormatWith(new { }, env);
36+
/// "{env:BUILD_NUMBER ?? \"0\"}".FormatWith(new { }, env);
37+
/// </example>
2238
public static string FormatWith<T>(this string template, T source, IEnvironment environment)
2339
{
2440
if (template == null)
2541
{
2642
throw new ArgumentNullException(nameof(template));
2743
}
2844

29-
// {MajorMinorPatch}+{Branch}
30-
var objType = source.GetType();
3145
foreach (Match match in TokensRegex.Matches(template))
3246
{
33-
var memberAccessExpression = TrimBraces(match.Value);
3447
string propertyValue;
48+
string fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null;
3549

36-
// Support evaluation of environment variables in the format string
37-
// For example: {env:JENKINS_BUILD_NUMBER ?? fall-back-string}
38-
39-
if (match.Groups["env"].Success)
50+
if (match.Groups["envvar"].Success)
4051
{
41-
memberAccessExpression = memberAccessExpression.Substring(memberAccessExpression.IndexOf(':') + 1);
42-
string envVar = memberAccessExpression, fallback = null;
43-
var components = (memberAccessExpression.Contains("??")) ? memberAccessExpression.Split(new[] { "??" }, StringSplitOptions.None) : null;
44-
if (components != null)
45-
{
46-
envVar = components[0].Trim();
47-
fallback = components[1].Trim();
48-
}
49-
50-
propertyValue = environment.GetEnvironmentVariable(envVar);
51-
if (propertyValue == null)
52-
{
53-
if (fallback != null)
54-
propertyValue = fallback;
55-
else
56-
throw new ArgumentException($"Environment variable {envVar} not found and no fallback string provided");
57-
}
52+
string envVar = match.Groups["envvar"].Value;
53+
propertyValue = environment.GetEnvironmentVariable(envVar) ?? fallback
54+
?? throw new ArgumentException($"Environment variable {envVar} not found and no fallback string provided");
5855
}
5956
else
6057
{
58+
var objType = source.GetType();
59+
string memberAccessExpression = match.Groups["member"].Value;
6160
var expression = CompileDataBinder(objType, memberAccessExpression);
62-
propertyValue = expression(source);
61+
// It would be better to throw if the expression and fallback produce null, but provide an empty string for back compat.
62+
propertyValue = expression(source)?.ToString() ?? fallback ?? "";
6363
}
64+
6465
template = template.Replace(match.Value, propertyValue);
6566
}
6667

6768
return template;
6869
}
6970

70-
71-
private static string TrimBraces(string originalExpression)
72-
{
73-
if (!string.IsNullOrWhiteSpace(originalExpression))
74-
{
75-
return originalExpression.TrimStart('{').TrimEnd('}');
76-
}
77-
return originalExpression;
78-
}
79-
80-
private static Func<object, string> CompileDataBinder(Type type, string expr)
71+
private static Func<object, object> CompileDataBinder(Type type, string expr)
8172
{
82-
var param = Expression.Parameter(typeof(object));
73+
ParameterExpression param = Expression.Parameter(typeof(object));
8374
Expression body = Expression.Convert(param, type);
84-
var members = expr.Split('.');
85-
body = members.Aggregate(body, Expression.PropertyOrField);
86-
87-
var staticOrPublic = BindingFlags.Static | BindingFlags.Public;
88-
var method = GetMethodInfo("ToString", staticOrPublic, new[] { body.Type });
89-
if (method == null)
90-
{
91-
method = GetMethodInfo("ToString", staticOrPublic, new[] { typeof(object) });
92-
body = Expression.Call(method, Expression.Convert(body, typeof(object)));
93-
}
94-
else
95-
{
96-
body = Expression.Call(method, body);
97-
}
98-
99-
return Expression.Lambda<Func<object, string>>(body, param).Compile();
100-
}
101-
102-
private static MethodInfo GetMethodInfo(string name, BindingFlags bindingFlags, Type[] types)
103-
{
104-
var methodInfo = typeof(Convert).GetMethod(name, bindingFlags, null, types, null);
105-
return methodInfo;
75+
body = expr.Split('.').Aggregate(body, Expression.PropertyOrField);
76+
body = Expression.Convert(body, typeof(object)); // Convert result in case the body produces a Nullable value type.
77+
return Expression.Lambda<Func<object, object>>(body, param).Compile();
10678
}
10779
}
10880
}

0 commit comments

Comments
 (0)