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..7f71f21
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/BindNodeSignal/BindNodeSignalGeneratorTests.cs
@@ -0,0 +1,629 @@
+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_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_Common_Class_002", DiagnosticSeverity.Error)
+ .WithLocation(0)
+ .WithArguments("Hud", "__BindNodeSignals_Generated"));
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error)
+ .WithLocation(1)
+ .WithArguments("Hud", "__UnbindNodeSignals_Generated"));
+
+ 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.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 a78788c..c4a2930 100644
--- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -3,11 +3,21 @@
### 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
+ GF_Godot_BindNodeSignal_010 | GFramework.Godot | Error | BindNodeSignalDiagnostics
diff --git a/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
new file mode 100644
index 0000000..610662a
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/BindNodeSignalGenerator.cs
@@ -0,0 +1,611 @@
+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;
+
+///
+/// 为带有 [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 => IsBindNodeSignalAttributeName(attribute.Name));
+ }
+
+ 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 methodAttributes = candidates
+ .Where(static candidate => candidate is not null)
+ .Select(static candidate => candidate!)
+ .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))
+ {
+ var typeSymbol = group.TypeSymbol;
+ if (!CanGenerateForType(context, group, typeSymbol))
+ continue;
+
+ if (typeSymbol.ReportGeneratedMethodConflicts(
+ context,
+ group.Methods[0].Method.Identifier.GetLocation(),
+ BindMethodName,
+ UnbindMethodName))
+ continue;
+
+ var bindings = new List();
+
+ foreach (var candidate in group.Methods)
+ {
+ foreach (var attribute in methodAttributes[candidate])
+ {
+ 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;
+ }
+
+ 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)
+ {
+ 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 bool TryResolveCtorString(
+ AttributeData attribute,
+ int index,
+ out string value)
+ {
+ value = 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(
+ 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 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)
+ {
+ 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; }
+ }
+
+ ///
+ /// 使用引用相等比较 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 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
new file mode 100644
index 0000000..95f4b2d
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Diagnostics/BindNodeSignalDiagnostics.cs
@@ -0,0 +1,129 @@
+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);
+
+ ///
+ /// 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);
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
index 1ac7e80..6aa3ac8 100644
--- a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
+++ b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
@@ -4,9 +4,6 @@ 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 +92,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.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.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()`。
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.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.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..c6261f4 100644
--- a/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
+++ b/GFramework.SourceGenerators/Rule/ContextGetGenerator.cs
@@ -220,6 +220,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;