From 9ab09cf47bc0f339fecca22915a475f923f1dce0 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:16:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(godot):=20=E6=B7=BB=E5=8A=A0=20GetNode?= =?UTF-8?q?=20=E6=BA=90=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了 [GetNode] 属性用于标记 Godot 节点字段 - 创建了 GetNodeGenerator 源代码生成器自动注入节点获取逻辑 - 添加了节点路径推导和多种查找模式支持 - 集成了生成器到 Godot 脚手架模板中 - 添加了完整的诊断规则和错误提示 - 创建了单元测试验证生成器功能 - 更新了解决方案配置以包含新的测试项目 - 在 README 中添加了详细的使用文档和示例代码 --- .../GetNodeAttribute.cs | 40 ++ .../NodeLookupMode.cs | 28 + .../Core/GeneratorTest.cs | 37 ++ ...mework.Godot.SourceGenerators.Tests.csproj | 27 + .../GetNode/GetNodeGeneratorTests.cs | 243 ++++++++ .../GlobalUsings.cs | 18 + .../AnalyzerReleases.Shipped.md | 3 + .../AnalyzerReleases.Unshipped.md | 13 + .../GFramework.Godot.SourceGenerators.csproj | 4 - .../GetNodeGenerator.cs | 554 ++++++++++++++++++ GFramework.Godot.SourceGenerators/README.md | 32 + .../diagnostics/GetNodeDiagnostics.cs | 82 +++ GFramework.csproj | 3 + GFramework.sln | 14 + .../Node/ControllerTemplate.cs | 11 +- .../Node/PageControllerTemplate.cs | 13 +- 16 files changed, 1115 insertions(+), 7 deletions(-) create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/GetNodeAttribute.cs create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/NodeLookupMode.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Core/GeneratorTest.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj create mode 100644 GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs create mode 100644 GFramework.Godot.SourceGenerators/AnalyzerReleases.Shipped.md create mode 100644 GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md create mode 100644 GFramework.Godot.SourceGenerators/GetNodeGenerator.cs create mode 100644 GFramework.Godot.SourceGenerators/diagnostics/GetNodeDiagnostics.cs diff --git a/GFramework.Godot.SourceGenerators.Abstractions/GetNodeAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/GetNodeAttribute.cs new file mode 100644 index 0000000..cbbb51a --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Abstractions/GetNodeAttribute.cs @@ -0,0 +1,40 @@ +#nullable enable +namespace GFramework.Godot.SourceGenerators.Abstractions; + +/// +/// 标记 Godot 节点字段,Source Generator 会为其生成节点获取逻辑。 +/// +[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] +public sealed class GetNodeAttribute : Attribute +{ + /// + /// 初始化 的新实例。 + /// + public GetNodeAttribute() + { + } + + /// + /// 初始化 的新实例,并指定节点路径。 + /// + /// 节点路径。 + public GetNodeAttribute(string path) + { + Path = path; + } + + /// + /// 获取或设置节点路径。未设置时将根据字段名推导。 + /// + public string? Path { get; set; } + + /// + /// 获取或设置节点是否必填。默认为 true。 + /// + public bool Required { get; set; } = true; + + /// + /// 获取或设置节点查找模式。默认为 。 + /// + public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto; +} \ No newline at end of file diff --git a/GFramework.Godot.SourceGenerators.Abstractions/NodeLookupMode.cs b/GFramework.Godot.SourceGenerators.Abstractions/NodeLookupMode.cs new file mode 100644 index 0000000..f78a634 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Abstractions/NodeLookupMode.cs @@ -0,0 +1,28 @@ +#nullable enable +namespace GFramework.Godot.SourceGenerators.Abstractions; + +/// +/// 节点路径的查找模式。 +/// +public enum NodeLookupMode +{ + /// + /// 自动推断。未显式设置路径时默认按唯一名查找。 + /// + Auto = 0, + + /// + /// 按唯一名查找,对应 Godot 的 %Name 语法。 + /// + UniqueName = 1, + + /// + /// 按相对路径查找。 + /// + RelativePath = 2, + + /// + /// 按绝对路径查找。 + /// + AbsolutePath = 3 +} \ No newline at end of file diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/GeneratorTest.cs b/GFramework.Godot.SourceGenerators.Tests/Core/GeneratorTest.cs new file mode 100644 index 0000000..8e98e31 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Core/GeneratorTest.cs @@ -0,0 +1,37 @@ +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; + +namespace GFramework.Godot.SourceGenerators.Tests.Core; + +/// +/// 提供源代码生成器测试的通用功能。 +/// +/// 要测试的源代码生成器类型,必须具有无参构造函数。 +public static class GeneratorTest + where TGenerator : new() +{ + /// + /// 运行源代码生成器测试。 + /// + /// 输入源代码。 + /// 期望生成的源文件集合。 + public static async Task RunAsync( + string source, + params (string filename, string content)[] generatedSources) + { + var test = new CSharpSourceGeneratorTest + { + TestState = + { + Sources = { source } + }, + DisabledDiagnostics = { "GF_Common_Trace_001" } + }; + + foreach (var (filename, content) in generatedSources) + test.TestState.GeneratedSources.Add( + (typeof(TGenerator), filename, content)); + + await test.RunAsync(); + } +} \ No newline at end of file diff --git a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj new file mode 100644 index 0000000..541c8b8 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + $(TestTargetFrameworks) + disable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs new file mode 100644 index 0000000..b0df01f --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs @@ -0,0 +1,243 @@ +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; + +[TestFixture] +public class GetNodeGeneratorTests +{ + [Test] + public async Task Generates_InferredUniqueNameBindings_And_ReadyHook_WhenReadyIsMissing() + { + 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 GetNodeAttribute(string path) { Path = path; } + public string? Path { get; set; } + public bool Required { get; set; } = true; + public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto; + } + + public enum NodeLookupMode + { + Auto = 0, + UniqueName = 1, + RelativePath = 2, + AbsolutePath = 3 + } + } + + 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!; + + [GetNode] + private HBoxContainer m_rightContainer = null!; + } + } + """; + + const string expected = """ + // + #nullable enable + + namespace TestApp; + + partial class TopBar + { + private void __InjectGetNodes_Generated() + { + _leftContainer = GetNode("%LeftContainer"); + m_rightContainer = GetNode("%RightContainer"); + } + + partial void OnGetNodeReadyGenerated(); + + public override void _Ready() + { + __InjectGetNodes_Generated(); + OnGetNodeReadyGenerated(); + } + } + + """; + + await GeneratorTest.RunAsync( + source, + ("TestApp_TopBar.GetNode.g.cs", expected)); + } + + [Test] + public async Task Generates_ManualInjectionOnly_WhenReadyAlreadyExists() + { + 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 GetNodeAttribute(string path) { Path = path; } + public string? Path { get; set; } + public bool Required { get; set; } = true; + public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto; + } + + public enum NodeLookupMode + { + Auto = 0, + UniqueName = 1, + RelativePath = 2, + AbsolutePath = 3 + } + } + + 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("%LeftContainer")] + private HBoxContainer _leftContainer = null!; + + [GetNode(Required = false, Lookup = NodeLookupMode.RelativePath)] + private HBoxContainer? _rightContainer; + + public override void _Ready() + { + __InjectGetNodes_Generated(); + } + } + } + """; + + const string expected = """ + // + #nullable enable + + namespace TestApp; + + partial class TopBar + { + private void __InjectGetNodes_Generated() + { + _leftContainer = GetNode("%LeftContainer"); + _rightContainer = GetNodeOrNull("RightContainer"); + } + } + + """; + + await GeneratorTest.RunAsync( + source, + ("TestApp_TopBar.GetNode.g.cs", expected)); + } + + [Test] + public async Task Reports_Diagnostic_When_FieldType_IsNotGodotNode() + { + 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 string? Path { get; set; } + public bool Required { get; set; } = true; + public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto; + } + + public enum NodeLookupMode + { + Auto = 0, + UniqueName = 1, + RelativePath = 2, + AbsolutePath = 3 + } + } + + 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; + } + } + + namespace TestApp + { + public partial class TopBar : Node + { + [GetNode] + private string _leftContainer = string.Empty; + } + } + """; + + var test = new CSharpSourceGeneratorTest + { + TestState = + { + Sources = { source } + }, + DisabledDiagnostics = { "GF_Common_Trace_001" } + }; + + test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_GetNode_004", DiagnosticSeverity.Error) + .WithSpan(39, 24, 39, 38) + .WithArguments("_leftContainer")); + + await test.RunAsync(); + } +} \ No newline at end of file diff --git a/GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs b/GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs new file mode 100644 index 0000000..4d27181 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2025 GeWuYou +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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 diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Shipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..2080dcd --- /dev/null +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..a78788c --- /dev/null +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,13 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### 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 diff --git a/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj b/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj index c62c5dd..38e251d 100644 --- a/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj +++ b/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj @@ -63,8 +63,4 @@ - - - - diff --git a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs new file mode 100644 index 0000000..5133b4f --- /dev/null +++ b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs @@ -0,0 +1,554 @@ +using System.Collections.Immutable; +using System.Text; +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; + +/// +/// 为带有 [GetNode] 的字段生成 Godot 节点获取逻辑。 +/// +[Generator] +public sealed class GetNodeGenerator : IIncrementalGenerator +{ + private const string GodotAbsolutePathPrefix = "/"; + private const string GodotUniqueNamePrefix = "%"; + + private const string GetNodeAttributeMetadataName = + $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.GetNodeAttribute"; + + private const string GetNodeLookupModeMetadataName = + $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.NodeLookupMode"; + + private const string InjectionMethodName = "__InjectGetNodes_Generated"; + private const string ReadyHookMethodName = "OnGetNodeReadyGenerated"; + + 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 VariableDeclaratorSyntax + { + Parent: VariableDeclarationSyntax + { + Parent: FieldDeclarationSyntax fieldDeclaration + } + }) + return false; + + return fieldDeclaration.AttributeLists + .SelectMany(static list => list.Attributes) + .Any(static attribute => attribute.Name.ToString().Contains("GetNode", StringComparison.Ordinal)); + } + + private static FieldCandidate? Transform(GeneratorSyntaxContext context) + { + if (context.Node is not VariableDeclaratorSyntax variable) + return null; + + if (ModelExtensions.GetDeclaredSymbol(context.SemanticModel, variable) is not IFieldSymbol fieldSymbol) + return null; + + return new FieldCandidate(variable, fieldSymbol); + } + + private static void Execute( + SourceProductionContext context, + Compilation compilation, + ImmutableArray candidates) + { + if (candidates.IsDefaultOrEmpty) + return; + + var getNodeAttribute = compilation.GetTypeByMetadataName(GetNodeAttributeMetadataName); + var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node"); + + if (getNodeAttribute is null || godotNodeSymbol is null) + return; + + var fieldCandidates = candidates + .Where(static candidate => candidate is not null) + .Select(static candidate => candidate!) + .Where(candidate => ResolveAttribute(candidate.FieldSymbol, getNodeAttribute) is not null) + .ToList(); + + foreach (var group in GroupByContainingType(fieldCandidates)) + { + var typeSymbol = group.TypeSymbol; + + if (!CanGenerateForType(context, group, typeSymbol)) + continue; + + var bindings = new List(); + + foreach (var candidate in group.Fields) + { + var attribute = ResolveAttribute(candidate.FieldSymbol, getNodeAttribute); + if (attribute is null) + continue; + + if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding)) + continue; + + bindings.Add(binding); + } + + if (bindings.Count == 0) + continue; + + ReportMissingReadyHookCall(context, group, typeSymbol); + + var source = GenerateSource(typeSymbol, bindings, FindReadyMethod(typeSymbol) is null); + context.AddSource(GetHintName(typeSymbol), source); + } + } + + private static bool CanGenerateForType( + SourceProductionContext context, + TypeGroup group, + INamedTypeSymbol typeSymbol) + { + if (typeSymbol.ContainingType is not null) + { + context.ReportDiagnostic(Diagnostic.Create( + GetNodeDiagnostics.NestedClassNotSupported, + group.Fields[0].Variable.Identifier.GetLocation(), + typeSymbol.Name)); + return false; + } + + if (IsPartial(typeSymbol)) + return true; + + context.ReportDiagnostic(Diagnostic.Create( + CommonDiagnostics.ClassMustBePartial, + group.Fields[0].Variable.Identifier.GetLocation(), + typeSymbol.Name)); + + return false; + } + + private static bool TryCreateBinding( + SourceProductionContext context, + FieldCandidate candidate, + AttributeData attribute, + INamedTypeSymbol godotNodeSymbol, + out NodeBindingInfo binding) + { + binding = default!; + + if (candidate.FieldSymbol.IsStatic) + { + ReportFieldDiagnostic(context, + GetNodeDiagnostics.StaticFieldNotSupported, + candidate); + return false; + } + + if (candidate.FieldSymbol.IsReadOnly) + { + ReportFieldDiagnostic(context, + GetNodeDiagnostics.ReadOnlyFieldNotSupported, + candidate); + return false; + } + + if (!IsGodotNodeType(candidate.FieldSymbol.Type, godotNodeSymbol)) + { + ReportFieldDiagnostic(context, + GetNodeDiagnostics.FieldTypeMustDeriveFromNode, + candidate); + return false; + } + + if (!TryResolvePath(candidate.FieldSymbol, attribute, out var path)) + { + ReportFieldDiagnostic(context, + GetNodeDiagnostics.CannotInferNodePath, + candidate); + return false; + } + + binding = new NodeBindingInfo( + candidate.FieldSymbol, + path, + ResolveRequired(attribute)); + + return true; + } + + private static void ReportFieldDiagnostic( + SourceProductionContext context, + DiagnosticDescriptor descriptor, + FieldCandidate candidate) + { + context.ReportDiagnostic(Diagnostic.Create( + descriptor, + candidate.Variable.Identifier.GetLocation(), + candidate.FieldSymbol.Name)); + } + + private static void ReportMissingReadyHookCall( + SourceProductionContext context, + TypeGroup group, + INamedTypeSymbol typeSymbol) + { + var readyMethod = FindReadyMethod(typeSymbol); + if (readyMethod is null || CallsGeneratedInjection(readyMethod)) + return; + + context.ReportDiagnostic(Diagnostic.Create( + GetNodeDiagnostics.ManualReadyHookRequired, + readyMethod.Locations.FirstOrDefault() ?? group.Fields[0].Variable.Identifier.GetLocation(), + typeSymbol.Name)); + } + + private static AttributeData? ResolveAttribute( + IFieldSymbol fieldSymbol, + INamedTypeSymbol getNodeAttribute) + { + return fieldSymbol.GetAttributes() + .FirstOrDefault(attribute => + SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, getNodeAttribute)); + } + + private static bool IsPartial(INamedTypeSymbol typeSymbol) + { + return typeSymbol.DeclaringSyntaxReferences + .Select(static reference => reference.GetSyntax()) + .OfType() + .All(static declaration => + declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword))); + } + + private static bool IsGodotNodeType(ITypeSymbol typeSymbol, INamedTypeSymbol godotNodeSymbol) + { + var current = typeSymbol as INamedTypeSymbol; + while (current is not null) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, godotNodeSymbol) || + SymbolEqualityComparer.Default.Equals(current, godotNodeSymbol)) + return true; + + current = current.BaseType; + } + + return false; + } + + private static IMethodSymbol? FindReadyMethod(INamedTypeSymbol typeSymbol) + { + return typeSymbol.GetMembers() + .OfType() + .FirstOrDefault(static method => + method.Name == "_Ready" && + !method.IsStatic && + method.Parameters.Length == 0 && + method.MethodKind == MethodKind.Ordinary); + } + + private static bool CallsGeneratedInjection(IMethodSymbol readyMethod) + { + foreach (var syntaxReference in readyMethod.DeclaringSyntaxReferences) + { + if (syntaxReference.GetSyntax() is not MethodDeclarationSyntax methodSyntax) + continue; + + var bodyText = methodSyntax.Body?.ToString(); + if (!string.IsNullOrEmpty(bodyText) && + bodyText.Contains(InjectionMethodName, StringComparison.Ordinal)) + return true; + + var expressionBodyText = methodSyntax.ExpressionBody?.ToString(); + if (!string.IsNullOrEmpty(expressionBodyText) && + expressionBodyText.Contains(InjectionMethodName, StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static bool ResolveRequired(AttributeData attribute) + { + return attribute.GetNamedArgument("Required", true); + } + + private static bool TryResolvePath( + IFieldSymbol fieldSymbol, + AttributeData attribute, + out string path) + { + var explicitPath = ResolveExplicitPath(attribute); + if (!string.IsNullOrWhiteSpace(explicitPath)) + return ReturnResolvedPath(explicitPath!, out path); + + var inferredName = InferNodeName(fieldSymbol.Name); + if (string.IsNullOrWhiteSpace(inferredName)) + { + path = string.Empty; + return false; + } + + var resolvedName = inferredName!; + return TryResolveInferredPath(attribute, resolvedName, out path); + } + + private static bool ReturnResolvedPath(string resolvedPath, out string path) + { + path = resolvedPath; + return true; + } + + private static bool TryResolveInferredPath( + AttributeData attribute, + string inferredName, + out string path) + { + path = BuildPathPrefix(ResolveLookup(attribute)) + inferredName; + return true; + } + + private static string BuildPathPrefix(NodeLookupModeValue lookupMode) + { + switch (lookupMode) + { + case NodeLookupModeValue.RelativePath: + return string.Empty; + case NodeLookupModeValue.AbsolutePath: + return GodotAbsolutePathPrefix; + default: + return GodotUniqueNamePrefix; + } + } + + private static string? ResolveExplicitPath(AttributeData attribute) + { + var namedPath = attribute.GetNamedArgument("Path"); + if (!string.IsNullOrWhiteSpace(namedPath)) + return namedPath; + + if (attribute.ConstructorArguments.Length == 0) + return null; + + return attribute.ConstructorArguments[0].Value as string; + } + + private static NodeLookupModeValue ResolveLookup(AttributeData attribute) + { + foreach (var namedArgument in attribute.NamedArguments) + { + if (!string.Equals(namedArgument.Key, "Lookup", StringComparison.Ordinal)) + continue; + + if (namedArgument.Value.Type?.ToDisplayString() != GetNodeLookupModeMetadataName) + continue; + + if (namedArgument.Value.Value is int value) + return (NodeLookupModeValue)value; + } + + return NodeLookupModeValue.Auto; + } + + private static string? InferNodeName(string fieldName) + { + var workingName = fieldName.TrimStart('_'); + if (workingName.StartsWith("m_", StringComparison.OrdinalIgnoreCase)) + workingName = workingName.Substring(2); + + workingName = workingName.TrimStart('_'); + if (string.IsNullOrWhiteSpace(workingName)) + return null; + + if (workingName.IndexOfAny(['_', '-', ' ']) >= 0) + { + var parts = workingName + .Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries); + + return parts.Length == 0 + ? null + : string.Concat(parts.Select(ToPascalToken)); + } + + return ToPascalToken(workingName); + } + + private static string ToPascalToken(string token) + { + if (string.IsNullOrEmpty(token)) + return token; + + if (token.Length == 1) + return token.ToUpperInvariant(); + + return char.ToUpperInvariant(token[0]) + token.Substring(1); + } + + private static string GenerateSource( + INamedTypeSymbol typeSymbol, + IReadOnlyList bindings, + bool generateReadyOverride) + { + 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 {InjectionMethodName}()") + .AppendLine(" {"); + + foreach (var binding in bindings) + { + var typeName = binding.FieldSymbol.Type + .WithNullableAnnotation(NullableAnnotation.None) + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var accessor = binding.Required ? "GetNode" : "GetNodeOrNull"; + var pathLiteral = EscapeStringLiteral(binding.Path); + sb.AppendLine( + $" {binding.FieldSymbol.Name} = {accessor}<{typeName}>(\"{pathLiteral}\");"); + } + + sb.AppendLine(" }"); + + if (generateReadyOverride) + { + sb.AppendLine() + .AppendLine($" partial void {ReadyHookMethodName}();") + .AppendLine() + .AppendLine(" public override void _Ready()") + .AppendLine(" {") + .AppendLine($" {InjectionMethodName}();") + .AppendLine($" {ReadyHookMethodName}();") + .AppendLine(" }"); + } + + sb.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(".", "_") + ".GetNode.g.cs"; + } + + private static string EscapeStringLiteral(string value) + { + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\""); + } + + private static IReadOnlyList GroupByContainingType(IEnumerable candidates) + { + var groups = new List(); + + foreach (var candidate in candidates) + { + var group = groups.FirstOrDefault(existing => + SymbolEqualityComparer.Default.Equals(existing.TypeSymbol, candidate.FieldSymbol.ContainingType)); + + if (group is null) + { + group = new TypeGroup(candidate.FieldSymbol.ContainingType); + groups.Add(group); + } + + group.Fields.Add(candidate); + } + + return groups; + } + + private sealed class FieldCandidate + { + public FieldCandidate( + VariableDeclaratorSyntax variable, + IFieldSymbol fieldSymbol) + { + Variable = variable; + FieldSymbol = fieldSymbol; + } + + public VariableDeclaratorSyntax Variable { get; } + + public IFieldSymbol FieldSymbol { get; } + } + + private sealed class NodeBindingInfo + { + public NodeBindingInfo( + IFieldSymbol fieldSymbol, + string path, + bool required) + { + FieldSymbol = fieldSymbol; + Path = path; + Required = required; + } + + public IFieldSymbol FieldSymbol { get; } + + public string Path { get; } + + public bool Required { get; } + } + + private enum NodeLookupModeValue + { + Auto = 0, + UniqueName = 1, + RelativePath = 2, + AbsolutePath = 3 + } + + private sealed class TypeGroup + { + public TypeGroup(INamedTypeSymbol typeSymbol) + { + TypeSymbol = typeSymbol; + } + + public INamedTypeSymbol TypeSymbol { get; } + + public List Fields { get; } = new(); + } +} \ No newline at end of file diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md index 6da7cb3..291c557 100644 --- a/GFramework.Godot.SourceGenerators/README.md +++ b/GFramework.Godot.SourceGenerators/README.md @@ -6,8 +6,40 @@ - 与 Godot 场景相关的编译期生成能力 - 基于 Roslyn 的增量生成器实现 +- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode()` 样板代码 ## 使用建议 - 仅在 Godot + C# 项目中启用 - 非 Godot 项目可只使用 GFramework.SourceGenerators + +## GetNode 用法 + +```csharp +using GFramework.Godot.SourceGenerators.Abstractions; +using Godot; + +public partial class TopBar : HBoxContainer +{ + [GetNode] + private HBoxContainer _leftContainer = null!; + + [GetNode] + private HBoxContainer _rightContainer = null!; + + public override void _Ready() + { + __InjectGetNodes_Generated(); + OnReadyAfterGetNode(); + } + + private void OnReadyAfterGetNode() + { + } +} +``` + +当未显式填写路径时,生成器会默认将字段名推导为唯一名路径: + +- `_leftContainer` -> `%LeftContainer` +- `m_rightContainer` -> `%RightContainer` diff --git a/GFramework.Godot.SourceGenerators/diagnostics/GetNodeDiagnostics.cs b/GFramework.Godot.SourceGenerators/diagnostics/GetNodeDiagnostics.cs new file mode 100644 index 0000000..14a720d --- /dev/null +++ b/GFramework.Godot.SourceGenerators/diagnostics/GetNodeDiagnostics.cs @@ -0,0 +1,82 @@ +using GFramework.SourceGenerators.Common.Constants; +using Microsoft.CodeAnalysis; + +namespace GFramework.Godot.SourceGenerators.Diagnostics; + +/// +/// GetNode 生成器相关诊断。 +/// +public static class GetNodeDiagnostics +{ + /// + /// 嵌套类型不受支持。 + /// + public static readonly DiagnosticDescriptor NestedClassNotSupported = + new( + "GF_Godot_GetNode_001", + "Nested classes are not supported", + "Class '{0}' cannot use [GetNode] inside a nested type", + PathContests.GodotNamespace, + DiagnosticSeverity.Error, + true); + + /// + /// static 字段不受支持。 + /// + public static readonly DiagnosticDescriptor StaticFieldNotSupported = + new( + "GF_Godot_GetNode_002", + "Static fields are not supported", + "Field '{0}' cannot be static when using [GetNode]", + PathContests.GodotNamespace, + DiagnosticSeverity.Error, + true); + + /// + /// readonly 字段不受支持。 + /// + public static readonly DiagnosticDescriptor ReadOnlyFieldNotSupported = + new( + "GF_Godot_GetNode_003", + "Readonly fields are not supported", + "Field '{0}' cannot be readonly when using [GetNode]", + PathContests.GodotNamespace, + DiagnosticSeverity.Error, + true); + + /// + /// 字段类型必须继承自 Godot.Node。 + /// + public static readonly DiagnosticDescriptor FieldTypeMustDeriveFromNode = + new( + "GF_Godot_GetNode_004", + "Field type must derive from Godot.Node", + "Field '{0}' must be a Godot.Node type to use [GetNode]", + PathContests.GodotNamespace, + DiagnosticSeverity.Error, + true); + + /// + /// 无法从字段名推导路径。 + /// + public static readonly DiagnosticDescriptor CannotInferNodePath = + new( + "GF_Godot_GetNode_005", + "Cannot infer node path", + "Field '{0}' does not provide a path and its name cannot be converted to a node path", + PathContests.GodotNamespace, + DiagnosticSeverity.Error, + true); + + /// + /// 现有 _Ready 中未调用生成注入逻辑。 + /// + public static readonly DiagnosticDescriptor ManualReadyHookRequired = + new( + "GF_Godot_GetNode_006", + "Call generated injection from _Ready", + "Class '{0}' defines _Ready(); call __InjectGetNodes_Generated() there or remove _Ready() to use the generated hook", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); +} \ No newline at end of file diff --git a/GFramework.csproj b/GFramework.csproj index 3d65cb3..1594e9a 100644 --- a/GFramework.csproj +++ b/GFramework.csproj @@ -46,6 +46,7 @@ + @@ -85,6 +86,7 @@ + @@ -110,6 +112,7 @@ + diff --git a/GFramework.sln b/GFramework.sln index 7076c6a..0cef5d4 100644 --- a/GFramework.sln +++ b/GFramework.sln @@ -34,6 +34,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Ecs.Arch.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Game.Tests", "GFramework.Game.Tests\GFramework.Game.Tests.csproj", "{738DC58A-0387-4D75-AA96-1C1D8C29D350}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.SourceGenerators.Tests", "GFramework.Godot.SourceGenerators.Tests\GFramework.Godot.SourceGenerators.Tests.csproj", "{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -248,6 +250,18 @@ Global {738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x64.Build.0 = Release|Any CPU {738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.ActiveCfg = Release|Any CPU {738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.Build.0 = Release|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.Build.0 = Debug|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.Build.0 = Debug|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.Build.0 = Release|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.ActiveCfg = Release|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.Build.0 = Release|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.ActiveCfg = Release|Any CPU + {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Godot/script_templates/Node/ControllerTemplate.cs b/Godot/script_templates/Node/ControllerTemplate.cs index 155deb8..5931e7f 100644 --- a/Godot/script_templates/Node/ControllerTemplate.cs +++ b/Godot/script_templates/Node/ControllerTemplate.cs @@ -2,6 +2,7 @@ // meta-description: 负责管理场景的生命周期和架构关联 using Godot; using GFramework.Core.Abstractions.Controller; +using GFramework.Godot.SourceGenerators.Abstractions; using GFramework.SourceGenerators.Abstractions.Logging; using GFramework.SourceGenerators.Abstractions.Rule; @@ -16,7 +17,15 @@ public partial class _CLASS_ :_BASE_,IController /// public override void _Ready() { - + __InjectGetNodes_Generated(); + OnReadyAfterGetNode(); + } + + /// + /// 节点注入完成后的初始化钩子。 + /// + private void OnReadyAfterGetNode() + { } } diff --git a/Godot/script_templates/Node/PageControllerTemplate.cs b/Godot/script_templates/Node/PageControllerTemplate.cs index 5e9c606..24601e2 100644 --- a/Godot/script_templates/Node/PageControllerTemplate.cs +++ b/Godot/script_templates/Node/PageControllerTemplate.cs @@ -5,6 +5,7 @@ using GFramework.Core.Abstractions.Controller; using GFramework.Core.Extensions; using GFramework.Game.Abstractions.UI; using GFramework.Godot.UI; +using GFramework.Godot.SourceGenerators.Abstractions; using GFramework.SourceGenerators.Abstractions.Logging; using GFramework.SourceGenerators.Abstractions.Rule; @@ -19,7 +20,15 @@ public partial class _CLASS_ :_BASE_,IController,IUiPageBehaviorProvider,IUiPage /// public override void _Ready() { - + __InjectGetNodes_Generated(); + OnReadyAfterGetNode(); + } + + /// + /// 节点注入完成后的初始化钩子。 + /// + private void OnReadyAfterGetNode() + { } /// /// 页面行为实例的私有字段 @@ -84,4 +93,4 @@ public partial class _CLASS_ :_BASE_,IController,IUiPageBehaviorProvider,IUiPage { } -} \ No newline at end of file +} From b95c65a30e50220fea7e82dac51e0ad645042d43 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:23:51 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(generator):=20=E4=BC=98=E5=8C=96Ge?= =?UTF-8?q?tNodeGenerator=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用语法树遍历替代字符串匹配来检测注入方法调用 - 添加IsGeneratedInjectionInvocation辅助方法提高代码可读性 - 将字段分组逻辑从列表查找改为字典映射提升性能 - 优化GroupByContainingType方法的时间复杂度 --- .../GetNodeGenerator.cs | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs index 5133b4f..4518608 100644 --- a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs +++ b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs @@ -270,20 +270,34 @@ public sealed class GetNodeGenerator : IIncrementalGenerator if (syntaxReference.GetSyntax() is not MethodDeclarationSyntax methodSyntax) continue; - var bodyText = methodSyntax.Body?.ToString(); - if (!string.IsNullOrEmpty(bodyText) && - bodyText.Contains(InjectionMethodName, StringComparison.Ordinal)) - return true; - - var expressionBodyText = methodSyntax.ExpressionBody?.ToString(); - if (!string.IsNullOrEmpty(expressionBodyText) && - expressionBodyText.Contains(InjectionMethodName, StringComparison.Ordinal)) + if (methodSyntax.DescendantNodes() + .OfType() + .Any(IsGeneratedInjectionInvocation)) return true; } return false; } + private static bool IsGeneratedInjectionInvocation(InvocationExpressionSyntax invocation) + { + switch (invocation.Expression) + { + case IdentifierNameSyntax identifierName: + return string.Equals( + identifierName.Identifier.ValueText, + InjectionMethodName, + StringComparison.Ordinal); + case MemberAccessExpressionSyntax memberAccess: + return string.Equals( + memberAccess.Name.Identifier.ValueText, + InjectionMethodName, + StringComparison.Ordinal); + default: + return false; + } + } + private static bool ResolveRequired(AttributeData attribute) { return attribute.GetNamedArgument("Required", true); @@ -479,23 +493,23 @@ public sealed class GetNodeGenerator : IIncrementalGenerator private static IReadOnlyList GroupByContainingType(IEnumerable candidates) { - var groups = new List(); + var groupMap = new Dictionary(SymbolEqualityComparer.Default); + var orderedGroups = new List(); foreach (var candidate in candidates) { - var group = groups.FirstOrDefault(existing => - SymbolEqualityComparer.Default.Equals(existing.TypeSymbol, candidate.FieldSymbol.ContainingType)); - - if (group is null) + var typeSymbol = candidate.FieldSymbol.ContainingType; + if (!groupMap.TryGetValue(typeSymbol, out var group)) { - group = new TypeGroup(candidate.FieldSymbol.ContainingType); - groups.Add(group); + group = new TypeGroup(typeSymbol); + groupMap.Add(typeSymbol, group); + orderedGroups.Add(group); } group.Fields.Add(candidate); } - return groups; + return orderedGroups; } private sealed class FieldCandidate From fc386fb4bc4a1462d871e536726970e536aa1342 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:28:39 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(generator):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=96=87=E4=BB=B6=E5=A4=B9=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 logging 文件夹引用 - 将 diagnostics 文件夹重命名为 Diagnostics - 更新项目文件中的文件夹路径配置 --- .../{diagnostics => Diagnostics}/GetNodeDiagnostics.cs | 0 .../GFramework.Godot.SourceGenerators.csproj | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) rename GFramework.Godot.SourceGenerators/{diagnostics => Diagnostics}/GetNodeDiagnostics.cs (100%) diff --git a/GFramework.Godot.SourceGenerators/diagnostics/GetNodeDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/GetNodeDiagnostics.cs similarity index 100% rename from GFramework.Godot.SourceGenerators/diagnostics/GetNodeDiagnostics.cs rename to GFramework.Godot.SourceGenerators/Diagnostics/GetNodeDiagnostics.cs diff --git a/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj b/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj index 38e251d..3934979 100644 --- a/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj +++ b/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj @@ -60,7 +60,6 @@ - - +