Skip to content

Commit fe9fc3f

Browse files
John LuoViktorHoferdougburainersigwald
committed
Migrate GenerateFileFromTemplate to Microsoft.DotNet.Build.Tasks.Templating (#7403)
Co-authored-by: Viktor Hofer <viktor.hofer@microsoft.com> Co-authored-by: Doug Bunting <6431421+dougbu@users.noreply.github.com> Co-authored-by: Rainer Sigwald <raines@microsoft.com>
1 parent 99023a1 commit fe9fc3f

10 files changed

Lines changed: 405 additions & 0 deletions

Arcade.sln

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.NuGetRepac
9595
EndProject
9696
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}"
9797
EndProject
98+
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}"
99+
EndProject
100+
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}"
101+
EndProject
98102
Global
99103
GlobalSection(SolutionConfigurationPlatforms) = preSolution
100104
Debug|Any CPU = Debug|Any CPU
@@ -593,6 +597,30 @@ Global
593597
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x64.Build.0 = Release|Any CPU
594598
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x86.ActiveCfg = Release|Any CPU
595599
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x86.Build.0 = Release|Any CPU
600+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
601+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
602+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x64.ActiveCfg = Debug|Any CPU
603+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x64.Build.0 = Debug|Any CPU
604+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x86.ActiveCfg = Debug|Any CPU
605+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x86.Build.0 = Debug|Any CPU
606+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
607+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|Any CPU.Build.0 = Release|Any CPU
608+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x64.ActiveCfg = Release|Any CPU
609+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x64.Build.0 = Release|Any CPU
610+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x86.ActiveCfg = Release|Any CPU
611+
{AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x86.Build.0 = Release|Any CPU
612+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
613+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
614+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x64.ActiveCfg = Debug|Any CPU
615+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x64.Build.0 = Debug|Any CPU
616+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x86.ActiveCfg = Debug|Any CPU
617+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x86.Build.0 = Debug|Any CPU
618+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
619+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|Any CPU.Build.0 = Release|Any CPU
620+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x64.ActiveCfg = Release|Any CPU
621+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x64.Build.0 = Release|Any CPU
622+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x86.ActiveCfg = Release|Any CPU
623+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x86.Build.0 = Release|Any CPU
596624
EndGlobalSection
597625
GlobalSection(SolutionProperties) = preSolution
598626
HideSolutionNode = FALSE
@@ -614,6 +642,7 @@ Global
614642
{4626A7D1-7AC0-4E21-9FED-083EF2A8194C} = {C53DD924-C212-49EA-9BC4-1827421361EF}
615643
{41F3EF12-6062-44CE-A1DB-1DCA76122AF8} = {C53DD924-C212-49EA-9BC4-1827421361EF}
616644
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71} = {C53DD924-C212-49EA-9BC4-1827421361EF}
645+
{FB4168D5-6EA6-4777-AD4F-95758C177FE8} = {C53DD924-C212-49EA-9BC4-1827421361EF}
617646
EndGlobalSection
618647
GlobalSection(ExtensibilityGlobals) = postSolution
619648
SolutionGuid = {32B9C883-432E-4FC8-A1BF-090EB033DD5B}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Text;
7+
using Microsoft.Build.Framework;
8+
using Microsoft.Build.Utilities;
9+
10+
namespace Microsoft.DotNet.Build.Tasks.Templating
11+
{
12+
/// <summary>
13+
/// <para>
14+
/// Generates a new file at <see cref="OutputPath"/>.
15+
/// </para>
16+
/// <para>
17+
/// The <see cref="TemplateFile"/> can define variables for substitution using <see cref="Properties"/>.
18+
/// </para>
19+
/// <example>
20+
/// The input file might look like this:
21+
/// <code>
22+
/// 2 + 2 = ${Sum}
23+
/// </code>
24+
/// When the task is invoked like this, it will produce "2 + 2 = 4"
25+
/// <code>
26+
/// &lt;GenerateFileFromTemplate Properties="Sum=4;OtherValue=123;" ... &gt;
27+
/// </code>
28+
/// </example>
29+
/// </summary>
30+
public class GenerateFileFromTemplate : Task
31+
{
32+
/// <summary>
33+
/// The template file using the variable syntax <c>${VarName}</c>.
34+
/// If your template file needs to output this format, you can escape the dollar sign with a backtick e.g. <c>`${NotReplaced}</c>.
35+
/// </summary>
36+
[Required]
37+
public string TemplateFile { get; set; }
38+
39+
/// <summary>
40+
/// The destination for the generated file.
41+
/// </summary>
42+
[Required]
43+
public string OutputPath { get; set; }
44+
45+
/// <summary>
46+
/// Key=Value pairs of values, separated by semicolons e.g. <c>Properties="Sum=4;OtherValue=123;"</c>.
47+
/// </summary>
48+
[Required]
49+
public string[] Properties { get; set; }
50+
51+
/// <summary>
52+
/// The destination for the generated file resolved by this task.
53+
/// </summary>
54+
[Output]
55+
public string ResolvedOutputPath { get; set; }
56+
57+
public override bool Execute()
58+
{
59+
ResolvedOutputPath = Path.GetFullPath(OutputPath.Replace('\\', '/'));
60+
61+
if (!File.Exists(TemplateFile))
62+
{
63+
Log.LogError($"File {TemplateFile} does not exist");
64+
return false;
65+
}
66+
67+
IDictionary<string, string> values = MSBuildListSplitter.GetNamedProperties(Properties, Log);
68+
string template = File.ReadAllText(TemplateFile);
69+
70+
string result = Replace(template, values);
71+
Directory.CreateDirectory(Path.GetDirectoryName(ResolvedOutputPath));
72+
File.WriteAllText(ResolvedOutputPath, result);
73+
74+
return !Log.HasLoggedErrors;
75+
}
76+
77+
public string Replace(string template, IDictionary<string, string> values)
78+
{
79+
StringBuilder sb = new();
80+
StringBuilder varNameSb = new();
81+
int line = 1;
82+
for (int i = 0; i < template.Length; i++)
83+
{
84+
char templateChar = template[i];
85+
char nextTemplateChar = i + 1 >= template.Length
86+
? '\0'
87+
: template[i + 1];
88+
89+
// count lines in the template file
90+
if (templateChar == '\n')
91+
{
92+
line++;
93+
}
94+
95+
if (templateChar == '`' && (nextTemplateChar == '$' || nextTemplateChar == '`'))
96+
{
97+
// skip the backtick for known escape characters
98+
i++;
99+
sb.Append(nextTemplateChar);
100+
continue;
101+
}
102+
103+
if (templateChar != '$' || nextTemplateChar != '{')
104+
{
105+
// variables begin with ${. Moving on.
106+
sb.Append(templateChar);
107+
continue;
108+
}
109+
110+
varNameSb.Clear();
111+
i += 2;
112+
for (; i < template.Length; i++)
113+
{
114+
templateChar = template[i];
115+
if (templateChar != '}')
116+
{
117+
varNameSb.Append(templateChar);
118+
}
119+
else
120+
{
121+
// Found the end of the variable substitution
122+
string varName = varNameSb.ToString();
123+
if (values.TryGetValue(varName, out string value))
124+
{
125+
sb.Append(value);
126+
}
127+
else
128+
{
129+
Log.LogWarning(null, null, null, TemplateFile,
130+
line, 0, 0, 0,
131+
message: $"No property value is available for '{varName}'");
132+
}
133+
134+
varNameSb.Clear();
135+
break;
136+
}
137+
}
138+
139+
if (varNameSb.Length > 0)
140+
{
141+
Log.LogWarning(null, null, null, TemplateFile,
142+
line, 0, 0, 0,
143+
message: "Expected closing bracket for variable placeholder. No substitution will be made.");
144+
sb.Append("${").Append(varNameSb.ToString());
145+
}
146+
}
147+
148+
return sb.ToString();
149+
}
150+
}
151+
}
152+
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using Microsoft.Build.Utilities;
7+
8+
namespace Microsoft.DotNet.Build.Tasks.Templating
9+
{
10+
internal static class MSBuildListSplitter
11+
{
12+
public static IDictionary<string, string> GetNamedProperties(string[] input, TaskLoggingHelper log)
13+
{
14+
Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase);
15+
if (input == null)
16+
{
17+
return values;
18+
}
19+
20+
foreach (string item in input)
21+
{
22+
int splitIdx = item.IndexOf('=');
23+
if (splitIdx < 0)
24+
{
25+
log.LogWarning($"Property: {item} does not have a valid '=' separator");
26+
continue;
27+
}
28+
29+
string key = item.Substring(0, splitIdx).Trim();
30+
if (string.IsNullOrEmpty(key))
31+
{
32+
log.LogWarning($"Property: {item} does not have a valid property name");
33+
continue;
34+
}
35+
36+
string value = item.Substring(splitIdx + 1);
37+
values[key] = value;
38+
}
39+
40+
return values;
41+
}
42+
}
43+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<!-- Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. -->
2+
<Project Sdk="Microsoft.NET.Sdk">
3+
4+
<PropertyGroup>
5+
<TargetFramework>netstandard2.0</TargetFramework>
6+
<Description>Templating task package</Description>
7+
<PackageTags>Arcade Build Tool Templating</PackageTags>
8+
<IncludeSymbols>false</IncludeSymbols>
9+
<IncludeSource>false</IncludeSource>
10+
<IsPackable>true</IsPackable>
11+
<BuildOutputTargetFolder>tools\</BuildOutputTargetFolder>
12+
<DevelopmentDependency>true</DevelopmentDependency>
13+
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
14+
<EnableDefaultNoneItems>false</EnableDefaultNoneItems>
15+
</PropertyGroup>
16+
17+
<ItemGroup>
18+
<None Include="build/**/*.*" Pack="true">
19+
<PackagePath>build</PackagePath>
20+
</None>
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<PackageReference Include="Microsoft.Build.Framework" Version="$(MicrosoftBuildFrameworkVersion)" />
25+
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="$(MicrosoftBuildUtilitiesCoreVersion)" />
26+
</ItemGroup>
27+
28+
<Import Project="$(RepoRoot)eng\BuildTask.targets" />
29+
</Project>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!-- Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. -->
2+
<Project>
3+
4+
<PropertyGroup>
5+
<MicrosoftDotNetBuildTasksTemplatingAssembly>$(MSBuildThisFileDirectory)..\tools\netstandard2.0\Microsoft.DotNet.Build.Tasks.Templating.dll</MicrosoftDotNetBuildTasksTemplatingAssembly>
6+
</PropertyGroup>
7+
8+
<UsingTask TaskName="Microsoft.DotNet.Build.Tasks.Templating.GenerateFileFromTemplate" AssemblyFile="$(MicrosoftDotNetBuildTasksTemplatingAssembly)" />
9+
10+
</Project>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.IO;
6+
using Microsoft.Arcade.Test.Common;
7+
using Xunit;
8+
9+
namespace Microsoft.DotNet.Build.Tasks.Templating.Tests
10+
{
11+
public class GenerateFileFromTemplateTests
12+
{
13+
[Fact]
14+
public void GenerateFileFromTemplate_SubstitutesValidProperties()
15+
{
16+
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
17+
string filePath = Path.Combine(tempDir, "Directory.Build.props");
18+
19+
try
20+
{
21+
GenerateFileFromTemplate task = new();
22+
task.TemplateFile = GetFullPath("Directory.Build.props.in");
23+
task.OutputPath = filePath;
24+
task.Properties = new[] { "DefaultNetCoreTargetFramework=net6.0" };
25+
26+
Assert.True(task.Execute());
27+
Assert.Equal(ReadAllText("Directory.Build.props.in").Replace("${DefaultNetCoreTargetFramework}", "net6.0"), File.ReadAllText(filePath));
28+
}
29+
finally
30+
{
31+
Directory.Delete(tempDir, true);
32+
}
33+
}
34+
35+
[Theory]
36+
[InlineData("DefaultNetCoreTargetFramework=")]
37+
[InlineData("=net6.0")]
38+
[InlineData("net6.0")]
39+
[InlineData("DefaultNetCoreTargetFramework:net6.0")]
40+
[InlineData("Default_NetCore_Target_Framework=net6.0")]
41+
public void GenerateFileFromTemplate_RemovesInvalidProperties(string invalidProperty)
42+
{
43+
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
44+
string filePath = Path.Combine(tempDir, "Directory.Build.props");
45+
46+
try
47+
{
48+
GenerateFileFromTemplate task = new();
49+
task.BuildEngine = new MockBuildEngine();
50+
task.TemplateFile = GetFullPath("Directory.Build.props.in");
51+
task.OutputPath = filePath;
52+
task.Properties = new[] { invalidProperty };
53+
54+
Assert.True(task.Execute());
55+
Assert.Equal(ReadAllText("Directory.Build.props.in").Replace("${DefaultNetCoreTargetFramework}", string.Empty), File.ReadAllText(filePath));
56+
}
57+
finally
58+
{
59+
Directory.Delete(tempDir, true);
60+
}
61+
}
62+
63+
[Theory]
64+
[InlineData("Directory.Build.props.malformedbraces.in")]
65+
[InlineData("Directory.Build.props.nobraces.in")]
66+
public void GenerateFileFromTemplate_IgnoresMalformedTemplate(string filename)
67+
{
68+
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
69+
string filePath = Path.Combine(tempDir, "Directory.Build.props");
70+
71+
try
72+
{
73+
GenerateFileFromTemplate task = new();
74+
task.BuildEngine = new MockBuildEngine();
75+
task.TemplateFile = GetFullPath(filename);
76+
task.OutputPath = filePath;
77+
task.Properties = new[] { "DefaultNetCoreTargetFramework=net6.0" };
78+
79+
Assert.True(task.Execute());
80+
Assert.Equal(ReadAllText(filename), File.ReadAllText(filePath));
81+
}
82+
finally
83+
{
84+
Directory.Delete(tempDir, true);
85+
}
86+
}
87+
88+
public static string GetFullPath(string relativeTestInputPath)
89+
{
90+
return Path.Combine(
91+
Path.GetDirectoryName(typeof(GenerateFileFromTemplateTests).Assembly.Location),
92+
"testassets",
93+
relativeTestInputPath);
94+
}
95+
96+
public static string ReadAllText(string relativeTestInputPath)
97+
{
98+
string path = GetFullPath(relativeTestInputPath);
99+
return File.ReadAllText(path);
100+
}
101+
}
102+
}

0 commit comments

Comments
 (0)