feat(godot): 添加AutoScene和AutoRegisterExportedCollections源代码生成器

- 实现AutoSceneGenerator为标记了[AutoScene]特性的Godot节点生成场景行为样板代码
- 实现AutoRegisterExportedCollectionsGenerator为导出集合生成批量注册样板方法
- 添加AutoBehaviorDiagnostics和AutoRegisterExportedCollectionsDiagnostics诊断描述符
- 创建AnalyzerReleases.Unshipped.md文件跟踪新的分析器规则
- 添加完整的单元测试覆盖两个生成器的功能和错误情况
- 更新.gitignore文件排除dotnet-home和脚本缓存目录
This commit is contained in:
GeWuYou 2026-04-13 10:51:40 +08:00
parent eb307bf188
commit 80acf84e95
8 changed files with 203 additions and 5 deletions

4
.gitignore vendored
View File

@ -5,6 +5,8 @@ riderModule.iml
/_ReSharper.Caches/
GFramework.sln.DotSettings.user
.idea/
dotnet-home/
scripts/__pycache__/
# ai
opencode.json
.claude/settings.local.json
@ -14,4 +16,4 @@ docs/.omc/
docs/.vitepress/cache/
local-plan/
# tool
.venv/
.venv/

View File

@ -82,4 +82,52 @@ public class AutoSceneGeneratorTests
source,
("TestApp_GameplayRoot.AutoScene.g.cs", expected));
}
[Test]
public async Task Reports_Diagnostic_When_AutoScene_Arguments_Are_Invalid()
{
const string source = """
using System;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class AutoSceneAttribute : Attribute
{
public AutoSceneAttribute() { }
}
}
namespace Godot
{
public class Node { }
public class Node2D : Node { }
}
namespace TestApp
{
[{|#0:AutoScene|}]
public partial class GameplayRoot : Node2D
{
}
}
""";
var test = new CSharpSourceGeneratorTest<AutoSceneGenerator, DefaultVerifier>
{
TestState =
{
Sources = { source }
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
};
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoBehavior_004", DiagnosticSeverity.Error)
.WithLocation(0)
.WithArguments("AutoSceneAttribute", "GameplayRoot", "a single string scene key argument"));
await test.RunAsync();
}
}

View File

@ -67,4 +67,58 @@ public class AutoRegisterExportedCollectionsGeneratorTests
source,
("TestApp_Bootstrapper.AutoRegisterExportedCollections.g.cs", expected));
}
[Test]
public async Task Reports_Diagnostic_When_Collection_Element_Type_Cannot_Be_Inferred()
{
const string source = """
using System;
using System.Collections;
using GFramework.Godot.SourceGenerators.Abstractions;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class AutoRegisterExportedCollectionsAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
public sealed class RegisterExportedCollectionAttribute : Attribute
{
public RegisterExportedCollectionAttribute(string registryMemberName, string registerMethodName) { }
}
}
namespace TestApp
{
public sealed class IntRegistry
{
public void Register(int value) { }
}
[AutoRegisterExportedCollections]
public partial class Bootstrapper
{
private readonly IntRegistry _registry = new();
[RegisterExportedCollection(nameof(_registry), nameof(IntRegistry.Register))]
public IEnumerable {|#0:Values|} { get; } = new ArrayList();
}
}
""";
var test = new CSharpSourceGeneratorTest<AutoRegisterExportedCollectionsGenerator, DefaultVerifier>
{
TestState =
{
Sources = { source }
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
};
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_AutoExport_005", DiagnosticSeverity.Error)
.WithLocation(0)
.WithArguments("Values"));
await test.RunAsync();
}
}

View File

@ -24,7 +24,9 @@
GF_AutoBehavior_001 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
GF_AutoBehavior_002 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
GF_AutoBehavior_003 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
GF_AutoBehavior_004 | GFramework.Godot.SourceGenerators.Behavior | Error | AutoBehaviorDiagnostics
GF_AutoExport_001 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_002 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_003 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_004 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_005 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics

View File

@ -1,7 +1,6 @@
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
using GFramework.SourceGenerators.Common.Extensions;
namespace GFramework.Godot.SourceGenerators.Behavior;
@ -79,7 +78,7 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator
continue;
}
if (attribute.ConstructorArguments.Length != 1 || attribute.ConstructorArguments[0].Value is not string key)
if (!TryGetSceneKey(context, candidate.TypeSymbol, attribute, out var key))
continue;
context.AddSource(GetHintName(candidate.TypeSymbol), GenerateSource(candidate.TypeSymbol, key));
@ -122,6 +121,32 @@ public sealed class AutoSceneGenerator : IIncrementalGenerator
return false;
}
private static bool TryGetSceneKey(
SourceProductionContext context,
INamedTypeSymbol typeSymbol,
AttributeData attribute,
out string key)
{
key = string.Empty;
if (attribute.ConstructorArguments.Length == 1 &&
attribute.ConstructorArguments[0].Value is string sceneKey)
{
key = sceneKey;
return true;
}
context.ReportDiagnostic(Diagnostic.Create(
AutoBehaviorDiagnostics.InvalidAttributeArguments,
attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ??
typeSymbol.Locations.FirstOrDefault() ??
Location.None,
"AutoSceneAttribute",
typeSymbol.Name,
"a single string scene key argument"));
return false;
}
private static string GenerateSource(INamedTypeSymbol typeSymbol, string key)
{
var builder = new StringBuilder();

View File

@ -2,10 +2,20 @@ using GFramework.SourceGenerators.Common.Constants;
namespace GFramework.Godot.SourceGenerators.Diagnostics;
/// <summary>
/// 定义行为类自动生成器使用的诊断描述符。
/// </summary>
/// <remarks>
/// 这些规则覆盖 <c>AutoScene</c> 与 <c>AutoUiPage</c> 等行为生成器的常见使用约束,
/// 以便在生成被跳过前向调用方报告明确的失败原因。
/// </remarks>
internal static class AutoBehaviorDiagnostics
{
private const string Category = $"{PathContests.GodotNamespace}.SourceGenerators.Behavior";
/// <summary>
/// 报告行为生成器不支持在嵌套类型上运行。
/// </summary>
public static readonly DiagnosticDescriptor NestedClassNotSupported = new(
"GF_AutoBehavior_001",
"Auto behavior generators do not support nested classes",
@ -14,6 +24,9 @@ internal static class AutoBehaviorDiagnostics
DiagnosticSeverity.Error,
true);
/// <summary>
/// 报告目标类型没有继承生成器要求的 Godot 基类。
/// </summary>
public static readonly DiagnosticDescriptor MissingBaseType = new(
"GF_AutoBehavior_002",
"Auto behavior generators require a compatible base type",
@ -22,6 +35,9 @@ internal static class AutoBehaviorDiagnostics
DiagnosticSeverity.Error,
true);
/// <summary>
/// 报告 UI 页面声明中使用了不存在的 <c>UiLayer</c> 名称。
/// </summary>
public static readonly DiagnosticDescriptor InvalidUiLayerName = new(
"GF_AutoBehavior_003",
"Unknown UiLayer name",
@ -29,4 +45,15 @@ internal static class AutoBehaviorDiagnostics
Category,
DiagnosticSeverity.Error,
true);
/// <summary>
/// 报告行为生成器特性参数不满足约定签名,导致生成器无法推导所需元数据。
/// </summary>
public static readonly DiagnosticDescriptor InvalidAttributeArguments = new(
"GF_AutoBehavior_004",
"Auto behavior attribute arguments are invalid",
"Attribute '{0}' on '{1}' must provide {2}",
Category,
DiagnosticSeverity.Error,
true);
}

View File

@ -2,10 +2,20 @@ using GFramework.SourceGenerators.Common.Constants;
namespace GFramework.Godot.SourceGenerators.Diagnostics;
/// <summary>
/// 定义导出集合自动注册生成器使用的诊断描述符。
/// </summary>
/// <remarks>
/// 这些规则用于在源生成阶段验证集合成员、注册目标以及元素类型推导,
/// 避免把配置错误延后到生成代码编译或运行时才暴露。
/// </remarks>
internal static class AutoRegisterExportedCollectionsDiagnostics
{
private const string Category = $"{PathContests.GodotNamespace}.SourceGenerators.Registration";
/// <summary>
/// 报告自动注册生成器不支持嵌套类型。
/// </summary>
public static readonly DiagnosticDescriptor NestedClassNotSupported = new(
"GF_AutoExport_001",
"AutoRegisterExportedCollections does not support nested classes",
@ -14,6 +24,9 @@ internal static class AutoRegisterExportedCollectionsDiagnostics
DiagnosticSeverity.Error,
true);
/// <summary>
/// 报告特性引用的注册表成员在宿主类型上不存在。
/// </summary>
public static readonly DiagnosticDescriptor RegistryMemberNotFound = new(
"GF_AutoExport_002",
"Registry member was not found",
@ -22,6 +35,9 @@ internal static class AutoRegisterExportedCollectionsDiagnostics
DiagnosticSeverity.Error,
true);
/// <summary>
/// 报告注册表上未找到与集合元素类型兼容的注册方法。
/// </summary>
public static readonly DiagnosticDescriptor RegisterMethodNotFound = new(
"GF_AutoExport_003",
"Register method was not found",
@ -30,6 +46,9 @@ internal static class AutoRegisterExportedCollectionsDiagnostics
DiagnosticSeverity.Error,
true);
/// <summary>
/// 报告被标记成员不是可枚举集合,因此无法执行批量注册。
/// </summary>
public static readonly DiagnosticDescriptor CollectionTypeMustBeEnumerable = new(
"GF_AutoExport_004",
"Exported collection must be enumerable",
@ -37,4 +56,15 @@ internal static class AutoRegisterExportedCollectionsDiagnostics
Category,
DiagnosticSeverity.Error,
true);
/// <summary>
/// 报告集合元素类型无法在编译期推导,因此无法安全匹配注册方法。
/// </summary>
public static readonly DiagnosticDescriptor CollectionElementTypeCouldNotBeInferred = new(
"GF_AutoExport_005",
"Exported collection element type could not be inferred",
"Member '{0}' must expose a generic enumerable element type to use RegisterExportedCollection safely",
Category,
DiagnosticSeverity.Error,
true);
}

View File

@ -1,7 +1,6 @@
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
using GFramework.SourceGenerators.Common.Extensions;
namespace GFramework.Godot.SourceGenerators.Registration;
@ -208,12 +207,23 @@ public sealed class AutoRegisterExportedCollectionsGenerator : IIncrementalGener
return false;
var elementType = TryGetElementType(collectionType);
if (elementType is null)
{
// Non-generic IEnumerable exposes elements as object at compile time, which is not safe
// for validating or generating a strongly typed registry call.
context.ReportDiagnostic(Diagnostic.Create(
AutoRegisterExportedCollectionsDiagnostics.CollectionElementTypeCouldNotBeInferred,
collectionMember.Locations.FirstOrDefault() ?? Location.None,
collectionMember.Name));
return false;
}
var hasCompatibleMethod = registryType.GetMembers(registerMethodName)
.OfType<IMethodSymbol>()
.Any(method =>
!method.IsStatic &&
method.Parameters.Length == 1 &&
(elementType is null || elementType.IsAssignableTo(method.Parameters[0].Type as INamedTypeSymbol)));
elementType.IsAssignableTo(method.Parameters[0].Type as INamedTypeSymbol));
if (!hasCompatibleMethod)
{