From 61ee3a8f0c02aac7100a0033f87c8d2a0da05698 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:22:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(Godot.SourceGenerators):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20Godot=20=E9=A1=B9=E7=9B=AE=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 project.godot 文件解析功能,支持 AutoLoad 和 Input Action 元数据提取 - 生成 AutoLoads 强类型访问入口,提供 GetRequiredNode 和 TryGetNode 方法 - 生成 InputActions 常量类,避免手写字符串魔法值 - 添加 AutoLoadAttribute 特性支持显式类型映射声明 - 实现标识符冲突检测和自动后缀追加机制 - 添加完整的诊断系统支持,包括类型继承检查和重复条目警告 - 创建 MSBuild 集成目标文件确保生成器正确加载 - 提供详细的 README 文档说明使用方法和最佳实践 --- .../AutoLoadAttribute.cs | 30 + .../Core/AdditionalTextGeneratorTestDriver.cs | 111 +++ .../GodotProjectMetadataGeneratorTests.cs | 319 ++++++++ .../AnalyzerReleases.Unshipped.md | 6 + .../Diagnostics/GodotProjectDiagnostics.cs | 75 ++ ....GFramework.Godot.SourceGenerators.targets | 14 +- .../GodotProjectMetadataGenerator.cs | 763 ++++++++++++++++++ GFramework.Godot.SourceGenerators/README.md | 88 ++ 8 files changed, 1405 insertions(+), 1 deletion(-) create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs create mode 100644 GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs create mode 100644 GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs diff --git a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs new file mode 100644 index 00000000..e406f8b6 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs @@ -0,0 +1,30 @@ +#nullable enable +namespace GFramework.Godot.SourceGenerators.Abstractions; + +/// +/// 显式声明某个 Godot 节点类型与 project.godot 中 AutoLoad 名称之间的映射关系。 +/// +/// +/// 当 AutoLoad 条目无法仅靠类型名唯一推断到 C# 节点类型时, +/// 可以通过该特性为生成器提供稳定的强类型映射入口。 +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class AutoLoadAttribute : Attribute +{ + /// + /// 初始化 的新实例。 + /// + /// project.godot 中声明的 AutoLoad 名称。 + /// + /// 。 + /// + public AutoLoadAttribute(string name) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// 获取在 project.godot 中声明的 AutoLoad 名称。 + /// + public string Name { get; } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs new file mode 100644 index 00000000..f1aaa03f --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs @@ -0,0 +1,111 @@ +using System.Collections.Immutable; +using System.IO; + +namespace GFramework.Godot.SourceGenerators.Tests.Core; + +/// +/// 提供基于 的源生成器测试驱动。 +/// +public static class AdditionalTextGeneratorTestDriver +{ + /// + /// 运行指定的增量生成器,并返回生成结果。 + /// + /// 要运行的生成器类型。 + /// 输入源码。 + /// AdditionalFiles 集合。 + /// 生成器运行结果。 + public static GeneratorDriverRunResult Run( + string source, + params (string path, string content)[] additionalFiles) + where TGenerator : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + typeof(TGenerator).Name + "Tests", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var additionalTexts = additionalFiles + .Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content)) + .ToImmutableArray(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new[] { new TGenerator().AsSourceGenerator() }, + additionalTexts: additionalTexts, + parseOptions: (CSharpParseOptions)syntaxTree.Options); + + driver = driver.RunGenerators(compilation); + return driver.GetRunResult(); + } + + /// + /// 将生成结果转换为文件名到文本的映射,便于断言。 + /// + /// 生成器运行结果。 + /// 按 HintName 索引的生成源码。 + public static IReadOnlyDictionary ToGeneratedSourceMap(GeneratorDriverRunResult result) + { + return result.Results + .Single() + .GeneratedSources + .ToDictionary( + static item => item.HintName, + static item => NormalizeLineEndings(item.SourceText.ToString()), + StringComparer.Ordinal); + } + + /// + /// 规范化换行,避免测试在不同平台上产生伪差异。 + /// + /// 待规范化文本。 + /// 使用当前平台换行符的内容。 + public static string NormalizeLineEndings(string content) + { + return content + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal) + .Replace("\n", Environment.NewLine, StringComparison.Ordinal); + } + + private static IEnumerable GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))? + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + ?? Array.Empty(); + + return trustedPlatformAssemblies + .Select(static path => MetadataReference.CreateFromFile(path)); + } + + /// + /// 用于测试 AdditionalFiles 的内存实现。 + /// + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _text; + + /// + /// 初始化一个内存 AdditionalText。 + /// + /// 虚拟文件路径。 + /// 文件内容。 + public InMemoryAdditionalText( + string path, + string content) + { + Path = path; + _text = SourceText.From(content); + } + + /// + public override string Path { get; } + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs new file mode 100644 index 00000000..252e7e1e --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs @@ -0,0 +1,319 @@ +using GFramework.Godot.SourceGenerators.Tests.Core; + +namespace GFramework.Godot.SourceGenerators.Tests.Project; + +/// +/// 验证基于 project.godot 的项目元数据生成行为。 +/// +[TestFixture] +public class GodotProjectMetadataGeneratorTests +{ + /// + /// 验证会根据 AutoLoad 与 Input Action 生成稳定的强类型入口。 + /// + [Test] + public void Run_Should_Generate_AutoLoads_And_InputActions() + { + const string source = """ + using System; + + namespace GFramework.Godot.SourceGenerators.Abstractions + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoLoadAttribute : Attribute + { + public AutoLoadAttribute(string name) + { + Name = name; + } + + public string Name { get; } + } + } + + namespace Godot + { + public class MainLoop + { + } + + public class Node + { + public T? GetNodeOrNull(string path) where T : Node => default; + } + + public sealed class SceneTree : MainLoop + { + public Node? Root { get; set; } + } + + public static class Engine + { + public static MainLoop? GetMainLoop() => default; + } + } + + namespace TestApp + { + using GFramework.Godot.SourceGenerators.Abstractions; + using Godot; + + [AutoLoad("GameServices")] + public partial class GameServices : Node + { + } + } + """; + + const string projectFile = """ + [autoload] + GameServices="*res://autoload/game_services.tscn" + AudioBus="*res://autoload/audio_bus.gd" + + [input] + move_up={ + "deadzone": 0.5 + } + ui_cancel={ + "deadzone": 0.5 + } + """; + + const string expectedAutoLoads = """ + // + #nullable enable + + namespace GFramework.Godot.Generated; + + /// + /// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。 + /// + public static partial class AutoLoads + { + /// + /// 获取 AutoLoad GameServices。 + /// + public static global::TestApp.GameServices GameServices => GetRequiredNode("GameServices"); + + /// + /// 尝试获取 AutoLoad GameServices。 + /// + public static bool TryGetGameServices(out global::TestApp.GameServices? value) + { + return TryGetNode("GameServices", out value); + } + + /// + /// 获取 AutoLoad AudioBus。 + /// + public static global::Godot.Node AudioBus => GetRequiredNode("AudioBus"); + + /// + /// 尝试获取 AutoLoad AudioBus。 + /// + public static bool TryGetAudioBus(out global::Godot.Node? value) + { + return TryGetNode("AudioBus", out value); + } + + /// + /// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。 + /// + /// 节点类型。 + /// AutoLoad 名称。 + /// 已解析的 AutoLoad 节点。 + private static TNode GetRequiredNode(string autoLoadName) + where TNode : global::Godot.Node + { + if (TryGetNode(autoLoadName, out TNode? value)) + { + return value!; + } + + throw new global::System.InvalidOperationException($"AutoLoad '{autoLoadName}' is not available on the active SceneTree root."); + } + + /// + /// 尝试从当前 SceneTree 根节点解析 AutoLoad。 + /// + /// 节点类型。 + /// AutoLoad 名称。 + /// 解析到的节点实例。 + /// 若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad,则返回 true + private static bool TryGetNode(string autoLoadName, out TNode? value) + where TNode : global::Godot.Node + { + value = default; + + if (global::Godot.Engine.GetMainLoop() is not global::Godot.SceneTree sceneTree) + { + return false; + } + + var root = sceneTree.Root; + if (root is null) + { + return false; + } + + value = root.GetNodeOrNull($"/root/{autoLoadName}"); + return value is not null; + } + } + + """; + + const string expectedInputActions = """ + // + #nullable enable + + namespace GFramework.Godot.Generated; + + /// + /// 提供 project.godot 中 Input Action 名称的强类型常量。 + /// + public static partial class InputActions + { + /// + /// Input Action move_up 的稳定名称。 + /// + public const string MoveUp = "move_up"; + + /// + /// Input Action ui_cancel 的稳定名称。 + /// + public const string UiCancel = "ui_cancel"; + + } + + """; + + var result = AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Is.EqualTo(AdditionalTextGeneratorTestDriver.NormalizeLineEndings(expectedAutoLoads))); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Is.EqualTo(AdditionalTextGeneratorTestDriver.NormalizeLineEndings(expectedInputActions))); + } + + /// + /// 验证 [AutoLoad] 标记在非节点类型上时会产生诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AutoLoad_Type_Does_Not_Derive_From_Node() + { + const string source = """ + using System; + + namespace GFramework.Godot.SourceGenerators.Abstractions + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoLoadAttribute : Attribute + { + public AutoLoadAttribute(string name) + { + Name = name; + } + + public string Name { get; } + } + } + + namespace Godot + { + public class Node + { + } + } + + namespace TestApp + { + using GFramework.Godot.SourceGenerators.Abstractions; + + [AutoLoad("ConfigHub")] + public partial class ConfigHub + { + } + } + """; + + const string projectFile = """ + [autoload] + ConfigHub="*res://autoload/config_hub.tscn" + """; + + var result = AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_Godot_Project_001")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("ConfigHub")); + }); + } + + /// + /// 验证 Input Action 标识符冲突时会追加稳定后缀并给出警告。 + /// + [Test] + public void Run_Should_Report_Diagnostic_And_Append_Suffix_When_Input_Action_Identifiers_Collide() + { + const string source = """ + namespace Godot + { + public class MainLoop + { + } + + public class Node + { + public T? GetNodeOrNull(string path) where T : Node => default; + } + + public sealed class SceneTree : MainLoop + { + public Node? Root { get; set; } + } + + public static class Engine + { + public static MainLoop? GetMainLoop() => default; + } + } + """; + + const string projectFile = """ + [input] + move_up={ + } + move-up={ + } + """; + + var result = AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_004" })); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Contain("public const string MoveUp = \"move_up\";")); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Contain("public const string MoveUp_2 = \"move-up\";")); + } +} diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md index 73b213f9..cb47a83d 100644 --- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -33,3 +33,9 @@ GF_AutoExport_006 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_007 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_008 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics + GF_Godot_Project_001 | GFramework.Godot | Error | GodotProjectDiagnostics + GF_Godot_Project_002 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_003 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_004 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_005 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_006 | GFramework.Godot | Warning | GodotProjectDiagnostics diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs new file mode 100644 index 00000000..2533372e --- /dev/null +++ b/GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs @@ -0,0 +1,75 @@ +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.Godot.SourceGenerators.Diagnostics; + +/// +/// 基于 project.godot 的项目元数据生成相关诊断。 +/// +public static class GodotProjectDiagnostics +{ + /// + /// 标记了 [AutoLoad] 的类型必须继承自 Godot.Node。 + /// + public static readonly DiagnosticDescriptor AutoLoadTypeMustDeriveFromNode = new( + "GF_Godot_Project_001", + "AutoLoad types must derive from Godot.Node", + "Type '{0}' uses [AutoLoad] but does not derive from Godot.Node", + PathContests.GodotNamespace, + DiagnosticSeverity.Error, + true); + + /// + /// 多个类型映射到同一 AutoLoad 名称时会退化为非强类型访问。 + /// + public static readonly DiagnosticDescriptor DuplicateAutoLoadMapping = new( + "GF_Godot_Project_002", + "Duplicate AutoLoad mappings were found", + "AutoLoad '{0}' is mapped by multiple types ({1}); the generated accessor falls back to Godot.Node until the mapping is unique", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 多个 AutoLoad 名称映射到同一个标识符时会追加稳定后缀。 + /// + public static readonly DiagnosticDescriptor AutoLoadIdentifierCollision = new( + "GF_Godot_Project_003", + "Generated AutoLoad identifier collision", + "AutoLoad '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 多个 Input Action 名称映射到同一个标识符时会追加稳定后缀。 + /// + public static readonly DiagnosticDescriptor InputActionIdentifierCollision = new( + "GF_Godot_Project_004", + "Generated Input Action identifier collision", + "Input action '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 同一个 project.godot 中存在重复 AutoLoad 条目。 + /// + public static readonly DiagnosticDescriptor DuplicateAutoLoadEntry = new( + "GF_Godot_Project_005", + "Duplicate AutoLoad entry in project.godot", + "AutoLoad '{0}' is declared multiple times in project.godot; only the first declaration is used", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 同一个 project.godot 中存在重复 Input Action 条目。 + /// + public static readonly DiagnosticDescriptor DuplicateInputActionEntry = new( + "GF_Godot_Project_006", + "Duplicate Input Action entry in project.godot", + "Input action '{0}' is declared multiple times in project.godot; only the first declaration is used", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); +} diff --git a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets index 37971c98..cf19200c 100644 --- a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets +++ b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets @@ -3,14 +3,26 @@ + + + project.godot + + + + + + - \ No newline at end of file + diff --git a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs new file mode 100644 index 00000000..ba3ddc21 --- /dev/null +++ b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs @@ -0,0 +1,763 @@ +using GFramework.Godot.SourceGenerators.Diagnostics; +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.Godot.SourceGenerators; + +/// +/// 读取 project.godot 项目元数据,并生成 AutoLoad 与 Input Action 的强类型访问入口。 +/// +/// +/// 该生成器把 Godot 项目层面的事实模型暴露为稳定的编译期 API: +/// +/// +/// [autoload] 段生成统一访问入口,并在可唯一解析到 C# 节点类型时生成强类型属性。 +/// +/// +/// [input] 段生成输入动作常量,避免手写魔法字符串。 +/// +/// +/// 对于类型映射冲突或标识符冲突,该生成器会优先给出诊断并退化为可工作的稳定输出,而不是静默生成不确定代码。 +/// +[Generator] +public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator +{ + private const string ProjectFileName = "project.godot"; + private const string GeneratedNamespace = $"{PathContests.GodotNamespace}.Generated"; + + private const string AutoLoadAttributeMetadataName = + $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoLoadAttribute"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var projectFiles = context.AdditionalTextsProvider + .Where(static file => + string.Equals(Path.GetFileName(file.Path), ProjectFileName, StringComparison.OrdinalIgnoreCase)) + .Select(static (file, cancellationToken) => ParseProjectFile(file, cancellationToken)) + .Collect(); + + var typeCandidates = context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax, + static (syntaxContext, _) => TransformTypeCandidate(syntaxContext)) + .Where(static candidate => candidate is not null) + .Collect(); + + var generationInput = context.CompilationProvider.Combine(projectFiles).Combine(typeCandidates); + context.RegisterSourceOutput(generationInput, static (productionContext, input) => + Execute(productionContext, input.Left.Left, input.Left.Right, input.Right)); + } + + private static GodotTypeCandidate? TransformTypeCandidate(GeneratorSyntaxContext context) + { + if (context.Node is not ClassDeclarationSyntax classDeclaration) + return null; + + if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol) + return null; + + return new GodotTypeCandidate(classDeclaration, typeSymbol); + } + + private static void Execute( + SourceProductionContext context, + Compilation compilation, + ImmutableArray projectFileResults, + ImmutableArray typeCandidates) + { + if (projectFileResults.IsDefaultOrEmpty) + return; + + var projectResult = projectFileResults + .OrderBy(static result => result.FilePath, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + foreach (var diagnostic in projectResult.Diagnostics) + { + context.ReportDiagnostic(diagnostic); + } + + var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node"); + if (godotNodeSymbol is null) + return; + + var autoLoadAttributeSymbol = compilation.GetTypeByMetadataName(AutoLoadAttributeMetadataName); + var concreteCandidates = typeCandidates + .Where(static candidate => candidate is not null) + .Select(static candidate => candidate!) + .ToArray(); + + var typedMappings = BuildTypedMappings( + context, + projectResult, + concreteCandidates, + autoLoadAttributeSymbol, + godotNodeSymbol); + + if (projectResult.AutoLoads.Length > 0) + { + var autoLoadMembers = CreateAutoLoadMembers(context, projectResult, typedMappings); + context.AddSource( + "GFramework_Godot_Generated_AutoLoads.g.cs", + SourceText.From(GenerateAutoLoadSource(autoLoadMembers), Encoding.UTF8)); + } + + if (projectResult.InputActions.Length > 0) + { + var inputActionMembers = CreateInputActionMembers(context, projectResult); + context.AddSource( + "GFramework_Godot_Generated_InputActions.g.cs", + SourceText.From(GenerateInputActionsSource(inputActionMembers), Encoding.UTF8)); + } + } + + private static Dictionary BuildTypedMappings( + SourceProductionContext context, + ProjectMetadataParseResult projectResult, + IReadOnlyList typeCandidates, + INamedTypeSymbol? autoLoadAttributeSymbol, + INamedTypeSymbol godotNodeSymbol) + { + var projectAutoLoadNames = new HashSet( + projectResult.AutoLoads.Select(static entry => entry.Name), + StringComparer.Ordinal); + + var explicitMappings = new Dictionary>(StringComparer.Ordinal); + var implicitCandidates = new Dictionary>(StringComparer.Ordinal); + + foreach (var candidate in typeCandidates) + { + var typeSymbol = candidate.TypeSymbol; + var derivesFromNode = typeSymbol.IsAssignableTo(godotNodeSymbol); + + if (derivesFromNode) + { + if (!implicitCandidates.TryGetValue(typeSymbol.Name, out var implicitList)) + { + implicitList = new List(); + implicitCandidates.Add(typeSymbol.Name, implicitList); + } + + implicitList.Add(typeSymbol); + } + + if (autoLoadAttributeSymbol is null) + continue; + + var autoLoadAttribute = typeSymbol.GetAttributes() + .FirstOrDefault(attribute => + SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, autoLoadAttributeSymbol)); + + if (autoLoadAttribute is null) + continue; + + if (!derivesFromNode) + { + context.ReportDiagnostic(Diagnostic.Create( + GodotProjectDiagnostics.AutoLoadTypeMustDeriveFromNode, + candidate.ClassDeclaration.Identifier.GetLocation(), + typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + continue; + } + + if (!TryGetAutoLoadName(autoLoadAttribute, out var autoLoadName)) + continue; + + if (!projectAutoLoadNames.Contains(autoLoadName)) + continue; + + if (!explicitMappings.TryGetValue(autoLoadName, out var explicitList)) + { + explicitList = new List(); + explicitMappings.Add(autoLoadName, explicitList); + } + + explicitList.Add(typeSymbol); + } + + var resolvedMappings = new Dictionary(StringComparer.Ordinal); + + foreach (var projectAutoLoadName in projectAutoLoadNames.OrderBy(static name => name, StringComparer.Ordinal)) + { + if (explicitMappings.TryGetValue(projectAutoLoadName, out var explicitList)) + { + var distinctExplicitTypes = DistinctTypeSymbols(explicitList); + + if (distinctExplicitTypes.Length == 1) + { + resolvedMappings.Add(projectAutoLoadName, distinctExplicitTypes[0]); + } + else if (distinctExplicitTypes.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateAutoLoadMapping, + Location.None, + projectAutoLoadName, + string.Join( + ", ", + distinctExplicitTypes.Select(static type => + type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))))); + } + + continue; + } + + if (!implicitCandidates.TryGetValue(projectAutoLoadName, out var implicitList)) + continue; + + var distinctImplicitTypes = DistinctTypeSymbols(implicitList); + + if (distinctImplicitTypes.Length == 1) + resolvedMappings.Add(projectAutoLoadName, distinctImplicitTypes[0]); + } + + return resolvedMappings; + } + + private static bool TryGetAutoLoadName(AttributeData attribute, out string autoLoadName) + { + autoLoadName = string.Empty; + + if (attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Value is not string rawName || + string.IsNullOrWhiteSpace(rawName)) + { + return false; + } + + autoLoadName = rawName; + return true; + } + + private static IReadOnlyList CreateAutoLoadMembers( + SourceProductionContext context, + ProjectMetadataParseResult projectResult, + IReadOnlyDictionary typedMappings) + { + var identifierCounts = new Dictionary(StringComparer.Ordinal); + var members = new List(projectResult.AutoLoads.Length); + + foreach (var entry in projectResult.AutoLoads) + { + var baseIdentifier = SanitizeIdentifier(entry.Name, "AutoLoad"); + var identifier = ResolveUniqueIdentifier( + context, + identifierCounts, + entry.Name, + baseIdentifier, + GodotProjectDiagnostics.AutoLoadIdentifierCollision); + + var typeName = typedMappings.TryGetValue(entry.Name, out var typeSymbol) + ? typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : "global::Godot.Node"; + + members.Add(new GeneratedAutoLoadMember(entry.Name, identifier, typeName, entry.ResourcePath)); + } + + return members; + } + + private static IReadOnlyList CreateInputActionMembers( + SourceProductionContext context, + ProjectMetadataParseResult projectResult) + { + var identifierCounts = new Dictionary(StringComparer.Ordinal); + var members = new List(projectResult.InputActions.Length); + + foreach (var actionName in projectResult.InputActions) + { + var baseIdentifier = SanitizeIdentifier(actionName, "Action"); + var identifier = ResolveUniqueIdentifier( + context, + identifierCounts, + actionName, + baseIdentifier, + GodotProjectDiagnostics.InputActionIdentifierCollision); + + members.Add(new GeneratedInputActionMember(actionName, identifier)); + } + + return members; + } + + private static string ResolveUniqueIdentifier( + SourceProductionContext context, + IDictionary identifierCounts, + string originalName, + string baseIdentifier, + DiagnosticDescriptor collisionDiagnostic) + { + if (!identifierCounts.TryGetValue(baseIdentifier, out var count)) + { + identifierCounts.Add(baseIdentifier, 1); + return baseIdentifier; + } + + count++; + identifierCounts[baseIdentifier] = count; + + context.ReportDiagnostic(Diagnostic.Create( + collisionDiagnostic, + Location.None, + originalName, + baseIdentifier)); + + return $"{baseIdentifier}_{count}"; + } + + private static INamedTypeSymbol[] DistinctTypeSymbols(IEnumerable types) + { + var results = new List(); + + foreach (var type in types) + { + if (results.Any(existing => SymbolEqualityComparer.Default.Equals(existing, type))) + continue; + + results.Add(type); + } + + return results.ToArray(); + } + + private static string SanitizeIdentifier( + string rawName, + string fallbackPrefix) + { + var tokens = new List(); + var tokenBuilder = new StringBuilder(); + + foreach (var character in rawName) + { + if (char.IsLetterOrDigit(character)) + { + tokenBuilder.Append(character); + continue; + } + + FlushToken(tokens, tokenBuilder); + } + + FlushToken(tokens, tokenBuilder); + + var identifier = tokens.Count == 0 + ? fallbackPrefix + : string.Concat(tokens); + + if (string.IsNullOrWhiteSpace(identifier)) + identifier = fallbackPrefix; + + if (!SyntaxFacts.IsIdentifierStartCharacter(identifier[0])) + identifier = fallbackPrefix + identifier; + + return SyntaxFacts.GetKeywordKind(identifier) != SyntaxKind.None + ? identifier + "Value" + : identifier; + } + + private static void FlushToken( + ICollection tokens, + StringBuilder tokenBuilder) + { + if (tokenBuilder.Length == 0) + return; + + var token = tokenBuilder.ToString(); + tokenBuilder.Clear(); + + if (token.Length == 1) + { + tokens.Add(token.ToUpperInvariant()); + return; + } + + tokens.Add(char.ToUpperInvariant(token[0]) + token.Substring(1)); + } + + private static string GenerateAutoLoadSource(IReadOnlyList members) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {GeneratedNamespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine("/// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。"); + builder.AppendLine("/// "); + builder.AppendLine("public static partial class AutoLoads"); + builder.AppendLine("{"); + + foreach (var member in members) + { + builder.AppendLine(" /// "); + builder.AppendLine($" /// 获取 AutoLoad {member.AutoLoadName}。"); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public static {member.TypeName} {member.Identifier} => GetRequiredNode<{member.TypeName}>({SymbolDisplay.FormatLiteral(member.AutoLoadName, true)});"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" /// 尝试获取 AutoLoad {member.AutoLoadName}。"); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public static bool TryGet{member.Identifier}(out {member.TypeName}? value)"); + builder.AppendLine(" {"); + builder.AppendLine( + $" return TryGetNode({SymbolDisplay.FormatLiteral(member.AutoLoadName, true)}, out value);"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + + builder.AppendLine(" /// "); + builder.AppendLine(" /// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// 节点类型。"); + builder.AppendLine(" /// AutoLoad 名称。"); + builder.AppendLine(" /// 已解析的 AutoLoad 节点。"); + builder.AppendLine(" private static TNode GetRequiredNode(string autoLoadName)"); + builder.AppendLine(" where TNode : global::Godot.Node"); + builder.AppendLine(" {"); + builder.AppendLine(" if (TryGetNode(autoLoadName, out TNode? value))"); + builder.AppendLine(" {"); + builder.AppendLine(" return value!;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine( + " throw new global::System.InvalidOperationException($\"AutoLoad '{autoLoadName}' is not available on the active SceneTree root.\");"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// 尝试从当前 SceneTree 根节点解析 AutoLoad。"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// 节点类型。"); + builder.AppendLine(" /// AutoLoad 名称。"); + builder.AppendLine(" /// 解析到的节点实例。"); + builder.AppendLine(" /// 若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad,则返回 true"); + builder.AppendLine(" private static bool TryGetNode(string autoLoadName, out TNode? value)"); + builder.AppendLine(" where TNode : global::Godot.Node"); + builder.AppendLine(" {"); + builder.AppendLine(" value = default;"); + builder.AppendLine(); + builder.AppendLine(" if (global::Godot.Engine.GetMainLoop() is not global::Godot.SceneTree sceneTree)"); + builder.AppendLine(" {"); + builder.AppendLine(" return false;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" var root = sceneTree.Root;"); + builder.AppendLine(" if (root is null)"); + builder.AppendLine(" {"); + builder.AppendLine(" return false;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" value = root.GetNodeOrNull($\"/root/{autoLoadName}\");"); + builder.AppendLine(" return value is not null;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + private static string GenerateInputActionsSource(IReadOnlyList members) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {GeneratedNamespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine("/// 提供 project.godot 中 Input Action 名称的强类型常量。"); + builder.AppendLine("/// "); + builder.AppendLine("public static partial class InputActions"); + builder.AppendLine("{"); + + foreach (var member in members) + { + builder.AppendLine(" /// "); + builder.AppendLine($" /// Input Action {member.ActionName} 的稳定名称。"); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public const string {member.Identifier} = {SymbolDisplay.FormatLiteral(member.ActionName, true)};"); + builder.AppendLine(); + } + + builder.AppendLine("}"); + return builder.ToString(); + } + + private static ProjectMetadataParseResult ParseProjectFile( + AdditionalText file, + CancellationToken cancellationToken) + { + var text = file.GetText(cancellationToken); + if (text is null) + { + return new ProjectMetadataParseResult( + file.Path, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty); + } + + var currentSection = string.Empty; + var autoLoads = new List(); + var inputActions = new List(); + var diagnostics = new List(); + var seenAutoLoads = new HashSet(StringComparer.Ordinal); + var seenInputActions = new HashSet(StringComparer.Ordinal); + + foreach (var line in text.Lines) + { + var content = line.ToString().Trim(); + if (string.IsNullOrWhiteSpace(content) || content.StartsWith(";", StringComparison.Ordinal)) + continue; + + if (content.StartsWith("[", StringComparison.Ordinal) && content.EndsWith("]", StringComparison.Ordinal)) + { + currentSection = content.Substring(1, content.Length - 2).Trim(); + continue; + } + + if (!TryParseAssignment(content, out var key, out var value)) + continue; + + if (string.Equals(currentSection, "autoload", StringComparison.OrdinalIgnoreCase)) + { + if (!seenAutoLoads.Add(key)) + { + diagnostics.Add(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateAutoLoadEntry, + CreateFileLocation(file.Path), + key)); + continue; + } + + autoLoads.Add(new ProjectAutoLoadEntry( + key, + NormalizeProjectPath(value))); + continue; + } + + if (string.Equals(currentSection, "input", StringComparison.OrdinalIgnoreCase)) + { + if (!seenInputActions.Add(key)) + { + diagnostics.Add(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateInputActionEntry, + CreateFileLocation(file.Path), + key)); + continue; + } + + inputActions.Add(key); + } + } + + return new ProjectMetadataParseResult( + file.Path, + autoLoads.ToImmutableArray(), + inputActions.ToImmutableArray(), + diagnostics.ToImmutableArray()); + } + + private static string NormalizeProjectPath(string rawValue) + { + var trimmed = rawValue.Trim(); + + if (trimmed.Length >= 2 && + trimmed[0] == '"' && + trimmed[trimmed.Length - 1] == '"') + { + trimmed = trimmed.Substring(1, trimmed.Length - 2); + } + + return trimmed.TrimStart('*'); + } + + private static bool TryParseAssignment( + string line, + out string key, + out string value) + { + key = string.Empty; + value = string.Empty; + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) + return false; + + key = line.Substring(0, separatorIndex).Trim(); + if (string.IsNullOrWhiteSpace(key)) + return false; + + value = line.Substring(separatorIndex + 1).Trim(); + return true; + } + + private static Location CreateFileLocation(string filePath) + { + return Location.Create(filePath, TextSpan.FromBounds(0, 0), + new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); + } + + private sealed class GodotTypeCandidate + { + /// + /// 创建一个类型候选。 + /// + /// 类型语法节点。 + /// 类型符号。 + public GodotTypeCandidate( + ClassDeclarationSyntax classDeclaration, + INamedTypeSymbol typeSymbol) + { + ClassDeclaration = classDeclaration; + TypeSymbol = typeSymbol; + } + + /// + /// 获取类型声明语法。 + /// + public ClassDeclarationSyntax ClassDeclaration { get; } + + /// + /// 获取类型符号。 + /// + public INamedTypeSymbol TypeSymbol { get; } + } + + private sealed class ProjectAutoLoadEntry + { + /// + /// 初始化 AutoLoad 条目。 + /// + /// AutoLoad 名称。 + /// 资源路径。 + public ProjectAutoLoadEntry( + string name, + string resourcePath) + { + Name = name; + ResourcePath = resourcePath; + } + + /// + /// 获取 AutoLoad 名称。 + /// + public string Name { get; } + + /// + /// 获取资源路径。 + /// + public string ResourcePath { get; } + } + + private sealed class GeneratedAutoLoadMember + { + /// + /// 初始化一个生成后的 AutoLoad 成员描述。 + /// + /// 原始 AutoLoad 名称。 + /// 生成后的标识符。 + /// 类型名。 + /// 资源路径。 + public GeneratedAutoLoadMember( + string autoLoadName, + string identifier, + string typeName, + string resourcePath) + { + AutoLoadName = autoLoadName; + Identifier = identifier; + TypeName = typeName; + ResourcePath = resourcePath; + } + + /// + /// 获取原始 AutoLoad 名称。 + /// + public string AutoLoadName { get; } + + /// + /// 获取生成后的标识符。 + /// + public string Identifier { get; } + + /// + /// 获取类型名。 + /// + public string TypeName { get; } + + /// + /// 获取资源路径。 + /// + public string ResourcePath { get; } + } + + private sealed class GeneratedInputActionMember + { + /// + /// 初始化一个生成后的 Input Action 成员描述。 + /// + /// 原始动作名。 + /// 生成后的标识符。 + public GeneratedInputActionMember( + string actionName, + string identifier) + { + ActionName = actionName; + Identifier = identifier; + } + + /// + /// 获取原始动作名。 + /// + public string ActionName { get; } + + /// + /// 获取生成后的标识符。 + /// + public string Identifier { get; } + } + + private sealed class ProjectMetadataParseResult + { + /// + /// 初始化一个项目元数据解析结果。 + /// + /// 项目文件路径。 + /// AutoLoad 条目。 + /// Input Action 条目。 + /// 解析过程中的诊断。 + public ProjectMetadataParseResult( + string filePath, + ImmutableArray autoLoads, + ImmutableArray inputActions, + ImmutableArray diagnostics) + { + FilePath = filePath; + AutoLoads = autoLoads; + InputActions = inputActions; + Diagnostics = diagnostics; + } + + /// + /// 获取项目文件路径。 + /// + public string FilePath { get; } + + /// + /// 获取 AutoLoad 条目。 + /// + public ImmutableArray AutoLoads { get; } + + /// + /// 获取 Input Action 条目。 + /// + public ImmutableArray InputActions { get; } + + /// + /// 获取解析过程中的诊断。 + /// + public ImmutableArray Diagnostics { get; } + } +} diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md index 87f423d5..1310807f 100644 --- a/GFramework.Godot.SourceGenerators/README.md +++ b/GFramework.Godot.SourceGenerators/README.md @@ -6,6 +6,7 @@ - 与 Godot 场景相关的编译期生成能力 - 基于 Roslyn 的增量生成器实现 +- `project.godot` 项目元数据生成,产出 AutoLoad 与 Input Action 的强类型访问入口 - `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode()` 样板代码 - `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码 @@ -13,6 +14,93 @@ - 仅在 Godot + C# 项目中启用 - 非 Godot 项目可只使用 GFramework.SourceGenerators +- 当项目通过 NuGet 包引用本模块时,根目录下的 `project.godot` 会被自动加入 `AdditionalFiles` +- 当项目通过 `ProjectReference(OutputItemType=Analyzer)` 直接引用生成器时,需要手动把 `project.godot` 加入 + `AdditionalFiles` + +## project.godot 集成 + +默认情况下,生成器会读取 Godot 项目根目录下的 `project.godot`,并生成: + +- `GFramework.Godot.Generated.AutoLoads` +- `GFramework.Godot.Generated.InputActions` + +如果你需要覆盖默认项目文件名,可以在 MSBuild 中设置: + +```xml + + project.godot + +``` + +如果你在仓库内通过 analyzer 形式直接引用本项目,则需要显式配置: + +```xml + + + +``` + +## AutoLoad 强类型访问 + +当某个 AutoLoad 无法仅靠类型名唯一推断到 C# 节点类型时,可以使用 `[AutoLoad]` 显式声明映射: + +```csharp +using GFramework.Godot.SourceGenerators.Abstractions; +using Godot; + +[AutoLoad("GameServices")] +public partial class GameServices : Node +{ +} +``` + +对应 `project.godot`: + +```ini +[autoload] +GameServices="*res://autoload/game_services.tscn" +AudioBus="*res://autoload/audio_bus.gd" +``` + +生成器会产出统一入口: + +```csharp +using GFramework.Godot.Generated; + +var gameServices = AutoLoads.GameServices; + +if (AutoLoads.TryGetAudioBus(out var audioBus)) +{ +} +``` + +- 显式 `[AutoLoad]` 映射优先于隐式类型名推断 +- 若同名映射冲突,生成器会给出诊断并退化为 `Godot.Node` 访问 +- 若无法映射到 C# 节点类型,仍会生成可用的 `Godot.Node` 访问器 + +## Input Action 常量生成 + +`project.godot` 的 `[input]` 段会自动生成稳定常量,避免手写字符串: + +```ini +[input] +move_up={ +} +ui_cancel={ +} +``` + +```csharp +using GFramework.Godot.Generated; + +if (Input.IsActionJustPressed(InputActions.MoveUp)) +{ +} +``` + +- 动作名会转换为可补全的 C# 标识符,例如 `move_up -> MoveUp` +- 当多个动作名映射到同一标识符时,会追加稳定后缀并给出警告 ## GetNode 用法