feat(Godot.SourceGenerators): 添加 BindNodeSignal 源代码生成器

- 实现 BindNodeSignalGenerator 源代码生成器,用于自动化节点信号绑定
- 添加完整的诊断系统,包含 11 种不同的错误和警告场景检测
- 生成对称的绑定和解绑方法,确保资源正确释放
- 支持一个处理方法通过多个特性绑定到多个节点事件
- 实现生命周期钩子调用检查,确保在 _Ready 和 _ExitTree 中正确调用生成的方法
- 提供详细的单元测试覆盖各种使用场景和边界条件
- 生成器与现有的 GetNode 声明完全兼容并可共存
- 包含命名冲突检测和构造参数验证等安全检查机制
This commit is contained in:
GeWuYou 2026-03-31 10:26:44 +08:00
parent 9cca190aff
commit 2dfd6e044f
4 changed files with 309 additions and 11 deletions

View File

@ -375,6 +375,168 @@ public class BindNodeSignalGeneratorTests
await test.RunAsync(); await test.RunAsync();
} }
/// <summary>
/// 验证特性构造参数为空时会报告明确的参数无效诊断。
/// </summary>
[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<BindNodeSignalGenerator, DefaultVerifier>
{
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();
}
/// <summary>
/// 验证当用户自定义了与生成方法同名的成员时,会报告冲突而不是生成重复成员。
/// </summary>
[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<BindNodeSignalGenerator, DefaultVerifier>
{
TestState =
{
Sources = { source }
},
DisabledDiagnostics = { "GF_Common_Trace_001" },
TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
};
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error)
.WithLocation(0)
.WithArguments("Hud", "__BindNodeSignals_Generated"));
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error)
.WithLocation(1)
.WithArguments("Hud", "__UnbindNodeSignals_Generated"));
await test.RunAsync();
}
/// <summary> /// <summary>
/// 验证已有生命周期方法但未调用生成方法时会报告对称的警告。 /// 验证已有生命周期方法但未调用生成方法时会报告对称的警告。
/// </summary> /// </summary>

View File

@ -20,3 +20,5 @@
GF_Godot_BindNodeSignal_007 | GFramework.Godot | Error | BindNodeSignalDiagnostics GF_Godot_BindNodeSignal_007 | GFramework.Godot | Error | BindNodeSignalDiagnostics
GF_Godot_BindNodeSignal_008 | GFramework.Godot | Warning | BindNodeSignalDiagnostics GF_Godot_BindNodeSignal_008 | GFramework.Godot | Warning | BindNodeSignalDiagnostics
GF_Godot_BindNodeSignal_009 | GFramework.Godot | Warning | BindNodeSignalDiagnostics GF_Godot_BindNodeSignal_009 | GFramework.Godot | Warning | BindNodeSignalDiagnostics
GF_Godot_BindNodeSignal_010 | GFramework.Godot | Error | BindNodeSignalDiagnostics
GF_Godot_BindNodeSignal_011 | GFramework.Godot | Error | BindNodeSignalDiagnostics

View File

@ -40,7 +40,7 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
return methodDeclaration.AttributeLists return methodDeclaration.AttributeLists
.SelectMany(static list => list.Attributes) .SelectMany(static list => list.Attributes)
.Any(static attribute => attribute.Name.ToString().Contains("BindNodeSignal", StringComparison.Ordinal)); .Any(static attribute => IsBindNodeSignalAttributeName(attribute.Name));
} }
private static MethodCandidate? Transform(GeneratorSyntaxContext context) private static MethodCandidate? Transform(GeneratorSyntaxContext context)
@ -68,10 +68,18 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
if (bindNodeSignalAttribute is null || godotNodeSymbol is null) if (bindNodeSignalAttribute is null || godotNodeSymbol is null)
return; return;
var methodCandidates = candidates // 缓存每个方法上已解析的特性,避免在筛选和生成阶段重复做语义查询。
var methodAttributes = candidates
.Where(static candidate => candidate is not null) .Where(static candidate => candidate is not null)
.Select(static candidate => candidate!) .Select(static candidate => candidate!)
.Where(candidate => ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute).Count > 0) .ToDictionary(
static candidate => candidate,
candidate => ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute),
ReferenceEqualityComparer.Instance);
var methodCandidates = methodAttributes
.Where(static pair => pair.Value.Count > 0)
.Select(static pair => pair.Key)
.ToList(); .ToList();
foreach (var group in GroupByContainingType(methodCandidates)) foreach (var group in GroupByContainingType(methodCandidates))
@ -80,11 +88,14 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, group, typeSymbol)) if (!CanGenerateForType(context, group, typeSymbol))
continue; continue;
if (HasGeneratedMethodNameConflict(context, group, typeSymbol))
continue;
var bindings = new List<SignalBindingInfo>(); var bindings = new List<SignalBindingInfo>();
foreach (var candidate in group.Methods) foreach (var candidate in group.Methods)
{ {
foreach (var attribute in ResolveAttributes(candidate.MethodSymbol, bindNodeSignalAttribute)) foreach (var attribute in methodAttributes[candidate])
{ {
if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding)) if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding))
continue; continue;
@ -161,8 +172,29 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
return false; return false;
} }
var nodeFieldName = ResolveCtorString(attribute, 0); if (!TryResolveCtorString(attribute, 0, out var nodeFieldName))
var signalName = ResolveCtorString(attribute, 1); {
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); var fieldSymbol = FindField(candidate.MethodSymbol.ContainingType, nodeFieldName);
if (fieldSymbol is null) if (fieldSymbol is null)
@ -244,14 +276,25 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
context.ReportDiagnostic(Diagnostic.Create(descriptor, location, messageArgs)); context.ReportDiagnostic(Diagnostic.Create(descriptor, location, messageArgs));
} }
private static string ResolveCtorString( private static bool TryResolveCtorString(
AttributeData attribute, AttributeData attribute,
int index) int index,
out string value)
{ {
if (attribute.ConstructorArguments.Length <= index) value = string.Empty;
return string.Empty;
return attribute.ConstructorArguments[index].Value as string ?? string.Empty; if (attribute.ConstructorArguments.Length <= index)
return false;
var ctorArgument = attribute.ConstructorArguments[index];
if (ctorArgument.Kind != TypedConstantKind.Primitive || ctorArgument.Value is not string ctorString)
return false;
if (string.IsNullOrWhiteSpace(ctorString))
return false;
value = ctorString;
return true;
} }
private static IReadOnlyList<AttributeData> ResolveAttributes( private static IReadOnlyList<AttributeData> ResolveAttributes(
@ -347,6 +390,36 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
typeSymbol.Name)); typeSymbol.Name));
} }
private static bool HasGeneratedMethodNameConflict(
SourceProductionContext context,
TypeGroup group,
INamedTypeSymbol typeSymbol)
{
var hasConflict = false;
foreach (var generatedMethodName in new[] { BindMethodName, UnbindMethodName })
{
var conflictingMethod = typeSymbol.GetMembers()
.OfType<IMethodSymbol>()
.FirstOrDefault(method =>
method.Name == generatedMethodName &&
method.Parameters.Length == 0 &&
method.TypeParameters.Length == 0);
if (conflictingMethod is null)
continue;
context.ReportDiagnostic(Diagnostic.Create(
BindNodeSignalDiagnostics.GeneratedMethodNameConflict,
conflictingMethod.Locations.FirstOrDefault() ?? group.Methods[0].Method.Identifier.GetLocation(),
typeSymbol.Name,
generatedMethodName));
hasConflict = true;
}
return hasConflict;
}
private static IMethodSymbol? FindLifecycleMethod( private static IMethodSymbol? FindLifecycleMethod(
INamedTypeSymbol typeSymbol, INamedTypeSymbol typeSymbol,
string methodName) string methodName)
@ -396,6 +469,23 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
}; };
} }
private static bool IsBindNodeSignalAttributeName(NameSyntax attributeName)
{
var simpleName = GetAttributeSimpleName(attributeName);
return simpleName is "BindNodeSignal" or "BindNodeSignalAttribute";
}
private static string? GetAttributeSimpleName(NameSyntax attributeName)
{
return attributeName switch
{
IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText,
QualifiedNameSyntax qualifiedName => GetAttributeSimpleName(qualifiedName.Right),
AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText,
_ => null
};
}
private static string GenerateSource( private static string GenerateSource(
INamedTypeSymbol typeSymbol, INamedTypeSymbol typeSymbol,
IReadOnlyList<SignalBindingInfo> bindings) IReadOnlyList<SignalBindingInfo> bindings)
@ -520,4 +610,24 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
public List<MethodCandidate> Methods { get; } public List<MethodCandidate> Methods { get; }
} }
/// <summary>
/// 使用引用相等比较 MethodCandidate确保缓存字典复用同一语法候选对象。
/// </summary>
private sealed class ReferenceEqualityComparer : IEqualityComparer<MethodCandidate>
{
public static ReferenceEqualityComparer Instance { get; } = new();
public bool Equals(
MethodCandidate? x,
MethodCandidate? y)
{
return ReferenceEquals(x, y);
}
public int GetHashCode(MethodCandidate obj)
{
return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
}
}
} }

View File

@ -114,4 +114,28 @@ public static class BindNodeSignalDiagnostics
PathContests.GodotNamespace, PathContests.GodotNamespace,
DiagnosticSeverity.Warning, DiagnosticSeverity.Warning,
true); true);
/// <summary>
/// BindNodeSignalAttribute 构造参数无效。
/// </summary>
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);
/// <summary>
/// 用户代码中已存在与生成方法同名的成员。
/// </summary>
public static readonly DiagnosticDescriptor GeneratedMethodNameConflict =
new(
"GF_Godot_BindNodeSignal_011",
"Generated method name conflicts with an existing member",
"Class '{0}' already defines method '{1}()', which conflicts with [BindNodeSignal] generated code",
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
} }