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