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] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20AutoSceneGenerator?= =?UTF-8?q?=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,