feat(generator): 添加 BindNodeSignal 和 GetNode 源代码生成器

- 实现 BindNodeSignalGenerator 用于生成节点信号绑定与解绑逻辑
- 实现 GetNodeGenerator 用于生成 Godot 节点获取注入逻辑
- 添加 BindNodeSignalDiagnostics 提供详细的诊断错误信息
- 集成到 AnalyzerReleases.Unshipped.md 追踪新的分析规则
- 支持 [BindNodeSignal] 属性的方法自动生成事件绑定代码
- 支持 [GetNode] 属性的字段自动生成节点获取代码
- 提供生命周期方法集成的智能提示和验证功能
This commit is contained in:
GeWuYou 2026-03-31 11:11:23 +08:00
parent 2dfd6e044f
commit 6fa4580893
11 changed files with 236 additions and 64 deletions

View File

@ -526,11 +526,11 @@ public class BindNodeSignalGeneratorTests
TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck
}; };
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error) test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error)
.WithLocation(0) .WithLocation(0)
.WithArguments("Hud", "__BindNodeSignals_Generated")); .WithArguments("Hud", "__BindNodeSignals_Generated"));
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_BindNodeSignal_011", DiagnosticSeverity.Error) test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Common_Class_002", DiagnosticSeverity.Error)
.WithLocation(1) .WithLocation(1)
.WithArguments("Hud", "__UnbindNodeSignals_Generated")); .WithArguments("Hud", "__UnbindNodeSignals_Generated"));

View File

@ -1,8 +1,4 @@
using GFramework.Godot.SourceGenerators.Tests.Core; 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; namespace GFramework.Godot.SourceGenerators.Tests.GetNode;
@ -240,4 +236,71 @@ public class GetNodeGeneratorTests
await test.RunAsync(); 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<T>(string path) where T : Node => throw new InvalidOperationException(path);
public T? GetNodeOrNull<T>(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<GetNodeGenerator, DefaultVerifier>
{
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();
}
} }

View File

@ -21,4 +21,3 @@
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_010 | GFramework.Godot | Error | BindNodeSignalDiagnostics
GF_Godot_BindNodeSignal_011 | GFramework.Godot | Error | BindNodeSignalDiagnostics

View File

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using GFramework.Godot.SourceGenerators.Diagnostics; using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants; using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics; using GFramework.SourceGenerators.Common.Diagnostics;
@ -88,7 +89,11 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, group, typeSymbol)) if (!CanGenerateForType(context, group, typeSymbol))
continue; continue;
if (HasGeneratedMethodNameConflict(context, group, typeSymbol)) if (typeSymbol.ReportGeneratedMethodConflicts(
context,
group.Methods[0].Method.Identifier.GetLocation(),
BindMethodName,
UnbindMethodName))
continue; continue;
var bindings = new List<SignalBindingInfo>(); var bindings = new List<SignalBindingInfo>();
@ -390,36 +395,6 @@ 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)
@ -627,7 +602,7 @@ public sealed class BindNodeSignalGenerator : IIncrementalGenerator
public int GetHashCode(MethodCandidate obj) public int GetHashCode(MethodCandidate obj)
{ {
return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); return RuntimeHelpers.GetHashCode(obj);
} }
} }
} }

View File

@ -126,16 +126,4 @@ public static class BindNodeSignalDiagnostics
PathContests.GodotNamespace, PathContests.GodotNamespace,
DiagnosticSeverity.Error, DiagnosticSeverity.Error,
true); 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);
} }

View File

@ -1,12 +1,6 @@
using System.Collections.Immutable;
using System.Text;
using GFramework.Godot.SourceGenerators.Diagnostics; using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants; using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics; 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; namespace GFramework.Godot.SourceGenerators;
@ -95,6 +89,12 @@ public sealed class GetNodeGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, group, typeSymbol)) if (!CanGenerateForType(context, group, typeSymbol))
continue; continue;
if (typeSymbol.ReportGeneratedMethodConflicts(
context,
group.Fields[0].Variable.Identifier.GetLocation(),
InjectionMethodName))
continue;
var bindings = new List<NodeBindingInfo>(); var bindings = new List<NodeBindingInfo>();
foreach (var candidate in group.Fields) foreach (var candidate in group.Fields)

View File

@ -1,9 +1,10 @@
; Unshipped analyzer release ; 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 ### New Rules
Rule ID | Category | Severity | Notes Rule ID | Category | Severity | Notes
---------------------|-------------------|----------|------------------- ---------------------|-------------------|----------|-------------------
GF_Common_Class_001 | GFramework.Common | Error | CommonDiagnostics GF_Common_Class_001 | GFramework.Common | Error | CommonDiagnostics
GF_Common_Class_002 | GFramework.Common | Error | CommonDiagnostics
GF_Common_Trace_001 | GFramework.Trace | Info | CommonDiagnostics GF_Common_Trace_001 | GFramework.Trace | Info | CommonDiagnostics

View File

@ -1,6 +1,4 @@
using Microsoft.CodeAnalysis; namespace GFramework.SourceGenerators.Common.Diagnostics;
namespace GFramework.SourceGenerators.Common.Diagnostics;
/// <summary> /// <summary>
/// 提供通用诊断描述符的静态类 /// 提供通用诊断描述符的静态类
@ -27,6 +25,23 @@ public static class CommonDiagnostics
true true
); );
/// <summary>
/// 定义生成方法名与用户代码冲突的诊断描述符。
/// </summary>
/// <remarks>
/// 该诊断用于保护生成器保留的方法名,避免用户代码手动声明了相同零参数方法时出现重复成员错误,
/// 并使多个生成器可以复用同一条一致的冲突报告规则。
/// </remarks>
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
);
/// <summary> /// <summary>
/// 定义源代码生成器跟踪信息的诊断描述符 /// 定义源代码生成器跟踪信息的诊断描述符
/// </summary> /// </summary>

View File

@ -0,0 +1,49 @@
using GFramework.SourceGenerators.Common.Diagnostics;
namespace GFramework.SourceGenerators.Common.Extensions;
/// <summary>
/// 提供生成方法名冲突校验的通用扩展。
/// </summary>
public static class GeneratedMethodConflictExtensions
{
/// <summary>
/// 检查目标类型上是否已存在与生成器保留方法同名的零参数方法,并在冲突时报告统一诊断。
/// </summary>
/// <param name="typeSymbol">待校验的目标类型。</param>
/// <param name="context">源代码生成上下文。</param>
/// <param name="fallbackLocation">当冲突成员缺少源码位置时使用的后备位置。</param>
/// <param name="generatedMethodNames">生成器将保留的零参数方法名集合。</param>
/// <returns>若发现任一冲突则返回 <c>true</c>。</returns>
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<IMethodSymbol>()
.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;
}
}

View File

@ -377,6 +377,84 @@ public class ContextGetGeneratorTests
Assert.Pass(); 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<T>(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<ContextGetGenerator, DefaultVerifier>
{
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] [Test]
public async Task Ignores_NonInferable_Const_Field_For_GetAll_Class_Without_Diagnostic() public async Task Ignores_NonInferable_Const_Field_For_GetAll_Class_Without_Diagnostic()
{ {

View File

@ -1,7 +1,5 @@
using System.Text;
using GFramework.SourceGenerators.Common.Constants; using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics; using GFramework.SourceGenerators.Common.Diagnostics;
using GFramework.SourceGenerators.Common.Extensions;
using GFramework.SourceGenerators.Common.Info; using GFramework.SourceGenerators.Common.Info;
using GFramework.SourceGenerators.Diagnostics; using GFramework.SourceGenerators.Diagnostics;
@ -220,6 +218,12 @@ public sealed class ContextGetGenerator : IIncrementalGenerator
if (!CanGenerateForType(context, workItem, symbols)) if (!CanGenerateForType(context, workItem, symbols))
continue; continue;
if (workItem.TypeSymbol.ReportGeneratedMethodConflicts(
context,
GetTypeLocation(workItem),
InjectionMethodName))
continue;
var bindings = CollectBindings(context, workItem, descriptors, symbols); var bindings = CollectBindings(context, workItem, descriptors, symbols);
if (bindings.Count == 0 && workItem.GetAllDeclaration is null) if (bindings.Count == 0 && workItem.GetAllDeclaration is null)
continue; continue;