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 节点类型生成 SceneKeyStrGetScene, +/// 以便通过 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 的增量生成管线。 + /// + /// 用于注册语法筛选、语义转换和源输出阶段的增量生成上下文。 + /// + /// 管线首先通过语法节点名称快速筛选潜在候选,再结合语义模型确认类型符号。 + /// 最终输出阶段仅在 AutoSceneAttributeGodot.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; + +/// +/// 定义行为类自动生成器使用的诊断描述符。 +/// +/// +/// 这些规则覆盖 AutoSceneAutoUiPage 等行为生成器的常见使用约束, +/// 以便在生成被跳过前向调用方报告明确的失败原因。 +/// +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; + +/// +/// 为导出集合生成批量注册样板方法。 +/// +/// +/// 该生成器会扫描标记了 AutoRegisterExportedCollectionsAttributepartial 类型, +/// 为其中使用 RegisterExportedCollectionAttribute 声明的集合成员生成集中注册方法。 +/// 仅当集合可枚举、元素类型可推导、注册表成员存在且可找到兼容的实例注册方法时才会输出代码; +/// 否则通过 GF_AutoExport_001GF_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); +}