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!