diff --git a/.gitignore b/.gitignore
index 315acfbf..d6abff17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@ riderModule.iml
/_ReSharper.Caches/
GFramework.sln.DotSettings.user
.idea/
+dotnet-home/
+scripts/__pycache__/
# ai
opencode.json
.claude/settings.local.json
@@ -14,4 +16,4 @@ docs/.omc/
docs/.vitepress/cache/
local-plan/
# tool
-.venv/
\ No newline at end of file
+.venv/
diff --git a/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoRegisterExportedCollectionsAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoRegisterExportedCollectionsAttribute.cs
new file mode 100644
index 00000000..1c2699ae
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoRegisterExportedCollectionsAttribute.cs
@@ -0,0 +1,9 @@
+namespace GFramework.Godot.SourceGenerators.Abstractions;
+
+///
+/// 标记类型允许为带映射特性的导出集合生成批量注册代码。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+public sealed class AutoRegisterExportedCollectionsAttribute : Attribute
+{
+}
diff --git a/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoSceneAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoSceneAttribute.cs
new file mode 100644
index 00000000..3b73af7b
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoSceneAttribute.cs
@@ -0,0 +1,13 @@
+namespace GFramework.Godot.SourceGenerators.Abstractions;
+
+///
+/// 标记场景根节点类型,Source Generator 会生成场景行为样板代码。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+public sealed class AutoSceneAttribute(string key) : Attribute
+{
+ ///
+ /// 获取场景键。
+ ///
+ public string Key { get; } = key;
+}
diff --git a/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoUiPageAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoUiPageAttribute.cs
new file mode 100644
index 00000000..56957fb8
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Abstractions/UI/AutoUiPageAttribute.cs
@@ -0,0 +1,18 @@
+namespace GFramework.Godot.SourceGenerators.Abstractions;
+
+///
+/// 标记 UI 页面类型,Source Generator 会生成页面行为样板代码。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+public sealed class AutoUiPageAttribute(string key, string layerName) : Attribute
+{
+ ///
+ /// 获取 UI 键。
+ ///
+ public string Key { get; } = key;
+
+ ///
+ /// 获取 UiLayer 枚举成员名称。
+ ///
+ public string LayerName { get; } = layerName;
+}
diff --git a/GFramework.Godot.SourceGenerators.Abstractions/UI/RegisterExportedCollectionAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/UI/RegisterExportedCollectionAttribute.cs
new file mode 100644
index 00000000..dd809fc4
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Abstractions/UI/RegisterExportedCollectionAttribute.cs
@@ -0,0 +1,19 @@
+namespace GFramework.Godot.SourceGenerators.Abstractions;
+
+///
+/// 声明导出集合应当转发到哪个注册器成员及其方法。
+///
+[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+public sealed class RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName)
+ : Attribute
+{
+ ///
+ /// 获取注册器字段或属性名称。
+ ///
+ public string RegistryMemberName { get; } = registryMemberName;
+
+ ///
+ /// 获取注册方法名称。
+ ///
+ public string RegisterMethodName { get; } = registerMethodName;
+}
diff --git a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs
new file mode 100644
index 00000000..b50b0d73
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs
@@ -0,0 +1,331 @@
+using GFramework.Godot.SourceGenerators.Behavior;
+using GFramework.Godot.SourceGenerators.Tests.Core;
+
+namespace GFramework.Godot.SourceGenerators.Tests.Behavior;
+
+[TestFixture]
+public class AutoSceneGeneratorTests
+{
+ [Test]
+ public async Task Generates_Scene_Behavior_Boilerplate()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoSceneAttribute : Attribute
+ {
+ public AutoSceneAttribute(string key) { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class Node2D : Node { }
+ }
+
+ namespace GFramework.Game.Abstractions.Scene
+ {
+ public interface ISceneBehavior { }
+ }
+
+ namespace GFramework.Godot.Scene
+ {
+ using GFramework.Game.Abstractions.Scene;
+ using Godot;
+
+ public static class SceneBehaviorFactory
+ {
+ public static ISceneBehavior Create(T owner, string key)
+ where T : Node
+ {
+ return null!;
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ [AutoScene("Gameplay")]
+ public partial class GameplayRoot : Node2D
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class GameplayRoot
+ {
+ private global::GFramework.Game.Abstractions.Scene.ISceneBehavior? __autoSceneBehavior_Generated;
+
+ public static string SceneKeyStr => "Gameplay";
+
+ public global::GFramework.Game.Abstractions.Scene.ISceneBehavior GetScene()
+ {
+ return __autoSceneBehavior_Generated ??= global::GFramework.Godot.Scene.SceneBehaviorFactory.Create(this, SceneKeyStr);
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_GameplayRoot.AutoScene.g.cs", expected));
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_AutoScene_Arguments_Are_Invalid()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoSceneAttribute : Attribute
+ {
+ public AutoSceneAttribute() { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class Node2D : Node { }
+ }
+
+ namespace TestApp
+ {
+ [{|#0:AutoScene|}]
+ public partial class GameplayRoot : Node2D
+ {
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" }
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoBehavior_004", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("AutoSceneAttribute", "GameplayRoot", "a single string scene key argument"));
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Generates_Type_Constraints_For_Nullable_Reference_NotNull_And_Unmanaged_Parameters()
+ {
+ const string source = """
+ #nullable enable
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoSceneAttribute : Attribute
+ {
+ public AutoSceneAttribute(string key) { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class Node2D : Node { }
+ }
+
+ namespace GFramework.Game.Abstractions.Scene
+ {
+ public interface ISceneBehavior { }
+ }
+
+ namespace GFramework.Godot.Scene
+ {
+ using GFramework.Game.Abstractions.Scene;
+ using Godot;
+
+ public static class SceneBehaviorFactory
+ {
+ public static ISceneBehavior Create(T owner, string key)
+ where T : Node
+ {
+ return null!;
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ [AutoScene("Gameplay")]
+ public partial class GameplayRoot : Node2D
+ where TReference : class?
+ where TNotNull : notnull
+ where TValue : struct
+ where TUnmanaged : unmanaged
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class GameplayRoot
+ where TReference : class?
+ where TNotNull : notnull
+ where TValue : struct
+ where TUnmanaged : unmanaged
+ {
+ private global::GFramework.Game.Abstractions.Scene.ISceneBehavior? __autoSceneBehavior_Generated;
+
+ public static string SceneKeyStr => "Gameplay";
+
+ public global::GFramework.Game.Abstractions.Scene.ISceneBehavior GetScene()
+ {
+ return __autoSceneBehavior_Generated ??= global::GFramework.Godot.Scene.SceneBehaviorFactory.Create(this, SceneKeyStr);
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_GameplayRoot.AutoScene.g.cs", expected));
+ }
+
+ ///
+ /// 验证宿主类型声明同名 SceneKeyStr 属性时,生成器会报告保留成员冲突并停止生成。
+ ///
+ [Test]
+ public async Task Reports_Diagnostic_When_SceneKeyStr_Property_Name_Conflicts()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoSceneAttribute : Attribute
+ {
+ public AutoSceneAttribute(string key) { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class Node2D : Node { }
+ }
+
+ namespace TestApp
+ {
+ [AutoScene("Gameplay")]
+ public partial class GameplayRoot : Node2D
+ {
+ public static string {|#0:SceneKeyStr|} => "Conflict";
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("GameplayRoot", "SceneKeyStr"));
+
+ await test.RunAsync();
+ }
+
+ ///
+ /// 验证宿主类型声明同名缓存字段时,生成器会报告保留成员冲突并停止生成。
+ ///
+ [Test]
+ public async Task Reports_Diagnostic_When_Generated_Behavior_Field_Name_Conflicts()
+ {
+ const string source = """
+ using System;
+ using GFramework.Game.Abstractions.Scene;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoSceneAttribute : Attribute
+ {
+ public AutoSceneAttribute(string key) { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class Node2D : Node { }
+ }
+
+ namespace GFramework.Game.Abstractions.Scene
+ {
+ public interface ISceneBehavior { }
+ }
+
+ namespace TestApp
+ {
+ [AutoScene("Gameplay")]
+ public partial class GameplayRoot : Node2D
+ {
+ private ISceneBehavior? {|#0:__autoSceneBehavior_Generated|};
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("GameplayRoot", "__autoSceneBehavior_Generated"));
+
+ await test.RunAsync();
+ }
+}
diff --git a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs
new file mode 100644
index 00000000..8e7d1115
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs
@@ -0,0 +1,273 @@
+using GFramework.Godot.SourceGenerators.Behavior;
+using GFramework.Godot.SourceGenerators.Tests.Core;
+
+namespace GFramework.Godot.SourceGenerators.Tests.Behavior;
+
+[TestFixture]
+public class AutoUiPageGeneratorTests
+{
+ [Test]
+ public async Task Generates_Ui_Page_Behavior_Boilerplate()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoUiPageAttribute : Attribute
+ {
+ public AutoUiPageAttribute(string key, string layerName) { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class CanvasItem : Node { }
+ public class Control : CanvasItem { }
+ }
+
+ namespace GFramework.Game.Abstractions.Enums
+ {
+ public enum UiLayer
+ {
+ Page,
+ Overlay,
+ Modal
+ }
+ }
+
+ namespace GFramework.Game.Abstractions.UI
+ {
+ public interface IUiPageBehavior { }
+ }
+
+ namespace GFramework.Godot.UI
+ {
+ using GFramework.Game.Abstractions.Enums;
+ using GFramework.Game.Abstractions.UI;
+ using Godot;
+
+ public static class UiPageBehaviorFactory
+ {
+ public static IUiPageBehavior Create(T owner, string key, UiLayer layer)
+ where T : CanvasItem
+ {
+ return null!;
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ [AutoUiPage("MainMenu", "Page")]
+ public partial class MainMenu : Control
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class MainMenu
+ {
+ private global::GFramework.Game.Abstractions.UI.IUiPageBehavior? __autoUiPageBehavior_Generated;
+
+ public static string UiKeyStr => "MainMenu";
+
+ public global::GFramework.Game.Abstractions.UI.IUiPageBehavior GetPage()
+ {
+ return __autoUiPageBehavior_Generated ??= global::GFramework.Godot.UI.UiPageBehaviorFactory.Create(this, UiKeyStr, global::GFramework.Game.Abstractions.Enums.UiLayer.Page);
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_MainMenu.AutoUiPage.g.cs", expected));
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_AutoUiPage_Attribute_Arguments_Are_Invalid()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoUiPageAttribute : Attribute
+ {
+ public AutoUiPageAttribute(string key) { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class CanvasItem : Node { }
+ public class Control : CanvasItem { }
+ }
+
+ namespace GFramework.Game.Abstractions.Enums
+ {
+ public enum UiLayer
+ {
+ Page
+ }
+ }
+
+ namespace GFramework.Game.Abstractions.UI
+ {
+ public interface IUiPageBehavior { }
+ }
+
+ namespace GFramework.Godot.UI
+ {
+ using GFramework.Game.Abstractions.Enums;
+ using GFramework.Game.Abstractions.UI;
+ using Godot;
+
+ public static class UiPageBehaviorFactory
+ {
+ public static IUiPageBehavior Create(T owner, string key, UiLayer layer)
+ where T : CanvasItem
+ {
+ return null!;
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ [{|#0:AutoUiPage("MainMenu")|}]
+ public partial class MainMenu : Control
+ {
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoBehavior_004", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments(
+ "AutoUiPageAttribute",
+ "MainMenu",
+ "a string key argument and a string UiLayer name argument"));
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Generates_Type_Constraints_For_ClassNullable_NotNull_And_Unmanaged()
+ {
+ const string source = """
+ #nullable enable
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoUiPageAttribute : Attribute
+ {
+ public AutoUiPageAttribute(string key, string layerName) { }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node { }
+ public class CanvasItem : Node { }
+ public class Control : CanvasItem { }
+ }
+
+ namespace GFramework.Game.Abstractions.Enums
+ {
+ public enum UiLayer
+ {
+ Page
+ }
+ }
+
+ namespace GFramework.Game.Abstractions.UI
+ {
+ public interface IUiPageBehavior { }
+ }
+
+ namespace GFramework.Godot.UI
+ {
+ using GFramework.Game.Abstractions.Enums;
+ using GFramework.Game.Abstractions.UI;
+ using Godot;
+
+ public static class UiPageBehaviorFactory
+ {
+ public static IUiPageBehavior Create(T owner, string key, UiLayer layer)
+ where T : CanvasItem
+ {
+ return null!;
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ [AutoUiPage("MainMenu", "Page")]
+ public partial class MainMenu : Control
+ where TReference : class?
+ where TNotNull : notnull
+ where TUnmanaged : unmanaged
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class MainMenu
+ where TReference : class?
+ where TNotNull : notnull
+ where TUnmanaged : unmanaged
+ {
+ private global::GFramework.Game.Abstractions.UI.IUiPageBehavior? __autoUiPageBehavior_Generated;
+
+ public static string UiKeyStr => "MainMenu";
+
+ public global::GFramework.Game.Abstractions.UI.IUiPageBehavior GetPage()
+ {
+ return __autoUiPageBehavior_Generated ??= global::GFramework.Godot.UI.UiPageBehaviorFactory.Create(this, UiKeyStr, global::GFramework.Game.Abstractions.Enums.UiLayer.Page);
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_MainMenu.AutoUiPage.g.cs", expected));
+ }
+}
diff --git a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs
new file mode 100644
index 00000000..1c4927a4
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs
@@ -0,0 +1,504 @@
+using GFramework.Godot.SourceGenerators.Registration;
+using GFramework.Godot.SourceGenerators.Tests.Core;
+
+namespace GFramework.Godot.SourceGenerators.Tests.Registration;
+
+[TestFixture]
+public class AutoRegisterExportedCollectionsGeneratorTests
+{
+ [Test]
+ public async Task Generates_Batch_Registration_Method_For_Annotated_Collections()
+ {
+ const string source = """
+ #nullable enable
+ using System;
+ using System.Collections.Generic;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class IntRegistry
+ {
+ public void Register(int value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ where TReference : class?
+ where TNotNull : notnull
+ where TValue : struct
+ where TUnmanaged : unmanaged
+ {
+ private readonly IntRegistry? _registry = new();
+
+ [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
+ public List? Values { get; } = new();
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class Bootstrapper
+ where TReference : class?
+ where TNotNull : notnull
+ where TValue : struct
+ where TUnmanaged : unmanaged
+ {
+ private void __RegisterExportedCollections_Generated()
+ {
+ if (this.Values is not null && this._registry is not null)
+ {
+ foreach (var __generatedItem in this.Values)
+ {
+ this._registry.Register(__generatedItem);
+ }
+ }
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_Bootstrapper.AutoRegisterExportedCollections.g.cs", expected));
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_Collection_Element_Type_Cannot_Be_Inferred()
+ {
+ const string source = """
+ using System;
+ using System.Collections;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class IntRegistry
+ {
+ public void Register(int value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ private readonly IntRegistry _registry = new();
+
+ [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
+ public IEnumerable {|#0:Values|} { get; } = new ArrayList();
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" }
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_005", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("Values"));
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Generates_Batch_Registration_Method_When_Register_Method_Uses_Array_Parameter()
+ {
+ const string source = """
+ #nullable enable
+ using System;
+ using System.Collections.Generic;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class ArrayRegistry
+ {
+ public void Register(int[] value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ private readonly ArrayRegistry _registry = new();
+
+ [RegisterExportedCollection(nameof(_registry), nameof(ArrayRegistry.Register))]
+ public List Values { get; } = new();
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class Bootstrapper
+ {
+ private void __RegisterExportedCollections_Generated()
+ {
+ if (this.Values is not null && this._registry is not null)
+ {
+ foreach (var __generatedItem in this.Values)
+ {
+ this._registry.Register(__generatedItem);
+ }
+ }
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_Bootstrapper.AutoRegisterExportedCollections.g.cs", expected));
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_Collection_Member_Is_Not_Instance_Readable()
+ {
+ const string source = """
+ using System;
+ using System.Collections.Generic;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class IntRegistry
+ {
+ public void Register(int value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ private readonly IntRegistry _registry = new();
+
+ [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
+ public static List {|#0:StaticValues|} = new();
+
+ [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
+ public static List {|#1:StaticPropertyValues|} { get; } = new();
+
+ [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
+ public List {|#2:WriteOnlyValues|} { set { } }
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_006", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("StaticValues"));
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_006", DiagnosticSeverity.Error)
+ .WithLocation(1)
+ .WithArguments("StaticPropertyValues"));
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_006", DiagnosticSeverity.Error)
+ .WithLocation(2)
+ .WithArguments("WriteOnlyValues"));
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_Registry_Member_Is_Not_Instance_Readable()
+ {
+ const string source = """
+ using System;
+ using System.Collections.Generic;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class IntRegistry
+ {
+ public void Register(int value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ private static readonly IntRegistry {|#0:_registry|} = new();
+
+ [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
+ public List Values { get; } = new();
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_007", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("_registry", "Values"));
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_Register_Method_Is_Not_Accessible_From_Owner_Type()
+ {
+ const string source = """
+ using System;
+ using System.Collections.Generic;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class IntRegistry
+ {
+ private void Register(int value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ private readonly IntRegistry _registry = new();
+
+ [RegisterExportedCollection(nameof(_registry), "Register")]
+ public List {|#0:Values|} { get; } = new();
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_003", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("Register", "_registry", "Values"));
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_RegisterExportedCollection_Attribute_Arguments_Are_Invalid()
+ {
+ const string source = """
+ using System;
+ using System.Collections.Generic;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class IntRegistry
+ {
+ public void Register(int value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ private readonly IntRegistry _registry = new();
+
+ [{|#0:RegisterExportedCollection(nameof(_registry))|}]
+ public List Values { get; } = new();
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_008", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("Values"));
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Generates_Only_One_Source_When_Multiple_Partial_Declarations_Are_Annotated()
+ {
+ const string source = """
+ #nullable enable
+ using System;
+ using System.Collections.Generic;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
+ public sealed class RegisterExportedCollectionAttribute : Attribute
+ {
+ public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
+ }
+ }
+
+ namespace TestApp
+ {
+ public sealed class IntRegistry
+ {
+ public void Register(int value) { }
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ private readonly IntRegistry? _registry = new();
+ }
+
+ [AutoRegisterExportedCollections]
+ public partial class Bootstrapper
+ {
+ [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
+ public List? Values { get; } = new();
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class Bootstrapper
+ {
+ private void __RegisterExportedCollections_Generated()
+ {
+ if (this.Values is not null && this._registry is not null)
+ {
+ foreach (var __generatedItem in this.Values)
+ {
+ this._registry.Register(__generatedItem);
+ }
+ }
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_Bootstrapper.AutoRegisterExportedCollections.g.cs", expected));
+ }
+}
diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
index c4a29301..73b213f9 100644
--- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -3,21 +3,33 @@
### New Rules
- Rule ID | Category | Severity | Notes
------------------------------|------------------|----------|---------------------------
- GF_Godot_GetNode_001 | GFramework.Godot | Error | GetNodeDiagnostics
- GF_Godot_GetNode_002 | GFramework.Godot | Error | GetNodeDiagnostics
- GF_Godot_GetNode_003 | GFramework.Godot | Error | GetNodeDiagnostics
- GF_Godot_GetNode_004 | GFramework.Godot | Error | GetNodeDiagnostics
- GF_Godot_GetNode_005 | GFramework.Godot | Error | GetNodeDiagnostics
- GF_Godot_GetNode_006 | GFramework.Godot | Warning | GetNodeDiagnostics
- GF_Godot_BindNodeSignal_001 | GFramework.Godot | Error | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_002 | GFramework.Godot | Error | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_003 | GFramework.Godot | Error | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_004 | GFramework.Godot | Error | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_005 | GFramework.Godot | Error | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_006 | GFramework.Godot | Error | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_007 | GFramework.Godot | Error | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_008 | GFramework.Godot | Warning | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_009 | GFramework.Godot | Warning | BindNodeSignalDiagnostics
- GF_Godot_BindNodeSignal_010 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ Rule ID | Category | Severity | Notes
+-----------------------------|------------------------------------------------|----------|--------------------------------------------
+ GF_Godot_GetNode_001 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_002 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_003 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_004 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_005 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_006 | GFramework.Godot | Warning | GetNodeDiagnostics
+ GF_Godot_BindNodeSignal_001 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_002 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_003 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_004 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_005 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_006 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_007 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_008 | GFramework.Godot | Warning | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_009 | GFramework.Godot | Warning | BindNodeSignalDiagnostics
+ GF_Godot_BindNodeSignal_010 | GFramework.Godot | Error | BindNodeSignalDiagnostics
+ GF_AutoBehavior_001 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
+ GF_AutoBehavior_002 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
+ GF_AutoBehavior_003 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
+ GF_AutoBehavior_004 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
+ GF_AutoExport_001 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
+ GF_AutoExport_002 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
+ GF_AutoExport_003 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
+ GF_AutoExport_004 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
+ GF_AutoExport_005 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
+ 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
diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs
new file mode 100644
index 00000000..f2920be4
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs
@@ -0,0 +1,339 @@
+using GFramework.Godot.SourceGenerators.Diagnostics;
+using GFramework.SourceGenerators.Common.Constants;
+using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
+
+namespace GFramework.Godot.SourceGenerators.Behavior;
+
+///
+/// 为标记了 [AutoScene] 的 Godot 节点生成场景行为样板。
+///
+///
+/// 该生成器会为兼容的非嵌套 partial Godot 节点类型生成 SceneKeyStr 与 GetScene,
+/// 以便通过 SceneBehaviorFactory 延迟创建并缓存场景行为实例。
+/// 生成管线仅处理显式标记了 AutoSceneAttribute 的类,并在类型不满足基类、partial、
+/// 成员冲突或属性参数约束时通过诊断停止生成,而不是静默回退到不完整输出。
+///
+[Generator]
+public sealed class AutoSceneGenerator : IIncrementalGenerator
+{
+ private const string AutoSceneAttributeMetadataName =
+ $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoSceneAttribute";
+ private static readonly string[] GeneratedMemberNames =
+ [
+ "SceneKeyStr",
+ "__autoSceneBehavior_Generated"
+ ];
+
+ ///
+ /// 配置 AutoScene 的增量生成管线。
+ ///
+ /// 用于注册语法筛选、语义转换和源输出阶段的增量生成上下文。
+ ///
+ /// 管线首先通过语法节点名称快速筛选潜在候选,再结合语义模型确认类型符号。
+ /// 最终输出阶段仅在 AutoSceneAttribute、Godot.Node 等依赖可解析且目标类型满足生成约束时产出源码;
+ /// 否则会报告对应诊断,或在宿主依赖缺失时直接跳过生成。
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var candidates = context.SyntaxProvider.CreateSyntaxProvider(
+ static (node, _) => IsCandidate(node),
+ static (syntaxContext, _) => Transform(syntaxContext))
+ .Where(static candidate => candidate is not null);
+
+ var compilationAndCandidates = context.CompilationProvider.Combine(candidates.Collect());
+ context.RegisterSourceOutput(compilationAndCandidates,
+ static (spc, pair) => Execute(spc, pair.Left, pair.Right));
+ }
+
+ private static bool IsCandidate(SyntaxNode node)
+ {
+ return node is ClassDeclarationSyntax classDeclaration &&
+ classDeclaration.AttributeLists
+ .SelectMany(static list => list.Attributes)
+ .Any(static attribute => attribute.Name.ToString().Contains("AutoScene", StringComparison.Ordinal));
+ }
+
+ private static TypeCandidate? Transform(GeneratorSyntaxContext context)
+ {
+ if (context.Node is not ClassDeclarationSyntax classDeclaration)
+ return null;
+
+ if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol)
+ return null;
+
+ return new TypeCandidate(classDeclaration, typeSymbol);
+ }
+
+ private static void Execute(
+ SourceProductionContext context,
+ Compilation compilation,
+ ImmutableArray candidates)
+ {
+ if (candidates.IsDefaultOrEmpty)
+ return;
+
+ var autoSceneAttribute = compilation.GetTypeByMetadataName(AutoSceneAttributeMetadataName);
+ var godotNodeType = compilation.GetTypeByMetadataName("Godot.Node");
+
+ if (autoSceneAttribute is null || godotNodeType is null)
+ return;
+
+ foreach (var candidate in candidates.Where(static candidate => candidate is not null)
+ .Select(static candidate => candidate!))
+ {
+ var attribute = candidate.TypeSymbol.GetAttributes()
+ .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, autoSceneAttribute));
+
+ if (attribute is null)
+ continue;
+
+ if (!CanGenerateForType(context, candidate, godotNodeType))
+ continue;
+
+ if (candidate.TypeSymbol.ReportGeneratedMethodConflicts(
+ context,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ "GetScene"))
+ {
+ continue;
+ }
+
+ if (ReportGeneratedMemberConflicts(
+ context,
+ candidate.TypeSymbol,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ GeneratedMemberNames))
+ {
+ continue;
+ }
+
+ if (!TryGetSceneKey(context, candidate.TypeSymbol, attribute, out var key))
+ continue;
+
+ context.AddSource(GetHintName(candidate.TypeSymbol), GenerateSource(candidate.TypeSymbol, key));
+ }
+ }
+
+ private static bool CanGenerateForType(
+ SourceProductionContext context,
+ TypeCandidate candidate,
+ INamedTypeSymbol requiredBaseType)
+ {
+ if (candidate.TypeSymbol.ContainingType is not null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoBehaviorDiagnostics.NestedClassNotSupported,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ "AutoScene",
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ if (!IsPartial(candidate.TypeSymbol))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.ClassMustBePartial,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ if (candidate.TypeSymbol.IsAssignableTo(requiredBaseType))
+ return true;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoBehaviorDiagnostics.MissingBaseType,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name,
+ requiredBaseType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
+ "AutoScene"));
+ return false;
+ }
+
+ private static bool TryGetSceneKey(
+ SourceProductionContext context,
+ INamedTypeSymbol typeSymbol,
+ AttributeData attribute,
+ out string key)
+ {
+ key = string.Empty;
+
+ if (attribute.ConstructorArguments.Length == 1 &&
+ attribute.ConstructorArguments[0].Value is string sceneKey)
+ {
+ key = sceneKey;
+ return true;
+ }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoBehaviorDiagnostics.InvalidAttributeArguments,
+ attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ??
+ typeSymbol.Locations.FirstOrDefault() ??
+ Location.None,
+ "AutoSceneAttribute",
+ typeSymbol.Name,
+ "a single string scene key argument"));
+ return false;
+ }
+
+ private static string GenerateSource(INamedTypeSymbol typeSymbol, string key)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("// ");
+ builder.AppendLine("#nullable enable");
+ builder.AppendLine();
+
+ var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : typeSymbol.ContainingNamespace.ToDisplayString();
+
+ if (ns is not null)
+ {
+ builder.AppendLine($"namespace {ns};");
+ builder.AppendLine();
+ }
+
+ builder.AppendLine($"{GetTypeDeclarationKeyword(typeSymbol)} {GetTypeDeclarationName(typeSymbol)}");
+ AppendTypeConstraints(builder, typeSymbol);
+ builder.AppendLine("{");
+ builder.AppendLine(
+ " private global::GFramework.Game.Abstractions.Scene.ISceneBehavior? __autoSceneBehavior_Generated;");
+ builder.AppendLine();
+ builder.Append(" public static string SceneKeyStr => ");
+ builder.Append(SymbolDisplay.FormatLiteral(key, true));
+ builder.AppendLine(";");
+ builder.AppendLine();
+ builder.AppendLine(" public global::GFramework.Game.Abstractions.Scene.ISceneBehavior GetScene()");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ " return __autoSceneBehavior_Generated ??= global::GFramework.Godot.Scene.SceneBehaviorFactory.Create(this, SceneKeyStr);");
+ builder.AppendLine(" }");
+ builder.AppendLine("}");
+ return builder.ToString();
+ }
+
+ private static bool IsPartial(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.DeclaringSyntaxReferences
+ .Select(static reference => reference.GetSyntax())
+ .OfType()
+ .All(static declaration =>
+ declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)));
+ }
+
+ private static string GetHintName(INamedTypeSymbol typeSymbol)
+ {
+ var prefix = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? typeSymbol.Name
+ : $"{typeSymbol.ContainingNamespace.ToDisplayString()}.{typeSymbol.Name}";
+ return prefix.Replace('.', '_') + ".AutoScene.g.cs";
+ }
+
+ private static string GetTypeDeclarationKeyword(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.IsRecord
+ ? typeSymbol.TypeKind == TypeKind.Struct ? "partial record struct" : "partial record"
+ : typeSymbol.TypeKind == TypeKind.Struct
+ ? "partial struct"
+ : "partial class";
+ }
+
+ private static string GetTypeDeclarationName(INamedTypeSymbol typeSymbol)
+ {
+ if (typeSymbol.TypeParameters.Length == 0)
+ return typeSymbol.Name;
+
+ return
+ $"{typeSymbol.Name}<{string.Join(", ", typeSymbol.TypeParameters.Select(static parameter => parameter.Name))}>";
+ }
+
+ private static void AppendTypeConstraints(StringBuilder builder, INamedTypeSymbol typeSymbol)
+ {
+ foreach (var typeParameter in typeSymbol.TypeParameters)
+ {
+ var constraints = new List();
+
+ if (typeParameter.HasReferenceTypeConstraint)
+ {
+ constraints.Add(
+ typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated
+ ? "class?"
+ : "class");
+ }
+
+ if (typeParameter.HasNotNullConstraint)
+ constraints.Add("notnull");
+
+ // unmanaged implies the value-type constraint and must replace struct in generated constraints.
+ if (typeParameter.HasUnmanagedTypeConstraint)
+ constraints.Add("unmanaged");
+ else if (typeParameter.HasValueTypeConstraint)
+ constraints.Add("struct");
+
+ constraints.AddRange(typeParameter.ConstraintTypes.Select(static constraint =>
+ constraint.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
+
+ if (typeParameter.HasConstructorConstraint)
+ constraints.Add("new()");
+
+ if (constraints.Count == 0)
+ continue;
+
+ builder.Append(" where ");
+ builder.Append(typeParameter.Name);
+ builder.Append(" : ");
+ builder.AppendLine(string.Join(", ", constraints));
+ }
+ }
+
+ ///
+ /// 报告与生成器保留成员名冲突的字段或属性,避免生成代码出现重复成员编译错误。
+ ///
+ /// 用于上报诊断的源代码生成上下文。
+ /// 当前待生成的类型符号。
+ /// 冲突成员无定位信息时的后备位置。
+ /// 需要校验的生成器保留成员名集合。
+ /// 存在任意冲突时返回 true。
+ private static bool ReportGeneratedMemberConflicts(
+ SourceProductionContext context,
+ INamedTypeSymbol typeSymbol,
+ Location fallbackLocation,
+ string[] memberNames)
+ {
+ var hasConflict = false;
+
+ foreach (var memberName in memberNames)
+ {
+ var conflict = typeSymbol.GetMembers(memberName)
+ .FirstOrDefault(member =>
+ !member.IsImplicitlyDeclared &&
+ member is IPropertySymbol or IFieldSymbol);
+
+ if (conflict is null)
+ continue;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.GeneratedMethodNameConflict,
+ conflict.Locations.FirstOrDefault() ?? fallbackLocation,
+ typeSymbol.Name,
+ memberName));
+ hasConflict = true;
+ }
+
+ return hasConflict;
+ }
+
+ private sealed class TypeCandidate
+ {
+ public TypeCandidate(ClassDeclarationSyntax classDeclaration, INamedTypeSymbol typeSymbol)
+ {
+ ClassDeclaration = classDeclaration;
+ TypeSymbol = typeSymbol;
+ }
+
+ public ClassDeclarationSyntax ClassDeclaration { get; }
+
+ public INamedTypeSymbol TypeSymbol { get; }
+ }
+}
diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs
new file mode 100644
index 00000000..1e0fe7f6
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs
@@ -0,0 +1,301 @@
+using GFramework.Godot.SourceGenerators.Diagnostics;
+using GFramework.SourceGenerators.Common.Constants;
+using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
+
+namespace GFramework.Godot.SourceGenerators.Behavior;
+
+///
+/// 为标记了 [AutoUiPage] 的 Godot CanvasItem 生成页面行为样板。
+///
+[Generator]
+public sealed class AutoUiPageGenerator : IIncrementalGenerator
+{
+ private const string AutoUiPageAttributeMetadataName =
+ $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoUiPageAttribute";
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var candidates = context.SyntaxProvider.CreateSyntaxProvider(
+ static (node, _) => IsCandidate(node),
+ static (syntaxContext, _) => Transform(syntaxContext))
+ .Where(static candidate => candidate is not null);
+
+ var compilationAndCandidates = context.CompilationProvider.Combine(candidates.Collect());
+ context.RegisterSourceOutput(compilationAndCandidates,
+ static (spc, pair) => Execute(spc, pair.Left, pair.Right));
+ }
+
+ private static bool IsCandidate(SyntaxNode node)
+ {
+ return node is ClassDeclarationSyntax classDeclaration &&
+ classDeclaration.AttributeLists
+ .SelectMany(static list => list.Attributes)
+ .Any(static attribute => attribute.Name.ToString().Contains("AutoUiPage", StringComparison.Ordinal));
+ }
+
+ private static TypeCandidate? Transform(GeneratorSyntaxContext context)
+ {
+ if (context.Node is not ClassDeclarationSyntax classDeclaration)
+ return null;
+
+ if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol)
+ return null;
+
+ return new TypeCandidate(classDeclaration, typeSymbol);
+ }
+
+ private static void Execute(
+ SourceProductionContext context,
+ Compilation compilation,
+ ImmutableArray candidates)
+ {
+ if (candidates.IsDefaultOrEmpty)
+ return;
+
+ var autoUiPageAttribute = compilation.GetTypeByMetadataName(AutoUiPageAttributeMetadataName);
+ var canvasItemType = compilation.GetTypeByMetadataName("Godot.CanvasItem");
+ var uiLayerType = compilation.GetTypeByMetadataName("GFramework.Game.Abstractions.Enums.UiLayer");
+
+ if (autoUiPageAttribute is null || canvasItemType is null || uiLayerType is null)
+ return;
+
+ foreach (var candidate in candidates.Where(static candidate => candidate is not null)
+ .Select(static candidate => candidate!))
+ {
+ var attribute = candidate.TypeSymbol.GetAttributes()
+ .FirstOrDefault(attr =>
+ SymbolEqualityComparer.Default.Equals(attr.AttributeClass, autoUiPageAttribute));
+
+ if (attribute is null)
+ continue;
+
+ if (!CanGenerateForType(context, candidate, canvasItemType, "AutoUiPage"))
+ continue;
+
+ if (candidate.TypeSymbol.ReportGeneratedMethodConflicts(
+ context,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ "GetPage"))
+ {
+ continue;
+ }
+
+ if (!TryCreateSpec(context, candidate.TypeSymbol, attribute, uiLayerType, out var spec))
+ continue;
+
+ context.AddSource(GetHintName(candidate.TypeSymbol), GenerateSource(candidate.TypeSymbol, spec));
+ }
+ }
+
+ private static bool CanGenerateForType(
+ SourceProductionContext context,
+ TypeCandidate candidate,
+ INamedTypeSymbol requiredBaseType,
+ string generatorName)
+ {
+ if (candidate.TypeSymbol.ContainingType is not null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoBehaviorDiagnostics.NestedClassNotSupported,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ generatorName,
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ if (!IsPartial(candidate.TypeSymbol))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.ClassMustBePartial,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ if (candidate.TypeSymbol.IsAssignableTo(requiredBaseType))
+ return true;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoBehaviorDiagnostics.MissingBaseType,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name,
+ requiredBaseType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
+ generatorName));
+ return false;
+ }
+
+ private static bool TryCreateSpec(
+ SourceProductionContext context,
+ INamedTypeSymbol typeSymbol,
+ AttributeData attribute,
+ INamedTypeSymbol uiLayerType,
+ out UiPageSpec spec)
+ {
+ spec = null!;
+
+ if (attribute.ConstructorArguments.Length != 2 ||
+ attribute.ConstructorArguments[0].Value is not string key ||
+ attribute.ConstructorArguments[1].Value is not string layerName)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoBehaviorDiagnostics.InvalidAttributeArguments,
+ attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation()
+ ?? typeSymbol.Locations.FirstOrDefault()
+ ?? Location.None,
+ "AutoUiPageAttribute",
+ typeSymbol.Name,
+ "a string key argument and a string UiLayer name argument"));
+ return false;
+ }
+
+ if (!uiLayerType.GetMembers(layerName).Any())
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoBehaviorDiagnostics.InvalidUiLayerName,
+ attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None,
+ layerName,
+ typeSymbol.Name));
+ return false;
+ }
+
+ spec = new UiPageSpec(key, layerName);
+ return true;
+ }
+
+ private static string GenerateSource(INamedTypeSymbol typeSymbol, UiPageSpec spec)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("// ");
+ builder.AppendLine("#nullable enable");
+ builder.AppendLine();
+
+ var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : typeSymbol.ContainingNamespace.ToDisplayString();
+
+ if (ns is not null)
+ {
+ builder.AppendLine($"namespace {ns};");
+ builder.AppendLine();
+ }
+
+ builder.AppendLine($"{GetTypeDeclarationKeyword(typeSymbol)} {GetTypeDeclarationName(typeSymbol)}");
+ AppendTypeConstraints(builder, typeSymbol);
+ builder.AppendLine("{");
+ builder.AppendLine(
+ " private global::GFramework.Game.Abstractions.UI.IUiPageBehavior? __autoUiPageBehavior_Generated;");
+ builder.AppendLine();
+ builder.Append(" public static string UiKeyStr => ");
+ builder.Append(SymbolDisplay.FormatLiteral(spec.Key, true));
+ builder.AppendLine(";");
+ builder.AppendLine();
+ builder.AppendLine(" public global::GFramework.Game.Abstractions.UI.IUiPageBehavior GetPage()");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" return __autoUiPageBehavior_Generated ??= global::GFramework.Godot.UI.UiPageBehaviorFactory.Create(this, UiKeyStr, global::GFramework.Game.Abstractions.Enums.UiLayer.{spec.LayerName});");
+ builder.AppendLine(" }");
+ builder.AppendLine("}");
+ return builder.ToString();
+ }
+
+ private static bool IsPartial(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.DeclaringSyntaxReferences
+ .Select(static reference => reference.GetSyntax())
+ .OfType()
+ .All(static declaration =>
+ declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)));
+ }
+
+ private static string GetHintName(INamedTypeSymbol typeSymbol)
+ {
+ var prefix = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? typeSymbol.Name
+ : $"{typeSymbol.ContainingNamespace.ToDisplayString()}.{typeSymbol.Name}";
+ return prefix.Replace('.', '_') + ".AutoUiPage.g.cs";
+ }
+
+ private static string GetTypeDeclarationKeyword(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.IsRecord
+ ? typeSymbol.TypeKind == TypeKind.Struct ? "partial record struct" : "partial record"
+ : typeSymbol.TypeKind == TypeKind.Struct
+ ? "partial struct"
+ : "partial class";
+ }
+
+ private static string GetTypeDeclarationName(INamedTypeSymbol typeSymbol)
+ {
+ if (typeSymbol.TypeParameters.Length == 0)
+ return typeSymbol.Name;
+
+ return
+ $"{typeSymbol.Name}<{string.Join(", ", typeSymbol.TypeParameters.Select(static parameter => parameter.Name))}>";
+ }
+
+ private static void AppendTypeConstraints(StringBuilder builder, INamedTypeSymbol typeSymbol)
+ {
+ foreach (var typeParameter in typeSymbol.TypeParameters)
+ {
+ var constraints = new List();
+
+ if (typeParameter.HasReferenceTypeConstraint)
+ {
+ constraints.Add(
+ typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated
+ ? "class?"
+ : "class");
+ }
+
+ if (typeParameter.HasNotNullConstraint)
+ constraints.Add("notnull");
+
+ // unmanaged implies the value-type constraint and must replace struct in generated constraints.
+ if (typeParameter.HasUnmanagedTypeConstraint)
+ constraints.Add("unmanaged");
+ else if (typeParameter.HasValueTypeConstraint)
+ constraints.Add("struct");
+
+ constraints.AddRange(typeParameter.ConstraintTypes.Select(static constraint =>
+ constraint.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
+
+ if (typeParameter.HasConstructorConstraint)
+ constraints.Add("new()");
+
+ if (constraints.Count == 0)
+ continue;
+
+ builder.Append(" where ");
+ builder.Append(typeParameter.Name);
+ builder.Append(" : ");
+ builder.AppendLine(string.Join(", ", constraints));
+ }
+ }
+
+ private sealed class TypeCandidate
+ {
+ public TypeCandidate(ClassDeclarationSyntax classDeclaration, INamedTypeSymbol typeSymbol)
+ {
+ ClassDeclaration = classDeclaration;
+ TypeSymbol = typeSymbol;
+ }
+
+ public ClassDeclarationSyntax ClassDeclaration { get; }
+
+ public INamedTypeSymbol TypeSymbol { get; }
+ }
+
+ private sealed class UiPageSpec
+ {
+ public UiPageSpec(string key, string layerName)
+ {
+ Key = key;
+ LayerName = layerName;
+ }
+
+ public string Key { get; }
+
+ public string LayerName { get; }
+ }
+}
diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs
new file mode 100644
index 00000000..42c9dbec
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs
@@ -0,0 +1,59 @@
+using GFramework.SourceGenerators.Common.Constants;
+
+namespace GFramework.Godot.SourceGenerators.Diagnostics;
+
+///
+/// 定义行为类自动生成器使用的诊断描述符。
+///
+///
+/// 这些规则覆盖 AutoScene 与 AutoUiPage 等行为生成器的常见使用约束,
+/// 以便在生成被跳过前向调用方报告明确的失败原因。
+///
+internal static class AutoBehaviorDiagnostics
+{
+ private const string Category = $"{PathContests.GodotNamespace}.SourceGenerators.Behavior";
+
+ ///
+ /// 报告行为生成器不支持在嵌套类型上运行。
+ ///
+ public static readonly DiagnosticDescriptor NestedClassNotSupported = new(
+ "GF_AutoBehavior_001",
+ "Auto behavior generators do not support nested classes",
+ "Generator '{0}' does not support nested class '{1}'",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告目标类型没有继承生成器要求的 Godot 基类。
+ ///
+ public static readonly DiagnosticDescriptor MissingBaseType = new(
+ "GF_AutoBehavior_002",
+ "Auto behavior generators require a compatible base type",
+ "Type '{0}' must inherit from '{1}' to use '{2}'",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告 UI 页面声明中使用了不存在的 UiLayer 名称。
+ ///
+ public static readonly DiagnosticDescriptor InvalidUiLayerName = new(
+ "GF_AutoBehavior_003",
+ "Unknown UiLayer name",
+ "Ui layer '{0}' on '{1}' does not exist on GFramework.Game.Abstractions.Enums.UiLayer",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告行为生成器特性参数不满足约定签名,导致生成器无法推导所需元数据。
+ ///
+ public static readonly DiagnosticDescriptor InvalidAttributeArguments = new(
+ "GF_AutoBehavior_004",
+ "Auto behavior attribute arguments are invalid",
+ "Attribute '{0}' on '{1}' must provide {2}",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+}
diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs
new file mode 100644
index 00000000..d77b3255
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs
@@ -0,0 +1,103 @@
+using GFramework.SourceGenerators.Common.Constants;
+
+namespace GFramework.Godot.SourceGenerators.Diagnostics;
+
+///
+/// 定义导出集合自动注册生成器使用的诊断描述符。
+///
+///
+/// 这些规则用于在源生成阶段验证集合成员、注册目标以及元素类型推导,
+/// 避免把配置错误延后到生成代码编译或运行时才暴露。
+///
+internal static class AutoRegisterExportedCollectionsDiagnostics
+{
+ private const string Category = $"{PathContests.GodotNamespace}.SourceGenerators.Registration";
+
+ ///
+ /// 报告自动注册生成器不支持嵌套类型。
+ ///
+ public static readonly DiagnosticDescriptor NestedClassNotSupported = new(
+ "GF_AutoExport_001",
+ "AutoRegisterExportedCollections does not support nested classes",
+ "AutoRegisterExportedCollections does not support nested class '{0}'",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告特性引用的注册表成员在宿主类型上不存在。
+ ///
+ public static readonly DiagnosticDescriptor RegistryMemberNotFound = new(
+ "GF_AutoExport_002",
+ "Registry member was not found",
+ "Member '{0}' referenced by exported collection '{1}' was not found on '{2}'",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告注册表上未找到与集合元素类型兼容的注册方法。
+ ///
+ public static readonly DiagnosticDescriptor RegisterMethodNotFound = new(
+ "GF_AutoExport_003",
+ "Register method was not found",
+ "Method '{0}' was not found on registry member '{1}' for exported collection '{2}'",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告被标记成员不是可枚举集合,因此无法执行批量注册。
+ ///
+ public static readonly DiagnosticDescriptor CollectionTypeMustBeEnumerable = new(
+ "GF_AutoExport_004",
+ "Exported collection must be enumerable",
+ "Member '{0}' must be enumerable to use RegisterExportedCollection",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告集合元素类型无法在编译期推导,因此无法安全匹配注册方法。
+ ///
+ public static readonly DiagnosticDescriptor CollectionElementTypeCouldNotBeInferred = new(
+ "GF_AutoExport_005",
+ "Exported collection element type could not be inferred",
+ "Member '{0}' must expose a generic enumerable element type to use RegisterExportedCollection safely",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告被标记为导出集合的成员不是实例可读成员,因此无法生成 this.<member> 访问代码。
+ ///
+ public static readonly DiagnosticDescriptor CollectionMemberMustBeInstanceReadable = new(
+ "GF_AutoExport_006",
+ "Exported collection member must be an instance readable member",
+ "Member '{0}' must be an instance field or readable non-indexer instance property to use RegisterExportedCollection",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告注册表成员不是实例可读成员,因此生成器无法安全读取并调用注册方法。
+ ///
+ public static readonly DiagnosticDescriptor RegistryMemberMustBeInstanceReadable = new(
+ "GF_AutoExport_007",
+ "Registry member must be an instance readable member",
+ "Registry member '{0}' referenced by exported collection '{1}' must be an instance field or readable non-indexer instance property",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 报告 RegisterExportedCollectionAttribute 构造参数不满足约定,导致无法解析注册目标成员与方法名。
+ ///
+ public static readonly DiagnosticDescriptor InvalidAttributeArguments = new(
+ "GF_AutoExport_008",
+ "RegisterExportedCollection attribute arguments are invalid",
+ "Attribute 'RegisterExportedCollectionAttribute' on member '{0}' must provide a string registry member name and a string register method name",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+}
diff --git a/GFramework.Godot.SourceGenerators/GlobalUsings.cs b/GFramework.Godot.SourceGenerators/GlobalUsings.cs
index 0413980e..9efb3c18 100644
--- a/GFramework.Godot.SourceGenerators/GlobalUsings.cs
+++ b/GFramework.Godot.SourceGenerators/GlobalUsings.cs
@@ -12,10 +12,12 @@
// limitations under the License.
global using System;
+global using System.Collections.Immutable;
global using System.Collections.Generic;
global using System.Linq;
+global using System.Text;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.CodeAnalysis;
global using Microsoft.CodeAnalysis.CSharp;
-global using Microsoft.CodeAnalysis.CSharp.Syntax;
\ No newline at end of file
+global using Microsoft.CodeAnalysis.CSharp.Syntax;
diff --git a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs
new file mode 100644
index 00000000..df0c433b
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs
@@ -0,0 +1,520 @@
+using GFramework.Godot.SourceGenerators.Diagnostics;
+using GFramework.SourceGenerators.Common.Constants;
+using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
+
+namespace GFramework.Godot.SourceGenerators.Registration;
+
+///
+/// 为导出集合生成批量注册样板方法。
+///
+///
+/// 该生成器会扫描标记了 AutoRegisterExportedCollectionsAttribute 的 partial 类型,
+/// 为其中使用 RegisterExportedCollectionAttribute 声明的集合成员生成集中注册方法。
+/// 仅当集合可枚举、元素类型可推导、注册表成员存在且可找到兼容的实例注册方法时才会输出代码;
+/// 否则通过 GF_AutoExport_001 到 GF_AutoExport_008 以及公共 ClassMustBePartial 诊断显式阻止生成。
+///
+[Generator]
+public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGenerator
+{
+ private const string AutoRegisterExportedCollectionsAttributeMetadataName =
+ $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoRegisterExportedCollectionsAttribute";
+
+ private const string RegisterExportedCollectionAttributeMetadataName =
+ $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.RegisterExportedCollectionAttribute";
+
+ private const string GeneratedMethodName = "__RegisterExportedCollections_Generated";
+
+ ///
+ /// 配置导出集合自动注册的增量生成管线。
+ ///
+ /// 用于注册候选筛选、语义转换和最终源输出的增量生成上下文。
+ ///
+ /// 管线先通过语法名称筛选减少分析范围,再在输出阶段验证特性、集合形状、注册目标与方法签名。
+ /// 当依赖类型无法解析时,生成器不会报告噪声诊断而是直接跳过;当用户代码违反生成约束时,会报告明确诊断并停止该类型的生成。
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var candidates = context.SyntaxProvider.CreateSyntaxProvider(
+ static (node, _) => IsCandidate(node),
+ static (syntaxContext, _) => Transform(syntaxContext))
+ .Where(static candidate => candidate is not null);
+
+ var compilationAndCandidates = context.CompilationProvider.Combine(candidates.Collect());
+ context.RegisterSourceOutput(compilationAndCandidates,
+ static (spc, pair) => Execute(spc, pair.Left, pair.Right));
+ }
+
+ private static bool IsCandidate(SyntaxNode node)
+ {
+ return node is ClassDeclarationSyntax classDeclaration &&
+ classDeclaration.AttributeLists
+ .SelectMany(static list => list.Attributes)
+ .Any(static attribute =>
+ attribute.Name.ToString().Contains("AutoRegisterExportedCollections", StringComparison.Ordinal));
+ }
+
+ private static TypeCandidate? Transform(GeneratorSyntaxContext context)
+ {
+ if (context.Node is not ClassDeclarationSyntax classDeclaration)
+ return null;
+
+ if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol)
+ return null;
+
+ return new TypeCandidate(classDeclaration, typeSymbol);
+ }
+
+ private static void Execute(
+ SourceProductionContext context,
+ Compilation compilation,
+ ImmutableArray candidates)
+ {
+ if (candidates.IsDefaultOrEmpty)
+ return;
+
+ var autoRegisterAttribute =
+ compilation.GetTypeByMetadataName(AutoRegisterExportedCollectionsAttributeMetadataName);
+ var registerCollectionAttribute =
+ compilation.GetTypeByMetadataName(RegisterExportedCollectionAttributeMetadataName);
+ var enumerableType = compilation.GetTypeByMetadataName("System.Collections.IEnumerable");
+
+ if (autoRegisterAttribute is null || registerCollectionAttribute is null || enumerableType is null)
+ return;
+
+ foreach (var candidate in candidates
+ .Where(static candidate => candidate is not null)
+ .Select(static candidate => candidate!)
+ .GroupBy(static candidate => candidate.TypeSymbol, SymbolEqualityComparer.Default)
+ .Select(static group => group.First()))
+ {
+ if (!candidate.TypeSymbol.GetAttributes().Any(attribute =>
+ SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, autoRegisterAttribute)))
+ {
+ continue;
+ }
+
+ if (!CanGenerateForType(context, candidate))
+ continue;
+
+ if (candidate.TypeSymbol.ReportGeneratedMethodConflicts(
+ context,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ GeneratedMethodName))
+ {
+ continue;
+ }
+
+ var registrations = CollectRegistrations(
+ context,
+ compilation,
+ candidate.TypeSymbol,
+ registerCollectionAttribute,
+ enumerableType);
+
+ if (registrations.Count == 0)
+ continue;
+
+ context.AddSource(GetHintName(candidate.TypeSymbol), GenerateSource(candidate.TypeSymbol, registrations));
+ }
+ }
+
+ private static bool CanGenerateForType(SourceProductionContext context, TypeCandidate candidate)
+ {
+ if (candidate.TypeSymbol.ContainingType is not null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.NestedClassNotSupported,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ if (IsPartial(candidate.TypeSymbol))
+ return true;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.ClassMustBePartial,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ private static List CollectRegistrations(
+ SourceProductionContext context,
+ Compilation compilation,
+ INamedTypeSymbol typeSymbol,
+ INamedTypeSymbol registerCollectionAttribute,
+ INamedTypeSymbol enumerableType)
+ {
+ var registrations = new List();
+
+ foreach (var member in typeSymbol.GetMembers())
+ {
+ if (member is not IFieldSymbol and not IPropertySymbol)
+ continue;
+
+ var attribute = member.GetAttributes()
+ .FirstOrDefault(attr =>
+ SymbolEqualityComparer.Default.Equals(attr.AttributeClass, registerCollectionAttribute));
+
+ if (attribute is null)
+ continue;
+
+ if (!TryCreateRegistration(
+ context,
+ compilation,
+ typeSymbol,
+ member,
+ attribute,
+ enumerableType,
+ out var registration))
+ {
+ continue;
+ }
+
+ registrations.Add(registration);
+ }
+
+ return registrations;
+ }
+
+ private static bool TryCreateRegistration(
+ SourceProductionContext context,
+ Compilation compilation,
+ INamedTypeSymbol ownerType,
+ ISymbol collectionMember,
+ AttributeData attribute,
+ INamedTypeSymbol enumerableType,
+ out RegistrationSpec registration)
+ {
+ registration = null!;
+
+ if (!IsInstanceReadableMember(collectionMember))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.CollectionMemberMustBeInstanceReadable,
+ collectionMember.Locations.FirstOrDefault() ?? Location.None,
+ collectionMember.Name));
+ return false;
+ }
+
+ var collectionType = collectionMember switch
+ {
+ IFieldSymbol field => field.Type,
+ IPropertySymbol property => property.Type,
+ _ => null
+ };
+
+ if (collectionType is null)
+ return false;
+
+ if (!collectionType.IsAssignableTo(enumerableType))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.CollectionTypeMustBeEnumerable,
+ collectionMember.Locations.FirstOrDefault() ?? Location.None,
+ collectionMember.Name));
+ return false;
+ }
+
+ if (!TryGetRegistrationAttributeArguments(context, collectionMember, attribute, out var registryMemberName,
+ out var registerMethodName))
+ return false;
+
+ var registryMember = ownerType.GetMembers(registryMemberName)
+ .FirstOrDefault(member => member is IFieldSymbol or IPropertySymbol);
+
+ if (registryMember is null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.RegistryMemberNotFound,
+ collectionMember.Locations.FirstOrDefault() ?? Location.None,
+ registryMemberName,
+ collectionMember.Name,
+ ownerType.Name));
+ return false;
+ }
+
+ if (!IsInstanceReadableMember(registryMember))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.RegistryMemberMustBeInstanceReadable,
+ registryMember.Locations.FirstOrDefault() ?? Location.None,
+ registryMemberName,
+ collectionMember.Name));
+ return false;
+ }
+
+ var registryType = registryMember switch
+ {
+ IFieldSymbol field => field.Type as INamedTypeSymbol,
+ IPropertySymbol property => property.Type as INamedTypeSymbol,
+ _ => null
+ };
+
+ if (registryType is null)
+ return false;
+
+ var elementType = TryGetElementType(collectionType);
+ if (elementType is null)
+ {
+ // Non-generic IEnumerable exposes elements as object at compile time, which is not safe
+ // for validating or generating a strongly typed registry call.
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.CollectionElementTypeCouldNotBeInferred,
+ collectionMember.Locations.FirstOrDefault() ?? Location.None,
+ collectionMember.Name));
+ return false;
+ }
+
+ var hasCompatibleMethod = registryType.GetMembers(registerMethodName)
+ .OfType()
+ .Any(method =>
+ !method.IsStatic &&
+ method.Parameters.Length == 1 &&
+ compilation.IsSymbolAccessibleWithin(method, ownerType) &&
+ CanAcceptElementType(compilation, elementType, method.Parameters[0].Type));
+
+ if (!hasCompatibleMethod)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.RegisterMethodNotFound,
+ collectionMember.Locations.FirstOrDefault() ?? Location.None,
+ registerMethodName,
+ registryMemberName,
+ collectionMember.Name));
+ return false;
+ }
+
+ registration = new RegistrationSpec(collectionMember.Name, registryMemberName, registerMethodName);
+ return true;
+ }
+
+ private static bool IsInstanceReadableMember(ISymbol member)
+ {
+ // Generated code always reads through `this.`, so only instance fields and
+ // readable non-indexer instance properties are valid targets.
+ return member switch
+ {
+ IFieldSymbol field => !field.IsStatic,
+ IPropertySymbol property =>
+ !property.IsStatic &&
+ property.Parameters.Length == 0 &&
+ property.GetMethod is not null,
+ _ => false
+ };
+ }
+
+ private static bool CanAcceptElementType(
+ Compilation compilation,
+ ITypeSymbol elementType,
+ ITypeSymbol parameterType)
+ {
+ if (elementType.IsAssignableTo(parameterType as INamedTypeSymbol))
+ return true;
+
+ // Fall back to Roslyn's conversion rules so arrays and other non-named types are
+ // validated the same way the generated invocation will be bound by the compiler.
+ return compilation.ClassifyConversion(elementType, parameterType).IsImplicit;
+ }
+
+ private static bool TryGetRegistrationAttributeArguments(
+ SourceProductionContext context,
+ ISymbol collectionMember,
+ AttributeData attribute,
+ out string registryMemberName,
+ out string registerMethodName)
+ {
+ registryMemberName = string.Empty;
+ registerMethodName = string.Empty;
+
+ if (attribute.ConstructorArguments.Length != 2 ||
+ attribute.ConstructorArguments[0].Value is not string registryName ||
+ attribute.ConstructorArguments[1].Value is not string methodName)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterExportedCollectionsDiagnostics.InvalidAttributeArguments,
+ attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation()
+ ?? collectionMember.Locations.FirstOrDefault()
+ ?? Location.None,
+ collectionMember.Name));
+ return false;
+ }
+
+ registryMemberName = registryName;
+ registerMethodName = methodName;
+ return true;
+ }
+
+ private static ITypeSymbol? TryGetElementType(ITypeSymbol typeSymbol)
+ {
+ if (typeSymbol is IArrayTypeSymbol arrayType)
+ return arrayType.ElementType;
+
+ if (typeSymbol is INamedTypeSymbol namedType &&
+ namedType.IsGenericType &&
+ namedType.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T)
+ {
+ return namedType.TypeArguments[0];
+ }
+
+ var enumerableInterface = typeSymbol.AllInterfaces
+ .FirstOrDefault(interfaceType =>
+ interfaceType.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T);
+
+ return enumerableInterface?.TypeArguments[0];
+ }
+
+ private static string GenerateSource(INamedTypeSymbol typeSymbol, IReadOnlyList registrations)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("// ");
+ builder.AppendLine("#nullable enable");
+ builder.AppendLine();
+
+ var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : typeSymbol.ContainingNamespace.ToDisplayString();
+
+ if (ns is not null)
+ {
+ builder.AppendLine($"namespace {ns};");
+ builder.AppendLine();
+ }
+
+ builder.AppendLine($"{GetTypeDeclarationKeyword(typeSymbol)} {GetTypeDeclarationName(typeSymbol)}");
+ AppendTypeConstraints(builder, typeSymbol);
+ builder.AppendLine("{");
+ builder.AppendLine($" private void {GeneratedMethodName}()");
+ builder.AppendLine(" {");
+
+ foreach (var registration in registrations)
+ {
+ builder.Append(" if (this.");
+ builder.Append(registration.CollectionMemberName);
+ builder.Append(" is not null && this.");
+ builder.Append(registration.RegistryMemberName);
+ builder.AppendLine(" is not null)");
+ builder.AppendLine(" {");
+ builder.Append(" foreach (var __generatedItem in this.");
+ builder.Append(registration.CollectionMemberName);
+ builder.AppendLine(")");
+ builder.AppendLine(" {");
+ builder.Append(" this.");
+ builder.Append(registration.RegistryMemberName);
+ builder.Append('.');
+ builder.Append(registration.RegisterMethodName);
+ builder.AppendLine("(__generatedItem);");
+ builder.AppendLine(" }");
+ builder.AppendLine(" }");
+ }
+
+ builder.AppendLine(" }");
+ builder.AppendLine("}");
+ return builder.ToString();
+ }
+
+ private static bool IsPartial(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.DeclaringSyntaxReferences
+ .Select(static reference => reference.GetSyntax())
+ .OfType()
+ .All(static declaration =>
+ declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)));
+ }
+
+ private static string GetHintName(INamedTypeSymbol typeSymbol)
+ {
+ var prefix = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? typeSymbol.Name
+ : $"{typeSymbol.ContainingNamespace.ToDisplayString()}.{typeSymbol.Name}";
+ return prefix.Replace('.', '_') + ".AutoRegisterExportedCollections.g.cs";
+ }
+
+ private static string GetTypeDeclarationKeyword(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.IsRecord
+ ? typeSymbol.TypeKind == TypeKind.Struct ? "partial record struct" : "partial record"
+ : typeSymbol.TypeKind == TypeKind.Struct
+ ? "partial struct"
+ : "partial class";
+ }
+
+ private static string GetTypeDeclarationName(INamedTypeSymbol typeSymbol)
+ {
+ if (typeSymbol.TypeParameters.Length == 0)
+ return typeSymbol.Name;
+
+ return
+ $"{typeSymbol.Name}<{string.Join(", ", typeSymbol.TypeParameters.Select(static parameter => parameter.Name))}>";
+ }
+
+ private static void AppendTypeConstraints(StringBuilder builder, INamedTypeSymbol typeSymbol)
+ {
+ foreach (var typeParameter in typeSymbol.TypeParameters)
+ {
+ var constraints = new List();
+
+ if (typeParameter.HasReferenceTypeConstraint)
+ {
+ constraints.Add(
+ typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated
+ ? "class?"
+ : "class");
+ }
+
+ if (typeParameter.HasNotNullConstraint)
+ constraints.Add("notnull");
+
+ // unmanaged implies the value-type constraint and must replace struct in generated constraints.
+ if (typeParameter.HasUnmanagedTypeConstraint)
+ constraints.Add("unmanaged");
+ else if (typeParameter.HasValueTypeConstraint)
+ constraints.Add("struct");
+
+ constraints.AddRange(typeParameter.ConstraintTypes.Select(static constraint =>
+ constraint.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
+
+ if (typeParameter.HasConstructorConstraint)
+ constraints.Add("new()");
+
+ if (constraints.Count == 0)
+ continue;
+
+ builder.Append(" where ");
+ builder.Append(typeParameter.Name);
+ builder.Append(" : ");
+ builder.AppendLine(string.Join(", ", constraints));
+ }
+ }
+
+ private sealed class TypeCandidate
+ {
+ public TypeCandidate(ClassDeclarationSyntax classDeclaration, INamedTypeSymbol typeSymbol)
+ {
+ ClassDeclaration = classDeclaration;
+ TypeSymbol = typeSymbol;
+ }
+
+ public ClassDeclarationSyntax ClassDeclaration { get; }
+
+ public INamedTypeSymbol TypeSymbol { get; }
+ }
+
+ private sealed class RegistrationSpec
+ {
+ public RegistrationSpec(string collectionMemberName, string registryMemberName, string registerMethodName)
+ {
+ CollectionMemberName = collectionMemberName;
+ RegistryMemberName = registryMemberName;
+ RegisterMethodName = registerMethodName;
+ }
+
+ public string CollectionMemberName { get; }
+
+ public string RegistryMemberName { get; }
+
+ public string RegisterMethodName { get; }
+ }
+}
diff --git a/GFramework.SourceGenerators.Abstractions/Architectures/AutoRegisterModuleAttribute.cs b/GFramework.SourceGenerators.Abstractions/Architectures/AutoRegisterModuleAttribute.cs
new file mode 100644
index 00000000..04daaa46
--- /dev/null
+++ b/GFramework.SourceGenerators.Abstractions/Architectures/AutoRegisterModuleAttribute.cs
@@ -0,0 +1,9 @@
+namespace GFramework.SourceGenerators.Abstractions.Architectures;
+
+///
+/// 标记架构模块类型,Source Generator 会根据注册特性生成 Install 方法。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+public sealed class AutoRegisterModuleAttribute : Attribute
+{
+}
diff --git a/GFramework.SourceGenerators.Abstractions/Architectures/RegisterModelAttribute.cs b/GFramework.SourceGenerators.Abstractions/Architectures/RegisterModelAttribute.cs
new file mode 100644
index 00000000..6c47d8b1
--- /dev/null
+++ b/GFramework.SourceGenerators.Abstractions/Architectures/RegisterModelAttribute.cs
@@ -0,0 +1,13 @@
+namespace GFramework.SourceGenerators.Abstractions.Architectures;
+
+///
+/// 声明架构模块需要自动注册的模型类型。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+public sealed class RegisterModelAttribute(Type modelType) : Attribute
+{
+ ///
+ /// 获取要注册的模型类型。
+ ///
+ public Type ModelType { get; } = modelType;
+}
diff --git a/GFramework.SourceGenerators.Abstractions/Architectures/RegisterSystemAttribute.cs b/GFramework.SourceGenerators.Abstractions/Architectures/RegisterSystemAttribute.cs
new file mode 100644
index 00000000..7119db2f
--- /dev/null
+++ b/GFramework.SourceGenerators.Abstractions/Architectures/RegisterSystemAttribute.cs
@@ -0,0 +1,13 @@
+namespace GFramework.SourceGenerators.Abstractions.Architectures;
+
+///
+/// 声明架构模块需要自动注册的系统类型。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+public sealed class RegisterSystemAttribute(Type systemType) : Attribute
+{
+ ///
+ /// 获取要注册的系统类型。
+ ///
+ public Type SystemType { get; } = systemType;
+}
diff --git a/GFramework.SourceGenerators.Abstractions/Architectures/RegisterUtilityAttribute.cs b/GFramework.SourceGenerators.Abstractions/Architectures/RegisterUtilityAttribute.cs
new file mode 100644
index 00000000..88e5d93c
--- /dev/null
+++ b/GFramework.SourceGenerators.Abstractions/Architectures/RegisterUtilityAttribute.cs
@@ -0,0 +1,13 @@
+namespace GFramework.SourceGenerators.Abstractions.Architectures;
+
+///
+/// 声明架构模块需要自动注册的工具类型。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+public sealed class RegisterUtilityAttribute(Type utilityType) : Attribute
+{
+ ///
+ /// 获取要注册的工具类型。
+ ///
+ public Type UtilityType { get; } = utilityType;
+}
diff --git a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs
new file mode 100644
index 00000000..964bba4a
--- /dev/null
+++ b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs
@@ -0,0 +1,373 @@
+using GFramework.SourceGenerators.Architectures;
+using GFramework.SourceGenerators.Tests.Core;
+
+namespace GFramework.SourceGenerators.Tests.Architectures;
+
+[TestFixture]
+public class AutoRegisterModuleGeneratorTests
+{
+ ///
+ /// 验证同一声明上的注册特性会按照源码中的书写顺序生成安装代码。
+ ///
+ [Test]
+ public async Task Generates_Module_Install_Method_In_Attribute_Order()
+ {
+ const string source = """
+ using System;
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ namespace GFramework.SourceGenerators.Abstractions.Architectures
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterModuleAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterModelAttribute : Attribute
+ {
+ public RegisterModelAttribute(Type modelType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterSystemAttribute : Attribute
+ {
+ public RegisterSystemAttribute(Type systemType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterUtilityAttribute : Attribute
+ {
+ public RegisterUtilityAttribute(Type utilityType) { }
+ }
+ }
+
+ namespace GFramework.Core.Abstractions.Architectures
+ {
+ public interface IArchitecture
+ {
+ T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel;
+ T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem;
+ T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility;
+ }
+ }
+
+ namespace GFramework.Core.Abstractions.Model
+ {
+ public interface IModel { }
+ }
+
+ namespace GFramework.Core.Abstractions.Systems
+ {
+ public interface ISystem { }
+ }
+
+ namespace GFramework.Core.Abstractions.Utility
+ {
+ public interface IUtility { }
+ }
+
+ namespace TestApp
+ {
+ using GFramework.Core.Abstractions.Model;
+ using GFramework.Core.Abstractions.Systems;
+ using GFramework.Core.Abstractions.Utility;
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ public sealed class PlayerModel : IModel { }
+ public sealed class CombatSystem : ISystem { }
+ public sealed class AudioUtility : IUtility { }
+
+ [AutoRegisterModule]
+ [RegisterSystem(typeof(CombatSystem))]
+ [RegisterModel(typeof(PlayerModel))]
+ [RegisterUtility(typeof(AudioUtility))]
+ public partial class GameplayModule
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class GameplayModule
+ {
+ public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture)
+ {
+ architecture.RegisterSystem(new global::TestApp.CombatSystem());
+ architecture.RegisterModel(new global::TestApp.PlayerModel());
+ architecture.RegisterUtility(new global::TestApp.AudioUtility());
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_GameplayModule.AutoRegisterModule.g.cs", expected));
+ }
+
+ ///
+ /// 验证 partial 声明分布在多个文件时,生成器仍然会使用稳定的跨文件顺序生成注册代码。
+ ///
+ [Test]
+ public async Task Generates_Module_Install_Method_In_Deterministic_Order_Across_Partial_Declarations()
+ {
+ const string commonSource = """
+ using System;
+
+ namespace GFramework.SourceGenerators.Abstractions.Architectures
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterModuleAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterModelAttribute : Attribute
+ {
+ public RegisterModelAttribute(Type modelType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterSystemAttribute : Attribute
+ {
+ public RegisterSystemAttribute(Type systemType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterUtilityAttribute : Attribute
+ {
+ public RegisterUtilityAttribute(Type utilityType) { }
+ }
+ }
+
+ namespace GFramework.Core.Abstractions.Architectures
+ {
+ public interface IArchitecture
+ {
+ T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel;
+ T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem;
+ T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility;
+ }
+ }
+
+ namespace GFramework.Core.Abstractions.Model
+ {
+ public interface IModel { }
+ }
+
+ namespace GFramework.Core.Abstractions.Systems
+ {
+ public interface ISystem { }
+ }
+
+ namespace GFramework.Core.Abstractions.Utility
+ {
+ public interface IUtility { }
+ }
+
+ namespace TestApp
+ {
+ using GFramework.Core.Abstractions.Model;
+ using GFramework.Core.Abstractions.Systems;
+ using GFramework.Core.Abstractions.Utility;
+
+ public sealed class PlayerModel : IModel { }
+ public sealed class CombatSystem : ISystem { }
+ public sealed class AudioUtility : IUtility { }
+ }
+ """;
+
+ const string partASource = """
+ namespace TestApp
+ {
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ // Padding ensures this attribute lives later in the file than the attributes in PartB.
+ // The generator should still place it first because PartA sorts before PartB.
+ // padding 01
+ // padding 02
+ // padding 03
+ // padding 04
+ // padding 05
+ // padding 06
+ // padding 07
+ // padding 08
+ // padding 09
+ // padding 10
+ [AutoRegisterModule]
+ [RegisterUtility(typeof(AudioUtility))]
+ public partial class GameplayModule
+ {
+ }
+ }
+ """;
+
+ const string partBSource = """
+ namespace TestApp
+ {
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ [RegisterSystem(typeof(CombatSystem))]
+ [RegisterModel(typeof(PlayerModel))]
+ public partial class GameplayModule
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class GameplayModule
+ {
+ public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture)
+ {
+ architecture.RegisterUtility(new global::TestApp.AudioUtility());
+ architecture.RegisterSystem(new global::TestApp.CombatSystem());
+ architecture.RegisterModel(new global::TestApp.PlayerModel());
+ }
+ }
+
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources =
+ {
+ ("Common.cs", commonSource),
+ ("GameplayModule.PartA.cs", partASource),
+ ("GameplayModule.PartB.cs", partBSource)
+ },
+ GeneratedSources =
+ {
+ (typeof(AutoRegisterModuleGenerator), "TestApp_GameplayModule.AutoRegisterModule.g.cs", NormalizeLineEndings(expected))
+ }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" }
+ };
+
+ await test.RunAsync();
+ }
+
+ ///
+ /// 验证生成器会保留可空引用、notnull 与 unmanaged 约束。
+ ///
+ [Test]
+ public async Task Generates_Type_Constraints_For_NullableReference_NotNull_And_Unmanaged()
+ {
+ const string source = """
+ #nullable enable
+ using System;
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ namespace GFramework.SourceGenerators.Abstractions.Architectures
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterModuleAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterModelAttribute : Attribute
+ {
+ public RegisterModelAttribute(Type modelType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterSystemAttribute : Attribute
+ {
+ public RegisterSystemAttribute(Type systemType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterUtilityAttribute : Attribute
+ {
+ public RegisterUtilityAttribute(Type utilityType) { }
+ }
+ }
+
+ namespace GFramework.Core.Abstractions.Architectures
+ {
+ public interface IArchitecture
+ {
+ T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel;
+ T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem;
+ T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility;
+ }
+ }
+
+ namespace GFramework.Core.Abstractions.Model
+ {
+ public interface IModel { }
+ }
+
+ namespace GFramework.Core.Abstractions.Systems
+ {
+ public interface ISystem { }
+ }
+
+ namespace GFramework.Core.Abstractions.Utility
+ {
+ public interface IUtility { }
+ }
+
+ namespace TestApp
+ {
+ using GFramework.Core.Abstractions.Model;
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ public sealed class PlayerModel : IModel { }
+
+ [AutoRegisterModule]
+ [RegisterModel(typeof(PlayerModel))]
+ public partial class GameplayModule
+ where TNullableRef : class?
+ where TNotNull : notnull
+ where TUnmanaged : unmanaged
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class GameplayModule
+ where TNullableRef : class?
+ where TNotNull : notnull
+ where TUnmanaged : unmanaged
+ {
+ public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture)
+ {
+ architecture.RegisterModel(new global::TestApp.PlayerModel());
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_GameplayModule.AutoRegisterModule.g.cs", expected));
+ }
+
+ ///
+ /// 将测试快照统一为当前平台换行符,避免不同系统上的源生成输出比较出现伪差异。
+ ///
+ /// 原始快照内容。
+ /// 使用当前平台换行符的快照内容。
+ private static string NormalizeLineEndings(string content)
+ {
+ return content
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace("\r", "\n", StringComparison.Ordinal)
+ .Replace("\n", Environment.NewLine, StringComparison.Ordinal);
+ }
+}
diff --git a/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs
index c8fec0f1..c8804f58 100644
--- a/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs
@@ -624,7 +624,7 @@ public class ContextGetGeneratorTests
Sources = { source.Source },
GeneratedSources =
{
- (typeof(ContextGetGenerator), "TestApp_BattlePanel.ContextGet.g.cs", expected)
+ (typeof(ContextGetGenerator), "TestApp_BattlePanel.ContextGet.g.cs", NormalizeLineEndings(expected))
}
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
@@ -725,7 +725,7 @@ public class ContextGetGeneratorTests
Sources = { source.Source },
GeneratedSources =
{
- (typeof(ContextGetGenerator), "TestApp_BattlePanel.ContextGet.g.cs", expected)
+ (typeof(ContextGetGenerator), "TestApp_BattlePanel.ContextGet.g.cs", NormalizeLineEndings(expected))
}
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
@@ -740,6 +740,14 @@ public class ContextGetGeneratorTests
Assert.Pass();
}
+ private static string NormalizeLineEndings(string content)
+ {
+ return content
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace("\r", "\n", StringComparison.Ordinal)
+ .Replace("\n", Environment.NewLine, StringComparison.Ordinal);
+ }
+
[Test]
public async Task Skips_Nullable_Service_Like_Field_For_ContextAware_GetAll_Class()
{
@@ -1266,4 +1274,4 @@ public class ContextGetGeneratorTests
("TestApp_InventoryPanel.ContextGet.g.cs", expected));
Assert.Pass();
}
-}
\ No newline at end of file
+}
diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
index df517406..d55048c9 100644
--- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -3,33 +3,38 @@
### New Rules
- Rule ID | Category | Severity | Notes
-----------------------------|------------------------------------|----------|--------------------------------
- GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics
- GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic
- GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
- GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
- GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
- GF_ContextRegistration_001 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
- GF_ContextRegistration_002 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
- GF_ContextRegistration_003 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
- GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_007 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
- GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic
- GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic
- GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic
- GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic
- GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic
- GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer
+ Rule ID | Category | Severity | Notes
+----------------------------|------------------------------------------|----------|--------------------------------
+ GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics
+ GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic
+ GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
+ GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
+ GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
+ GF_ContextRegistration_001 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
+ GF_ContextRegistration_002 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
+ GF_ContextRegistration_003 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
+ GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_007 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_AutoModule_001 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
+ GF_AutoModule_002 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
+ GF_AutoModule_003 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
+ GF_AutoModule_004 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
+ GF_AutoModule_005 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
+ GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic
+ GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic
+ GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic
+ GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic
+ GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic
+ GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer
diff --git a/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs
new file mode 100644
index 00000000..0385d221
--- /dev/null
+++ b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs
@@ -0,0 +1,453 @@
+using GFramework.SourceGenerators.Abstractions.Architectures;
+using GFramework.SourceGenerators.Common.Constants;
+using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
+using GFramework.SourceGenerators.Diagnostics;
+
+namespace GFramework.SourceGenerators.Architectures;
+
+///
+/// 为标记了 的模块生成固定顺序的组件注册代码。
+///
+[Generator]
+public sealed class AutoRegisterModuleGenerator : IIncrementalGenerator
+{
+ private const string AutoRegisterModuleAttributeMetadataName =
+ $"{PathContests.SourceGeneratorsAbstractionsPath}.Architectures.AutoRegisterModuleAttribute";
+
+ private const string RegisterModelAttributeMetadataName =
+ $"{PathContests.SourceGeneratorsAbstractionsPath}.Architectures.RegisterModelAttribute";
+
+ private const string RegisterSystemAttributeMetadataName =
+ $"{PathContests.SourceGeneratorsAbstractionsPath}.Architectures.RegisterSystemAttribute";
+
+ private const string RegisterUtilityAttributeMetadataName =
+ $"{PathContests.SourceGeneratorsAbstractionsPath}.Architectures.RegisterUtilityAttribute";
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var candidates = context.SyntaxProvider.CreateSyntaxProvider(
+ static (node, _) => IsCandidate(node),
+ static (syntaxContext, _) => Transform(syntaxContext))
+ .Where(static candidate => candidate is not null);
+
+ var compilationAndCandidates = context.CompilationProvider.Combine(candidates.Collect());
+
+ context.RegisterSourceOutput(
+ compilationAndCandidates,
+ static (spc, pair) => Execute(spc, pair.Left, pair.Right));
+ }
+
+ private static bool IsCandidate(SyntaxNode node)
+ {
+ return node is ClassDeclarationSyntax classDeclaration &&
+ classDeclaration.AttributeLists
+ .SelectMany(static list => list.Attributes)
+ .Any(static attribute =>
+ attribute.Name.ToString().Contains("AutoRegisterModule", StringComparison.Ordinal));
+ }
+
+ private static TypeCandidate? Transform(GeneratorSyntaxContext context)
+ {
+ if (context.Node is not ClassDeclarationSyntax classDeclaration)
+ return null;
+
+ if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol)
+ return null;
+
+ return new TypeCandidate(classDeclaration, typeSymbol);
+ }
+
+ private static void Execute(
+ SourceProductionContext context,
+ Compilation compilation,
+ ImmutableArray candidates)
+ {
+ if (candidates.IsDefaultOrEmpty)
+ return;
+
+ var autoRegisterModuleAttribute = compilation.GetTypeByMetadataName(AutoRegisterModuleAttributeMetadataName);
+ var registerModelAttribute = compilation.GetTypeByMetadataName(RegisterModelAttributeMetadataName);
+ var registerSystemAttribute = compilation.GetTypeByMetadataName(RegisterSystemAttributeMetadataName);
+ var registerUtilityAttribute = compilation.GetTypeByMetadataName(RegisterUtilityAttributeMetadataName);
+ var architectureType =
+ compilation.GetTypeByMetadataName($"{PathContests.CoreAbstractionsNamespace}.Architectures.IArchitecture");
+ var modelType = compilation.GetTypeByMetadataName($"{PathContests.CoreAbstractionsNamespace}.Model.IModel");
+ var systemType = compilation.GetTypeByMetadataName($"{PathContests.CoreAbstractionsNamespace}.Systems.ISystem");
+ var utilityType =
+ compilation.GetTypeByMetadataName($"{PathContests.CoreAbstractionsNamespace}.Utility.IUtility");
+
+ if (autoRegisterModuleAttribute is null ||
+ registerModelAttribute is null ||
+ registerSystemAttribute is null ||
+ registerUtilityAttribute is null ||
+ architectureType is null ||
+ modelType is null ||
+ systemType is null ||
+ utilityType is null)
+ {
+ return;
+ }
+
+ foreach (var candidate in candidates.Where(static candidate => candidate is not null)
+ .Select(static candidate => candidate!))
+ {
+ if (!candidate.TypeSymbol.GetAttributes().Any(attribute =>
+ SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, autoRegisterModuleAttribute)))
+ {
+ continue;
+ }
+
+ if (!CanGenerateForType(context, candidate))
+ continue;
+
+ if (HasInstallConflict(context, candidate, architectureType))
+ continue;
+
+ var registrations = CollectRegistrations(
+ context,
+ candidate.TypeSymbol,
+ registerModelAttribute,
+ registerSystemAttribute,
+ registerUtilityAttribute,
+ modelType,
+ systemType,
+ utilityType);
+
+ if (registrations.Count == 0)
+ continue;
+
+ context.AddSource(GetHintName(candidate.TypeSymbol), GenerateSource(candidate.TypeSymbol, registrations));
+ }
+ }
+
+ private static bool CanGenerateForType(SourceProductionContext context, TypeCandidate candidate)
+ {
+ if (candidate.TypeSymbol.ContainingType is not null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterModuleDiagnostics.NestedClassNotSupported,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ if (IsPartial(candidate.TypeSymbol))
+ return true;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.ClassMustBePartial,
+ candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name));
+ return false;
+ }
+
+ private static bool HasInstallConflict(
+ SourceProductionContext context,
+ TypeCandidate candidate,
+ INamedTypeSymbol architectureType)
+ {
+ var installMethod = candidate.TypeSymbol.GetMembers("Install")
+ .OfType()
+ .FirstOrDefault(method =>
+ !method.IsImplicitlyDeclared &&
+ method.Parameters.Length == 1 &&
+ method.TypeParameters.Length == 0 &&
+ method.ReturnsVoid &&
+ method.Parameters[0].Type is ITypeSymbol parameterType &&
+ SymbolEqualityComparer.Default.Equals(parameterType, architectureType));
+
+ if (installMethod is null)
+ return false;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterModuleDiagnostics.InstallMethodConflict,
+ installMethod.Locations.FirstOrDefault() ?? candidate.ClassDeclaration.Identifier.GetLocation(),
+ candidate.TypeSymbol.Name));
+ return true;
+ }
+
+ private static List CollectRegistrations(
+ SourceProductionContext context,
+ INamedTypeSymbol typeSymbol,
+ INamedTypeSymbol registerModelAttribute,
+ INamedTypeSymbol registerSystemAttribute,
+ INamedTypeSymbol registerUtilityAttribute,
+ INamedTypeSymbol modelType,
+ INamedTypeSymbol systemType,
+ INamedTypeSymbol utilityType)
+ {
+ var registrations = new List();
+
+ foreach (var attribute in typeSymbol.GetAttributes()
+ // Roslyn 会把 partial 类型上的属性合并到同一个集合中。
+ // 先按语法树标识排序,才能让每个文件内的 Span.Start 成为可比较的稳定顺序键。
+ .OrderBy(GetAttributeSyntaxTreeOrderKey, StringComparer.Ordinal)
+ .ThenBy(GetAttributeOrder)
+ .ThenBy(GetAttributeTypeOrderKey, StringComparer.Ordinal))
+ {
+ if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, registerModelAttribute))
+ {
+ if (TryCreateRegistration(
+ context,
+ typeSymbol,
+ attribute,
+ "RegisterModelAttribute",
+ modelType,
+ RegistrationKind.Model,
+ out var registration))
+ {
+ registrations.Add(registration);
+ }
+
+ continue;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, registerSystemAttribute))
+ {
+ if (TryCreateRegistration(
+ context,
+ typeSymbol,
+ attribute,
+ "RegisterSystemAttribute",
+ systemType,
+ RegistrationKind.System,
+ out var registration))
+ {
+ registrations.Add(registration);
+ }
+
+ continue;
+ }
+
+ if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, registerUtilityAttribute))
+ continue;
+
+ if (TryCreateRegistration(
+ context,
+ typeSymbol,
+ attribute,
+ "RegisterUtilityAttribute",
+ utilityType,
+ RegistrationKind.Utility,
+ out var utilityRegistration))
+ {
+ registrations.Add(utilityRegistration);
+ }
+ }
+
+ return registrations;
+ }
+
+ private static bool TryCreateRegistration(
+ SourceProductionContext context,
+ INamedTypeSymbol ownerType,
+ AttributeData attribute,
+ string attributeDisplayName,
+ INamedTypeSymbol expectedInterface,
+ RegistrationKind registrationKind,
+ out RegistrationSpec registration)
+ {
+ registration = default;
+
+ if (attribute.ConstructorArguments.Length != 1 ||
+ attribute.ConstructorArguments[0].Value is not INamedTypeSymbol componentType)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterModuleDiagnostics.RegistrationTypeRequired,
+ GetAttributeLocation(attribute),
+ attributeDisplayName,
+ ownerType.Name));
+ return false;
+ }
+
+ if (!componentType.IsAssignableTo(expectedInterface))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterModuleDiagnostics.RegistrationTypeMustImplementExpectedInterface,
+ GetAttributeLocation(attribute),
+ componentType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
+ ownerType.Name,
+ expectedInterface.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
+ return false;
+ }
+
+ if (componentType.IsAbstract ||
+ !componentType.InstanceConstructors.Any(ctor =>
+ ctor.Parameters.Length == 0 &&
+ ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ AutoRegisterModuleDiagnostics.RegistrationTypeMustHaveParameterlessConstructor,
+ GetAttributeLocation(attribute),
+ componentType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
+ ownerType.Name));
+ return false;
+ }
+
+ registration = new RegistrationSpec(
+ registrationKind,
+ componentType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
+ return true;
+ }
+
+ private static string GenerateSource(INamedTypeSymbol typeSymbol, IReadOnlyList registrations)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("// ");
+ builder.AppendLine("#nullable enable");
+ builder.AppendLine();
+
+ var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : typeSymbol.ContainingNamespace.ToDisplayString();
+
+ if (ns is not null)
+ {
+ builder.AppendLine($"namespace {ns};");
+ builder.AppendLine();
+ }
+
+ builder.AppendLine($"{GetTypeDeclarationKeyword(typeSymbol)} {GetTypeDeclarationName(typeSymbol)}");
+ AppendTypeConstraints(builder, typeSymbol);
+ builder.AppendLine("{");
+ builder.AppendLine(
+ $" public void Install(global::{PathContests.CoreAbstractionsNamespace}.Architectures.IArchitecture architecture)");
+ builder.AppendLine(" {");
+
+ foreach (var registration in registrations)
+ {
+ builder.Append(" architecture.");
+ builder.Append(registration.Kind switch
+ {
+ RegistrationKind.Model => "RegisterModel",
+ RegistrationKind.System => "RegisterSystem",
+ RegistrationKind.Utility => "RegisterUtility",
+ _ => throw new ArgumentOutOfRangeException(nameof(registration.Kind))
+ });
+ builder.Append("(new ");
+ builder.Append(registration.ComponentTypeDisplayName);
+ builder.AppendLine("());");
+ }
+
+ builder.AppendLine(" }");
+ builder.AppendLine("}");
+ return builder.ToString();
+ }
+
+ private static string GetHintName(INamedTypeSymbol typeSymbol)
+ {
+ var prefix = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? typeSymbol.Name
+ : $"{typeSymbol.ContainingNamespace.ToDisplayString()}.{typeSymbol.Name}";
+ return prefix.Replace('.', '_') + ".AutoRegisterModule.g.cs";
+ }
+
+ private static bool IsPartial(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.DeclaringSyntaxReferences
+ .Select(static reference => reference.GetSyntax())
+ .OfType()
+ .All(static declaration =>
+ declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)));
+ }
+
+ private static int GetAttributeOrder(AttributeData attribute)
+ {
+ return attribute.ApplicationSyntaxReference?.Span.Start ?? int.MaxValue;
+ }
+
+ private static string GetAttributeSyntaxTreeOrderKey(AttributeData attribute)
+ {
+ var syntaxTree = attribute.ApplicationSyntaxReference?.SyntaxTree;
+ if (syntaxTree is null)
+ return string.Empty;
+
+ if (!string.IsNullOrEmpty(syntaxTree.FilePath))
+ return syntaxTree.FilePath;
+
+ // In-memory compilations may not assign file paths. Fall back to the syntax tree text so
+ // attributes from different partial declarations still get a deterministic cross-file order.
+ return syntaxTree.ToString();
+ }
+
+ private static string GetAttributeTypeOrderKey(AttributeData attribute)
+ {
+ return attribute.ConstructorArguments.FirstOrDefault().Value is INamedTypeSymbol componentType
+ ? componentType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ : string.Empty;
+ }
+
+ private static Location GetAttributeLocation(AttributeData attribute)
+ {
+ return attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None;
+ }
+
+ private static string GetTypeDeclarationKeyword(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.IsRecord
+ ? typeSymbol.TypeKind == TypeKind.Struct ? "partial record struct" : "partial record"
+ : typeSymbol.TypeKind == TypeKind.Struct
+ ? "partial struct"
+ : "partial class";
+ }
+
+ private static string GetTypeDeclarationName(INamedTypeSymbol typeSymbol)
+ {
+ if (typeSymbol.TypeParameters.Length == 0)
+ return typeSymbol.Name;
+
+ return
+ $"{typeSymbol.Name}<{string.Join(", ", typeSymbol.TypeParameters.Select(static parameter => parameter.Name))}>";
+ }
+
+ private static void AppendTypeConstraints(StringBuilder builder, INamedTypeSymbol typeSymbol)
+ {
+ foreach (var typeParameter in typeSymbol.TypeParameters)
+ {
+ var constraints = new List();
+
+ if (typeParameter.HasReferenceTypeConstraint)
+ {
+ constraints.Add(
+ typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated
+ ? "class?"
+ : "class");
+ }
+
+ if (typeParameter.HasNotNullConstraint)
+ constraints.Add("notnull");
+
+ // unmanaged implies the value-type constraint and must replace struct in generated constraints.
+ if (typeParameter.HasUnmanagedTypeConstraint)
+ constraints.Add("unmanaged");
+ else if (typeParameter.HasValueTypeConstraint)
+ constraints.Add("struct");
+
+ constraints.AddRange(typeParameter.ConstraintTypes.Select(static constraint =>
+ constraint.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
+
+ if (typeParameter.HasConstructorConstraint)
+ constraints.Add("new()");
+
+ if (constraints.Count == 0)
+ continue;
+
+ builder.Append(" where ");
+ builder.Append(typeParameter.Name);
+ builder.Append(" : ");
+ builder.AppendLine(string.Join(", ", constraints));
+ }
+ }
+
+ private sealed record TypeCandidate(ClassDeclarationSyntax ClassDeclaration, INamedTypeSymbol TypeSymbol);
+
+ private readonly record struct RegistrationSpec(RegistrationKind Kind, string ComponentTypeDisplayName);
+
+ private enum RegistrationKind
+ {
+ Model,
+ System,
+ Utility
+ }
+}
diff --git a/GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs
new file mode 100644
index 00000000..83b9566f
--- /dev/null
+++ b/GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs
@@ -0,0 +1,48 @@
+using GFramework.SourceGenerators.Common.Constants;
+
+namespace GFramework.SourceGenerators.Diagnostics;
+
+internal static class AutoRegisterModuleDiagnostics
+{
+ private const string Category = $"{PathContests.SourceGeneratorsPath}.Architecture";
+
+ public static readonly DiagnosticDescriptor NestedClassNotSupported = new(
+ "GF_AutoModule_001",
+ "AutoRegisterModule does not support nested classes",
+ "AutoRegisterModule does not support nested class '{0}'",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ public static readonly DiagnosticDescriptor RegistrationTypeRequired = new(
+ "GF_AutoModule_002",
+ "Registration attribute requires a concrete type",
+ "Attribute '{0}' on '{1}' requires a concrete type argument",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ public static readonly DiagnosticDescriptor RegistrationTypeMustImplementExpectedInterface = new(
+ "GF_AutoModule_003",
+ "Registration type does not implement the expected interface",
+ "Type '{0}' used by '{1}' must implement '{2}'",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ public static readonly DiagnosticDescriptor RegistrationTypeMustHaveParameterlessConstructor = new(
+ "GF_AutoModule_004",
+ "Registration type must have an accessible parameterless constructor",
+ "Type '{0}' used by '{1}' must have an accessible parameterless constructor",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+
+ public static readonly DiagnosticDescriptor InstallMethodConflict = new(
+ "GF_AutoModule_005",
+ "Install method conflicts with generated code",
+ "Class '{0}' already defines 'Install(IArchitecture)', which conflicts with AutoRegisterModule generated code",
+ Category,
+ DiagnosticSeverity.Error,
+ true);
+}