Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Xamarin, how to generate constants for images file names in the project?

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.

like image 710
Jules Avatar asked Nov 27 '20 10:11

Jules


1 Answers

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.

like image 94
Roald Avatar answered Oct 25 '22 13:10

Roald