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 1/5] =?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 用法
From 7dafec72be83c068e9aa0000eee479e3fc4db777 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 14 Apr 2026 08:22:28 +0800
Subject: [PATCH 2/5] =?UTF-8?q?docs(docs):=20=E6=B7=BB=E5=8A=A0=E6=96=87?=
=?UTF-8?q?=E6=A1=A3=E9=85=8D=E7=BD=AE=E5=92=8CAPI=E5=8F=82=E8=80=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增.vitepress/config.mts配置文件,包含本地搜索、代码块保护等功能
- 添加API参考文档,涵盖核心架构、事件系统、属性系统等完整API
- 添加源码生成器文档,介绍Log、ContextAware、EnumExtensions等生成器用法
- 配置多语言导航和侧边栏结构,完善文档站点设置
- 添加代码示例和使用指南,提供完整的框架使用参考
---
AGENTS.md | 21 +++
docs/.vitepress/config.mts | 1 +
docs/zh-CN/api-reference/index.md | 21 +--
.../godot-project-generator.md | 169 ++++++++++++++++++
docs/zh-CN/source-generators/index.md | 60 +++++++
docs/zh-CN/tutorials/godot-integration.md | 21 ++-
6 files changed, 282 insertions(+), 11 deletions(-)
create mode 100644 docs/zh-CN/source-generators/godot-project-generator.md
diff --git a/AGENTS.md b/AGENTS.md
index 8e2d97b3..c63a2bfa 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,6 +19,27 @@ All AI agents and contributors must follow these rules when writing, reviewing,
- After resolving the host Windows Git path, prefer an explicit session-local binding for subsequent commands so the
shell does not fall back to Linux `/usr/bin/git` later in the same WSL session.
+## Subagent Usage Rules
+
+- Use subagents only when the task is complex, the context is likely to grow too large, or the work can be split into
+ independent parallel subtasks.
+- The main agent MUST identify the critical path first. Do not delegate the immediate blocking task if the next local
+ step depends on that result.
+- Use `explorer` subagents for read-only discovery, comparison, tracing, and narrow codebase questions.
+- Use `worker` subagents only for bounded implementation tasks with an explicit file or module ownership boundary.
+- Every delegation MUST specify:
+ - the concrete objective
+ - the expected output format
+ - the files or subsystem the subagent owns
+ - any constraints about tests, diagnostics, or compatibility
+- Subagents are not allowed to revert or overwrite unrelated changes from the user or other agents. They must adapt to
+ concurrent work instead of assuming exclusive ownership of the repository.
+- Prefer lightweight models such as `gpt-5.1-codex-mini` for narrow exploration, indexing, and comparison tasks.
+- Prefer stronger models such as `gpt-5.4` for cross-module design work, non-trivial refactors, and tasks that require
+ higher confidence reasoning.
+- The main agent remains responsible for reviewing and integrating subagent output. Unreviewed subagent conclusions do
+ not count as final results.
+
## Commenting Rules (MUST)
All generated or modified code MUST include clear and meaningful comments where required by the rules below.
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 3b93e141..bcac727e 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -251,6 +251,7 @@ export default defineConfig({
{ text: 'ContextAware 生成器', link: '/zh-CN/source-generators/context-aware-generator' },
{ text: 'Priority 生成器', link: '/zh-CN/source-generators/priority-generator' },
{ text: 'Context Get 注入', link: '/zh-CN/source-generators/context-get-generator' },
+ { text: 'Godot 项目元数据', link: '/zh-CN/source-generators/godot-project-generator' },
{ text: 'GetNode 生成器 (Godot)', link: '/zh-CN/source-generators/get-node-generator' },
{ text: 'BindNodeSignal 生成器 (Godot)', link: '/zh-CN/source-generators/bind-node-signal-generator' }
]
diff --git a/docs/zh-CN/api-reference/index.md b/docs/zh-CN/api-reference/index.md
index b491cc5a..6ecbafe8 100644
--- a/docs/zh-CN/api-reference/index.md
+++ b/docs/zh-CN/api-reference/index.md
@@ -452,16 +452,17 @@ Godot 引擎集成模块。
#### 常用 Attribute
-| Attribute | 说明 | 文档 |
-|--------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------|
-| `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
-| `RegisterModelAttribute` | 声明模块内自动注册的 `IModel` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
-| `RegisterSystemAttribute` | 声明模块内自动注册的 `ISystem` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
-| `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
-| `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) |
-| `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) |
-| `AutoRegisterExportedCollectionsAttribute` | 为宿主类开启导出集合批量注册生成 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
-| `RegisterExportedCollectionAttribute` | 指定集合与注册器成员的映射关系 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
+| Attribute | 说明 | 文档 |
+|--------------------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------------------------|
+| `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
+| `RegisterModelAttribute` | 声明模块内自动注册的 `IModel` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
+| `RegisterSystemAttribute` | 声明模块内自动注册的 `ISystem` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
+| `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
+| `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) |
+| `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) |
+| `AutoLoadAttribute` | 显式声明 `project.godot` AutoLoad 与 C# 节点类型映射 | [Godot 项目元数据生成器](../source-generators/godot-project-generator.md) |
+| `AutoRegisterExportedCollectionsAttribute` | 为宿主类开启导出集合批量注册生成 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
+| `RegisterExportedCollectionAttribute` | 指定集合与注册器成员的映射关系 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
## 常见用法示例
diff --git a/docs/zh-CN/source-generators/godot-project-generator.md b/docs/zh-CN/source-generators/godot-project-generator.md
new file mode 100644
index 00000000..96bc19b1
--- /dev/null
+++ b/docs/zh-CN/source-generators/godot-project-generator.md
@@ -0,0 +1,169 @@
+# Godot 项目元数据生成器
+
+> 从 `project.godot` 生成 AutoLoad 与 Input Action 的强类型访问入口。
+
+## 概述
+
+`GFramework.Godot.SourceGenerators` 会读取 Godot 项目根目录下的 `project.godot`,并把其中最常用的项目级元数据暴露为稳定的编译期
+API。
+
+当前覆盖:
+
+- `[autoload]` 段:生成 `GFramework.Godot.Generated.AutoLoads`
+- `[input]` 段:生成 `GFramework.Godot.Generated.InputActions`
+
+这项能力的目标不是替代场景级生成器,而是把 Godot 工程配置和 C# 代码之间的字符串约定收敛到编译期。
+
+## 接入方式
+
+### NuGet 引用
+
+当项目通过 NuGet 引用 `GeWuYou.GFramework.Godot.SourceGenerators` 时,生成器会默认把项目根目录下的 `project.godot` 加入
+`AdditionalFiles`。
+
+如需覆盖默认路径,可以设置:
+
+```xml
+
+ project.godot
+
+```
+
+### 仓库内直接引用生成器
+
+如果你通过 `ProjectReference(OutputItemType=Analyzer)` 直接引用生成器项目,则需要手动加入:
+
+```xml
+
+
+
+```
+
+## AutoLoad 访问层
+
+### 基础行为
+
+假设 `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))
+{
+}
+```
+
+- 对于能唯一映射到 C# 节点类型的条目,属性会是强类型的
+- 对于无法映射或对应非 C# 脚本的条目,属性会退化为 `Godot.Node`
+- 生成器通过 `Godot.Engine.GetMainLoop()` 与当前 `SceneTree.Root` 解析 `/root/` 节点
+
+### 显式映射
+
+当 AutoLoad 名称无法仅靠类名唯一推断时,可以使用 `[AutoLoad]` 明确指定:
+
+```csharp
+using GFramework.Godot.SourceGenerators.Abstractions;
+using Godot;
+
+[AutoLoad("GameServices")]
+public partial class GameServices : Node
+{
+}
+```
+
+规则如下:
+
+- 显式 `[AutoLoad]` 映射优先于隐式类名推断
+- 标记了 `[AutoLoad]` 的类型必须继承 `Godot.Node`
+- 若多个类型映射到同一个 AutoLoad,生成器会报告诊断,并退化为 `Godot.Node` 访问器,直到映射唯一
+
+## Input Action 常量
+
+### 基础行为
+
+假设 `project.godot` 中有:
+
+```ini
+[input]
+move_up={
+}
+ui_cancel={
+}
+```
+
+生成器会产出:
+
+```csharp
+using GFramework.Godot.Generated;
+
+if (Input.IsActionJustPressed(InputActions.MoveUp))
+{
+}
+```
+
+转换规则:
+
+- `move_up` -> `MoveUp`
+- `ui_cancel` -> `UiCancel`
+- 非法字符会被清理后再转换为 PascalCase
+- 如果多个动作名落到同一个标识符,生成器会追加稳定数字后缀,例如 `MoveUp_2`
+
+## 与现有 Godot 生成器的关系
+
+这项能力和现有的场景级生成器是互补的:
+
+- `AutoLoads` / `InputActions` 解决的是项目级元数据访问
+- `[GetNode]` 解决的是场景节点引用注入
+- `[BindNodeSignal]` 解决的是节点事件订阅样板
+
+推荐组合方式:
+
+```csharp
+using GFramework.Godot.Generated;
+using GFramework.Godot.SourceGenerators.Abstractions;
+using Godot;
+
+public partial class MainHud : Control
+{
+ [GetNode]
+ private Button _startButton = null!;
+
+ public override void _Ready()
+ {
+ __InjectGetNodes_Generated();
+
+ if (Input.IsActionPressed(InputActions.UiCancel))
+ {
+ }
+
+ var services = AutoLoads.GameServices;
+ }
+}
+```
+
+## 诊断与约束
+
+当前会重点报告以下问题:
+
+- `[AutoLoad]` 标记在非 `Godot.Node` 类型上
+- 多个类型映射到同一个 AutoLoad 名称
+- 不同 AutoLoad 名称或 Input Action 名称在清洗后发生标识符冲突
+- `project.godot` 内部重复声明同名 AutoLoad 或 Input Action
+
+这些诊断的目的不是阻断所有生成,而是在可能的情况下保留稳定输出,同时把不确定性显式暴露出来。
+
+## 相关文档
+
+- [GetNode 生成器](./get-node-generator)
+- [BindNodeSignal 生成器](./bind-node-signal-generator)
+- [Godot 集成教程](../tutorials/godot-integration)
diff --git a/docs/zh-CN/source-generators/index.md b/docs/zh-CN/source-generators/index.md
index 60aada08..8fd91e66 100644
--- a/docs/zh-CN/source-generators/index.md
+++ b/docs/zh-CN/source-generators/index.md
@@ -16,6 +16,7 @@ GFramework.SourceGenerators 是 GFramework 框架的源代码生成器包,通
- [Priority 属性生成器](#priority-属性生成器)
- [Context Get 注入生成器](#context-get-注入生成器)
- [AutoRegisterModule 生成器](#autoregistermodule-生成器)
+- [Godot 项目元数据生成](#godot-项目元数据生成)
- [GetNode 生成器 (Godot)](#getnode-生成器)
- [BindNodeSignal 生成器 (Godot)](#bindnodesignal-生成器)
- [AutoUiPage 生成器 (Godot)](#autouipage-生成器)
@@ -52,6 +53,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译
### Godot 专用生成器
+- **Godot 项目元数据生成 (Godot)**:从 `project.godot` 生成 AutoLoad 与 Input Action 的强类型访问入口
- **[GetNode] 属性 (Godot)**:自动获取 Godot 节点引用,支持多种查找模式
- **[BindNodeSignal] 属性 (Godot)**:自动生成 Godot 节点信号绑定与解绑逻辑
- **[AutoUiPage] 属性 (Godot)**:自动生成 UI 页面行为包装与页面 Key
@@ -435,6 +437,64 @@ public enum PlayerState
| GenerateIsMethods | bool | true | 是否为每个枚举值生成 IsX 方法 |
| GenerateIsInMethod | bool | true | 是否生成 IsIn 方法 |
+## Godot 项目元数据生成
+
+Godot 项目元数据生成器会读取 `project.godot`,把项目级配置转换为稳定的编译期 API。
+
+当前生成两个统一入口:
+
+- `GFramework.Godot.Generated.AutoLoads`
+- `GFramework.Godot.Generated.InputActions`
+
+默认情况下,NuGet 包引用会自动把项目根目录下的 `project.godot` 加入 `AdditionalFiles`。如果你是在仓库内通过 analyzer
+形式直接引用生成器,则需要手动加入:
+
+```xml
+
+
+
+```
+
+### AutoLoad 映射
+
+当某个 AutoLoad 不能仅靠类名唯一推断到 C# 节点类型时,可以使用 `[AutoLoad]` 指定映射:
+
+```csharp
+using GFramework.Godot.SourceGenerators.Abstractions;
+using Godot;
+
+[AutoLoad("GameServices")]
+public partial class GameServices : Node
+{
+}
+```
+
+生成后可以直接使用:
+
+```csharp
+using GFramework.Godot.Generated;
+
+var services = AutoLoads.GameServices;
+
+if (AutoLoads.TryGetAudioBus(out var audioBus))
+{
+}
+```
+
+### Input Action 常量
+
+`[input]` 段会被转换为强类型常量:
+
+```csharp
+using GFramework.Godot.Generated;
+
+if (Input.IsActionPressed(InputActions.MoveUp))
+{
+}
+```
+
+完整说明请见:[Godot 项目元数据生成器](./godot-project-generator)
+
## GetNode 生成器
GetNode 生成器为标记了 `[GetNode]` 特性的字段自动生成 Godot 节点获取代码,无需手动调用 `GetNode()` 方法。
diff --git a/docs/zh-CN/tutorials/godot-integration.md b/docs/zh-CN/tutorials/godot-integration.md
index d1d90d93..b9352ac8 100644
--- a/docs/zh-CN/tutorials/godot-integration.md
+++ b/docs/zh-CN/tutorials/godot-integration.md
@@ -14,6 +14,25 @@
## Godot 特定功能
+### 0. project.godot 编译期接入
+
+如果项目引用了 `GFramework.Godot.SourceGenerators`,现在可以直接把 `project.godot` 中的 AutoLoad 与 Input Action 暴露为强类型
+API:
+
+```csharp
+using GFramework.Godot.Generated;
+
+var services = AutoLoads.GameServices;
+
+if (Input.IsActionPressed(InputActions.MoveUp))
+{
+}
+```
+
+这项能力适合和 `[GetNode]`、`[BindNodeSignal]` 一起使用:前者解决项目级配置入口,后两者解决场景级节点和事件样板。
+
+详细说明见:[Godot 项目元数据生成器](../source-generators/godot-project-generator)
+
### 1. 节点生命周期绑定
GFramework.Godot 提供了与 Godot 节点生命周期的无缝集成,确保框架初始化与 Godot 场景树同步。
@@ -1335,4 +1354,4 @@ public class GodotPerformanceTests
---
**教程版本**: 1.0.0
-**更新日期**: 2026-01-12
\ No newline at end of file
+**更新日期**: 2026-01-12
From 833a295b84f9e3942ba19102875d8b69ac510f17 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 14 Apr 2026 09:05:33 +0800
Subject: [PATCH 3/5] =?UTF-8?q?feat(godot):=20=E6=B7=BB=E5=8A=A0=20Godot?=
=?UTF-8?q?=20=E9=9B=86=E6=88=90=E5=8A=9F=E8=83=BD=E5=92=8C=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 AdditionalTextGeneratorTestDriver 用于源生成器测试
- 添加 AutoLoadAttribute 特性支持 AutoLoad 类型映射
- 扩展项目构建目标,支持自定义 project.godot 路径验证
- 创建完整 Godot 集成教程文档,涵盖节点生命周期、信号系统等功能
- 添加源代码生成器测试项目配置和相关依赖包引用
---
.../AutoLoadAttribute.cs | 15 +-
.../Abstractions/AutoLoadAttributeTests.cs | 49 ++++
.../Core/AdditionalTextGeneratorTestDriver.cs | 7 +-
.../AdditionalTextGeneratorTestDriverTests.cs | 21 ++
...mework.Godot.SourceGenerators.Tests.csproj | 1 +
.../GodotProjectMetadataGeneratorTests.cs | 241 ++++++++++++++++++
....GFramework.Godot.SourceGenerators.targets | 12 +
.../GodotProjectMetadataGenerator.cs | 35 ++-
GFramework.Godot.SourceGenerators/README.md | 7 +-
.../godot-project-generator.md | 5 +-
docs/zh-CN/tutorials/godot-integration.md | 2 +-
11 files changed, 379 insertions(+), 16 deletions(-)
create mode 100644 GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs
create mode 100644 GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs
diff --git a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs
index e406f8b6..a6b54ede 100644
--- a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs
+++ b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs
@@ -18,9 +18,22 @@ public sealed class AutoLoadAttribute : Attribute
///
/// 为 。
///
+ ///
+ /// 为空字符串或仅包含空白字符。
+ ///
public AutoLoadAttribute(string name)
{
- Name = name ?? throw new ArgumentNullException(nameof(name));
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("AutoLoad name cannot be empty or whitespace.", nameof(name));
+ }
+
+ Name = name;
}
///
diff --git a/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs b/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs
new file mode 100644
index 00000000..9d8ddff8
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs
@@ -0,0 +1,49 @@
+using GFramework.Godot.SourceGenerators.Abstractions;
+
+namespace GFramework.Godot.SourceGenerators.Tests.Abstractions;
+
+///
+/// 验证 的参数约束。
+///
+[TestFixture]
+public class AutoLoadAttributeTests
+{
+ ///
+ /// 验证构造函数会保留合法的 AutoLoad 名称。
+ ///
+ [Test]
+ public void Constructor_Should_Store_Name_When_Name_Is_Valid()
+ {
+ var attribute = new AutoLoadAttribute("GameServices");
+
+ Assert.That(attribute.Name, Is.EqualTo("GameServices"));
+ }
+
+ ///
+ /// 验证构造函数会拒绝空引用。
+ ///
+ [Test]
+ public void Constructor_Should_Throw_When_Name_Is_Null()
+ {
+ var exception = Assert.Throws(() => new AutoLoadAttribute(null!));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("name"));
+ }
+
+ ///
+ /// 验证构造函数会拒绝空字符串与仅空白字符串。
+ ///
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("\t")]
+ public void Constructor_Should_Throw_When_Name_Is_Empty_Or_Whitespace(string name)
+ {
+ var exception = Assert.Throws(() => new AutoLoadAttribute(name));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception!.ParamName, Is.EqualTo("name"));
+ Assert.That(exception.Message, Does.Contain("empty or whitespace"));
+ });
+ }
+}
diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs
index f1aaa03f..127c5ed4 100644
--- a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs
+++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs
@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.IO;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
namespace GFramework.Godot.SourceGenerators.Tests.Core;
@@ -60,13 +62,12 @@ public static class AdditionalTextGeneratorTestDriver
/// 规范化换行,避免测试在不同平台上产生伪差异。
///
/// 待规范化文本。
- /// 使用当前平台换行符的内容。
+ /// 统一使用 LF (\n) 的内容。
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);
+ .Replace("\r", "\n", StringComparison.Ordinal);
}
private static IEnumerable GetMetadataReferences()
diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs
new file mode 100644
index 00000000..e6f7f343
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs
@@ -0,0 +1,21 @@
+namespace GFramework.Godot.SourceGenerators.Tests.Core;
+
+///
+/// 验证 的文本规范化行为。
+///
+[TestFixture]
+public class AdditionalTextGeneratorTestDriverTests
+{
+ ///
+ /// 验证不同平台换行最终都会被统一为 LF。
+ ///
+ [Test]
+ public void NormalizeLineEndings_Should_Convert_All_Line_Endings_To_Lf()
+ {
+ const string content = "line1\r\nline2\rline3\nline4";
+
+ var normalized = AdditionalTextGeneratorTestDriver.NormalizeLineEndings(content);
+
+ Assert.That(normalized, Is.EqualTo("line1\nline2\nline3\nline4"));
+ }
+}
diff --git a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj
index d02277db..b4ce059d 100644
--- a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj
+++ b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj
@@ -21,6 +21,7 @@
+
diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
index 252e7e1e..63265bd2 100644
--- a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
+++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
@@ -316,4 +316,245 @@ public class GodotProjectMetadataGeneratorTests
generatedSources["GFramework_Godot_Generated_InputActions.g.cs"],
Does.Contain("public const string MoveUp_2 = \"move-up\";"));
}
+
+ ///
+ /// 验证多个显式映射指向同一个 AutoLoad 时会报告重复映射,并退化为 Godot.Node。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Explicit_AutoLoad_Mappings_Are_Duplicated()
+ {
+ var result = RunGenerator(
+ CreateSource(
+ """
+ namespace TestApp
+ {
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ [AutoLoad("AudioBus")]
+ public partial class PrimaryAudioBus : Node
+ {
+ }
+
+ [AutoLoad("AudioBus")]
+ public partial class SecondaryAudioBus : Node
+ {
+ }
+ }
+ """,
+ includeAutoLoadAttribute: true),
+ """
+ [autoload]
+ AudioBus="*res://autoload/audio_bus.tscn"
+ """);
+
+ var diagnostics = result.Results.Single().Diagnostics;
+ var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" }));
+ Assert.That(diagnostics.Single().GetMessage(), Does.Contain("PrimaryAudioBus"));
+ Assert.That(diagnostics.Single().GetMessage(), Does.Contain("SecondaryAudioBus"));
+ Assert.That(
+ generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
+ Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"AudioBus\");"));
+ });
+ }
+
+ ///
+ /// 验证不同命名空间下的同名节点类型会触发隐式映射冲突诊断,并退化为 Godot.Node。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Implicit_AutoLoad_Mappings_Are_Ambiguous()
+ {
+ var result = RunGenerator(
+ CreateSource(
+ """
+ namespace TestApp.Audio
+ {
+ using Godot;
+
+ public partial class AudioBus : Node
+ {
+ }
+ }
+
+ namespace TestApp.Debug
+ {
+ using Godot;
+
+ public partial class AudioBus : Node
+ {
+ }
+ }
+ """),
+ """
+ [autoload]
+ AudioBus="*res://autoload/audio_bus.tscn"
+ """);
+
+ var diagnostics = result.Results.Single().Diagnostics;
+ var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" }));
+ Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Audio.AudioBus"));
+ Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Debug.AudioBus"));
+ Assert.That(
+ generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
+ Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"AudioBus\");"));
+ });
+ }
+
+ ///
+ /// 验证 AutoLoad 标识符冲突时会追加稳定后缀并报告诊断。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_And_Append_Suffix_When_AutoLoad_Identifiers_Collide()
+ {
+ var result = RunGenerator(
+ CreateSource("namespace TestApp { }"),
+ """
+ [autoload]
+ audio_bus="*res://autoload/audio_bus.tscn"
+ audio-bus="*res://autoload/audio_bus_debug.tscn"
+ """);
+
+ var diagnostics = result.Results.Single().Diagnostics;
+ var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_003" }));
+ Assert.That(
+ generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
+ Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"audio_bus\");"));
+ Assert.That(
+ generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
+ Does.Contain("public static global::Godot.Node AudioBus_2 => GetRequiredNode(\"audio-bus\");"));
+ });
+ }
+
+ ///
+ /// 验证重复 AutoLoad 条目会报告诊断,并只保留第一条声明参与生成。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_AutoLoads()
+ {
+ var result = RunGenerator(
+ CreateSource("namespace TestApp { }"),
+ """
+ [autoload]
+ GameServices="*res://autoload/game_services.tscn"
+ GameServices="*res://autoload/game_services_debug.tscn"
+ """);
+
+ var diagnostics = result.Results.Single().Diagnostics;
+ var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_005" }));
+ Assert.That(diagnostics.Single().GetMessage(), Does.Contain("GameServices"));
+ Assert.That(
+ generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
+ Does.Contain("public static global::Godot.Node GameServices => GetRequiredNode(\"GameServices\");"));
+ Assert.That(
+ generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
+ Does.Not.Contain("GameServices_2"));
+ });
+ }
+
+ ///
+ /// 验证重复 Input Action 条目会报告诊断,并只保留第一条声明参与生成。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_Input_Actions()
+ {
+ var result = RunGenerator(
+ CreateSource("namespace TestApp { }"),
+ """
+ [input]
+ move_up={
+ }
+ move_up={
+ }
+ """);
+
+ var diagnostics = result.Results.Single().Diagnostics;
+ var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_006" }));
+ Assert.That(diagnostics.Single().GetMessage(), Does.Contain("move_up"));
+ 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.Not.Contain("MoveUp_2"));
+ });
+ }
+
+ private static GeneratorDriverRunResult RunGenerator(
+ string source,
+ string projectFile)
+ {
+ return AdditionalTextGeneratorTestDriver.Run(
+ source,
+ ("project.godot", projectFile));
+ }
+
+ private static string CreateSource(
+ string applicationSource,
+ bool includeAutoLoadAttribute = false)
+ {
+ var autoLoadAttributeSource = includeAutoLoadAttribute
+ ? """
+ 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; }
+ }
+ }
+ """
+ : string.Empty;
+
+ return autoLoadAttributeSource + """
+ 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;
+ }
+ }
+
+ """ + applicationSource;
+ }
}
diff --git a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets
index cf19200c..5116e4da 100644
--- a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets
+++ b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets
@@ -9,6 +9,12 @@
让 Godot 项目元数据生成能力在常规 Godot C# 工程里开箱即用。
-->
project.godot
+
+ <_GFrameworkGodotProjectFileName>$([System.IO.Path]::GetFileName('$(GFrameworkGodotProjectFile)'))
+ <_GFrameworkGodotProjectFileNameLower>$([System.String]::Copy('$(_GFrameworkGodotProjectFileName)').ToLowerInvariant())
@@ -21,6 +27,12 @@
+
+
+
+
diff --git a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs
index ba3ddc21..c080cc71 100644
--- a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs
+++ b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs
@@ -1,5 +1,8 @@
+using System.IO;
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
+using GFramework.SourceGenerators.Common.Extensions;
+using Microsoft.CodeAnalysis.Text;
namespace GFramework.Godot.SourceGenerators;
@@ -178,6 +181,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
foreach (var projectAutoLoadName in projectAutoLoadNames.OrderBy(static name => name, StringComparer.Ordinal))
{
+ // 显式 [AutoLoad] 映射优先于按类型名推断,因为它代表了用户给出的稳定契约。
if (explicitMappings.TryGetValue(projectAutoLoadName, out var explicitList))
{
var distinctExplicitTypes = DistinctTypeSymbols(explicitList);
@@ -188,14 +192,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
}
else if (distinctExplicitTypes.Length > 1)
{
- context.ReportDiagnostic(Diagnostic.Create(
- GodotProjectDiagnostics.DuplicateAutoLoadMapping,
- Location.None,
- projectAutoLoadName,
- string.Join(
- ", ",
- distinctExplicitTypes.Select(static type =>
- type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)))));
+ ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctExplicitTypes);
}
continue;
@@ -207,7 +204,14 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
var distinctImplicitTypes = DistinctTypeSymbols(implicitList);
if (distinctImplicitTypes.Length == 1)
+ {
resolvedMappings.Add(projectAutoLoadName, distinctImplicitTypes[0]);
+ }
+ else if (distinctImplicitTypes.Length > 1)
+ {
+ // 隐式推断只在唯一命中时才安全;出现同名候选时改为诊断并退化成 Godot.Node。
+ ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctImplicitTypes);
+ }
}
return resolvedMappings;
@@ -228,6 +232,21 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
return true;
}
+ private static void ReportDuplicateAutoLoadMapping(
+ SourceProductionContext context,
+ string autoLoadName,
+ IEnumerable duplicateTypes)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ GodotProjectDiagnostics.DuplicateAutoLoadMapping,
+ Location.None,
+ autoLoadName,
+ string.Join(
+ ", ",
+ duplicateTypes.Select(static type =>
+ type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)))));
+ }
+
private static IReadOnlyList CreateAutoLoadMembers(
SourceProductionContext context,
ProjectMetadataParseResult projectResult,
diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md
index 1310807f..2e83adb3 100644
--- a/GFramework.Godot.SourceGenerators/README.md
+++ b/GFramework.Godot.SourceGenerators/README.md
@@ -25,11 +25,14 @@
- `GFramework.Godot.Generated.AutoLoads`
- `GFramework.Godot.Generated.InputActions`
-如果你需要覆盖默认项目文件名,可以在 MSBuild 中设置:
+如果你需要覆盖默认项目文件路径,可以在 MSBuild 中设置:
+
+- 路径可以调整到项目根目录下的其他位置
+- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件
```xml
- project.godot
+ Config/project.godot
```
diff --git a/docs/zh-CN/source-generators/godot-project-generator.md b/docs/zh-CN/source-generators/godot-project-generator.md
index 96bc19b1..9e26f242 100644
--- a/docs/zh-CN/source-generators/godot-project-generator.md
+++ b/docs/zh-CN/source-generators/godot-project-generator.md
@@ -23,9 +23,12 @@ API。
如需覆盖默认路径,可以设置:
+- 可以改成项目根目录下的其他相对路径
+- 文件名必须仍然是 `project.godot`,否则生成器会给出警告并忽略该文件
+
```xml
- project.godot
+ Config/project.godot
```
diff --git a/docs/zh-CN/tutorials/godot-integration.md b/docs/zh-CN/tutorials/godot-integration.md
index b9352ac8..94f1d51c 100644
--- a/docs/zh-CN/tutorials/godot-integration.md
+++ b/docs/zh-CN/tutorials/godot-integration.md
@@ -1354,4 +1354,4 @@ public class GodotPerformanceTests
---
**教程版本**: 1.0.0
-**更新日期**: 2026-01-12
+**更新日期**: 2026-04-14
From bb7abc0d8f32264a92e675a6db87c1c5f21749dc Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 14 Apr 2026 09:23:49 +0800
Subject: [PATCH 4/5] =?UTF-8?q?test(Godot):=20=E6=B7=BB=E5=8A=A0=E9=A1=B9?=
=?UTF-8?q?=E7=9B=AE=E5=85=83=E6=95=B0=E6=8D=AE=E7=94=9F=E6=88=90=E5=99=A8?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 验证 AutoLoad 和 Input Action 强类型入口生成
- 测试非节点类型上的 AutoLoad 标记诊断
- 验证输入动作标识符冲突处理和后缀追加
- 测试多个显式映射指向相同 AutoLoad 的重复检测
- 验证不同命名空间同名节点类型的冲突处理
- 测试 AutoLoad 标识符冲突的诊断和后缀追加
- 验证项目文件中重复 AutoLoad 条目的处理
- 测试重复输入动作条目的诊断和保留机制
---
.../GodotProjectMetadataGeneratorTests.cs | 62 ++++---------------
1 file changed, 13 insertions(+), 49 deletions(-)
diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
index 63265bd2..96dbe30f 100644
--- a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
+++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
@@ -14,56 +14,20 @@ public class GodotProjectMetadataGeneratorTests
[Test]
public void Run_Should_Generate_AutoLoads_And_InputActions()
{
- const string source = """
- using System;
+ var source = CreateSource(
+ """
+ namespace TestApp
+ {
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
- 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
- {
- }
- }
- """;
+ [AutoLoad("GameServices")]
+ public partial class GameServices : Node
+ {
+ }
+ }
+ """,
+ includeAutoLoadAttribute: true);
const string projectFile = """
[autoload]
From 31a439e18411ebc18e8ed9b26bf2887bb26d7ebe Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 14 Apr 2026 09:51:52 +0800
Subject: [PATCH 5/5] =?UTF-8?q?test(Godot):=20=E6=B7=BB=E5=8A=A0=E9=A1=B9?=
=?UTF-8?q?=E7=9B=AE=E5=85=83=E6=95=B0=E6=8D=AE=E7=94=9F=E6=88=90=E5=99=A8?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 验证基于 project.godot 的 AutoLoad 和 Input Action 强类型入口生成
- 测试 AutoLoad 类型非节点继承时的诊断报告功能
- 验证 Input Action 标识符冲突时的后缀追加和警告机制
- 测试多个显式映射指向同一 AutoLoad 时的重复检测
- 验证不同命名空间同名节点类型的隐式映射冲突处理
- 测试 AutoLoad 和 Input Action 重复条目的诊断和保留逻辑
- 验证缺失或空 project.godot 文件时的无生成行为
---
.../GodotProjectMetadataGeneratorTests.cs | 60 +++++++++++++++++++
1 file changed, 60 insertions(+)
diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
index 96dbe30f..b033a865 100644
--- a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
+++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs
@@ -463,6 +463,66 @@ public class GodotProjectMetadataGeneratorTests
});
}
+ ///
+ /// 验证缺少 project.godot AdditionalText 时不会生成任何源码或诊断。
+ ///
+ [Test]
+ public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Missing()
+ {
+ var result = AdditionalTextGeneratorTestDriver.Run(
+ CreateSource("namespace TestApp { }"));
+
+ var generatorResult = result.Results.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(generatorResult.Diagnostics, Is.Empty);
+ Assert.That(generatorResult.GeneratedSources, Is.Empty);
+ });
+ }
+
+ ///
+ /// 验证空的 project.godot 内容不会生成任何源码或诊断。
+ ///
+ [Test]
+ public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Empty()
+ {
+ var result = RunGenerator(
+ CreateSource("namespace TestApp { }"),
+ string.Empty);
+
+ var generatorResult = result.Results.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(generatorResult.Diagnostics, Is.Empty);
+ Assert.That(generatorResult.GeneratedSources, Is.Empty);
+ });
+ }
+
+ ///
+ /// 验证只有空节的 project.godot 不会生成任何源码或诊断。
+ ///
+ [Test]
+ public void Run_Should_Not_Generate_Sources_When_Project_File_Has_Empty_Sections()
+ {
+ var result = RunGenerator(
+ CreateSource("namespace TestApp { }"),
+ """
+ [autoload]
+
+ [input]
+ """);
+
+ var generatorResult = result.Results.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(generatorResult.Diagnostics, Is.Empty);
+ Assert.That(generatorResult.GeneratedSources, Is.Empty);
+ });
+ }
+
private static GeneratorDriverRunResult RunGenerator(
string source,
string projectFile)