xamarinxamarin.formsvisual-studio-mac

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.


Solution

  • 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.