C# 9.0 Source Generation isĀ progressing quite nicely latelyĀ (Thanks, Jared!), with the addition of the ability to interact with the MSBuild environment such as getting Properties and Items to control how the generation happens.
In this post, Iāll explain how to parseĀ .resw
Ā files of a project to generate an enum that contains all the resources.
The full sample for this article isĀ here in the Fonderie Generators project.
Reading msbuild items and properties
In theĀ Roslyn generators cookbook, new entries have been added to include the APIs needed to get information from msbuild. In order to make the reading of those properties easier, hereās a small extensions class:
internal static class SourceGeneratorContextExtensions
{
private const string SourceItemGroupMetadata = "build_metadata.AdditionalFiles.SourceItemGroup";
public static string GetMSBuildProperty(
this SourceGeneratorContext context,
string name,
string defaultValue = "")
{
context.AnalyzerConfigOptions.GlobalOptions.TryGetValue($"build_property.{name}", out var value);
return value ?? defaultValue;
}
public static string[] GetMSBuildItems(this SourceGeneratorContext context, string name)
=> context
.AdditionalFiles
.Where(f => context.AnalyzerConfigOptions
.GetOptions(f)
.TryGetValue(SourceItemGroupMetadata, out var sourceItemGroup)
&& sourceItemGroup == name)
.Select(f => f.Path)
.ToArray();
}
Letās dive into what those extensions do.
GetMSBuildProperty
TheĀ GetMSBuildProperty
Ā method is assuming that a defined property has a non-empty value, as per the msbuild semantics. Hereās how to get the default namespace for the current project:
var defineConstants = context.GetMSBuildProperty("RootNamespace");
Assuming that the associated msbuild property is added in the generatorās associated props file:
<ItemGroup>
<CompilerVisibleProperty Include="RootNamespace" />
</ItemGroup>
GetMSBuildItems
ForĀ GetMSBuildItems
, since the roslyn APIs does not provide a way to discriminate items per their MSBuild item name, we need to use some metadata that can be added to theĀ AdditionalFiles
Ā items. In order to get theĀ resw
Ā files from a WinUI project, we can do the following:
var priResources = context.GetMSBuildItems("PRIResource");
For this code to work, we need to change a little bit the way items are added to the roslyn context:
<Target Name="_InjectAdditionalFiles" BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun">
<ItemGroup>
<AdditionalFiles Include="@(PRIResource)" SourceItemGroup="PRIResource" />
</ItemGroup>
</Target>
The use of a target here is needed because of the way NuGet packages property or targets files are handled by msbuild. If the ItemGroup is included directly at the root of the project, its evaluation is performed too early in the build process. This sequence misses items being added by the project or dynamically by other targets.
At this point, thereās no clear injection point to execute this targe in Roslyn, butĀ GenerateMSBuildEditorConfigFileShouldRunĀ seems like an appropriate location for doing so at this point, right before the capture of the properties and items by the build.
Finally, to be able to discriminate items in theĀ AdditionalFiles
Ā group, we use theĀ SourceItemGroup
Ā metadata. For Roslyn to pick it up, we need to add the following:
<ItemGroup>
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="SourceItemGroup" />
</ItemGroup>
Generating code from the resw file
Now that we can read the items and properties, we can write a small generator that creates an enum with all the names found in aĀ resw
Ā file:
[Generator]
public class ReswConstantsGenerator : ISourceGenerator
{
public void Initialize(InitializationContext context)
{
// Debugger.Launch(); // Uncomment this line for debugging
// No initialization required for this one
}
public void Execute(SourceGeneratorContext context)
{
var resources = context.GetMSBuildItems("PRIResource");
if (resources.Any())
{
var sb = new IndentedStringBuilder();
using (sb.BlockInvariant($"namespace {context.GetMSBuildProperty("RootNamespace")}"))
{
using (sb.BlockInvariant($"internal enum PriResources"))
{
foreach (var item in resources)
{
XmlDocument doc = new XmlDocument();
doc.Load(item);
// Extract all localization keys from Win10 resource file
var nodes = doc.SelectNodes("//data")
.Cast<XmlElement>()
.Select(node => node.GetAttribute("name"))
.ToArray();
foreach (var node in nodes)
{
sb.AppendLineInvariant($"{node},");
}
}
}
}
context.AddSource("PriResources", SourceText.From(sb.ToString(), Encoding.UTF8));
}
}
}
This will generate a file which contains an enum with all the resource names, in the default namespace of the current assembly.
Note that this generator does not validate the nameās format, and if there are reserved characters or keywords, those are needed to be rewritten for C# to accept it.
Wrapping up
This simple sample should most likely be improved.
For instance, it could be interesting to create a generator analyzing another generator to create aĀ .targets
Ā file which contains the appropriateĀ CompilerVisibleItemMetadata
Ā andĀ CompilerVisibleProperty
Ā for that generator to work properly.
The extension also only supports getting the identity of an item, but getting additional metadata would be useful, like getting theĀ Link
Ā attribute when dealing with linked files in projects.
You can find theĀ sample of this article here, and as of the writing of this post, Visual Studio 16.8 Preview 2.1 does not yet show the generated code or highlights properly but builds with the generated code properly. This should be improving the next previews.
Until next time, happy generation!