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] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0=20BindNod?= =?UTF-8?q?eSignal=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