diff --git a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs index e406f8b6..a6b54ede 100644 --- a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs +++ b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs @@ -18,9 +18,22 @@ public sealed class AutoLoadAttribute : Attribute /// /// 。 /// + /// + /// 为空字符串或仅包含空白字符。 + /// public AutoLoadAttribute(string name) { - Name = name ?? throw new ArgumentNullException(nameof(name)); + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("AutoLoad name cannot be empty or whitespace.", nameof(name)); + } + + Name = name; } /// diff --git a/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs b/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs new file mode 100644 index 00000000..9d8ddff8 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs @@ -0,0 +1,49 @@ +using GFramework.Godot.SourceGenerators.Abstractions; + +namespace GFramework.Godot.SourceGenerators.Tests.Abstractions; + +/// +/// 验证 的参数约束。 +/// +[TestFixture] +public class AutoLoadAttributeTests +{ + /// + /// 验证构造函数会保留合法的 AutoLoad 名称。 + /// + [Test] + public void Constructor_Should_Store_Name_When_Name_Is_Valid() + { + var attribute = new AutoLoadAttribute("GameServices"); + + Assert.That(attribute.Name, Is.EqualTo("GameServices")); + } + + /// + /// 验证构造函数会拒绝空引用。 + /// + [Test] + public void Constructor_Should_Throw_When_Name_Is_Null() + { + var exception = Assert.Throws(() => new AutoLoadAttribute(null!)); + + Assert.That(exception!.ParamName, Is.EqualTo("name")); + } + + /// + /// 验证构造函数会拒绝空字符串与仅空白字符串。 + /// + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void Constructor_Should_Throw_When_Name_Is_Empty_Or_Whitespace(string name) + { + var exception = Assert.Throws(() => new AutoLoadAttribute(name)); + + Assert.Multiple(() => + { + Assert.That(exception!.ParamName, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("empty or whitespace")); + }); + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs index f1aaa03f..127c5ed4 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; using System.IO; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; namespace GFramework.Godot.SourceGenerators.Tests.Core; @@ -60,13 +62,12 @@ public static class AdditionalTextGeneratorTestDriver /// 规范化换行,避免测试在不同平台上产生伪差异。 /// /// 待规范化文本。 - /// 使用当前平台换行符的内容。 + /// 统一使用 LF (\n) 的内容。 public static string NormalizeLineEndings(string content) { return content .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace("\r", "\n", StringComparison.Ordinal) - .Replace("\n", Environment.NewLine, StringComparison.Ordinal); + .Replace("\r", "\n", StringComparison.Ordinal); } private static IEnumerable GetMetadataReferences() diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs new file mode 100644 index 00000000..e6f7f343 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs @@ -0,0 +1,21 @@ +namespace GFramework.Godot.SourceGenerators.Tests.Core; + +/// +/// 验证 的文本规范化行为。 +/// +[TestFixture] +public class AdditionalTextGeneratorTestDriverTests +{ + /// + /// 验证不同平台换行最终都会被统一为 LF。 + /// + [Test] + public void NormalizeLineEndings_Should_Convert_All_Line_Endings_To_Lf() + { + const string content = "line1\r\nline2\rline3\nline4"; + + var normalized = AdditionalTextGeneratorTestDriver.NormalizeLineEndings(content); + + Assert.That(normalized, Is.EqualTo("line1\nline2\nline3\nline4")); + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj index d02277db..b4ce059d 100644 --- a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj +++ b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs index 252e7e1e..63265bd2 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs @@ -316,4 +316,245 @@ public class GodotProjectMetadataGeneratorTests generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], Does.Contain("public const string MoveUp_2 = \"move-up\";")); } + + /// + /// 验证多个显式映射指向同一个 AutoLoad 时会报告重复映射,并退化为 Godot.Node。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Explicit_AutoLoad_Mappings_Are_Duplicated() + { + var result = RunGenerator( + CreateSource( + """ + namespace TestApp + { + using GFramework.Godot.SourceGenerators.Abstractions; + using Godot; + + [AutoLoad("AudioBus")] + public partial class PrimaryAudioBus : Node + { + } + + [AutoLoad("AudioBus")] + public partial class SecondaryAudioBus : Node + { + } + } + """, + includeAutoLoadAttribute: true), + """ + [autoload] + AudioBus="*res://autoload/audio_bus.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("PrimaryAudioBus")); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("SecondaryAudioBus")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"AudioBus\");")); + }); + } + + /// + /// 验证不同命名空间下的同名节点类型会触发隐式映射冲突诊断,并退化为 Godot.Node。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Implicit_AutoLoad_Mappings_Are_Ambiguous() + { + var result = RunGenerator( + CreateSource( + """ + namespace TestApp.Audio + { + using Godot; + + public partial class AudioBus : Node + { + } + } + + namespace TestApp.Debug + { + using Godot; + + public partial class AudioBus : Node + { + } + } + """), + """ + [autoload] + AudioBus="*res://autoload/audio_bus.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Audio.AudioBus")); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Debug.AudioBus")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"AudioBus\");")); + }); + } + + /// + /// 验证 AutoLoad 标识符冲突时会追加稳定后缀并报告诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_And_Append_Suffix_When_AutoLoad_Identifiers_Collide() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + """ + [autoload] + audio_bus="*res://autoload/audio_bus.tscn" + audio-bus="*res://autoload/audio_bus_debug.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_003" })); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"audio_bus\");")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus_2 => GetRequiredNode(\"audio-bus\");")); + }); + } + + /// + /// 验证重复 AutoLoad 条目会报告诊断,并只保留第一条声明参与生成。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_AutoLoads() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + """ + [autoload] + GameServices="*res://autoload/game_services.tscn" + GameServices="*res://autoload/game_services_debug.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_005" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("GameServices")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node GameServices => GetRequiredNode(\"GameServices\");")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Not.Contain("GameServices_2")); + }); + } + + /// + /// 验证重复 Input Action 条目会报告诊断,并只保留第一条声明参与生成。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_Input_Actions() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + """ + [input] + move_up={ + } + move_up={ + } + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_006" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("move_up")); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Contain("public const string MoveUp = \"move_up\";")); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Not.Contain("MoveUp_2")); + }); + } + + private static GeneratorDriverRunResult RunGenerator( + string source, + string projectFile) + { + return AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + } + + private static string CreateSource( + string applicationSource, + bool includeAutoLoadAttribute = false) + { + var autoLoadAttributeSource = includeAutoLoadAttribute + ? """ + using System; + + namespace GFramework.Godot.SourceGenerators.Abstractions + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoLoadAttribute : Attribute + { + public AutoLoadAttribute(string name) + { + Name = name; + } + + public string Name { get; } + } + } + """ + : string.Empty; + + return autoLoadAttributeSource + """ + namespace Godot + { + public class MainLoop + { + } + + public class Node + { + public T? GetNodeOrNull(string path) where T : Node => default; + } + + public sealed class SceneTree : MainLoop + { + public Node? Root { get; set; } + } + + public static class Engine + { + public static MainLoop? GetMainLoop() => default; + } + } + + """ + applicationSource; + } } diff --git a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets index cf19200c..5116e4da 100644 --- a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets +++ b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets @@ -9,6 +9,12 @@ 让 Godot 项目元数据生成能力在常规 Godot C# 工程里开箱即用。 --> project.godot + + <_GFrameworkGodotProjectFileName>$([System.IO.Path]::GetFileName('$(GFrameworkGodotProjectFile)')) + <_GFrameworkGodotProjectFileNameLower>$([System.String]::Copy('$(_GFrameworkGodotProjectFileName)').ToLowerInvariant()) @@ -21,6 +27,12 @@ + + + + diff --git a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs index ba3ddc21..c080cc71 100644 --- a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs +++ b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs @@ -1,5 +1,8 @@ +using System.IO; using GFramework.Godot.SourceGenerators.Diagnostics; using GFramework.SourceGenerators.Common.Constants; +using GFramework.SourceGenerators.Common.Extensions; +using Microsoft.CodeAnalysis.Text; namespace GFramework.Godot.SourceGenerators; @@ -178,6 +181,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator foreach (var projectAutoLoadName in projectAutoLoadNames.OrderBy(static name => name, StringComparer.Ordinal)) { + // 显式 [AutoLoad] 映射优先于按类型名推断,因为它代表了用户给出的稳定契约。 if (explicitMappings.TryGetValue(projectAutoLoadName, out var explicitList)) { var distinctExplicitTypes = DistinctTypeSymbols(explicitList); @@ -188,14 +192,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator } else if (distinctExplicitTypes.Length > 1) { - context.ReportDiagnostic(Diagnostic.Create( - GodotProjectDiagnostics.DuplicateAutoLoadMapping, - Location.None, - projectAutoLoadName, - string.Join( - ", ", - distinctExplicitTypes.Select(static type => - type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))))); + ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctExplicitTypes); } continue; @@ -207,7 +204,14 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator var distinctImplicitTypes = DistinctTypeSymbols(implicitList); if (distinctImplicitTypes.Length == 1) + { resolvedMappings.Add(projectAutoLoadName, distinctImplicitTypes[0]); + } + else if (distinctImplicitTypes.Length > 1) + { + // 隐式推断只在唯一命中时才安全;出现同名候选时改为诊断并退化成 Godot.Node。 + ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctImplicitTypes); + } } return resolvedMappings; @@ -228,6 +232,21 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator return true; } + private static void ReportDuplicateAutoLoadMapping( + SourceProductionContext context, + string autoLoadName, + IEnumerable duplicateTypes) + { + context.ReportDiagnostic(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateAutoLoadMapping, + Location.None, + autoLoadName, + string.Join( + ", ", + duplicateTypes.Select(static type => + type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat))))); + } + private static IReadOnlyList CreateAutoLoadMembers( SourceProductionContext context, ProjectMetadataParseResult projectResult, diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md index 1310807f..2e83adb3 100644 --- a/GFramework.Godot.SourceGenerators/README.md +++ b/GFramework.Godot.SourceGenerators/README.md @@ -25,11 +25,14 @@ - `GFramework.Godot.Generated.AutoLoads` - `GFramework.Godot.Generated.InputActions` -如果你需要覆盖默认项目文件名,可以在 MSBuild 中设置: +如果你需要覆盖默认项目文件路径,可以在 MSBuild 中设置: + +- 路径可以调整到项目根目录下的其他位置 +- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件 ```xml - project.godot + Config/project.godot ``` diff --git a/docs/zh-CN/source-generators/godot-project-generator.md b/docs/zh-CN/source-generators/godot-project-generator.md index 96bc19b1..9e26f242 100644 --- a/docs/zh-CN/source-generators/godot-project-generator.md +++ b/docs/zh-CN/source-generators/godot-project-generator.md @@ -23,9 +23,12 @@ API。 如需覆盖默认路径,可以设置: +- 可以改成项目根目录下的其他相对路径 +- 文件名必须仍然是 `project.godot`,否则生成器会给出警告并忽略该文件 + ```xml - project.godot + Config/project.godot ``` diff --git a/docs/zh-CN/tutorials/godot-integration.md b/docs/zh-CN/tutorials/godot-integration.md index b9352ac8..94f1d51c 100644 --- a/docs/zh-CN/tutorials/godot-integration.md +++ b/docs/zh-CN/tutorials/godot-integration.md @@ -1354,4 +1354,4 @@ public class GodotPerformanceTests --- **教程版本**: 1.0.0 -**更新日期**: 2026-01-12 +**更新日期**: 2026-04-14