Skip to content
Merged
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
35 changes: 18 additions & 17 deletions docs/input/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,33 +87,34 @@ skip updating the `AssemblyFileVersion` while still updating the

### assembly-file-versioning-format

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

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

Uses `JENKINS_BUILD_NUMBER` if available in the environment otherwise the
`fallback_string` Example Syntax #2:
```yaml
# use a variable if non-null or a fallback value otherwise
assembly-file-versioning-format: '{Major}.{Minor}.{Patch}.{WeightedPreReleaseNumber ?? 0}'

`'{Major}.{Minor}.{Patch}.{env:JENKINS_BUILD_NUMBER}'`.
# use an environment variable or raise an error if not available
assembly-file-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_NUMBER}'

Uses `JENKINS_BUILD_NUMBER` if available in the environment otherwise the
parsing fails. String interpolation is supported as in
`assembly-informational-format`
# use an environment variable if available or a fallback value otherwise
assembly-file-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_NUMBER ?? 42}'
```

### assembly-versioning-format

Follows the same semantics as `assembly-file-versioning-format` and overwrites
the value of `assembly-versioning-scheme`.
Specifies the format of `AssemblyVersion` and
overwrites the value of `assembly-versioning-scheme`.
Follows the same formatting semantics as `assembly-file-versioning-format`.

### assembly-informational-format

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

### mode

Expand Down
122 changes: 120 additions & 2 deletions src/GitVersionCore.Tests/StringFormatWithExtensionTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using GitVersion;
using GitVersion.Helpers;
using GitVersionCore.Tests.Helpers;
Expand All @@ -6,7 +7,6 @@
namespace GitVersionCore.Tests
{
[TestFixture]

public class StringFormatWithExtensionTests
{
private IEnvironment environment;
Expand Down Expand Up @@ -70,7 +70,7 @@ public void FormatWithEnvVarTokenWithFallback()
}

[Test]
public void FormatWithUnsetEnvVarTokenWithFallback()
public void FormatWithUnsetEnvVarToken_WithFallback()
{
environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null);
var propertyObject = new { };
Expand All @@ -80,6 +80,15 @@ public void FormatWithUnsetEnvVarTokenWithFallback()
Assert.AreEqual(expected, actual);
}

[Test]
public void FormatWithUnsetEnvVarToken_WithoutFallback()
{
environment.SetEnvironmentVariable("GIT_VERSION_UNSET_TEST_VAR", null);
var propertyObject = new { };
var target = "{env:GIT_VERSION_UNSET_TEST_VAR}";
Assert.Throws<ArgumentException>(() => target.FormatWith(propertyObject, environment));
}

[Test]
public void FormatWithMultipleEnvVars()
{
Expand Down Expand Up @@ -133,5 +142,114 @@ public void FormatWIthNullPropagationWithMultipleSpaces()
var actual = target.FormatWith(propertyObject, environment);
Assert.AreEqual(expected, actual);
}

[Test]
public void FormatEnvVar_WithFallback_QuotedAndEmpty()
{
environment.SetEnvironmentVariable("ENV_VAR", null);
var propertyObject = new { };
var target = "{env:ENV_VAR ?? \"\"}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo(""));
}

[Test]
public void FormatProperty_String()
{
var propertyObject = new { Property = "Value" };
var target = "{Property}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo("Value"));
}

[Test]
public void FormatProperty_Integer()
{
var propertyObject = new { Property = 42 };
var target = "{Property}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo("42"));
}

[Test]
public void FormatProperty_NullObject()
{
var propertyObject = new { Property = (object)null };
var target = "{Property}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo(""));
}

[Test]
public void FormatProperty_NullInteger()
{
var propertyObject = new { Property = (int?)null };
var target = "{Property}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo(""));
}

[Test]
public void FormatProperty_String_WithFallback()
{
var propertyObject = new { Property = "Value" };
var target = "{Property ?? fallback}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo("Value"));
}

[Test]
public void FormatProperty_Integer_WithFallback()
{
var propertyObject = new { Property = 42 };
var target = "{Property ?? fallback}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo("42"));
}

[Test]
public void FormatProperty_NullObject_WithFallback()
{
var propertyObject = new { Property = (object)null };
var target = "{Property ?? fallback}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo("fallback"));
}

[Test]
public void FormatProperty_NullInteger_WithFallback()
{
var propertyObject = new { Property = (int?)null };
var target = "{Property ?? fallback}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo("fallback"));
}

[Test]
public void FormatProperty_NullObject_WithFallback_Quoted()
{
var propertyObject = new { Property = (object)null };
var target = "{Property ?? \"fallback\"}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo("fallback"));
}

[Test]
public void FormatProperty_NullObject_WithFallback_QuotedAndPadded()
{
var propertyObject = new { Property = (object)null };
var target = "{Property ?? \" fallback \"}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo(" fallback "));
}

[Test]
public void FormatProperty_NullObject_WithFallback_QuotedAndEmpty()
{
var propertyObject = new { Property = (object)null };
var target = "{Property ?? \"\"}";
var actual = target.FormatWith(propertyObject, environment);
Assert.That(actual, Is.EqualTo(""));
}
}
}
108 changes: 40 additions & 68 deletions src/GitVersionCore/Helpers/StringFormatWith.cs
Original file line number Diff line number Diff line change
@@ -1,108 +1,80 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.RegularExpressions;

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

/// <summary>
/// Formats a string template with the given source object.
/// Expression like {Id} are replaced with the corresponding
/// property value in the <paramref name="source" />.
/// Supports property access expressions.
/// </summary>
/// <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>
/// <param name="source">The source object to apply to format</param>
/// Formats the <paramref name="template"/>, replacing each expression wrapped in curly braces
/// with the corresponding property from the <paramref name="source"/> or <paramref name="environment"/>.
/// </summary>
/// <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>
/// <param name="source">The source object to apply to the <paramref name="template"/></param>
/// <param name="environment"></param>
/// <exception cref="ArgumentNullException">The <paramref name="template"/> is null.</exception>
/// <exception cref="ArgumentException">An environment variable was null and no fallback was provided.</exception>
/// <remarks>
/// An expression containing "." is treated as a property or field access on the <paramref name="source"/>.
/// An expression starting with "env:" is replaced with the value of the corresponding variable from the <paramref name="environment"/>.
/// Each expression may specify a single hardcoded fallback value using the {Prop ?? "fallback"} syntax, which applies if the expression evaluates to null.
/// </remarks>
/// <example>
/// // replace an expression with a property value
/// "Hello {Name}".FormatWith(new { Name = "Fred" }, env);
/// "Hello {Name ?? \"Fred\"}".FormatWith(new { Name = GetNameOrNull() }, env);
/// // replace an expression with an environment variable
/// "{env:BUILD_NUMBER}".FormatWith(new { }, env);
/// "{env:BUILD_NUMBER ?? \"0\"}".FormatWith(new { }, env);
/// </example>
public static string FormatWith<T>(this string template, T source, IEnvironment environment)
{
if (template == null)
{
throw new ArgumentNullException(nameof(template));
}

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

// Support evaluation of environment variables in the format string
// For example: {env:JENKINS_BUILD_NUMBER ?? fall-back-string}

if (match.Groups["env"].Success)
if (match.Groups["envvar"].Success)
{
memberAccessExpression = memberAccessExpression.Substring(memberAccessExpression.IndexOf(':') + 1);
string envVar = memberAccessExpression, fallback = null;
var components = (memberAccessExpression.Contains("??")) ? memberAccessExpression.Split(new[] { "??" }, StringSplitOptions.None) : null;
if (components != null)
{
envVar = components[0].Trim();
fallback = components[1].Trim();
}

propertyValue = environment.GetEnvironmentVariable(envVar);
if (propertyValue == null)
{
if (fallback != null)
propertyValue = fallback;
else
throw new ArgumentException($"Environment variable {envVar} not found and no fallback string provided");
}
string envVar = match.Groups["envvar"].Value;
propertyValue = environment.GetEnvironmentVariable(envVar) ?? fallback
?? throw new ArgumentException($"Environment variable {envVar} not found and no fallback string provided");
}
else
{
var objType = source.GetType();
string memberAccessExpression = match.Groups["member"].Value;
var expression = CompileDataBinder(objType, memberAccessExpression);
propertyValue = expression(source);
// It would be better to throw if the expression and fallback produce null, but provide an empty string for back compat.
propertyValue = expression(source)?.ToString() ?? fallback ?? "";
}

template = template.Replace(match.Value, propertyValue);
}

return template;
}


private static string TrimBraces(string originalExpression)
{
if (!string.IsNullOrWhiteSpace(originalExpression))
{
return originalExpression.TrimStart('{').TrimEnd('}');
}
return originalExpression;
}

private static Func<object, string> CompileDataBinder(Type type, string expr)
private static Func<object, object> CompileDataBinder(Type type, string expr)
{
var param = Expression.Parameter(typeof(object));
ParameterExpression param = Expression.Parameter(typeof(object));
Expression body = Expression.Convert(param, type);
var members = expr.Split('.');
body = members.Aggregate(body, Expression.PropertyOrField);

var staticOrPublic = BindingFlags.Static | BindingFlags.Public;
var method = GetMethodInfo("ToString", staticOrPublic, new[] { body.Type });
if (method == null)
{
method = GetMethodInfo("ToString", staticOrPublic, new[] { typeof(object) });
body = Expression.Call(method, Expression.Convert(body, typeof(object)));
}
else
{
body = Expression.Call(method, body);
}

return Expression.Lambda<Func<object, string>>(body, param).Compile();
}

private static MethodInfo GetMethodInfo(string name, BindingFlags bindingFlags, Type[] types)
{
var methodInfo = typeof(Convert).GetMethod(name, bindingFlags, null, types, null);
return methodInfo;
body = expr.Split('.').Aggregate(body, Expression.PropertyOrField);
body = Expression.Convert(body, typeof(object)); // Convert result in case the body produces a Nullable value type.
return Expression.Lambda<Func<object, object>>(body, param).Compile();
}
}
}