8

I'm looking for a way to generate constants classes c# file(s) for image file names in my project. So I can use them in code and xaml, runtime and design time, when the classes are regenerated (when image files have changed) this would highlight potential issues.

In a past project we used TypeWriter which used reflection to look at project files and ran our own scripts to produce code files based on a template defined in our scripts.

I hate magic strings and just want this extra level of safety.

I guess to be complete, as well as the Xamarin shared project, it would also need to be availble in iOS and Android projects too.

Ideally I'd like to trigger the the script on file changes, but this could be ran manually.

I'm using Visual Studio for Mac, so there are less Nuget packes / Extensions.

I’m hoping I can easily extend this functionality to create constants for colors in my app.xml.cs.

Jules
  • 7,568
  • 14
  • 102
  • 186
  • Hi, how about using sqlite to store path of images and get it when need to use it? It should be security for data. – Junior Jiang Nov 30 '20 at 03:13
  • Thanks @JuniorJiang-MSFT you'd still need to generate this data and constants – Jules Nov 30 '20 at 07:50
  • Okey, you are using which way to store the image files? I will check whether it is possible to achieve that. – Junior Jiang Nov 30 '20 at 09:38
  • @JuniorJiang-MSFT no just the names of the files / paths, so they can use as constants e.g. AppLogo = "/whatever/app/logo.svg" – Jules Nov 30 '20 at 09:40
  • Okey, seems to understand a little bit. – Junior Jiang Nov 30 '20 at 09:47
  • 3
    This looks like a great use case for https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/ – Bijington Dec 08 '20 at 10:23
  • have you already considered a custom msbuild task? https://learn.microsoft.com/en-us/visualstudio/msbuild/task-writing?view=vs-2019 – Roald Dec 17 '20 at 09:34
  • No, would that give access to project objects and be able to iterate files? I’m guessing I’d have to write all that myself and just parse files like any text file? – Jules Dec 17 '20 at 09:36
  • EnvDte isn’t available in vs for Mac – Jules Dec 17 '20 at 09:41
  • @Roald could you point me an example showing how I could access project objects / write a cs file? – Jules Dec 18 '20 at 05:28
  • I can't really find a complete example that does what you want, information on msbuild is pretty scarce and scattered around. You would be able to read the data of your project like files etc but you won't be able to edit your .csproj like you would with vs automation. So you would need to add the files once to your project manually and then overwrite them with the task (maybe that's not necessary for sdk style projects). – Roald Dec 18 '20 at 17:06
  • How about an example which simple reads a file and writes to another as a build action. I could add a file to the project myself, as long as I could overwrite it. – Jules Dec 20 '20 at 07:06

2 Answers2

3

Like others pointed out in the comments this is a great use case for source generators.

I actually wanted this feature for quite some time now so I went ahead and wrote a proof of concept implementation:

namespace FileExplorer
{
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Text;
    using Microsoft.CodeAnalysis;
    using Microsoft.CodeAnalysis.CSharp;
    using Microsoft.CodeAnalysis.Text;

    [Generator]
    public class FileExplorerGenerator : ISourceGenerator
    {
        public void Initialize(GeneratorInitializationContext context)
        {
        }

        public void Execute(GeneratorExecutionContext context)
        {
            var filesByType = context.AdditionalFiles
                .Select(file =>
                {
                    var options = context.AnalyzerConfigOptions.GetOptions(file);

                    options.TryGetValue("build_metadata.AdditionalFiles.TypeName", out var typeName);
                    options.TryGetValue("build_metadata.AdditionalFiles.RelativeTo", out var relativeTo);
                    options.TryGetValue("build_metadata.AdditionalFiles.BrowseFrom", out var browseFrom);

                    return new { typeName, file.Path, relativeTo, browseFrom };
                })
                .Where(file => !string.IsNullOrEmpty(file.typeName) && !string.IsNullOrEmpty(file.relativeTo) && !string.IsNullOrEmpty(file.browseFrom))
                .GroupBy(file => file.typeName, file => File.Create(file.Path, file.relativeTo!, file.browseFrom!));

            foreach (var files in filesByType)
            {
                var (namespaceName, typeName) = SplitLast(files.Key!, '.');

                var root = Folder.Create(typeName, files.Where(file => ValidateFile(file, context)).ToArray());

                var result = @$"
                    namespace {namespaceName ?? "FileExplorer"}
                    {{
                        {Generate(root)}
                    }}";

                var formatted = SyntaxFactory.ParseCompilationUnit(result).NormalizeWhitespace().ToFullString();
                context.AddSource($"FileExplorer_{typeName}", SourceText.From(formatted, Encoding.UTF8));
            }            
        }

        static string Generate(Folder folder)
            => @$"               
                public static partial class {FormatIdentifier(folder.Name)}
                {{
                    {string.Concat(folder.Folders.Select(Generate))}
                    {string.Concat(folder.Files.Select(Generate))}
                }}";

        static string Generate(File file)
        {
            static string Escape(string segment) => $"@\"{segment.Replace("\"", "\"\"")}\"";

            var path = file.RuntimePath
                .Append(file.RuntimeName)
                .Select(Escape);

            return @$"public static readonly string @{FormatIdentifier(file.DesigntimeName)} = System.IO.Path.Combine({string.Join(", ", path)});";
        }

        static readonly DiagnosticDescriptor invalidFileSegment = new("FE0001", "Invalid path segment", "The path '{0}' contains some segments that are not valid as identifiers: {1}", "Naming", DiagnosticSeverity.Warning, true);

        static bool ValidateFile(File file, GeneratorExecutionContext context)
        {
            static bool IsInvalidIdentifier(string text)
                => char.IsDigit(text[0]) || text.Any(character => !char.IsDigit(character) && !char.IsLetter(character) && character != '_');

            var invalid = file.DesigntimePath
                .Append(file.DesigntimeName)
                .Where(IsInvalidIdentifier)
                .ToArray();

            if (invalid.Any())
            {
                var fullPath = Path.Combine(file.RuntimePath.Append(file.RuntimeName).ToArray());
                context.ReportDiagnostic(Diagnostic.Create(invalidFileSegment, Location.None, fullPath, string.Join(", ", invalid.Select(segment => $"'{segment}'"))));
            }

            return !invalid.Any();
        }
        
        static string FormatIdentifier(string text)
        {
            var result = text.ToCharArray();

            result[0] = char.ToUpper(result[0]);

            return new string(result);
        }

        static (string?, string) SplitLast(string text, char delimiter)
        {
            var index = text.LastIndexOf(delimiter);

            return index == -1
                ? (null, text)
                : (text.Substring(0, index), text.Substring(index + 1));
        }

        record File(IReadOnlyList<string> DesigntimePath, IReadOnlyList<string> RuntimePath, string DesigntimeName, string RuntimeName)
        {
            public IReadOnlyList<string> DesigntimePath { get; } = DesigntimePath;
            public IReadOnlyList<string> RuntimePath { get; } = RuntimePath;
            public string DesigntimeName { get; } = DesigntimeName;
            public string RuntimeName { get; } = RuntimeName;

            public static File Create(string absolutePath, string runtimeRoot, string designtimeRoot)
            {
                static string[] MakeRelative(string absolute, string to) =>
                    Path.GetDirectoryName(absolute.Replace('/', Path.DirectorySeparatorChar))!
                        .Split(new[] { to.Replace('/', Path.DirectorySeparatorChar) }, StringSplitOptions.None)
                        .Last()
                        .Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);

                var designtimePath = MakeRelative(absolutePath, designtimeRoot);
                var runtimePath = MakeRelative(absolutePath, runtimeRoot);

                return new File
                (
                    designtimePath,
                    runtimePath,
                    Path.GetFileNameWithoutExtension(absolutePath) + Path.GetExtension(absolutePath).Replace('.', '_'),
                    Path.GetFileName(absolutePath)
                );
            }
        }

        record Folder(string Name, IReadOnlyList<Folder> Folders, IReadOnlyList<File> Files)
        {
            public string Name { get; } = Name;
            public IReadOnlyList<Folder> Folders { get; } = Folders;
            public IReadOnlyList<File> Files { get; } = Files;

            public static Folder Create(string name, IReadOnlyList<File> files)
                => Create(name, files, 0);

            static Folder Create(string name, IReadOnlyList<File> files, int level)
            {
                var folders = files
                    .Where(file => file.DesigntimePath.Count > level)
                    .GroupBy(file => file.DesigntimePath[level])
                    .Select(next => Create(next.Key, next.ToArray(), level + 1))
                    .ToArray();

                return new Folder(name, folders, files.Where(file => file.DesigntimePath.Count == level).ToArray());
            }
        }
    }
}

in your project file you would specify the folders to generate constants for like this:

<ItemGroup>
    <AdditionalFiles Include="assets\**\*" RelativeTo="MyProject" BrowseFrom="MyProject/assets/mobile" TypeName="MyProject.Definitions.MobileAssets" CopyToOutputDirectory="PreserveNewest" />
    <AdditionalFiles Include="lang\**\*" RelativeTo="MyProject" BrowseFrom="MyProject/lang" TypeName="MyProject.Definitions.Languages" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

it will then generate constants like this:

using MyProject.Definitions;

Console.WriteLine(MobileAssets.App.Ios.Dialog.Cancel_0_1_png);
Console.WriteLine(MobileAssets.Sound.Aac.Damage.Fallsmall_m4a);
Console.WriteLine(Languages.En_US_lang);

Since the setup for a project with source generators has a few moving parts I uploaded the complete solution to github: sourcegen-fileexplorer

Editor support is still a bit shaky, it works pretty well in Visual Studio even though when editing the code for the generator itself it sometimes needs a restart, highlighting and completion are currently broken in Rider due to this.
Could't test it in Visual Studio for Mac, sorry.

Also I am not sure if this will integrate well into a Xamarin project but I don't think there should be too many problems.

Saving the output

You can save the generator output to disk by adding this to your .csproj:

<PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

<ItemGroup>
    <!-- Exclude the output of source generators from the compilation -->
    <Compile Remove="$(CompilerGeneratedFilesOutputPath)/**/*.cs" />
</ItemGroup>

A longer explanation can be found here: https://andrewlock.net/creating-a-source-generator-part-6-saving-source-generator-output-in-source-control/

Roald
  • 1,722
  • 11
  • 21
  • Thanks so much, I've added an issue to your [repo](https://github.com/roald-di/sourcegen-fileexplorer/issues/1) where I could go into more detail. I will add the bounty again if it expires and make sure you get the points – Jules Dec 15 '20 at 08:47
  • Thanks for efforts however I'm really looking for something which will create an actual file, which can be used at design time and runtime... Something which can be commited and reviewed when files change. I was hoping I could expand on the solution and use it to create things like colour constants from the app.xml etc too. – Jules Dec 15 '20 at 14:32
  • @Jules Sure no problem :) but I do think that a source generator is a better solution to this problem since, unlike a separate artifact, it can never be out of sync with the file system. maybe you can actually expand the solution.. you should be able to feed the app.xml to a source generator as well. – Roald Dec 15 '20 at 14:55
  • If I could only dump to a file – Jules Dec 15 '20 at 14:56
1

This is an example on how to do it with msbuild instead of source generators like in my other answer.

The custom task:

public class GeneratorTask : Task
{
    [Required]
    public string OutputFile { get; set; } = "";

    [Required]
    public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();

    [Required]
    public string TypeName { get; set; } = "";

    public override bool Execute()
    {
        if (string.IsNullOrWhiteSpace(OutputFile))
        {
            Log.LogError($"{nameof(OutputFile)} is not set");
            return false;
        }

        if (string.IsNullOrWhiteSpace(TypeName))
        {
            Log.LogError($"{nameof(TypeName)} is not set");
            return false;
        }

        try
        {
            var files = SourceFiles
                .Select(item => item.ItemSpec)
                .Distinct()
                .ToArray();

            var code = GenerateCode(files);

            var target = new FileInfo(OutputFile);

            if (target.Exists)
            {
                // Only try writing if the contents are different. Don't cause a rebuild
                var contents = File.ReadAllText(target.FullName, Encoding.UTF8);
                if (string.Equals(contents, code, StringComparison.Ordinal))
                {
                    return true;
                }
            }

            using var file = File.Open(target.FullName, FileMode.Create, FileAccess.Write, FileShare.None);
            using var sw = new StreamWriter(file, Encoding.UTF8);

            sw.Write(code);
        }
        catch (Exception e)
        {
            Log.LogErrorFromException(e);
            return false;
        }

        return true;
    }

    // Super simple codegen, see my other answer for something more sophisticated.
    string GenerateCode(IEnumerable<string> files)
    {
        var (namespaceName, typeName) = SplitLast(TypeName, '.');

        var code = $@"
// Generated code, do not edit.
namespace {namespaceName ?? "FileExplorer"}
{{
    public static class {typeName}
    {{
        {string.Join($"{Environment.NewLine}\t\t", files.Select(GenerateProperty))}
    }}
}}";

        static string GenerateProperty(string file)
        {
            var name = file
                .ToCharArray()
                .Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_')
                .ToArray();

            return $"public static readonly string {new string(name)} = \"{file.Replace("\\", "\\\\")}\";";
        }

        static (string?, string) SplitLast(string text, char delimiter)
        {
            var index = text.LastIndexOf(delimiter);

            return index == -1
                ? (null, text)
                : (text.Substring(0, index), text.Substring(index + 1));
        }

        return code;
    }
}

The FileExplorer.targets file:

<Project>

    <PropertyGroup>
        <ThisAssembly>$(MSBuildThisFileDirectory)bin\$(Configuration)\$(TargetFramework)\$(MSBuildThisFileName).dll</ThisAssembly>    
        <FirstRun>false</FirstRun>    
        <FirstRun Condition="!Exists('$(FileExplorerOutputFile)')">true</FirstRun>    
    </PropertyGroup>

    <UsingTask TaskName="$(MSBuildThisFileName).GeneratorTask" AssemblyFile="$(ThisAssembly)" />

    <!-- Pointing 'Outputs' to a non existing file will disable up-to-date checks and run the task every time, there's probably a better way -->
    <Target Name="FileExplorer" BeforeTargets="BeforeCompile;CoreCompile" Inputs="@(FileExplorerSourceFiles)" Outputs="$(FileExplorerOutputFile).nocache">
        
        <GeneratorTask SourceFiles="@(FileExplorerSourceFiles)" OutputFile="$(FileExplorerOutputFile)" TypeName="$(FileExplorerTypeName)" />
        
        <ItemGroup Condition="Exists('$(FileExplorerOutputFile)')">
            <FileWrites Include="$(FileExplorerOutputFile)" />
            <Compile Include="$(FileExplorerOutputFile)" Condition="$(FirstRun) == 'true'" />
        </ItemGroup>
    </Target>

</Project>

and then in your .csproj:

<PropertyGroup>
    <FileExplorerOutputFile>$(MSBuildThisFileDirectory)Assets.g.cs</FileExplorerOutputFile>
    <FileExplorerTypeName>FileExplorer.Definitions.Assets</FileExplorerTypeName>
</PropertyGroup>

<ItemGroup>
    <FileExplorerSourceFiles Include="assets\**\*" />
</ItemGroup>

<ItemGroup>
    <ProjectReference Include="..\FileExplorer\FileExplorer.csproj" />
</ItemGroup>

<Import Project="..\FileExplorer\FileExplorer.targets" />

this is the github repo with the complete example: msbuild-fileexplorer.


Tested in VS 2019 and Rider.
Keep in mind that I'm not a msbuild expert and this solution can probably be improved.

Roald
  • 1,722
  • 11
  • 21
  • I won’t be able to test this till January, but I’m sure it’s awesome ! Thanks. – Jules Dec 21 '20 at 17:50
  • thanks... I've made some progress... however the generate seems to fail writing out the constants... I've changed the SourceFile parameter... split it in two... but it still fails... some times after 30 lines, sometimes after 90... any ideas ? thanks... – Jules Jan 08 '21 at 12:08
  • @Jules does the error happen with the code on github or did you implement something yourself? do you have an error message? it's hard to tell what could be wrong without seeing the code, if you upload it somewhere maybe I can take a look – Roald Jan 08 '21 at 17:49
  • tbh I had a couple issues with the GitHub code. But I’ve got this working now in my own code. Also I had to drop down to .net framework 2 and fix some c# issues (older). Tbh atm my biggest issue is debugging the code, also I need to delete bin folders and restore nuget to test changes. But this isn’t a problem with your answer. I’m happy to get to this point – Jules Jan 08 '21 at 17:54