diff --git a/Arcade.sln b/Arcade.sln index 13a9337a627..b34b3272bfd 100644 --- a/Arcade.sln +++ b/Arcade.sln @@ -95,6 +95,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.NuGetRepac EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Build.Tasks.Feed.Tests", "src\Microsoft.DotNet.Build.Tasks.Feed.Tests\Microsoft.DotNet.Build.Tasks.Feed.Tests.csproj", "{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Build.Tasks.Templating", "src\Microsoft.DotNet.Build.Tasks.Templating\src\Microsoft.DotNet.Build.Tasks.Templating.csproj", "{AED823B2-2167-408E-9732-ECAD854FDCA5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Build.Tasks.Templating.Tests", "src\Microsoft.DotNet.Build.Tasks.Templating\test\Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj", "{FB4168D5-6EA6-4777-AD4F-95758C177FE8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -593,6 +597,30 @@ Global {6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x64.Build.0 = Release|Any CPU {6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x86.ActiveCfg = Release|Any CPU {6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x86.Build.0 = Release|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x64.ActiveCfg = Debug|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x64.Build.0 = Debug|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x86.ActiveCfg = Debug|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x86.Build.0 = Debug|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|Any CPU.Build.0 = Release|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x64.ActiveCfg = Release|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x64.Build.0 = Release|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x86.ActiveCfg = Release|Any CPU + {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x86.Build.0 = Release|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x64.Build.0 = Debug|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x86.Build.0 = Debug|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|Any CPU.Build.0 = Release|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x64.ActiveCfg = Release|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x64.Build.0 = Release|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x86.ActiveCfg = Release|Any CPU + {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -614,6 +642,7 @@ Global {4626A7D1-7AC0-4E21-9FED-083EF2A8194C} = {C53DD924-C212-49EA-9BC4-1827421361EF} {41F3EF12-6062-44CE-A1DB-1DCA76122AF8} = {C53DD924-C212-49EA-9BC4-1827421361EF} {6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71} = {C53DD924-C212-49EA-9BC4-1827421361EF} + {FB4168D5-6EA6-4777-AD4F-95758C177FE8} = {C53DD924-C212-49EA-9BC4-1827421361EF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {32B9C883-432E-4FC8-A1BF-090EB033DD5B} diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/GenerateFileFromTemplate.cs b/src/Microsoft.DotNet.Build.Tasks.Templating/src/GenerateFileFromTemplate.cs new file mode 100644 index 00000000000..8eb7e5db08d --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/GenerateFileFromTemplate.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.DotNet.Build.Tasks.Templating +{ + /// + /// + /// Generates a new file at . + /// + /// + /// The can define variables for substitution using . + /// + /// + /// The input file might look like this: + /// + /// 2 + 2 = ${Sum} + /// + /// When the task is invoked like this, it will produce "2 + 2 = 4" + /// + /// <GenerateFileFromTemplate Properties="Sum=4;OtherValue=123;" ... > + /// + /// + /// + public class GenerateFileFromTemplate : Task + { + /// + /// The template file using the variable syntax ${VarName}. + /// If your template file needs to output this format, you can escape the dollar sign with a backtick e.g. `${NotReplaced}. + /// + [Required] + public string TemplateFile { get; set; } + + /// + /// The destination for the generated file. + /// + [Required] + public string OutputPath { get; set; } + + /// + /// Key=Value pairs of values, separated by semicolons e.g. Properties="Sum=4;OtherValue=123;". + /// + [Required] + public string[] Properties { get; set; } + + /// + /// The destination for the generated file resolved by this task. + /// + [Output] + public string ResolvedOutputPath { get; set; } + + public override bool Execute() + { + ResolvedOutputPath = Path.GetFullPath(OutputPath.Replace('\\', '/')); + + if (!File.Exists(TemplateFile)) + { + Log.LogError($"File {TemplateFile} does not exist"); + return false; + } + + IDictionary values = MSBuildListSplitter.GetNamedProperties(Properties, Log); + string template = File.ReadAllText(TemplateFile); + + string result = Replace(template, values); + Directory.CreateDirectory(Path.GetDirectoryName(ResolvedOutputPath)); + File.WriteAllText(ResolvedOutputPath, result); + + return !Log.HasLoggedErrors; + } + + public string Replace(string template, IDictionary values) + { + StringBuilder sb = new StringBuilder(); + StringBuilder varNameSb = new StringBuilder(); + int line = 1; + for (int i = 0; i < template.Length; i++) + { + char templateChar = template[i]; + char nextTemplateChar = i + 1 >= template.Length + ? '\0' + : template[i + 1]; + + // count lines in the template file + if (templateChar == '\n') + { + line++; + } + + if (templateChar == '`' && (nextTemplateChar == '$' || nextTemplateChar == '`')) + { + // skip the backtick for known escape characters + i++; + sb.Append(nextTemplateChar); + continue; + } + + if (templateChar != '$' || nextTemplateChar != '{') + { + // variables begin with ${. Moving on. + sb.Append(templateChar); + continue; + } + + varNameSb.Clear(); + i += 2; + for (; i < template.Length; i++) + { + templateChar = template[i]; + if (templateChar != '}') + { + varNameSb.Append(templateChar); + } + else + { + // Found the end of the variable substitution + string varName = varNameSb.ToString(); + if (values.TryGetValue(varName, out string value)) + { + sb.Append(value); + } + else + { + Log.LogWarning(null, null, null, TemplateFile, + line, 0, 0, 0, + message: $"No property value is available for '{varName}'"); + } + + varNameSb.Clear(); + break; + } + } + + if (varNameSb.Length > 0) + { + Log.LogWarning(null, null, null, TemplateFile, + line, 0, 0, 0, + message: "Expected closing bracket for variable placeholder. No substitution will be made."); + sb.Append("${").Append(varNameSb.ToString()); + } + } + + return sb.ToString(); + } + } +} + diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/MSBuildListSplitter.cs b/src/Microsoft.DotNet.Build.Tasks.Templating/src/MSBuildListSplitter.cs new file mode 100644 index 00000000000..92b552ded70 --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/MSBuildListSplitter.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Build.Utilities; + +namespace Microsoft.DotNet.Build.Tasks.Templating +{ + internal static class MSBuildListSplitter + { + public static IDictionary GetNamedProperties(string[] input, TaskLoggingHelper log) + { + Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (input == null) + { + return values; + } + + foreach (string item in input) + { + int splitIdx = item.IndexOf('='); + if (splitIdx < 0) + { + log.LogWarning($"Property: {item} does not have a valid '=' separator"); + continue; + } + + string key = item.Substring(0, splitIdx).Trim(); + if (string.IsNullOrEmpty(key)) + { + log.LogWarning($"Property: {item} does not have a valid property name"); + continue; + } + + string value = item.Substring(splitIdx + 1); + values[key] = value; + } + + return values; + } + } +} diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/Microsoft.DotNet.Build.Tasks.Templating.csproj b/src/Microsoft.DotNet.Build.Tasks.Templating/src/Microsoft.DotNet.Build.Tasks.Templating.csproj new file mode 100644 index 00000000000..5ff51a445ad --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/Microsoft.DotNet.Build.Tasks.Templating.csproj @@ -0,0 +1,29 @@ + + + + + netstandard2.0 + Templating task package + Arcade Build Tool Templating + false + false + true + tools\ + true + true + false + + + + + build + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/build/Microsoft.DotNet.Build.Tasks.Templating.props b/src/Microsoft.DotNet.Build.Tasks.Templating/src/build/Microsoft.DotNet.Build.Tasks.Templating.props new file mode 100644 index 00000000000..4baeb5b308f --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/build/Microsoft.DotNet.Build.Tasks.Templating.props @@ -0,0 +1,10 @@ + + + + + $(MSBuildThisFileDirectory)..\tools\netstandard2.0\Microsoft.DotNet.Build.Tasks.Templating.dll + + + + + diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/GenerateFileFromTemplateTests.cs b/src/Microsoft.DotNet.Build.Tasks.Templating/test/GenerateFileFromTemplateTests.cs new file mode 100644 index 00000000000..085a4c7e750 --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/GenerateFileFromTemplateTests.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.DotNet.Arcade.Sdk.Tests.Utilities; +using Xunit; + +namespace Microsoft.DotNet.Build.Tasks.Templating.Tests +{ + public class GenerateFileFromTemplateTests + { + [Fact] + public void GenerateFileFromTemplate_SubstitutesValidProperties() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + string filePath = Path.Combine(tempDir, "Directory.Build.props"); + + try + { + GenerateFileFromTemplate task = new GenerateFileFromTemplate(); + task.TemplateFile = GetFullPath("Directory.Build.props.in"); + task.OutputPath = filePath; + task.Properties = new[] { "DefaultNetCoreTargetFramework=net6.0" }; + + Assert.True(task.Execute()); + Assert.Equal(ReadAllText("Directory.Build.props.in").Replace("${DefaultNetCoreTargetFramework}", "net6.0"), File.ReadAllText(filePath)); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Theory] + [InlineData("DefaultNetCoreTargetFramework=")] + [InlineData("=net6.0")] + [InlineData("net6.0")] + [InlineData("DefaultNetCoreTargetFramework:net6.0")] + [InlineData("Default_NetCore_Target_Framework=net6.0")] + public void GenerateFileFromTemplate_RemovesInvalidProperties(string invalidProperty) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + string filePath = Path.Combine(tempDir, "Directory.Build.props"); + + try + { + GenerateFileFromTemplate task = new GenerateFileFromTemplate(); + task.BuildEngine = new MockEngine(); + task.TemplateFile = GetFullPath("Directory.Build.props.in"); + task.OutputPath = filePath; + task.Properties = new[] { invalidProperty }; + + Assert.True(task.Execute()); + Assert.Equal(ReadAllText("Directory.Build.props.in").Replace("${DefaultNetCoreTargetFramework}", string.Empty), File.ReadAllText(filePath)); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Theory] + [InlineData("Directory.Build.props.malformedbraces.in")] + [InlineData("Directory.Build.props.nobraces.in")] + public void GenerateFileFromTemplate_IgnoresMalformedTemplate(string filename) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + string filePath = Path.Combine(tempDir, "Directory.Build.props"); + + try + { + GenerateFileFromTemplate task = new GenerateFileFromTemplate(); + task.BuildEngine = new MockEngine(); + task.TemplateFile = GetFullPath(filename); + task.OutputPath = filePath; + task.Properties = new[] { "DefaultNetCoreTargetFramework=net6.0" }; + + Assert.True(task.Execute()); + Assert.Equal(ReadAllText(filename), File.ReadAllText(filePath)); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + public static string GetFullPath(string relativeTestInputPath) + { + return Path.Combine( + Path.GetDirectoryName(typeof(GenerateFileFromTemplateTests).Assembly.Location), + "testassets", + relativeTestInputPath); + } + + public static string ReadAllText(string relativeTestInputPath) + { + string path = GetFullPath(relativeTestInputPath); + return File.ReadAllText(path); + } + } +} diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj b/src/Microsoft.DotNet.Build.Tasks.Templating/test/Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj new file mode 100644 index 00000000000..8ddb77dd434 --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp2.1;net472 + + + + + + + + + + + + + + Always + + + + + + + + diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.in b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.in new file mode 100644 index 00000000000..53438eec736 --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.in @@ -0,0 +1,6 @@ + + + ${DefaultNetCoreTargetFramework} + true + + \ No newline at end of file diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.malformedbraces.in b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.malformedbraces.in new file mode 100644 index 00000000000..ae2a552f86e --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.malformedbraces.in @@ -0,0 +1,6 @@ + + + ${DefaultNetCoreTargetFramework + true + + \ No newline at end of file diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.nobraces.in b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.nobraces.in new file mode 100644 index 00000000000..fd0defc397e --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.nobraces.in @@ -0,0 +1,6 @@ + + + DefaultNetCoreTargetFramework + true + + \ No newline at end of file