diff --git a/docs/input/docs/configuration.md b/docs/input/docs/configuration.md index ce8ee037c2..360b8ca5d5 100644 --- a/docs/input/docs/configuration.md +++ b/docs/input/docs/configuration.md @@ -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 diff --git a/src/GitVersionCore.Tests/StringFormatWithExtensionTests.cs b/src/GitVersionCore.Tests/StringFormatWithExtensionTests.cs index fbd3b7af2d..5b66c23cdc 100644 --- a/src/GitVersionCore.Tests/StringFormatWithExtensionTests.cs +++ b/src/GitVersionCore.Tests/StringFormatWithExtensionTests.cs @@ -1,3 +1,4 @@ +using System; using GitVersion; using GitVersion.Helpers; using GitVersionCore.Tests.Helpers; @@ -6,7 +7,6 @@ namespace GitVersionCore.Tests { [TestFixture] - public class StringFormatWithExtensionTests { private IEnvironment environment; @@ -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 { }; @@ -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(() => target.FormatWith(propertyObject, environment)); + } + [Test] public void FormatWithMultipleEnvVars() { @@ -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("")); + } } } diff --git a/src/GitVersionCore/Helpers/StringFormatWith.cs b/src/GitVersionCore/Helpers/StringFormatWith.cs index 0609091ddb..5c0c690e2f 100644 --- a/src/GitVersionCore/Helpers/StringFormatWith.cs +++ b/src/GitVersionCore/Helpers/StringFormatWith.cs @@ -1,24 +1,40 @@ 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:)??\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:(?\w+))|(?\w+))(\s+(\?\?)??\s+((?\w+)|""(?.*)""))??}", RegexOptions.Compiled); /// - /// Formats a string template with the given source object. - /// Expression like {Id} are replaced with the corresponding - /// property value in the . - /// Supports property access expressions. - /// - /// 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}'. - /// The source object to apply to format + /// Formats the , replacing each expression wrapped in curly braces + /// with the corresponding property from the or . + /// + /// The source template, which may contain expressions to be replaced, e.g '{Foo.Bar.CurrencySymbol} foo {Foo.Bar.Price}' + /// The source object to apply to the /// + /// The is null. + /// An environment variable was null and no fallback was provided. + /// + /// An expression containing "." is treated as a property or field access on the . + /// An expression starting with "env:" is replaced with the value of the corresponding variable from the . + /// Each expression may specify a single hardcoded fallback value using the {Prop ?? "fallback"} syntax, which applies if the expression evaluates to null. + /// + /// + /// // 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); + /// public static string FormatWith(this string template, T source, IEnvironment environment) { if (template == null) @@ -26,83 +42,39 @@ public static string FormatWith(this string template, T source, IEnvironment 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 CompileDataBinder(Type type, string expr) + private static Func 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>(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>(body, param).Compile(); } } }