From eb307bf188e37d0ef217043d46b2646468159a46 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:01:46 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=92=8C=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 定义了 Godot 源代码生成器的诊断规则表格 - 添加了上下文获取生成器的全面单元测试 - 实现了自动生成行为和注册导出集合的诊断功能 - 配置了全局 using 语句简化代码生成器实现 - 添加了完整的分析器发布跟踪文档记录新规则 --- ...utoRegisterExportedCollectionsAttribute.cs | 9 + .../UI/AutoSceneAttribute.cs | 13 + .../UI/AutoUiPageAttribute.cs | 18 + .../UI/RegisterExportedCollectionAttribute.cs | 19 + .../Behavior/AutoSceneGeneratorTests.cs | 85 ++++ .../Behavior/AutoUiPageGeneratorTests.cs | 97 ++++ ...gisterExportedCollectionsGeneratorTests.cs | 70 +++ .../AnalyzerReleases.Unshipped.md | 43 +- .../Behavior/AutoSceneGenerator.cs | 236 ++++++++++ .../Behavior/AutoUiPageGenerator.cs | 282 ++++++++++++ .../Diagnostics/AutoBehaviorDiagnostics.cs | 32 ++ ...oRegisterExportedCollectionsDiagnostics.cs | 40 ++ .../GlobalUsings.cs | 4 +- ...utoRegisterExportedCollectionsGenerator.cs | 405 +++++++++++++++++ .../AutoRegisterModuleAttribute.cs | 9 + .../Architectures/RegisterModelAttribute.cs | 13 + .../Architectures/RegisterSystemAttribute.cs | 13 + .../Architectures/RegisterUtilityAttribute.cs | 13 + .../AutoRegisterModuleGeneratorTests.cs | 108 +++++ .../Rule/ContextGetGeneratorTests.cs | 14 +- .../AnalyzerReleases.Unshipped.md | 65 +-- .../AutoRegisterModuleGenerator.cs | 416 ++++++++++++++++++ .../AutoRegisterModuleDiagnostics.cs | 48 ++ 23 files changed, 2000 insertions(+), 52 deletions(-) create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/UI/AutoRegisterExportedCollectionsAttribute.cs create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/UI/AutoSceneAttribute.cs create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/UI/AutoUiPageAttribute.cs create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/UI/RegisterExportedCollectionAttribute.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs create mode 100644 GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs create mode 100644 GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs create mode 100644 GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs create mode 100644 GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs create mode 100644 GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs create mode 100644 GFramework.SourceGenerators.Abstractions/Architectures/AutoRegisterModuleAttribute.cs create mode 100644 GFramework.SourceGenerators.Abstractions/Architectures/RegisterModelAttribute.cs create mode 100644 GFramework.SourceGenerators.Abstractions/Architectures/RegisterSystemAttribute.cs create mode 100644 GFramework.SourceGenerators.Abstractions/Architectures/RegisterUtilityAttribute.cs create mode 100644 GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs create mode 100644 GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs create mode 100644 GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs 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..c60d1f18 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs @@ -0,0 +1,85 @@ +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)); + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs new file mode 100644 index 00000000..4b9f41c1 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs @@ -0,0 +1,97 @@ +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)); + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs new file mode 100644 index 00000000..0202b824 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs @@ -0,0 +1,70 @@ +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 = """ + 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 List Values { get; } = new(); + } + } + """; + + const string expected = """ + // + #nullable enable + + namespace TestApp; + + partial class Bootstrapper + { + private void __RegisterExportedCollections_Generated() + { + foreach (var item in Values) + { + _registry.Register(item); + } + } + } + + """; + + 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..42e69dc7 100644 --- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -3,21 +3,28 @@ ### 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_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 diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs new file mode 100644 index 00000000..4fc9f874 --- /dev/null +++ b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs @@ -0,0 +1,236 @@ +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 节点生成场景行为样板。 +/// +[Generator] +public sealed class AutoSceneGenerator : IIncrementalGenerator +{ + private const string AutoSceneAttributeMetadataName = + $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoSceneAttribute"; + + 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 (attribute.ConstructorArguments.Length != 1 || attribute.ConstructorArguments[0].Value is not string 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 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("class"); + + 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; } + } +} diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs new file mode 100644 index 00000000..18f82597 --- /dev/null +++ b/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs @@ -0,0 +1,282 @@ +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) + { + 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("class"); + + 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..fd78d27a --- /dev/null +++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs @@ -0,0 +1,32 @@ +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.Godot.SourceGenerators.Diagnostics; + +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); + + 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); + + 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); +} diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs new file mode 100644 index 00000000..bb449e0f --- /dev/null +++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs @@ -0,0 +1,40 @@ +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); +} 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..a051ff2d --- /dev/null +++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs @@ -0,0 +1,405 @@ +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; + +/// +/// 为导出集合生成批量注册样板方法。 +/// +[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!)) + { + 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, + 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, + 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, typeSymbol, member, attribute, enumerableType, out var registration)) + continue; + + registrations.Add(registration); + } + + return registrations; + } + + private static bool TryCreateRegistration( + SourceProductionContext context, + INamedTypeSymbol ownerType, + ISymbol collectionMember, + AttributeData attribute, + INamedTypeSymbol enumerableType, + out RegistrationSpec registration) + { + registration = null!; + + 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(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; + } + + 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); + var hasCompatibleMethod = registryType.GetMembers(registerMethodName) + .OfType() + .Any(method => + !method.IsStatic && + method.Parameters.Length == 1 && + (elementType is null || elementType.IsAssignableTo(method.Parameters[0].Type as INamedTypeSymbol))); + + 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 TryGetRegistrationAttributeArguments( + 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) + { + 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(" foreach (var item in "); + builder.Append(registration.CollectionMemberName); + builder.AppendLine(")"); + builder.AppendLine(" {"); + builder.Append(" "); + builder.Append(registration.RegistryMemberName); + builder.Append('.'); + builder.Append(registration.RegisterMethodName); + builder.AppendLine("(item);"); + 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("class"); + + 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..2ced1eed --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs @@ -0,0 +1,108 @@ +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)); + } +} 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..7d5832a1 --- /dev/null +++ b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs @@ -0,0 +1,416 @@ +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().OrderBy(GetAttributeOrder)) + { + 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 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("class"); + + 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..b2a58ce3 --- /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 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); + + 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); +} From 80acf84e957b92c6dbd3542255df202790bf2c64 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:51:40 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(godot):=20=E6=B7=BB=E5=8A=A0AutoScene?= =?UTF-8?q?=E5=92=8CAutoRegisterExportedCollections=E6=BA=90=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现AutoSceneGenerator为标记了[AutoScene]特性的Godot节点生成场景行为样板代码 - 实现AutoRegisterExportedCollectionsGenerator为导出集合生成批量注册样板方法 - 添加AutoBehaviorDiagnostics和AutoRegisterExportedCollectionsDiagnostics诊断描述符 - 创建AnalyzerReleases.Unshipped.md文件跟踪新的分析器规则 - 添加完整的单元测试覆盖两个生成器的功能和错误情况 - 更新.gitignore文件排除dotnet-home和脚本缓存目录 --- .gitignore | 4 +- .../Behavior/AutoSceneGeneratorTests.cs | 48 +++++++++++++++++ ...gisterExportedCollectionsGeneratorTests.cs | 54 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 2 + .../Behavior/AutoSceneGenerator.cs | 29 +++++++++- .../Diagnostics/AutoBehaviorDiagnostics.cs | 27 ++++++++++ ...oRegisterExportedCollectionsDiagnostics.cs | 30 +++++++++++ ...utoRegisterExportedCollectionsGenerator.cs | 14 ++++- 8 files changed, 203 insertions(+), 5 deletions(-) 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.Tests/Behavior/AutoSceneGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs index c60d1f18..d6b73929 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs @@ -82,4 +82,52 @@ public class AutoSceneGeneratorTests 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(); + } } diff --git a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs index 0202b824..013be561 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs @@ -67,4 +67,58 @@ public class AutoRegisterExportedCollectionsGeneratorTests 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(); + } } diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md index 42e69dc7..2c852e8d 100644 --- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -24,7 +24,9 @@ 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 diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs index 4fc9f874..179ef8a9 100644 --- a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs @@ -1,7 +1,6 @@ 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; @@ -79,7 +78,7 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator continue; } - if (attribute.ConstructorArguments.Length != 1 || attribute.ConstructorArguments[0].Value is not string key) + if (!TryGetSceneKey(context, candidate.TypeSymbol, attribute, out var key)) continue; context.AddSource(GetHintName(candidate.TypeSymbol), GenerateSource(candidate.TypeSymbol, key)); @@ -122,6 +121,32 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator 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(); diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs index fd78d27a..42c9dbec 100644 --- a/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs +++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoBehaviorDiagnostics.cs @@ -2,10 +2,20 @@ 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", @@ -14,6 +24,9 @@ internal static class AutoBehaviorDiagnostics DiagnosticSeverity.Error, true); + /// + /// 报告目标类型没有继承生成器要求的 Godot 基类。 + /// public static readonly DiagnosticDescriptor MissingBaseType = new( "GF_AutoBehavior_002", "Auto behavior generators require a compatible base type", @@ -22,6 +35,9 @@ internal static class AutoBehaviorDiagnostics DiagnosticSeverity.Error, true); + /// + /// 报告 UI 页面声明中使用了不存在的 UiLayer 名称。 + /// public static readonly DiagnosticDescriptor InvalidUiLayerName = new( "GF_AutoBehavior_003", "Unknown UiLayer name", @@ -29,4 +45,15 @@ internal static class AutoBehaviorDiagnostics 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 index bb449e0f..9c7463cc 100644 --- a/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs +++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs @@ -2,10 +2,20 @@ 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", @@ -14,6 +24,9 @@ internal static class AutoRegisterExportedCollectionsDiagnostics DiagnosticSeverity.Error, true); + /// + /// 报告特性引用的注册表成员在宿主类型上不存在。 + /// public static readonly DiagnosticDescriptor RegistryMemberNotFound = new( "GF_AutoExport_002", "Registry member was not found", @@ -22,6 +35,9 @@ internal static class AutoRegisterExportedCollectionsDiagnostics DiagnosticSeverity.Error, true); + /// + /// 报告注册表上未找到与集合元素类型兼容的注册方法。 + /// public static readonly DiagnosticDescriptor RegisterMethodNotFound = new( "GF_AutoExport_003", "Register method was not found", @@ -30,6 +46,9 @@ internal static class AutoRegisterExportedCollectionsDiagnostics DiagnosticSeverity.Error, true); + /// + /// 报告被标记成员不是可枚举集合,因此无法执行批量注册。 + /// public static readonly DiagnosticDescriptor CollectionTypeMustBeEnumerable = new( "GF_AutoExport_004", "Exported collection must be enumerable", @@ -37,4 +56,15 @@ internal static class AutoRegisterExportedCollectionsDiagnostics 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); } diff --git a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs index a051ff2d..802f4c26 100644 --- a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs @@ -1,7 +1,6 @@ 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; @@ -208,12 +207,23 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener 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 && - (elementType is null || elementType.IsAssignableTo(method.Parameters[0].Type as INamedTypeSymbol))); + elementType.IsAssignableTo(method.Parameters[0].Type as INamedTypeSymbol)); if (!hasCompatibleMethod) { From ca1214f47f017e9091089f0c0ae4391d64a2a072 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:59:26 +0800 Subject: [PATCH 3/8] =?UTF-8?q?refactor(generators):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E6=89=A9=E5=B1=95=E6=96=B9=E6=B3=95=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AutoSceneGenerator 中引入 GFramework.SourceGenerators.Common.Extensions - 在 AutoRegisterExportedCollectionsGenerator 中引入 GFramework.SourceGenerators.Common.Extensions --- GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs | 1 + .../Registration/AutoRegisterExportedCollectionsGenerator.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs index 179ef8a9..a0b791f5 100644 --- a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs @@ -1,6 +1,7 @@ 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; diff --git a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs index 802f4c26..07e6757b 100644 --- a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs @@ -1,6 +1,7 @@ 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; From d21fac42b02efcc8ae695a3286d353c9b0813a5d Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:25:49 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0=20Aut?= =?UTF-8?q?oScene=20=E5=92=8C=20AutoRegisterExportedCollections=20?= =?UTF-8?q?=E6=BA=90=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 AutoSceneGenerator 为标记了 [AutoScene] 的 Godot 节点生成场景行为样板 - 实现 AutoRegisterExportedCollectionsGenerator 为导出集合生成批量注册方法 - 添加完整的单元测试覆盖两种源代码生成器的功能和诊断 - 支持泛型类型参数约束的正确生成 - 提供详细的诊断信息帮助用户修复配置错误 --- .../Behavior/AutoSceneGeneratorTests.cs | 86 +++++++++++++++++++ ...gisterExportedCollectionsGeneratorTests.cs | 24 ++++-- .../Behavior/AutoSceneGenerator.cs | 30 ++++++- ...utoRegisterExportedCollectionsGenerator.cs | 44 ++++++++-- 4 files changed, 170 insertions(+), 14 deletions(-) diff --git a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs index d6b73929..0e429cbc 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs @@ -130,4 +130,90 @@ public class AutoSceneGeneratorTests 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)); + } } diff --git a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs index 013be561..cd255d1d 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs @@ -10,6 +10,7 @@ public class AutoRegisterExportedCollectionsGeneratorTests 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; @@ -34,12 +35,16 @@ public class AutoRegisterExportedCollectionsGeneratorTests } [AutoRegisterExportedCollections] - public partial class Bootstrapper + public partial class Bootstrapper + where TReference : class? + where TNotNull : notnull + where TValue : struct + where TUnmanaged : unmanaged { - private readonly IntRegistry _registry = new(); + private readonly IntRegistry? _registry = new(); [RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))] - public List Values { get; } = new(); + public List? Values { get; } = new(); } } """; @@ -50,13 +55,20 @@ public class AutoRegisterExportedCollectionsGeneratorTests namespace TestApp; - partial class Bootstrapper + partial class Bootstrapper + where TReference : class? + where TNotNull : notnull + where TValue : struct + where TUnmanaged : unmanaged { private void __RegisterExportedCollections_Generated() { - foreach (var item in Values) + if (this.Values is not null && this._registry is not null) { - _registry.Register(item); + foreach (var __generatedItem in this.Values) + { + this._registry.Register(__generatedItem); + } } } } diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs index a0b791f5..d505222b 100644 --- a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs @@ -8,12 +8,27 @@ 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"; + /// + /// 配置 AutoScene 的增量生成管线。 + /// + /// 用于注册语法筛选、语义转换和源输出阶段的增量生成上下文。 + /// + /// 管线首先通过语法节点名称快速筛选潜在候选,再结合语义模型确认类型符号。 + /// 最终输出阶段仅在 AutoSceneAttributeGodot.Node 等依赖可解析且目标类型满足生成约束时产出源码; + /// 否则会报告对应诊断,或在宿主依赖缺失时直接跳过生成。 + /// public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates = context.SyntaxProvider.CreateSyntaxProvider( @@ -226,9 +241,20 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator var constraints = new List(); if (typeParameter.HasReferenceTypeConstraint) - constraints.Add("class"); + { + constraints.Add( + typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated + ? "class?" + : "class"); + } - if (typeParameter.HasValueTypeConstraint) + 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 => diff --git a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs index 07e6757b..b22bc003 100644 --- a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs @@ -8,6 +8,12 @@ namespace GFramework.Godot.SourceGenerators.Registration; /// /// 为导出集合生成批量注册样板方法。 /// +/// +/// 该生成器会扫描标记了 AutoRegisterExportedCollectionsAttributepartial 类型, +/// 为其中使用 RegisterExportedCollectionAttribute 声明的集合成员生成集中注册方法。 +/// 仅当集合可枚举、元素类型可推导、注册表成员存在且可找到兼容的实例注册方法时才会输出代码; +/// 否则通过 GF_AutoExport_001GF_AutoExport_005 以及公共 ClassMustBePartial 诊断显式阻止生成。 +/// [Generator] public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGenerator { @@ -19,6 +25,14 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener private const string GeneratedMethodName = "__RegisterExportedCollections_Generated"; + /// + /// 配置导出集合自动注册的增量生成管线。 + /// + /// 用于注册候选筛选、语义转换和最终源输出的增量生成上下文。 + /// + /// 管线先通过语法名称筛选减少分析范围,再在输出阶段验证特性、集合形状、注册目标与方法签名。 + /// 当依赖类型无法解析时,生成器不会报告噪声诊断而是直接跳过;当用户代码违反生成约束时,会报告明确诊断并停止该类型的生成。 + /// public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates = context.SyntaxProvider.CreateSyntaxProvider( @@ -305,15 +319,22 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener foreach (var registration in registrations) { - builder.Append(" foreach (var item in "); + 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(" "); + builder.AppendLine(" {"); + builder.Append(" this."); builder.Append(registration.RegistryMemberName); builder.Append('.'); builder.Append(registration.RegisterMethodName); - builder.AppendLine("(item);"); + builder.AppendLine("(__generatedItem);"); + builder.AppendLine(" }"); builder.AppendLine(" }"); } @@ -364,9 +385,20 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener var constraints = new List(); if (typeParameter.HasReferenceTypeConstraint) - constraints.Add("class"); + { + constraints.Add( + typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated + ? "class?" + : "class"); + } - if (typeParameter.HasValueTypeConstraint) + 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 => From 3fadba2d790a83114a7f1c2ddbbc0edddfd4b665 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:27:27 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9B=86=E5=90=88=E8=87=AA=E5=8A=A8=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E7=94=9F=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了 AutoRegisterExportedCollectionsGenerator 源生成器 - 支持扫描标记了 AutoRegisterExportedCollectionsAttribute 的 partial 类型 - 为使用 RegisterExportedCollectionAttribute 声明的集合成员生成集中注册方法 - 添加了类型验证和诊断报告功能 - 实现了集合元素类型推导和注册方法兼容性检查 - 生成批量注册样板代码以简化手动注册流程 - 添加了完整的单元测试覆盖各种使用场景 --- ...gisterExportedCollectionsGeneratorTests.cs | 66 +++++++++++++++++++ ...utoRegisterExportedCollectionsGenerator.cs | 29 +++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs index cd255d1d..db68697c 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs @@ -133,4 +133,70 @@ public class AutoRegisterExportedCollectionsGeneratorTests 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)); + } } diff --git a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs index b22bc003..8e094b13 100644 --- a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs @@ -104,6 +104,7 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener var registrations = CollectRegistrations( context, + compilation, candidate.TypeSymbol, registerCollectionAttribute, enumerableType); @@ -138,6 +139,7 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener private static List CollectRegistrations( SourceProductionContext context, + Compilation compilation, INamedTypeSymbol typeSymbol, INamedTypeSymbol registerCollectionAttribute, INamedTypeSymbol enumerableType) @@ -156,8 +158,17 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener if (attribute is null) continue; - if (!TryCreateRegistration(context, typeSymbol, member, attribute, enumerableType, out var registration)) + if (!TryCreateRegistration( + context, + compilation, + typeSymbol, + member, + attribute, + enumerableType, + out var registration)) + { continue; + } registrations.Add(registration); } @@ -167,6 +178,7 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener private static bool TryCreateRegistration( SourceProductionContext context, + Compilation compilation, INamedTypeSymbol ownerType, ISymbol collectionMember, AttributeData attribute, @@ -238,7 +250,7 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener .Any(method => !method.IsStatic && method.Parameters.Length == 1 && - elementType.IsAssignableTo(method.Parameters[0].Type as INamedTypeSymbol)); + CanAcceptElementType(compilation, elementType, method.Parameters[0].Type)); if (!hasCompatibleMethod) { @@ -255,6 +267,19 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener return true; } + 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( AttributeData attribute, out string registryMemberName, From be928718e355a82c077e5cc9ab4c8703cfb20994 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:00 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20AutoSceneGener?= =?UTF-8?q?ator=20=E4=B8=8E=20AutoRegisterExportedCollectionsGenerator=20?= =?UTF-8?q?=E7=9A=84=E9=AA=8C=E8=AF=81=E4=B8=8E=E5=AE=89=E5=85=A8=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### AutoSceneGenerator - 引入保留成员名称集合(GeneratedMemberNames),包含: - SceneKeyStr - __autoSceneBehavior_Generated - 实现 ReportGeneratedMemberConflicts 方法: - 检测用户定义成员与生成成员冲突 - 提供清晰的诊断信息 - 在生成流程中集成冲突检测,避免重复成员导致的编译错误 ### AutoRegisterExportedCollectionsGenerator - 增强集合注册生成器的验证逻辑: - 新增诊断 GF_AutoExport_006:导出集合成员必须为实例可读成员 - 新增诊断 GF_AutoExport_007:注册表成员必须为实例可读成员 - 实现 IsInstanceReadableMember 方法: - 校验成员为非静态字段或可读属性 - 修复符号访问性检查: - 确保注册方法对所有者类型可访问 - 优化生成逻辑: - 过滤重复的部分类声明,仅生成一次源码 ### Tests - AutoSceneGenerator - 覆盖保留成员冲突场景: - SceneKeyStr 冲突 - __autoSceneBehavior_Generated 冲突 - AutoRegisterExportedCollectionsGenerator - 覆盖完整验证逻辑: - 不可读成员 → GF_AutoExport_006 / 007 - 方法不可访问 → GF_AutoExport_003 - 多个 partial class → 仅生成一个源文件 --- .../Behavior/AutoSceneGeneratorTests.cs | 112 ++++++++ ...gisterExportedCollectionsGeneratorTests.cs | 247 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 2 + .../Behavior/AutoSceneGenerator.cs | 51 ++++ ...oRegisterExportedCollectionsDiagnostics.cs | 22 ++ ...utoRegisterExportedCollectionsGenerator.cs | 44 +++- 6 files changed, 475 insertions(+), 3 deletions(-) diff --git a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs index 0e429cbc..b50b0d73 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoSceneGeneratorTests.cs @@ -216,4 +216,116 @@ public class AutoSceneGeneratorTests 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/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs index db68697c..dd9b1c49 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs @@ -199,4 +199,251 @@ public class AutoRegisterExportedCollectionsGeneratorTests 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 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 2c852e8d..e3da075e 100644 --- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -30,3 +30,5 @@ 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 diff --git a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs index d505222b..f2920be4 100644 --- a/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Behavior/AutoSceneGenerator.cs @@ -19,6 +19,11 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator { private const string AutoSceneAttributeMetadataName = $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoSceneAttribute"; + private static readonly string[] GeneratedMemberNames = + [ + "SceneKeyStr", + "__autoSceneBehavior_Generated" + ]; /// /// 配置 AutoScene 的增量生成管线。 @@ -94,6 +99,15 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator continue; } + if (ReportGeneratedMemberConflicts( + context, + candidate.TypeSymbol, + candidate.ClassDeclaration.Identifier.GetLocation(), + GeneratedMemberNames)) + { + continue; + } + if (!TryGetSceneKey(context, candidate.TypeSymbol, attribute, out var key)) continue; @@ -273,6 +287,43 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator } } + /// + /// 报告与生成器保留成员名冲突的字段或属性,避免生成代码出现重复成员编译错误。 + /// + /// 用于上报诊断的源代码生成上下文。 + /// 当前待生成的类型符号。 + /// 冲突成员无定位信息时的后备位置。 + /// 需要校验的生成器保留成员名集合。 + /// 存在任意冲突时返回 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) diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs index 9c7463cc..15430d73 100644 --- a/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs +++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs @@ -67,4 +67,26 @@ internal static class AutoRegisterExportedCollectionsDiagnostics 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); } diff --git a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs index 8e094b13..bbd07644 100644 --- a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs @@ -12,7 +12,7 @@ namespace GFramework.Godot.SourceGenerators.Registration; /// 该生成器会扫描标记了 AutoRegisterExportedCollectionsAttributepartial 类型, /// 为其中使用 RegisterExportedCollectionAttribute 声明的集合成员生成集中注册方法。 /// 仅当集合可枚举、元素类型可推导、注册表成员存在且可找到兼容的实例注册方法时才会输出代码; -/// 否则通过 GF_AutoExport_001GF_AutoExport_005 以及公共 ClassMustBePartial 诊断显式阻止生成。 +/// 否则通过 GF_AutoExport_001GF_AutoExport_007 以及公共 ClassMustBePartial 诊断显式阻止生成。 /// [Generator] public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGenerator @@ -82,8 +82,11 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener 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!)) + 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))) @@ -187,6 +190,15 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener { 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, @@ -223,6 +235,16 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener 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, @@ -250,6 +272,7 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener .Any(method => !method.IsStatic && method.Parameters.Length == 1 && + compilation.IsSymbolAccessibleWithin(method, ownerType) && CanAcceptElementType(compilation, elementType, method.Parameters[0].Type)); if (!hasCompatibleMethod) @@ -267,6 +290,21 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener 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, From 62d448354c61221194740f13890ca016c4b1d93a Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:13:51 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E5=B1=9E=E6=80=A7=E5=8F=82=E6=95=B0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E6=B3=9B=E5=9E=8B=E7=BA=A6=E6=9D=9F=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=8C=E5=AE=8C=E5=96=84=E8=AF=8A=E6=96=AD=E4=BD=93?= =?UTF-8?q?=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 属性参数校验(Attribute Validation) - AutoUiPageGenerator - 新增 GF_AutoBehavior_004 诊断: - 检测 AutoUiPageAttribute 参数无效情况 - 添加测试用例验证错误参数的诊断报告 - AutoRegisterExportedCollectionsGenerator - 新增 GF_AutoExport_008 诊断: - 检测 RegisterExportedCollectionAttribute 参数无效情况 - 改进 TryGetRegistrationAttributeArguments 方法: - 精确报告错误位置 - 更新文档以包含新增诊断规则 ### 泛型约束支持(Generic Constraints) - AutoUiPageGenerator / AutoRegisterModuleGenerator - 支持以下泛型约束的正确生成: - class? - notnull - unmanaged - 添加对应测试用例确保生成正确性 ### 诊断体系优化(Diagnostics Improvements) - AutoRegisterModuleGenerator - 重构 AutoRegisterModuleDiagnostics: - 优化诊断定义顺序,提高可读性与维护性 --- .../Behavior/AutoUiPageGeneratorTests.cs | 176 ++++++++++++++++++ ...gisterExportedCollectionsGeneratorTests.cs | 55 ++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../Behavior/AutoUiPageGenerator.cs | 23 ++- ...oRegisterExportedCollectionsDiagnostics.cs | 11 ++ ...utoRegisterExportedCollectionsGenerator.cs | 13 +- .../AutoRegisterModuleGeneratorTests.cs | 99 ++++++++++ .../AutoRegisterModuleGenerator.cs | 15 +- .../AutoRegisterModuleDiagnostics.cs | 16 +- 9 files changed, 395 insertions(+), 14 deletions(-) diff --git a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs index 4b9f41c1..8e7d1115 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Behavior/AutoUiPageGeneratorTests.cs @@ -94,4 +94,180 @@ public class AutoUiPageGeneratorTests 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 index dd9b1c49..1c4927a4 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Registration/AutoRegisterExportedCollectionsGeneratorTests.cs @@ -377,6 +377,61 @@ public class AutoRegisterExportedCollectionsGeneratorTests 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() { diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md index e3da075e..73b213f9 100644 --- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -32,3 +32,4 @@ 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/AutoUiPageGenerator.cs b/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs index 18f82597..1e0fe7f6 100644 --- a/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Behavior/AutoUiPageGenerator.cs @@ -138,6 +138,14 @@ public sealed class AutoUiPageGenerator : IIncrementalGenerator 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; } @@ -233,9 +241,20 @@ public sealed class AutoUiPageGenerator : IIncrementalGenerator var constraints = new List(); if (typeParameter.HasReferenceTypeConstraint) - constraints.Add("class"); + { + constraints.Add( + typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated + ? "class?" + : "class"); + } - if (typeParameter.HasValueTypeConstraint) + 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 => diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs index 15430d73..d77b3255 100644 --- a/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs +++ b/GFramework.Godot.SourceGenerators/Diagnostics/AutoRegisterExportedCollectionsDiagnostics.cs @@ -89,4 +89,15 @@ internal static class AutoRegisterExportedCollectionsDiagnostics 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/Registration/AutoRegisterExportedCollectionsGenerator.cs b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs index bbd07644..df0c433b 100644 --- a/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs +++ b/GFramework.Godot.SourceGenerators/Registration/AutoRegisterExportedCollectionsGenerator.cs @@ -12,7 +12,7 @@ namespace GFramework.Godot.SourceGenerators.Registration; /// 该生成器会扫描标记了 AutoRegisterExportedCollectionsAttributepartial 类型, /// 为其中使用 RegisterExportedCollectionAttribute 声明的集合成员生成集中注册方法。 /// 仅当集合可枚举、元素类型可推导、注册表成员存在且可找到兼容的实例注册方法时才会输出代码; -/// 否则通过 GF_AutoExport_001GF_AutoExport_007 以及公共 ClassMustBePartial 诊断显式阻止生成。 +/// 否则通过 GF_AutoExport_001GF_AutoExport_008 以及公共 ClassMustBePartial 诊断显式阻止生成。 /// [Generator] public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGenerator @@ -218,7 +218,8 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener return false; } - if (!TryGetRegistrationAttributeArguments(attribute, out var registryMemberName, out var registerMethodName)) + if (!TryGetRegistrationAttributeArguments(context, collectionMember, attribute, out var registryMemberName, + out var registerMethodName)) return false; var registryMember = ownerType.GetMembers(registryMemberName) @@ -319,6 +320,8 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener } private static bool TryGetRegistrationAttributeArguments( + SourceProductionContext context, + ISymbol collectionMember, AttributeData attribute, out string registryMemberName, out string registerMethodName) @@ -330,6 +333,12 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener 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; } diff --git a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs index 2ced1eed..1dc54318 100644 --- a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs @@ -105,4 +105,103 @@ public class AutoRegisterModuleGeneratorTests source, ("TestApp_GameplayModule.AutoRegisterModule.g.cs", expected)); } + + [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)); + } } diff --git a/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs index 7d5832a1..8c09a80a 100644 --- a/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs +++ b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs @@ -382,9 +382,20 @@ public sealed class AutoRegisterModuleGenerator : IIncrementalGenerator var constraints = new List(); if (typeParameter.HasReferenceTypeConstraint) - constraints.Add("class"); + { + constraints.Add( + typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated + ? "class?" + : "class"); + } - if (typeParameter.HasValueTypeConstraint) + 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 => diff --git a/GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs index b2a58ce3..83b9566f 100644 --- a/GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs +++ b/GFramework.SourceGenerators/Diagnostics/AutoRegisterModuleDiagnostics.cs @@ -22,14 +22,6 @@ internal static class AutoRegisterModuleDiagnostics 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); - public static readonly DiagnosticDescriptor RegistrationTypeMustImplementExpectedInterface = new( "GF_AutoModule_003", "Registration type does not implement the expected interface", @@ -45,4 +37,12 @@ internal static class AutoRegisterModuleDiagnostics 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); } From 6898866b97e996b2e51f98755da442d7f08c0d1c Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:47:06 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(generator):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=86=8C=E6=A8=A1=E5=9D=97=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E7=9A=84=E8=B7=A8=E6=96=87=E4=BB=B6=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当partial类分布在多个文件中时,确保生成器使用稳定的跨文件顺序来生成注册代码。 添加了对语法树排序的支持,使相同声明上的注册特性能够按照源码中的书写顺序生成安装代码。 同时修复了测试快照换行符问题,确保跨平台兼容性。 --- .../AutoRegisterModuleGeneratorTests.cs | 166 ++++++++++++++++++ .../AutoRegisterModuleGenerator.cs | 28 ++- 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs index 1dc54318..964bba4a 100644 --- a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs @@ -6,6 +6,9 @@ namespace GFramework.SourceGenerators.Tests.Architectures; [TestFixture] public class AutoRegisterModuleGeneratorTests { + /// + /// 验证同一声明上的注册特性会按照源码中的书写顺序生成安装代码。 + /// [Test] public async Task Generates_Module_Install_Method_In_Attribute_Order() { @@ -106,6 +109,156 @@ public class AutoRegisterModuleGeneratorTests ("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() { @@ -204,4 +357,17 @@ public class AutoRegisterModuleGeneratorTests 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/Architectures/AutoRegisterModuleGenerator.cs b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs index 8c09a80a..0385d221 100644 --- a/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs +++ b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs @@ -179,7 +179,12 @@ public sealed class AutoRegisterModuleGenerator : IIncrementalGenerator { var registrations = new List(); - foreach (var attribute in typeSymbol.GetAttributes().OrderBy(GetAttributeOrder)) + 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)) { @@ -352,6 +357,27 @@ public sealed class AutoRegisterModuleGenerator : IIncrementalGenerator 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;