Add "enum cloning" source generator

This commit is contained in:
goaaats 2026-01-10 16:57:18 +01:00
parent 55eb7e41d8
commit 8bb6cdd8d6
14 changed files with 395 additions and 0 deletions

View file

@ -75,6 +75,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumina.Excel.Generator", "l
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumina.Excel", "lib\Lumina.Excel\src\Lumina.Excel\Lumina.Excel.csproj", "{88FB719B-EB41-73C5-8D25-C03E0C69904F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source Generators", "Source Generators", "{50BEC23B-FFFD-427B-A95D-27E1D1958FFF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.EnumGenerator", "generators\Dalamud.EnumGenerator\Dalamud.EnumGenerator\Dalamud.EnumGenerator.csproj", "{27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.EnumGenerator.Sample", "generators\Dalamud.EnumGenerator\Dalamud.EnumGenerator.Sample\Dalamud.EnumGenerator.Sample.csproj", "{8CDAEB2D-5022-450A-A97F-181C6270185F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.EnumGenerator.Tests", "generators\Dalamud.EnumGenerator\Dalamud.EnumGenerator.Tests\Dalamud.EnumGenerator.Tests.csproj", "{F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -173,6 +181,18 @@ Global
{88FB719B-EB41-73C5-8D25-C03E0C69904F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{88FB719B-EB41-73C5-8D25-C03E0C69904F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88FB719B-EB41-73C5-8D25-C03E0C69904F}.Release|Any CPU.Build.0 = Release|Any CPU
{27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Debug|Any CPU.ActiveCfg = Debug|x64
{27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Debug|Any CPU.Build.0 = Debug|x64
{27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Release|Any CPU.ActiveCfg = Release|x64
{27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Release|Any CPU.Build.0 = Release|x64
{8CDAEB2D-5022-450A-A97F-181C6270185F}.Debug|Any CPU.ActiveCfg = Debug|x64
{8CDAEB2D-5022-450A-A97F-181C6270185F}.Debug|Any CPU.Build.0 = Debug|x64
{8CDAEB2D-5022-450A-A97F-181C6270185F}.Release|Any CPU.ActiveCfg = Release|x64
{8CDAEB2D-5022-450A-A97F-181C6270185F}.Release|Any CPU.Build.0 = Release|x64
{F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Debug|Any CPU.ActiveCfg = Debug|x64
{F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Debug|Any CPU.Build.0 = Debug|x64
{F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Release|Any CPU.ActiveCfg = Release|x64
{F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Release|Any CPU.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -197,6 +217,9 @@ Global
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {E15BDA6D-E881-4482-94BA-BE5527E917FF}
{5A44DF0C-C9DA-940F-4D6B-4A11D13AEA3D} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{88FB719B-EB41-73C5-8D25-C03E0C69904F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8} = {50BEC23B-FFFD-427B-A95D-27E1D1958FFF}
{8CDAEB2D-5022-450A-A97F-181C6270185F} = {50BEC23B-FFFD-427B-A95D-27E1D1958FFF}
{F5D92D2D-D36F-4471-B657-8B9AA6C98AD6} = {50BEC23B-FFFD-427B-A95D-27E1D1958FFF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {79B65AC9-C940-410E-AB61-7EA7E12C7599}

View file

@ -88,6 +88,15 @@
<PackageReference Include="TerraFX.Interop.Windows" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\generators\Dalamud.EnumGenerator\Dalamud.EnumGenerator\Dalamud.EnumGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>
<ItemGroup>
<None Remove="EnumCloneMap.txt"/>
<AdditionalFiles Include="EnumCloneMap.txt" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Interface\ImGuiBackend\Renderers\imgui-frag.hlsl.bytes">
<LogicalName>imgui-frag.hlsl.bytes</LogicalName>

3
Dalamud/EnumCloneMap.txt Normal file
View file

@ -0,0 +1,3 @@
# Format: Target.Full.TypeName = Source.Full.EnumTypeName
# Example: Generate a local enum MyGeneratedEnum in namespace Sample.Gen mapped to SourceEnums.SampleSourceEnum
Dalamud.Game.Agent.AgentId = FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentId

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<RootNamespace>Dalamud.EnumGenerator.Sample</RootNamespace>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Dalamud.EnumGenerator\Dalamud.EnumGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>
<ItemGroup>
<None Remove="EnumCloneMap.txt"/>
<AdditionalFiles Include="EnumCloneMap.txt" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,4 @@
# Format: Target.Full.TypeName = Source.Full.EnumTypeName
# Example: Generate a local enum MyGeneratedEnum in namespace Sample.Gen mapped to SourceEnums.SampleSourceEnum
Dalamud.EnumGenerator.Sample.Gen.MyGeneratedEnum = Dalamud.EnumGenerator.Sample.SourceEnums.SampleSourceEnum

View file

@ -0,0 +1,9 @@
namespace Dalamud.EnumGenerator.Sample.SourceEnums
{
public enum SampleSourceEnum : long
{
First = 1,
Second = 2,
Third = 10000000000L
}
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Dalamud.EnumGenerator.Tests</RootNamespace>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.XUnit" Version="1.1.1"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.2"/>
<PackageReference Include="xunit" Version="2.4.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Dalamud.EnumGenerator\Dalamud.EnumGenerator.csproj"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,47 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;
namespace Dalamud.EnumGenerator.Tests;
public class EnumCloneMapTests
{
[Fact]
public void ParseMappings_SimpleLines_ParsesCorrectly()
{
var text = @"# Comment line
My.Namespace.Target = Other.Namespace.Source
Another.Target = Some.Source";
var results = Dalamud.EnumGenerator.EnumCloneGenerator.ParseMappings(text);
Assert.Equal(2, results.Length);
Assert.Equal("My.Namespace.Target", results[0].TargetFullName);
Assert.Equal("Other.Namespace.Source", results[0].SourceFullName);
Assert.Equal("Another.Target", results[1].TargetFullName);
}
[Fact]
public void Generator_ProducesFile_WhenSourceResolved()
{
// We'll create a compilation that contains a source enum type and add an AdditionalText mapping
var sourceEnum = @"namespace Foo.Bar { public enum SourceEnum { A = 1, B = 2 } }";
var mapText = "GeneratedNs.TargetEnum = Foo.Bar.SourceEnum";
var generator = new EnumCloneGenerator();
var driver = CSharpGeneratorDriver.Create(generator)
.AddAdditionalTexts(ImmutableArray.Create<AdditionalText>(new Utils.TestAdditionalFile("EnumCloneMap.txt", mapText)));
var compilation = CSharpCompilation.Create("TestGen", [CSharpSyntaxTree.ParseText(sourceEnum)],
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]);
driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics);
var generated = newCompilation.SyntaxTrees.Select(t => t.FilePath).Where(p => p.EndsWith("TargetEnum.CloneEnum.g.cs")).ToArray();
Assert.Single(generated);
}
}

View file

@ -0,0 +1,21 @@
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace Dalamud.EnumGenerator.Tests.Utils;
public class TestAdditionalFile : AdditionalText
{
private readonly SourceText text;
public TestAdditionalFile(string path, string text)
{
Path = path;
this.text = SourceText.From(text);
}
public override SourceText GetText(CancellationToken cancellationToken = new()) => this.text;
public override string Path { get; }
}

View file

@ -0,0 +1,3 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

View file

@ -0,0 +1,9 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
ENUMGEN001 | EnumGenerator | Warning | SourceGeneratorWithAttributes
ENUMGEN002 | EnumGenerator | Warning | SourceGeneratorWithAttributes

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<RootNamespace>Dalamud.EnumGenerator</RootNamespace>
<PackageId>Dalamud.EnumGenerator</PackageId>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.3.0"/>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,181 @@
using System;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Globalization;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace Dalamud.EnumGenerator;
[Generator]
public class EnumCloneGenerator : IIncrementalGenerator
{
private const string NewLine = "\r\n";
private const string MappingFileName = "EnumCloneMap.txt";
private static readonly DiagnosticDescriptor MissingSourceDescriptor = new(
id: "ENUMGEN001",
title: "Source enum not found",
messageFormat: "Source enum '{0}' could not be resolved by the compilation",
category: "EnumGenerator",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
private static readonly DiagnosticDescriptor DuplicateTargetDescriptor = new(
id: "ENUMGEN002",
title: "Duplicate target mapping",
messageFormat: "Target enum '{0}' is mapped multiple times; generation skipped for this target",
category: "EnumGenerator",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Read mappings from additional files named EnumCloneMap.txt
var mappingEntries = context.AdditionalTextsProvider
.Where(at => Path.GetFileName(at.Path).Equals(MappingFileName, StringComparison.OrdinalIgnoreCase))
.SelectMany((at, _) => ParseMappings(at.GetText()?.ToString() ?? string.Empty));
// Combine with compilation so we can resolve types
var compilationAndMaps = context.CompilationProvider.Combine(mappingEntries.Collect());
context.RegisterSourceOutput(compilationAndMaps, (spc, pair) =>
{
var compilation = pair.Left;
var maps = pair.Right;
// Detect duplicate targets first and report diagnostics
var duplicateTargets = maps.GroupBy(m => m.TargetFullName, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToImmutableArray();
foreach (var dup in duplicateTargets)
{
var diag = Diagnostic.Create(DuplicateTargetDescriptor, Location.None, dup);
spc.ReportDiagnostic(diag);
}
foreach (var (targetFullName, sourceFullName) in maps)
{
if (string.IsNullOrWhiteSpace(targetFullName) || string.IsNullOrWhiteSpace(sourceFullName))
continue;
if (duplicateTargets.Contains(targetFullName, StringComparer.OrdinalIgnoreCase))
continue;
// Resolve the source enum type by metadata name (namespace.type)
var sourceSymbol = compilation.GetTypeByMetadataName(sourceFullName);
if (sourceSymbol is null)
{
// Report diagnostic for missing source type
var diag = Diagnostic.Create(MissingSourceDescriptor, Location.None, sourceFullName);
spc.ReportDiagnostic(diag);
continue;
}
if (sourceSymbol.TypeKind != TypeKind.Enum)
continue;
var sourceNamed = sourceSymbol; // GetTypeByMetadataName already returns INamedTypeSymbol
// Split target into namespace and type name
string? targetNamespace = null;
var targetName = targetFullName;
var lastDot = targetFullName.LastIndexOf('.');
if (lastDot >= 0)
{
targetNamespace = targetFullName.Substring(0, lastDot);
targetName = targetFullName.Substring(lastDot + 1);
}
var underlyingType = sourceNamed.EnumUnderlyingType;
var underlyingDisplay = underlyingType?.ToDisplayString() ?? "int";
var fields = sourceNamed.GetMembers()
.OfType<IFieldSymbol>()
.Where(f => f.IsStatic && f.HasConstantValue)
.ToArray();
var memberLines = fields.Select(f =>
{
var name = f.Name;
var constValue = f.ConstantValue;
string literal;
var st = underlyingType?.SpecialType ?? SpecialType.System_Int32;
if (constValue is null)
{
literal = "0";
}
else if (st == SpecialType.System_UInt64)
{
literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) + "UL";
}
else if (st == SpecialType.System_UInt32)
{
literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) + "U";
}
else if (st == SpecialType.System_Int64)
{
literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) + "L";
}
else
{
literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) ?? throw new InvalidOperationException("Unable to convert enum constant value to string.");
}
return $" {name} = {literal},";
});
var membersText = string.Join(NewLine, memberLines);
var nsPrefix = targetNamespace is null ? string.Empty : $"namespace {targetNamespace};" + NewLine + NewLine;
var code = "// <auto-generated/>" + NewLine + NewLine
+ nsPrefix
+ $"public enum {targetName} : {underlyingDisplay}" + NewLine
+ "{" + NewLine
+ membersText + NewLine
+ "}" + NewLine;
var hintName = $"{targetName}.CloneEnum.g.cs";
spc.AddSource(hintName, SourceText.From(code, Encoding.UTF8));
}
});
}
internal static ImmutableArray<(string TargetFullName, string SourceFullName)> ParseMappings(string text)
{
var builder = ImmutableArray.CreateBuilder<(string, string)>();
using var reader = new StringReader(text);
string? line;
while ((line = reader.ReadLine()) != null)
{
// Remove comments starting with #
var commentIndex = line.IndexOf('#');
var content = commentIndex >= 0 ? line.Substring(0, commentIndex) : line;
content = content.Trim();
if (string.IsNullOrEmpty(content))
continue;
// Expected format: Target.Full.Name = Source.Full.Name
var idx = content.IndexOf('=');
if (idx <= 0)
continue;
var left = content.Substring(0, idx).Trim();
var right = content.Substring(idx + 1).Trim();
if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right))
continue;
builder.Add((left, right));
}
return builder.ToImmutable();
}
}

View file

@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Dalamud.EnumGenerator.Tests")]