From 5b996d86180cd40f704f20a23c99e91ff29c6f71 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 09:39:06 +0800
Subject: [PATCH 1/5] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0=20Bin?=
=?UTF-8?q?dNodeSignal=20=E6=BA=90=E7=94=9F=E6=88=90=E5=99=A8=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现 BindNodeSignalGenerator 源生成器,用于自动生成 Godot 节点事件绑定与解绑逻辑
- 添加 BindNodeSignalAttribute 特性,标记需要生成绑定逻辑的事件处理方法
- 实现完整的诊断系统,包括嵌套类型、静态方法、字段类型等错误检查
- 添加生命周期方法调用检查,在 _Ready 和 _ExitTree 中验证生成方法的调用
- 支持方法签名与事件委托的兼容性验证
- 实现单元测试覆盖各种使用场景和错误情况
---
.../BindNodeSignalAttribute.cs | 40 ++
.../BindNodeSignalGeneratorTests.cs | 467 ++++++++++++++++
.../BindNodeSignalGenerator.cs | 523 ++++++++++++++++++
.../Diagnostics/BindNodeSignalDiagnostics.cs | 117 ++++
4 files changed, 1147 insertions(+)
create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/BindNodeSignalAttribute.cs
create mode 100644 GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
create mode 100644 GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
create mode 100644 GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
diff --git a/GFramework.Godot.SourceGenerators.Abstractions/BindNodeSignalAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/BindNodeSignalAttribute.cs
new file mode 100644
index 0000000..b7db30a
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Abstractions/BindNodeSignalAttribute.cs
@@ -0,0 +1,40 @@
+#nullable enable
+namespace GFramework.Godot.SourceGenerators.Abstractions;
+
+///
+/// 标记 Godot 节点事件处理方法,Source Generator 会为其生成事件绑定与解绑逻辑。
+///
+///
+/// 该特性通过节点字段名与事件名建立声明式订阅关系,适用于将
+/// _Ready() / _ExitTree() 中重复的 += 与 -= 样板代码
+/// 收敛到生成器中统一维护。
+///
+[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+public sealed class BindNodeSignalAttribute : Attribute
+{
+ ///
+ /// 初始化 的新实例。
+ ///
+ /// 目标节点字段名。
+ /// 目标节点上的 CLR 事件名。
+ ///
+ /// 或 为 。
+ ///
+ public BindNodeSignalAttribute(
+ string nodeFieldName,
+ string signalName)
+ {
+ NodeFieldName = nodeFieldName ?? throw new ArgumentNullException(nameof(nodeFieldName));
+ SignalName = signalName ?? throw new ArgumentNullException(nameof(signalName));
+ }
+
+ ///
+ /// 获取目标节点字段名。
+ ///
+ public string NodeFieldName { get; }
+
+ ///
+ /// 获取目标节点上的 CLR 事件名。
+ ///
+ public string SignalName { get; }
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
new file mode 100644
index 0000000..8f07a84
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
@@ -0,0 +1,467 @@
+using GFramework.Godot.SourceGenerators.Tests.Core;
+
+namespace GFramework.Godot.SourceGenerators.Tests.BindNodeSignal;
+
+///
+/// 验证 的生成与诊断行为。
+///
+[TestFixture]
+public class BindNodeSignalGeneratorTests
+{
+ ///
+ /// 验证生成器会为已有生命周期调用生成成对的绑定与解绑方法。
+ ///
+ [Test]
+ public async Task Generates_Bind_And_Unbind_Methods_For_Existing_Lifecycle_Hooks()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+ public sealed class BindNodeSignalAttribute : Attribute
+ {
+ public BindNodeSignalAttribute(string nodeFieldName, string signalName)
+ {
+ NodeFieldName = nodeFieldName;
+ SignalName = signalName;
+ }
+
+ public string NodeFieldName { get; }
+
+ public string SignalName { get; }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ public virtual void _Ready() {}
+
+ public virtual void _ExitTree() {}
+ }
+
+ public class Button : Node
+ {
+ public event Action? Pressed
+ {
+ add {}
+ remove {}
+ }
+ }
+
+ public class SpinBox : Node
+ {
+ public delegate void ValueChangedEventHandler(double value);
+
+ public event ValueChangedEventHandler? ValueChanged
+ {
+ add {}
+ remove {}
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class Hud : Node
+ {
+ private Button _startButton = null!;
+ private SpinBox _startOreSpinBox = null!;
+
+ [BindNodeSignal(nameof(_startButton), nameof(Button.Pressed))]
+ private void OnStartButtonPressed()
+ {
+ }
+
+ [BindNodeSignal(nameof(_startOreSpinBox), nameof(SpinBox.ValueChanged))]
+ private void OnStartOreValueChanged(double value)
+ {
+ }
+
+ public override void _Ready()
+ {
+ __BindNodeSignals_Generated();
+ }
+
+ public override void _ExitTree()
+ {
+ __UnbindNodeSignals_Generated();
+ }
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class Hud
+ {
+ private void __BindNodeSignals_Generated()
+ {
+ _startButton.Pressed += OnStartButtonPressed;
+ _startOreSpinBox.ValueChanged += OnStartOreValueChanged;
+ }
+
+ private void __UnbindNodeSignals_Generated()
+ {
+ _startButton.Pressed -= OnStartButtonPressed;
+ _startOreSpinBox.ValueChanged -= OnStartOreValueChanged;
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_Hud.BindNodeSignal.g.cs", expected));
+ }
+
+ ///
+ /// 验证一个处理方法可以通过多个特性绑定到多个节点事件,且能与 GetNode 声明共存。
+ ///
+ [Test]
+ public async Task Generates_Multiple_Subscriptions_For_The_Same_Handler_And_Coexists_With_GetNode()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+ public sealed class BindNodeSignalAttribute : Attribute
+ {
+ public BindNodeSignalAttribute(string nodeFieldName, string signalName)
+ {
+ NodeFieldName = nodeFieldName;
+ SignalName = signalName;
+ }
+
+ public string NodeFieldName { get; }
+
+ public string SignalName { get; }
+ }
+
+ [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
+ public sealed class GetNodeAttribute : Attribute
+ {
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ public virtual void _Ready() {}
+
+ public virtual void _ExitTree() {}
+ }
+
+ public class Button : Node
+ {
+ public event Action? Pressed
+ {
+ add {}
+ remove {}
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class Hud : Node
+ {
+ [GetNode]
+ private Button _startButton = null!;
+
+ [GetNode]
+ private Button _cancelButton = null!;
+
+ [BindNodeSignal(nameof(_startButton), nameof(Button.Pressed))]
+ [BindNodeSignal(nameof(_cancelButton), nameof(Button.Pressed))]
+ private void OnAnyButtonPressed()
+ {
+ }
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class Hud
+ {
+ private void __BindNodeSignals_Generated()
+ {
+ _startButton.Pressed += OnAnyButtonPressed;
+ _cancelButton.Pressed += OnAnyButtonPressed;
+ }
+
+ private void __UnbindNodeSignals_Generated()
+ {
+ _startButton.Pressed -= OnAnyButtonPressed;
+ _cancelButton.Pressed -= OnAnyButtonPressed;
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_Hud.BindNodeSignal.g.cs", expected));
+ }
+
+ ///
+ /// 验证引用不存在的事件时会报告错误。
+ ///
+ [Test]
+ public async Task Reports_Diagnostic_When_Signal_Does_Not_Exist()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+ public sealed class BindNodeSignalAttribute : Attribute
+ {
+ public BindNodeSignalAttribute(string nodeFieldName, string signalName)
+ {
+ NodeFieldName = nodeFieldName;
+ SignalName = signalName;
+ }
+
+ public string NodeFieldName { get; }
+
+ public string SignalName { get; }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ }
+
+ public class Button : Node
+ {
+ public event Action? Pressed
+ {
+ add {}
+ remove {}
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class Hud : Node
+ {
+ private Button _startButton = null!;
+
+ [{|#0:BindNodeSignal(nameof(_startButton), "Released")|}]
+ private void OnStartButtonPressed()
+ {
+ }
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_006", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("_startButton", "Released"));
+
+ await test.RunAsync();
+ }
+
+ ///
+ /// 验证方法签名与事件委托不匹配时会报告错误。
+ ///
+ [Test]
+ public async Task Reports_Diagnostic_When_Method_Signature_Does_Not_Match_Event()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+ public sealed class BindNodeSignalAttribute : Attribute
+ {
+ public BindNodeSignalAttribute(string nodeFieldName, string signalName)
+ {
+ NodeFieldName = nodeFieldName;
+ SignalName = signalName;
+ }
+
+ public string NodeFieldName { get; }
+
+ public string SignalName { get; }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ }
+
+ public class SpinBox : Node
+ {
+ public delegate void ValueChangedEventHandler(double value);
+
+ public event ValueChangedEventHandler? ValueChanged
+ {
+ add {}
+ remove {}
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class Hud : Node
+ {
+ private SpinBox _startOreSpinBox = null!;
+
+ [{|#0:BindNodeSignal(nameof(_startOreSpinBox), nameof(SpinBox.ValueChanged))|}]
+ private void OnStartOreValueChanged()
+ {
+ }
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_007", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("OnStartOreValueChanged", "ValueChanged", "_startOreSpinBox"));
+
+ await test.RunAsync();
+ }
+
+ ///
+ /// 验证已有生命周期方法但未调用生成方法时会报告对称的警告。
+ ///
+ [Test]
+ public async Task Reports_Warnings_When_Lifecycle_Methods_Do_Not_Call_Generated_Methods()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+ public sealed class BindNodeSignalAttribute : Attribute
+ {
+ public BindNodeSignalAttribute(string nodeFieldName, string signalName)
+ {
+ NodeFieldName = nodeFieldName;
+ SignalName = signalName;
+ }
+
+ public string NodeFieldName { get; }
+
+ public string SignalName { get; }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ public virtual void _Ready() {}
+
+ public virtual void _ExitTree() {}
+ }
+
+ public class Button : Node
+ {
+ public event Action? Pressed
+ {
+ add {}
+ remove {}
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class Hud : Node
+ {
+ private Button _startButton = null!;
+
+ [BindNodeSignal(nameof(_startButton), nameof(Button.Pressed))]
+ private void OnStartButtonPressed()
+ {
+ }
+
+ public override void {|#0:_Ready|}()
+ {
+ }
+
+ public override void {|#1:_ExitTree|}()
+ {
+ }
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_008", DiagnosticSeverity.Warning)
+ .WithLocation(0)
+ .WithArguments("Hud"));
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_009", DiagnosticSeverity.Warning)
+ .WithLocation(1)
+ .WithArguments("Hud"));
+
+ await test.RunAsync();
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
new file mode 100644
index 0000000..27d8cb7
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
@@ -0,0 +1,523 @@
+using GFramework.Godot.SourceGenerators.Diagnostics;
+using GFramework.SourceGenerators.Common.Constants;
+using GFramework.SourceGenerators.Common.Diagnostics;
+
+namespace GFramework.Godot.SourceGenerators;
+
+///
+/// 为带有 [BindNodeSignal] 的方法生成 Godot 节点事件绑定与解绑逻辑。
+///
+[Generator]
+public sealed class BindNodeSignalGenerator : IIncrementalGenerator
+{
+ private const string BindNodeSignalAttributeMetadataName =
+ $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.BindNodeSignalAttribute";
+
+ private const string BindMethodName = "__BindNodeSignals_Generated";
+ private const string UnbindMethodName = "__UnbindNodeSignals_Generated";
+
+ ///
+ /// 初始化增量生成器。
+ ///
+ /// 生成器初始化上下文。
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var candidates = context.SyntaxProvider.CreateSyntaxProvider(
+ static (node, _) => IsCandidate(node),
+ static (ctx, _) => Transform(ctx))
+ .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)
+ {
+ if (node is not MethodDeclarationSyntax methodDeclaration)
+ return false;
+
+ return methodDeclaration.AttributeLists
+ .SelectMany(static list => list.Attributes)
+ .Any(static attribute => attribute.Name.ToString().Contains("BindNodeSignal", StringComparison.Ordinal));
+ }
+
+ private static MethodCandidate? Transform(GeneratorSyntaxContext context)
+ {
+ if (context.Node is not MethodDeclarationSyntax methodDeclaration)
+ return null;
+
+ if (context.SemanticModel.GetDeclaredSymbol(methodDeclaration) is not IMethodSymbol methodSymbol)
+ return null;
+
+ return new MethodCandidate(methodDeclaration, methodSymbol);
+ }
+
+ private static void Execute(
+ SourceProductionContext context,
+ Compilation compilation,
+ ImmutableArray candidates)
+ {
+ if (candidates.IsDefaultOrEmpty)
+ return;
+
+ var bindNodeSignalAttribute = compilation.GetTypeByMetadataName(BindNodeSignalAttributeMetadataName);
+ var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node");
+
+ if (bindNodeSignalAttribute is null || godotNodeSymbol is null)
+ return;
+
+ var methodCandidates = candidates
+ .Where(static candidate => candidate is not null)
+ .Select(static candidate => candidate!)
+ .Where(candidate => ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute).Count > 0)
+ .ToList();
+
+ foreach (var group in GroupByContainingType(methodCandidates))
+ {
+ var typeSymbol = group.TypeSymbol;
+ if (!CanGenerateForType(context, group, typeSymbol))
+ continue;
+
+ var bindings = new List();
+
+ foreach (var candidate in group.Methods)
+ {
+ foreach (var attribute in ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute))
+ {
+ if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding))
+ continue;
+
+ bindings.Add(binding);
+ }
+ }
+
+ if (bindings.Count == 0)
+ continue;
+
+ ReportMissingLifecycleHookCall(
+ context,
+ group,
+ typeSymbol,
+ "_Ready",
+ BindMethodName,
+ BindNodeSignalDiagnostics.ManualReadyHookRequired);
+
+ ReportMissingLifecycleHookCall(
+ context,
+ group,
+ typeSymbol,
+ "_ExitTree",
+ UnbindMethodName,
+ BindNodeSignalDiagnostics.ManualExitTreeHookRequired);
+
+ context.AddSource(GetHintName(typeSymbol), GenerateSource(typeSymbol, bindings));
+ }
+ }
+
+ private static bool CanGenerateForType(
+ SourceProductionContext context,
+ TypeGroup group,
+ INamedTypeSymbol typeSymbol)
+ {
+ if (typeSymbol.ContainingType is not null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ BindNodeSignalDiagnostics.NestedClassNotSupported,
+ group.Methods[0].Method.Identifier.GetLocation(),
+ typeSymbol.Name));
+ return false;
+ }
+
+ if (typeSymbol.AreAllDeclarationsPartial())
+ return true;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.ClassMustBePartial,
+ group.Methods[0].Method.Identifier.GetLocation(),
+ typeSymbol.Name));
+
+ return false;
+ }
+
+ private static bool TryCreateBinding(
+ SourceProductionContext context,
+ MethodCandidate candidate,
+ AttributeData attribute,
+ INamedTypeSymbol godotNodeSymbol,
+ out SignalBindingInfo binding)
+ {
+ binding = default!;
+
+ if (candidate.MethodSymbol.IsStatic)
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.StaticMethodNotSupported,
+ candidate,
+ attribute,
+ candidate.MethodSymbol.Name);
+ return false;
+ }
+
+ var nodeFieldName = ResolveCtorString(attribute, 0);
+ var signalName = ResolveCtorString(attribute, 1);
+
+ var fieldSymbol = FindField(candidate.MethodSymbol.ContainingType, nodeFieldName);
+ if (fieldSymbol is null)
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.NodeFieldNotFound,
+ candidate,
+ attribute,
+ candidate.MethodSymbol.Name,
+ nodeFieldName,
+ candidate.MethodSymbol.ContainingType.Name);
+ return false;
+ }
+
+ if (fieldSymbol.IsStatic)
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.NodeFieldMustBeInstanceField,
+ candidate,
+ attribute,
+ candidate.MethodSymbol.Name,
+ fieldSymbol.Name);
+ return false;
+ }
+
+ if (!fieldSymbol.Type.IsAssignableTo(godotNodeSymbol))
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.FieldTypeMustDeriveFromNode,
+ candidate,
+ attribute,
+ fieldSymbol.Name);
+ return false;
+ }
+
+ var eventSymbol = FindEvent(fieldSymbol.Type, signalName);
+ if (eventSymbol is null)
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.SignalNotFound,
+ candidate,
+ attribute,
+ fieldSymbol.Name,
+ signalName);
+ return false;
+ }
+
+ if (!IsMethodCompatibleWithEvent(candidate.MethodSymbol, eventSymbol))
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.MethodSignatureNotCompatible,
+ candidate,
+ attribute,
+ candidate.MethodSymbol.Name,
+ eventSymbol.Name,
+ fieldSymbol.Name);
+ return false;
+ }
+
+ binding = new SignalBindingInfo(fieldSymbol, eventSymbol, candidate.MethodSymbol);
+ return true;
+ }
+
+ private static void ReportMethodDiagnostic(
+ SourceProductionContext context,
+ DiagnosticDescriptor descriptor,
+ MethodCandidate candidate,
+ AttributeData attribute,
+ params object[] messageArgs)
+ {
+ var location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ??
+ candidate.Method.Identifier.GetLocation();
+
+ context.ReportDiagnostic(Diagnostic.Create(descriptor, location, messageArgs));
+ }
+
+ private static string ResolveCtorString(
+ AttributeData attribute,
+ int index)
+ {
+ if (attribute.ConstructorArguments.Length <= index)
+ return string.Empty;
+
+ return attribute.ConstructorArguments[index].Value as string ?? string.Empty;
+ }
+
+ private static IReadOnlyList ResolveAttributes(
+ IMethodSymbol methodSymbol,
+ INamedTypeSymbol bindNodeSignalAttribute)
+ {
+ return methodSymbol.GetAttributes()
+ .Where(attribute =>
+ SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, bindNodeSignalAttribute))
+ .ToList();
+ }
+
+ private static IFieldSymbol? FindField(
+ INamedTypeSymbol typeSymbol,
+ string nodeFieldName)
+ {
+ return typeSymbol.GetMembers()
+ .OfType()
+ .FirstOrDefault(field => string.Equals(field.Name, nodeFieldName, StringComparison.Ordinal));
+ }
+
+ private static IEventSymbol? FindEvent(
+ ITypeSymbol typeSymbol,
+ string signalName)
+ {
+ for (var current = typeSymbol as INamedTypeSymbol; current is not null; current = current.BaseType)
+ {
+ var eventSymbol = current.GetMembers()
+ .OfType()
+ .FirstOrDefault(evt => string.Equals(evt.Name, signalName, StringComparison.Ordinal));
+
+ if (eventSymbol is not null)
+ return eventSymbol;
+ }
+
+ return null;
+ }
+
+ private static bool IsMethodCompatibleWithEvent(
+ IMethodSymbol methodSymbol,
+ IEventSymbol eventSymbol)
+ {
+ if (!methodSymbol.ReturnsVoid)
+ return false;
+
+ if (methodSymbol.TypeParameters.Length > 0)
+ return false;
+
+ if (eventSymbol.Type is not INamedTypeSymbol delegateType)
+ return false;
+
+ var invokeMethod = delegateType.DelegateInvokeMethod;
+ if (invokeMethod is null || !invokeMethod.ReturnsVoid)
+ return false;
+
+ if (methodSymbol.Parameters.Length != invokeMethod.Parameters.Length)
+ return false;
+
+ // 这里采用“精确签名匹配”而不是宽松推断,确保生成代码的订阅行为可预测且诊断明确。
+ for (var index = 0; index < methodSymbol.Parameters.Length; index++)
+ {
+ var methodParameter = methodSymbol.Parameters[index];
+ var delegateParameter = invokeMethod.Parameters[index];
+
+ if (methodParameter.RefKind != delegateParameter.RefKind)
+ return false;
+
+ var methodParameterType = methodParameter.Type.WithNullableAnnotation(NullableAnnotation.None);
+ var delegateParameterType = delegateParameter.Type.WithNullableAnnotation(NullableAnnotation.None);
+
+ if (!SymbolEqualityComparer.Default.Equals(methodParameterType, delegateParameterType))
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void ReportMissingLifecycleHookCall(
+ SourceProductionContext context,
+ TypeGroup group,
+ INamedTypeSymbol typeSymbol,
+ string lifecycleMethodName,
+ string generatedMethodName,
+ DiagnosticDescriptor descriptor)
+ {
+ var lifecycleMethod = FindLifecycleMethod(typeSymbol, lifecycleMethodName);
+ if (lifecycleMethod is null || CallsGeneratedMethod(lifecycleMethod, generatedMethodName))
+ return;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ descriptor,
+ lifecycleMethod.Locations.FirstOrDefault() ?? group.Methods[0].Method.Identifier.GetLocation(),
+ typeSymbol.Name));
+ }
+
+ private static IMethodSymbol? FindLifecycleMethod(
+ INamedTypeSymbol typeSymbol,
+ string methodName)
+ {
+ return typeSymbol.GetMembers()
+ .OfType()
+ .FirstOrDefault(method =>
+ method.Name == methodName &&
+ !method.IsStatic &&
+ method.Parameters.Length == 0 &&
+ method.MethodKind == MethodKind.Ordinary);
+ }
+
+ private static bool CallsGeneratedMethod(
+ IMethodSymbol methodSymbol,
+ string generatedMethodName)
+ {
+ foreach (var syntaxReference in methodSymbol.DeclaringSyntaxReferences)
+ {
+ if (syntaxReference.GetSyntax() is not MethodDeclarationSyntax methodSyntax)
+ continue;
+
+ if (methodSyntax.DescendantNodes()
+ .OfType()
+ .Any(invocation => IsGeneratedMethodInvocation(invocation, generatedMethodName)))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsGeneratedMethodInvocation(
+ InvocationExpressionSyntax invocation,
+ string generatedMethodName)
+ {
+ return invocation.Expression switch
+ {
+ IdentifierNameSyntax identifierName => string.Equals(
+ identifierName.Identifier.ValueText,
+ generatedMethodName,
+ StringComparison.Ordinal),
+ MemberAccessExpressionSyntax memberAccess => string.Equals(
+ memberAccess.Name.Identifier.ValueText,
+ generatedMethodName,
+ StringComparison.Ordinal),
+ _ => false
+ };
+ }
+
+ private static string GenerateSource(
+ INamedTypeSymbol typeSymbol,
+ IReadOnlyList bindings)
+ {
+ var namespaceName = typeSymbol.GetNamespace();
+ var generics = typeSymbol.ResolveGenerics();
+
+ var sb = new StringBuilder()
+ .AppendLine("// ")
+ .AppendLine("#nullable enable");
+
+ if (namespaceName is not null)
+ {
+ sb.AppendLine()
+ .AppendLine($"namespace {namespaceName};");
+ }
+
+ sb.AppendLine()
+ .AppendLine($"partial class {typeSymbol.Name}{generics.Parameters}");
+
+ foreach (var constraint in generics.Constraints)
+ sb.AppendLine($" {constraint}");
+
+ sb.AppendLine("{")
+ .AppendLine($" private void {BindMethodName}()")
+ .AppendLine(" {");
+
+ foreach (var binding in bindings)
+ sb.AppendLine(
+ $" {binding.FieldSymbol.Name}.{binding.EventSymbol.Name} += {binding.MethodSymbol.Name};");
+
+ sb.AppendLine(" }")
+ .AppendLine()
+ .AppendLine($" private void {UnbindMethodName}()")
+ .AppendLine(" {");
+
+ foreach (var binding in bindings)
+ sb.AppendLine(
+ $" {binding.FieldSymbol.Name}.{binding.EventSymbol.Name} -= {binding.MethodSymbol.Name};");
+
+ sb.AppendLine(" }")
+ .AppendLine("}");
+
+ return sb.ToString();
+ }
+
+ private static string GetHintName(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ .Replace("global::", string.Empty)
+ .Replace("<", "_")
+ .Replace(">", "_")
+ .Replace(",", "_")
+ .Replace(" ", string.Empty)
+ .Replace(".", "_") + ".BindNodeSignal.g.cs";
+ }
+
+ private static IReadOnlyList GroupByContainingType(IEnumerable candidates)
+ {
+ var groupMap = new Dictionary(SymbolEqualityComparer.Default);
+ var orderedGroups = new List();
+
+ foreach (var candidate in candidates)
+ {
+ var typeSymbol = candidate.MethodSymbol.ContainingType;
+ if (!groupMap.TryGetValue(typeSymbol, out var group))
+ {
+ group = new TypeGroup(typeSymbol);
+ groupMap.Add(typeSymbol, group);
+ orderedGroups.Add(group);
+ }
+
+ group.Methods.Add(candidate);
+ }
+
+ return orderedGroups;
+ }
+
+ private sealed class MethodCandidate
+ {
+ public MethodCandidate(
+ MethodDeclarationSyntax method,
+ IMethodSymbol methodSymbol)
+ {
+ Method = method;
+ MethodSymbol = methodSymbol;
+ }
+
+ public MethodDeclarationSyntax Method { get; }
+
+ public IMethodSymbol MethodSymbol { get; }
+ }
+
+ private sealed class SignalBindingInfo
+ {
+ public SignalBindingInfo(
+ IFieldSymbol fieldSymbol,
+ IEventSymbol eventSymbol,
+ IMethodSymbol methodSymbol)
+ {
+ FieldSymbol = fieldSymbol;
+ EventSymbol = eventSymbol;
+ MethodSymbol = methodSymbol;
+ }
+
+ public IFieldSymbol FieldSymbol { get; }
+
+ public IEventSymbol EventSymbol { get; }
+
+ public IMethodSymbol MethodSymbol { get; }
+ }
+
+ private sealed class TypeGroup
+ {
+ public TypeGroup(INamedTypeSymbol typeSymbol)
+ {
+ TypeSymbol = typeSymbol;
+ Methods = new List();
+ }
+
+ public INamedTypeSymbol TypeSymbol { get; }
+
+ public List Methods { get; }
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
new file mode 100644
index 0000000..6b48535
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
@@ -0,0 +1,117 @@
+using GFramework.SourceGenerators.Common.Constants;
+
+namespace GFramework.Godot.SourceGenerators.Diagnostics;
+
+///
+/// BindNodeSignal 生成器相关诊断。
+///
+public static class BindNodeSignalDiagnostics
+{
+ ///
+ /// 嵌套类型不受支持。
+ ///
+ public static readonly DiagnosticDescriptor NestedClassNotSupported =
+ new(
+ "GF_Godot_BindNodeSignal_001",
+ "Nested classes are not supported",
+ "Class '{0}' cannot use [BindNodeSignal] inside a nested type",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// static 方法不受支持。
+ ///
+ public static readonly DiagnosticDescriptor StaticMethodNotSupported =
+ new(
+ "GF_Godot_BindNodeSignal_002",
+ "Static methods are not supported",
+ "Method '{0}' cannot be static when using [BindNodeSignal]",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 节点字段不存在。
+ ///
+ public static readonly DiagnosticDescriptor NodeFieldNotFound =
+ new(
+ "GF_Godot_BindNodeSignal_003",
+ "Referenced node field was not found",
+ "Method '{0}' references node field '{1}', but no matching field exists on class '{2}'",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 节点字段必须是实例字段。
+ ///
+ public static readonly DiagnosticDescriptor NodeFieldMustBeInstanceField =
+ new(
+ "GF_Godot_BindNodeSignal_004",
+ "Referenced node field must be an instance field",
+ "Method '{0}' references node field '{1}', but that field must be an instance field",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 字段类型必须继承自 Godot.Node。
+ ///
+ public static readonly DiagnosticDescriptor FieldTypeMustDeriveFromNode =
+ new(
+ "GF_Godot_BindNodeSignal_005",
+ "Field type must derive from Godot.Node",
+ "Field '{0}' must be a Godot.Node type to use [BindNodeSignal]",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 目标事件不存在。
+ ///
+ public static readonly DiagnosticDescriptor SignalNotFound =
+ new(
+ "GF_Godot_BindNodeSignal_006",
+ "Referenced event was not found",
+ "Field '{0}' does not contain an event named '{1}'",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 方法签名与事件委托不兼容。
+ ///
+ public static readonly DiagnosticDescriptor MethodSignatureNotCompatible =
+ new(
+ "GF_Godot_BindNodeSignal_007",
+ "Method signature is not compatible with the referenced event",
+ "Method '{0}' is not compatible with event '{1}' on field '{2}'",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 现有 _Ready 中未调用生成绑定逻辑。
+ ///
+ public static readonly DiagnosticDescriptor ManualReadyHookRequired =
+ new(
+ "GF_Godot_BindNodeSignal_008",
+ "Call generated signal binding from _Ready",
+ "Class '{0}' defines _Ready(); call __BindNodeSignals_Generated() there to bind [BindNodeSignal] handlers",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Warning,
+ true);
+
+ ///
+ /// 现有 _ExitTree 中未调用生成解绑逻辑。
+ ///
+ public static readonly DiagnosticDescriptor ManualExitTreeHookRequired =
+ new(
+ "GF_Godot_BindNodeSignal_009",
+ "Call generated signal unbinding from _ExitTree",
+ "Class '{0}' defines _ExitTree(); call __UnbindNodeSignals_Generated() there to unbind [BindNodeSignal] handlers",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Warning,
+ true);
+}
\ No newline at end of file
From 9cca190aff3312d79877b0a62824d75c3a0d784f Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 10:03:31 +0800
Subject: [PATCH 2/5] =?UTF-8?q?docs(generator):=20=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E6=BA=90=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8=E6=96=87=E6=A1=A3?=
=?UTF-8?q?=E5=92=8C=E5=88=86=E6=9E=90=E5=99=A8=E8=A7=84=E5=88=99=E6=B8=85?=
=?UTF-8?q?=E5=8D=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 AnalyzerReleases.Unshipped.md 文件记录代码分析规则
- 添加 GF_Godot_GetNode 系列规则定义(001-006)
- 添加 GF_Godot_BindNodeSignal 系列规则定义(001-009)
- 创建 README.md 文件详述源码生成器使用方法
- 文档化 GetNode 和 BindNodeSignal 特性用法示例
- 说明 Godot 场景相关的编译期生成能力
---
.../AnalyzerReleases.Unshipped.md | 25 +++++++----
GFramework.Godot.SourceGenerators/README.md | 45 +++++++++++++++++++
2 files changed, 62 insertions(+), 8 deletions(-)
diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
index a78788c..b478e72 100644
--- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -3,11 +3,20 @@
### 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
\ No newline at end of file
+ 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
diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md
index 291c557..87f423d 100644
--- a/GFramework.Godot.SourceGenerators/README.md
+++ b/GFramework.Godot.SourceGenerators/README.md
@@ -7,6 +7,7 @@
- 与 Godot 场景相关的编译期生成能力
- 基于 Roslyn 的增量生成器实现
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode()` 样板代码
+- `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码
## 使用建议
@@ -43,3 +44,47 @@ public partial class TopBar : HBoxContainer
- `_leftContainer` -> `%LeftContainer`
- `m_rightContainer` -> `%RightContainer`
+
+## BindNodeSignal 用法
+
+```csharp
+using GFramework.Godot.SourceGenerators.Abstractions;
+using Godot;
+
+public partial class Hud : Control
+{
+ [GetNode]
+ private Button _startButton = null!;
+
+ [GetNode]
+ private SpinBox _startOreSpinBox = null!;
+
+ [BindNodeSignal(nameof(_startButton), nameof(Button.Pressed))]
+ private void OnStartButtonPressed()
+ {
+ }
+
+ [BindNodeSignal(nameof(_startOreSpinBox), nameof(SpinBox.ValueChanged))]
+ private void OnStartOreValueChanged(double value)
+ {
+ }
+
+ public override void _Ready()
+ {
+ __InjectGetNodes_Generated();
+ __BindNodeSignals_Generated();
+ }
+
+ public override void _ExitTree()
+ {
+ __UnbindNodeSignals_Generated();
+ }
+}
+```
+
+生成器会产出两个辅助方法:
+
+- `__BindNodeSignals_Generated()`:负责统一订阅事件
+- `__UnbindNodeSignals_Generated()`:负责统一解绑事件
+
+当前设计只处理 CLR event 形式的 Godot 事件绑定,不会自动调用 `Connect()` / `Disconnect()`。
From 2dfd6e044f9685dd2350b88c89938758d9567a3e Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 10:26:44 +0800
Subject: [PATCH 3/5] =?UTF-8?q?feat(Godot.SourceGenerators):=20=E6=B7=BB?=
=?UTF-8?q?=E5=8A=A0=20BindNodeSignal=20=E6=BA=90=E4=BB=A3=E7=A0=81?=
=?UTF-8?q?=E7=94=9F=E6=88=90=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现 BindNodeSignalGenerator 源代码生成器,用于自动化节点信号绑定
- 添加完整的诊断系统,包含 11 种不同的错误和警告场景检测
- 生成对称的绑定和解绑方法,确保资源正确释放
- 支持一个处理方法通过多个特性绑定到多个节点事件
- 实现生命周期钩子调用检查,确保在 _Ready 和 _ExitTree 中正确调用生成的方法
- 提供详细的单元测试覆盖各种使用场景和边界条件
- 生成器与现有的 GetNode 声明完全兼容并可共存
- 包含命名冲突检测和构造参数验证等安全检查机制
---
.../BindNodeSignalGeneratorTests.cs | 162 ++++++++++++++++++
.../AnalyzerReleases.Unshipped.md | 2 +
.../BindNodeSignalGenerator.cs | 132 ++++++++++++--
.../Diagnostics/BindNodeSignalDiagnostics.cs | 24 +++
4 files changed, 309 insertions(+), 11 deletions(-)
diff --git a/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
index 8f07a84..a479c64 100644
--- a/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
+++ b/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
@@ -375,6 +375,168 @@ public class BindNodeSignalGeneratorTests
await test.RunAsync();
}
+ ///
+ /// 验证特性构造参数为空时会报告明确的参数无效诊断。
+ ///
+ [Test]
+ public async Task Reports_Diagnostic_When_Constructor_Argument_Is_Empty()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+ public sealed class BindNodeSignalAttribute : Attribute
+ {
+ public BindNodeSignalAttribute(string nodeFieldName, string signalName)
+ {
+ NodeFieldName = nodeFieldName;
+ SignalName = signalName;
+ }
+
+ public string NodeFieldName { get; }
+
+ public string SignalName { get; }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ }
+
+ public class Button : Node
+ {
+ public event Action? Pressed
+ {
+ add {}
+ remove {}
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class Hud : Node
+ {
+ private Button _startButton = null!;
+
+ [{|#0:BindNodeSignal(nameof(_startButton), "")|}]
+ private void OnStartButtonPressed()
+ {
+ }
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_010", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("OnStartButtonPressed", "signalName"));
+
+ await test.RunAsync();
+ }
+
+ ///
+ /// 验证当用户自定义了与生成方法同名的成员时,会报告冲突而不是生成重复成员。
+ ///
+ [Test]
+ public async Task Reports_Diagnostic_When_Generated_Method_Names_Already_Exist()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
+ public sealed class BindNodeSignalAttribute : Attribute
+ {
+ public BindNodeSignalAttribute(string nodeFieldName, string signalName)
+ {
+ NodeFieldName = nodeFieldName;
+ SignalName = signalName;
+ }
+
+ public string NodeFieldName { get; }
+
+ public string SignalName { get; }
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ }
+
+ public class Button : Node
+ {
+ public event Action? Pressed
+ {
+ add {}
+ remove {}
+ }
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class Hud : Node
+ {
+ private Button _startButton = null!;
+
+ [BindNodeSignal(nameof(_startButton), nameof(Button.Pressed))]
+ private void OnStartButtonPressed()
+ {
+ }
+
+ private void {|#0:__BindNodeSignals_Generated|}()
+ {
+ }
+
+ private void {|#1:__UnbindNodeSignals_Generated|}()
+ {
+ }
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" },
+ TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("Hud", "__BindNodeSignals_Generated"));
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error)
+ .WithLocation(1)
+ .WithArguments("Hud", "__UnbindNodeSignals_Generated"));
+
+ await test.RunAsync();
+ }
+
///
/// 验证已有生命周期方法但未调用生成方法时会报告对称的警告。
///
diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
index b478e72..76b9b7a 100644
--- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -20,3 +20,5 @@
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_Godot_BindNodeSignal_011 | GFramework.Godot | Error | BindNodeSignalDiagnostics
diff --git a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
index 27d8cb7..4fa0321 100644
--- a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
+++ b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
@@ -40,7 +40,7 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
return methodDeclaration.AttributeLists
.SelectMany(static list => list.Attributes)
- .Any(static attribute => attribute.Name.ToString().Contains("BindNodeSignal", StringComparison.Ordinal));
+ .Any(static attribute => IsBindNodeSignalAttributeName(attribute.Name));
}
private static MethodCandidate? Transform(GeneratorSyntaxContext context)
@@ -68,10 +68,18 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
if (bindNodeSignalAttribute is null || godotNodeSymbol is null)
return;
- var methodCandidates = candidates
+ // 缓存每个方法上已解析的特性,避免在筛选和生成阶段重复做语义查询。
+ var methodAttributes = candidates
.Where(static candidate => candidate is not null)
.Select(static candidate => candidate!)
- .Where(candidate => ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute).Count > 0)
+ .ToDictionary(
+ static candidate => candidate,
+ candidate => ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute),
+ ReferenceEqualityComparer.Instance);
+
+ var methodCandidates = methodAttributes
+ .Where(static pair => pair.Value.Count > 0)
+ .Select(static pair => pair.Key)
.ToList();
foreach (var group in GroupByContainingType(methodCandidates))
@@ -80,11 +88,14 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, group, typeSymbol))
continue;
+ if (HasGeneratedMethodNameConflict(context, group, typeSymbol))
+ continue;
+
var bindings = new List();
foreach (var candidate in group.Methods)
{
- foreach (var attribute in ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute))
+ foreach (var attribute in methodAttributes[candidate])
{
if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding))
continue;
@@ -161,8 +172,29 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
return false;
}
- var nodeFieldName = ResolveCtorString(attribute, 0);
- var signalName = ResolveCtorString(attribute, 1);
+ if (!TryResolveCtorString(attribute, 0, out var nodeFieldName))
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.InvalidConstructorArgument,
+ candidate,
+ attribute,
+ candidate.MethodSymbol.Name,
+ "nodeFieldName");
+ return false;
+ }
+
+ if (!TryResolveCtorString(attribute, 1, out var signalName))
+ {
+ ReportMethodDiagnostic(
+ context,
+ BindNodeSignalDiagnostics.InvalidConstructorArgument,
+ candidate,
+ attribute,
+ candidate.MethodSymbol.Name,
+ "signalName");
+ return false;
+ }
var fieldSymbol = FindField(candidate.MethodSymbol.ContainingType, nodeFieldName);
if (fieldSymbol is null)
@@ -244,14 +276,25 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
context.ReportDiagnostic(Diagnostic.Create(descriptor, location, messageArgs));
}
- private static string ResolveCtorString(
+ private static bool TryResolveCtorString(
AttributeData attribute,
- int index)
+ int index,
+ out string value)
{
- if (attribute.ConstructorArguments.Length <= index)
- return string.Empty;
+ value = string.Empty;
- return attribute.ConstructorArguments[index].Value as string ?? string.Empty;
+ if (attribute.ConstructorArguments.Length <= index)
+ return false;
+
+ var ctorArgument = attribute.ConstructorArguments[index];
+ if (ctorArgument.Kind != TypedConstantKind.Primitive || ctorArgument.Value is not string ctorString)
+ return false;
+
+ if (string.IsNullOrWhiteSpace(ctorString))
+ return false;
+
+ value = ctorString;
+ return true;
}
private static IReadOnlyList ResolveAttributes(
@@ -347,6 +390,36 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
typeSymbol.Name));
}
+ private static bool HasGeneratedMethodNameConflict(
+ SourceProductionContext context,
+ TypeGroup group,
+ INamedTypeSymbol typeSymbol)
+ {
+ var hasConflict = false;
+
+ foreach (var generatedMethodName in new[] { BindMethodName, UnbindMethodName })
+ {
+ var conflictingMethod = typeSymbol.GetMembers()
+ .OfType()
+ .FirstOrDefault(method =>
+ method.Name == generatedMethodName &&
+ method.Parameters.Length == 0 &&
+ method.TypeParameters.Length == 0);
+
+ if (conflictingMethod is null)
+ continue;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ BindNodeSignalDiagnostics.GeneratedMethodNameConflict,
+ conflictingMethod.Locations.FirstOrDefault() ?? group.Methods[0].Method.Identifier.GetLocation(),
+ typeSymbol.Name,
+ generatedMethodName));
+ hasConflict = true;
+ }
+
+ return hasConflict;
+ }
+
private static IMethodSymbol? FindLifecycleMethod(
INamedTypeSymbol typeSymbol,
string methodName)
@@ -396,6 +469,23 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
};
}
+ private static bool IsBindNodeSignalAttributeName(NameSyntax attributeName)
+ {
+ var simpleName = GetAttributeSimpleName(attributeName);
+ return simpleName is "BindNodeSignal" or "BindNodeSignalAttribute";
+ }
+
+ private static string? GetAttributeSimpleName(NameSyntax attributeName)
+ {
+ return attributeName switch
+ {
+ IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText,
+ QualifiedNameSyntax qualifiedName => GetAttributeSimpleName(qualifiedName.Right),
+ AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText,
+ _ => null
+ };
+ }
+
private static string GenerateSource(
INamedTypeSymbol typeSymbol,
IReadOnlyList bindings)
@@ -520,4 +610,24 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
public List Methods { get; }
}
+
+ ///
+ /// 使用引用相等比较 MethodCandidate,确保缓存字典复用同一语法候选对象。
+ ///
+ private sealed class ReferenceEqualityComparer : IEqualityComparer
+ {
+ public static ReferenceEqualityComparer Instance { get; } = new();
+
+ public bool Equals(
+ MethodCandidate? x,
+ MethodCandidate? y)
+ {
+ return ReferenceEquals(x, y);
+ }
+
+ public int GetHashCode(MethodCandidate obj)
+ {
+ return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
+ }
+ }
}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
index 6b48535..41bac93 100644
--- a/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
+++ b/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
@@ -114,4 +114,28 @@ public static class BindNodeSignalDiagnostics
PathContests.GodotNamespace,
DiagnosticSeverity.Warning,
true);
+
+ ///
+ /// BindNodeSignalAttribute 构造参数无效。
+ ///
+ public static readonly DiagnosticDescriptor InvalidConstructorArgument =
+ new(
+ "GF_Godot_BindNodeSignal_010",
+ "BindNodeSignal attribute arguments are invalid",
+ "Method '{0}' uses [BindNodeSignal] with an invalid '{1}' constructor argument; it must be a non-empty string literal",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 用户代码中已存在与生成方法同名的成员。
+ ///
+ public static readonly DiagnosticDescriptor GeneratedMethodNameConflict =
+ new(
+ "GF_Godot_BindNodeSignal_011",
+ "Generated method name conflicts with an existing member",
+ "Class '{0}' already defines method '{1}()', which conflicts with [BindNodeSignal] generated code",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
}
\ No newline at end of file
From 6fa45808939164fa4009990b594a2e68d6db9f64 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 11:11:23 +0800
Subject: [PATCH 4/5] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0=20Bin?=
=?UTF-8?q?dNodeSignal=20=E5=92=8C=20GetNode=20=E6=BA=90=E4=BB=A3=E7=A0=81?=
=?UTF-8?q?=E7=94=9F=E6=88=90=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现 BindNodeSignalGenerator 用于生成节点信号绑定与解绑逻辑
- 实现 GetNodeGenerator 用于生成 Godot 节点获取注入逻辑
- 添加 BindNodeSignalDiagnostics 提供详细的诊断错误信息
- 集成到 AnalyzerReleases.Unshipped.md 追踪新的分析规则
- 支持 [BindNodeSignal] 属性的方法自动生成事件绑定代码
- 支持 [GetNode] 属性的字段自动生成节点获取代码
- 提供生命周期方法集成的智能提示和验证功能
---
.../BindNodeSignalGeneratorTests.cs | 4 +-
.../GetNode/GetNodeGeneratorTests.cs | 71 ++++++++++++++++-
.../AnalyzerReleases.Unshipped.md | 1 -
.../BindNodeSignalGenerator.cs | 39 ++--------
.../Diagnostics/BindNodeSignalDiagnostics.cs | 12 ---
.../GetNodeGenerator.cs | 12 +--
.../AnalyzerReleases.Unshipped.md | 5 +-
.../Diagnostics/CommonDiagnostics.cs | 21 ++++-
.../GeneratedMethodConflictExtensions.cs | 49 ++++++++++++
.../Rule/ContextGetGeneratorTests.cs | 78 +++++++++++++++++++
.../Rule/ContextGetGenerator.cs | 8 +-
11 files changed, 236 insertions(+), 64 deletions(-)
create mode 100644 GFramework.SourceGenerators.Common/Extensions/GeneratedMethodConflictExtensions.cs
diff --git a/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
index a479c64..7f71f21 100644
--- a/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
+++ b/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
@@ -526,11 +526,11 @@ public class BindNodeSignalGeneratorTests
TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
};
- test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error)
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error)
.WithLocation(0)
.WithArguments("Hud", "__BindNodeSignals_Generated"));
- test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error)
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error)
.WithLocation(1)
.WithArguments("Hud", "__UnbindNodeSignals_Generated"));
diff --git a/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs
index b0df01f..266ff98 100644
--- a/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs
+++ b/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs
@@ -1,8 +1,4 @@
using GFramework.Godot.SourceGenerators.Tests.Core;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Testing;
-using Microsoft.CodeAnalysis.Testing;
-using NUnit.Framework;
namespace GFramework.Godot.SourceGenerators.Tests.GetNode;
@@ -240,4 +236,71 @@ public class GetNodeGeneratorTests
await test.RunAsync();
}
+
+ [Test]
+ public async Task Reports_Diagnostic_When_Generated_Injection_Method_Name_Already_Exists()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
+ public sealed class GetNodeAttribute : Attribute
+ {
+ public GetNodeAttribute() {}
+ }
+
+ public enum NodeLookupMode
+ {
+ Auto = 0
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ public virtual void _Ready() {}
+ public T GetNode(string path) where T : Node => throw new InvalidOperationException(path);
+ public T? GetNodeOrNull(string path) where T : Node => default;
+ }
+
+ public class HBoxContainer : Node
+ {
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class TopBar : HBoxContainer
+ {
+ [GetNode]
+ private HBoxContainer _leftContainer = null!;
+
+ private void {|#0:__InjectGetNodes_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("TopBar", "__InjectGetNodes_Generated"));
+
+ await test.RunAsync();
+ }
}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
index 76b9b7a..c4a2930 100644
--- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -21,4 +21,3 @@
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_Godot_BindNodeSignal_011 | GFramework.Godot | Error | BindNodeSignalDiagnostics
diff --git a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
index 4fa0321..d768432 100644
--- a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
+++ b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
@@ -1,3 +1,4 @@
+using System.Runtime.CompilerServices;
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
@@ -88,7 +89,11 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, group, typeSymbol))
continue;
- if (HasGeneratedMethodNameConflict(context, group, typeSymbol))
+ if (typeSymbol.ReportGeneratedMethodConflicts(
+ context,
+ group.Methods[0].Method.Identifier.GetLocation(),
+ BindMethodName,
+ UnbindMethodName))
continue;
var bindings = new List();
@@ -390,36 +395,6 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
typeSymbol.Name));
}
- private static bool HasGeneratedMethodNameConflict(
- SourceProductionContext context,
- TypeGroup group,
- INamedTypeSymbol typeSymbol)
- {
- var hasConflict = false;
-
- foreach (var generatedMethodName in new[] { BindMethodName, UnbindMethodName })
- {
- var conflictingMethod = typeSymbol.GetMembers()
- .OfType()
- .FirstOrDefault(method =>
- method.Name == generatedMethodName &&
- method.Parameters.Length == 0 &&
- method.TypeParameters.Length == 0);
-
- if (conflictingMethod is null)
- continue;
-
- context.ReportDiagnostic(Diagnostic.Create(
- BindNodeSignalDiagnostics.GeneratedMethodNameConflict,
- conflictingMethod.Locations.FirstOrDefault() ?? group.Methods[0].Method.Identifier.GetLocation(),
- typeSymbol.Name,
- generatedMethodName));
- hasConflict = true;
- }
-
- return hasConflict;
- }
-
private static IMethodSymbol? FindLifecycleMethod(
INamedTypeSymbol typeSymbol,
string methodName)
@@ -627,7 +602,7 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
public int GetHashCode(MethodCandidate obj)
{
- return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
+ return RuntimeHelpers.GetHashCode(obj);
}
}
}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
index 41bac93..95f4b2d 100644
--- a/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
+++ b/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
@@ -126,16 +126,4 @@ public static class BindNodeSignalDiagnostics
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
-
- ///
- /// 用户代码中已存在与生成方法同名的成员。
- ///
- public static readonly DiagnosticDescriptor GeneratedMethodNameConflict =
- new(
- "GF_Godot_BindNodeSignal_011",
- "Generated method name conflicts with an existing member",
- "Class '{0}' already defines method '{1}()', which conflicts with [BindNodeSignal] generated code",
- PathContests.GodotNamespace,
- DiagnosticSeverity.Error,
- true);
}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
index 1ac7e80..280171d 100644
--- a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
+++ b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
@@ -1,12 +1,6 @@
-using System.Collections.Immutable;
-using System.Text;
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
-using GFramework.SourceGenerators.Common.Extensions;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace GFramework.Godot.SourceGenerators;
@@ -95,6 +89,12 @@ public sealed class GetNodeGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, group, typeSymbol))
continue;
+ if (typeSymbol.ReportGeneratedMethodConflicts(
+ context,
+ group.Fields[0].Variable.Identifier.GetLocation(),
+ InjectionMethodName))
+ continue;
+
var bindings = new List();
foreach (var candidate in group.Fields)
diff --git a/GFramework.SourceGenerators.Common/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators.Common/AnalyzerReleases.Unshipped.md
index 84445da..35c6296 100644
--- a/GFramework.SourceGenerators.Common/AnalyzerReleases.Unshipped.md
+++ b/GFramework.SourceGenerators.Common/AnalyzerReleases.Unshipped.md
@@ -1,9 +1,10 @@
; Unshipped analyzer release
-; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
### New Rules
Rule ID | Category | Severity | Notes
---------------------|-------------------|----------|-------------------
GF_Common_Class_001 | GFramework.Common | Error | CommonDiagnostics
- GF_Common_Trace_001 | GFramework.Trace | Info | CommonDiagnostics
\ No newline at end of file
+ GF_Common_Class_002 | GFramework.Common | Error | CommonDiagnostics
+ GF_Common_Trace_001 | GFramework.Trace | Info | CommonDiagnostics
diff --git a/GFramework.SourceGenerators.Common/Diagnostics/CommonDiagnostics.cs b/GFramework.SourceGenerators.Common/Diagnostics/CommonDiagnostics.cs
index 87cb06c..c48b40e 100644
--- a/GFramework.SourceGenerators.Common/Diagnostics/CommonDiagnostics.cs
+++ b/GFramework.SourceGenerators.Common/Diagnostics/CommonDiagnostics.cs
@@ -1,6 +1,4 @@
-using Microsoft.CodeAnalysis;
-
-namespace GFramework.SourceGenerators.Common.Diagnostics;
+namespace GFramework.SourceGenerators.Common.Diagnostics;
///
/// 提供通用诊断描述符的静态类
@@ -27,6 +25,23 @@ public static class CommonDiagnostics
true
);
+ ///
+ /// 定义生成方法名与用户代码冲突的诊断描述符。
+ ///
+ ///
+ /// 该诊断用于保护生成器保留的方法名,避免用户代码手动声明了相同零参数方法时出现重复成员错误,
+ /// 并使多个生成器可以复用同一条一致的冲突报告规则。
+ ///
+ public static readonly DiagnosticDescriptor GeneratedMethodNameConflict =
+ new(
+ "GF_Common_Class_002",
+ "Generated method name conflicts with an existing member",
+ "Class '{0}' already defines method '{1}()', which conflicts with generated code",
+ "GFramework.Common",
+ DiagnosticSeverity.Error,
+ true
+ );
+
///
/// 定义源代码生成器跟踪信息的诊断描述符
///
diff --git a/GFramework.SourceGenerators.Common/Extensions/GeneratedMethodConflictExtensions.cs b/GFramework.SourceGenerators.Common/Extensions/GeneratedMethodConflictExtensions.cs
new file mode 100644
index 0000000..bdd1ad9
--- /dev/null
+++ b/GFramework.SourceGenerators.Common/Extensions/GeneratedMethodConflictExtensions.cs
@@ -0,0 +1,49 @@
+using GFramework.SourceGenerators.Common.Diagnostics;
+
+namespace GFramework.SourceGenerators.Common.Extensions;
+
+///
+/// 提供生成方法名冲突校验的通用扩展。
+///
+public static class GeneratedMethodConflictExtensions
+{
+ ///
+ /// 检查目标类型上是否已存在与生成器保留方法同名的零参数方法,并在冲突时报告统一诊断。
+ ///
+ /// 待校验的目标类型。
+ /// 源代码生成上下文。
+ /// 当冲突成员缺少源码位置时使用的后备位置。
+ /// 生成器将保留的零参数方法名集合。
+ /// 若发现任一冲突则返回 true。
+ public static bool ReportGeneratedMethodConflicts(
+ this INamedTypeSymbol typeSymbol,
+ SourceProductionContext context,
+ Location fallbackLocation,
+ params string[] generatedMethodNames)
+ {
+ var hasConflict = false;
+
+ foreach (var generatedMethodName in generatedMethodNames.Distinct(StringComparer.Ordinal))
+ {
+ var conflictingMethod = typeSymbol.GetMembers()
+ .OfType()
+ .FirstOrDefault(method =>
+ !method.IsImplicitlyDeclared &&
+ string.Equals(method.Name, generatedMethodName, StringComparison.Ordinal) &&
+ method.Parameters.Length == 0 &&
+ method.TypeParameters.Length == 0);
+
+ if (conflictingMethod is null)
+ continue;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.GeneratedMethodNameConflict,
+ conflictingMethod.Locations.FirstOrDefault() ?? fallbackLocation,
+ typeSymbol.Name,
+ generatedMethodName));
+ hasConflict = true;
+ }
+
+ return hasConflict;
+ }
+}
\ No newline at end of file
diff --git a/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs
index 1bea458..c8fec0f 100644
--- a/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Rule/ContextGetGeneratorTests.cs
@@ -377,6 +377,84 @@ public class ContextGetGeneratorTests
Assert.Pass();
}
+ [Test]
+ public async Task Reports_Diagnostic_When_Generated_Injection_Method_Name_Already_Exists()
+ {
+ var source = """
+ using System;
+ using GFramework.SourceGenerators.Abstractions.Rule;
+
+ namespace GFramework.SourceGenerators.Abstractions.Rule
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false)]
+ public sealed class ContextAwareAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Field, Inherited = false)]
+ public sealed class GetModelAttribute : Attribute { }
+ }
+
+ namespace GFramework.Core.Abstractions.Rule
+ {
+ public interface IContextAware { }
+ }
+
+ 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 GFramework.Core.Extensions
+ {
+ public static class ContextAwareServiceExtensions
+ {
+ public static T GetModel(this object contextAware) => default!;
+ }
+ }
+
+ namespace TestApp
+ {
+ public interface IInventoryModel : GFramework.Core.Abstractions.Model.IModel { }
+
+ [ContextAware]
+ public partial class InventoryPanel
+ {
+ [GetModel]
+ private IInventoryModel _model = null!;
+
+ private void {|#0:__InjectContextBindings_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("InventoryPanel", "__InjectContextBindings_Generated"));
+
+ await test.RunAsync();
+ }
+
[Test]
public async Task Ignores_NonInferable_Const_Field_For_GetAll_Class_Without_Diagnostic()
{
diff --git a/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs b/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
index 1263d11..d6bae00 100644
--- a/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
+++ b/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
@@ -1,7 +1,5 @@
-using System.Text;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
-using GFramework.SourceGenerators.Common.Extensions;
using GFramework.SourceGenerators.Common.Info;
using GFramework.SourceGenerators.Diagnostics;
@@ -220,6 +218,12 @@ public sealed class ContextGetGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, workItem, symbols))
continue;
+ if (workItem.TypeSymbol.ReportGeneratedMethodConflicts(
+ context,
+ GetTypeLocation(workItem),
+ InjectionMethodName))
+ continue;
+
var bindings = CollectBindings(context, workItem, descriptors, symbols);
if (bindings.Count == 0 && workItem.GetAllDeclaration is null)
continue;
From 693cad2adf90637e03b5212de0c4646ac637497c Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 12:09:47 +0800
Subject: [PATCH 5/5] =?UTF-8?q?refactor(generators):=20=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=BF=85=E8=A6=81=E7=9A=84=E5=91=BD=E5=90=8D?=
=?UTF-8?q?=E7=A9=BA=E9=97=B4=E5=BC=95=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 BindNodeSignalDiagnostics.cs 中添加 Microsoft.CodeAnalysis 引用
- 在 BindNodeSignalGenerator.cs 中添加 Roslyn 相关命名空间引用
- 在 GetNodeGenerator.cs 中添加 Roslyn 相关命名空间引用
- 在 GlobalUsings.cs 中集中管理全局命名空间引用
- 在 ContextGetGenerator.cs 中添加字符串和扩展方法引用
- 在 CommonDiagnostics.cs 中添加诊断相关命名空间引用
- 在 Common 全局引用文件中统一管理 Microsoft.CodeAnalysis 引用
---
GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs | 3 +++
GFramework.Godot.SourceGenerators/GetNodeGenerator.cs | 3 +++
GFramework.Godot.SourceGenerators/GlobalUsings.cs | 5 ++++-
GFramework.SourceGenerators.Common/GlobalUsings.cs | 3 ++-
GFramework.SourceGenerators/Rule/ContextGetGenerator.cs | 2 ++
5 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
index d768432..610662a 100644
--- a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
+++ b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
@@ -1,7 +1,10 @@
+using System.Collections.Immutable;
using System.Runtime.CompilerServices;
+using System.Text;
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
namespace GFramework.Godot.SourceGenerators;
diff --git a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
index 280171d..6aa3ac8 100644
--- a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
+++ b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
@@ -1,6 +1,9 @@
+using System.Collections.Immutable;
+using System.Text;
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
namespace GFramework.Godot.SourceGenerators;
diff --git a/GFramework.Godot.SourceGenerators/GlobalUsings.cs b/GFramework.Godot.SourceGenerators/GlobalUsings.cs
index 4d27181..0413980 100644
--- a/GFramework.Godot.SourceGenerators/GlobalUsings.cs
+++ b/GFramework.Godot.SourceGenerators/GlobalUsings.cs
@@ -15,4 +15,7 @@ global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
-global using System.Threading.Tasks;
\ No newline at end of file
+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
diff --git a/GFramework.SourceGenerators.Common/GlobalUsings.cs b/GFramework.SourceGenerators.Common/GlobalUsings.cs
index 4d27181..cd9579a 100644
--- a/GFramework.SourceGenerators.Common/GlobalUsings.cs
+++ b/GFramework.SourceGenerators.Common/GlobalUsings.cs
@@ -15,4 +15,5 @@ global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
-global using System.Threading.Tasks;
\ No newline at end of file
+global using System.Threading.Tasks;
+global using Microsoft.CodeAnalysis;
\ No newline at end of file
diff --git a/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs b/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
index d6bae00..c6261f4 100644
--- a/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
+++ b/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
@@ -1,5 +1,7 @@
+using System.Text;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
using GFramework.SourceGenerators.Common.Info;
using GFramework.SourceGenerators.Diagnostics;