From 6898866b97e996b2e51f98755da442d7f08c0d1c Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Mon, 13 Apr 2026 15:47:06 +0800
Subject: [PATCH] =?UTF-8?q?feat(generator):=20=E6=94=B9=E8=BF=9B=E8=87=AA?=
=?UTF-8?q?=E5=8A=A8=E6=B3=A8=E5=86=8C=E6=A8=A1=E5=9D=97=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E5=99=A8=E7=9A=84=E8=B7=A8=E6=96=87=E4=BB=B6=E9=A1=BA=E5=BA=8F?=
=?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
当partial类分布在多个文件中时,确保生成器使用稳定的跨文件顺序来生成注册代码。
添加了对语法树排序的支持,使相同声明上的注册特性能够按照源码中的书写顺序生成安装代码。
同时修复了测试快照换行符问题,确保跨平台兼容性。
---
.../AutoRegisterModuleGeneratorTests.cs | 166 ++++++++++++++++++
.../AutoRegisterModuleGenerator.cs | 28 ++-
2 files changed, 193 insertions(+), 1 deletion(-)
diff --git a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs
index 1dc54318..964bba4a 100644
--- a/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Architectures/AutoRegisterModuleGeneratorTests.cs
@@ -6,6 +6,9 @@ namespace GFramework.SourceGenerators.Tests.Architectures;
[TestFixture]
public class AutoRegisterModuleGeneratorTests
{
+ ///
+ /// 验证同一声明上的注册特性会按照源码中的书写顺序生成安装代码。
+ ///
[Test]
public async Task Generates_Module_Install_Method_In_Attribute_Order()
{
@@ -106,6 +109,156 @@ public class AutoRegisterModuleGeneratorTests
("TestApp_GameplayModule.AutoRegisterModule.g.cs", expected));
}
+ ///
+ /// 验证 partial 声明分布在多个文件时,生成器仍然会使用稳定的跨文件顺序生成注册代码。
+ ///
+ [Test]
+ public async Task Generates_Module_Install_Method_In_Deterministic_Order_Across_Partial_Declarations()
+ {
+ const string commonSource = """
+ using System;
+
+ namespace GFramework.SourceGenerators.Abstractions.Architectures
+ {
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+ public sealed class AutoRegisterModuleAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterModelAttribute : Attribute
+ {
+ public RegisterModelAttribute(Type modelType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterSystemAttribute : Attribute
+ {
+ public RegisterSystemAttribute(Type systemType) { }
+ }
+
+ [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+ public sealed class RegisterUtilityAttribute : Attribute
+ {
+ public RegisterUtilityAttribute(Type utilityType) { }
+ }
+ }
+
+ namespace GFramework.Core.Abstractions.Architectures
+ {
+ public interface IArchitecture
+ {
+ T RegisterModel(T model) where T : GFramework.Core.Abstractions.Model.IModel;
+ T RegisterSystem(T system) where T : GFramework.Core.Abstractions.Systems.ISystem;
+ T RegisterUtility(T utility) where T : GFramework.Core.Abstractions.Utility.IUtility;
+ }
+ }
+
+ 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 TestApp
+ {
+ using GFramework.Core.Abstractions.Model;
+ using GFramework.Core.Abstractions.Systems;
+ using GFramework.Core.Abstractions.Utility;
+
+ public sealed class PlayerModel : IModel { }
+ public sealed class CombatSystem : ISystem { }
+ public sealed class AudioUtility : IUtility { }
+ }
+ """;
+
+ const string partASource = """
+ namespace TestApp
+ {
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ // Padding ensures this attribute lives later in the file than the attributes in PartB.
+ // The generator should still place it first because PartA sorts before PartB.
+ // padding 01
+ // padding 02
+ // padding 03
+ // padding 04
+ // padding 05
+ // padding 06
+ // padding 07
+ // padding 08
+ // padding 09
+ // padding 10
+ [AutoRegisterModule]
+ [RegisterUtility(typeof(AudioUtility))]
+ public partial class GameplayModule
+ {
+ }
+ }
+ """;
+
+ const string partBSource = """
+ namespace TestApp
+ {
+ using GFramework.SourceGenerators.Abstractions.Architectures;
+
+ [RegisterSystem(typeof(CombatSystem))]
+ [RegisterModel(typeof(PlayerModel))]
+ public partial class GameplayModule
+ {
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class GameplayModule
+ {
+ public void Install(global::GFramework.Core.Abstractions.Architectures.IArchitecture architecture)
+ {
+ architecture.RegisterUtility(new global::TestApp.AudioUtility());
+ architecture.RegisterSystem(new global::TestApp.CombatSystem());
+ architecture.RegisterModel(new global::TestApp.PlayerModel());
+ }
+ }
+
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources =
+ {
+ ("Common.cs", commonSource),
+ ("GameplayModule.PartA.cs", partASource),
+ ("GameplayModule.PartB.cs", partBSource)
+ },
+ GeneratedSources =
+ {
+ (typeof(AutoRegisterModuleGenerator), "TestApp_GameplayModule.AutoRegisterModule.g.cs", NormalizeLineEndings(expected))
+ }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" }
+ };
+
+ await test.RunAsync();
+ }
+
+ ///
+ /// 验证生成器会保留可空引用、notnull 与 unmanaged 约束。
+ ///
[Test]
public async Task Generates_Type_Constraints_For_NullableReference_NotNull_And_Unmanaged()
{
@@ -204,4 +357,17 @@ public class AutoRegisterModuleGeneratorTests
source,
("TestApp_GameplayModule.AutoRegisterModule.g.cs", expected));
}
+
+ ///
+ /// 将测试快照统一为当前平台换行符,避免不同系统上的源生成输出比较出现伪差异。
+ ///
+ /// 原始快照内容。
+ /// 使用当前平台换行符的快照内容。
+ private static string NormalizeLineEndings(string content)
+ {
+ return content
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace("\r", "\n", StringComparison.Ordinal)
+ .Replace("\n", Environment.NewLine, StringComparison.Ordinal);
+ }
}
diff --git a/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs
index 8c09a80a..0385d221 100644
--- a/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs
+++ b/GFramework.SourceGenerators/Architectures/AutoRegisterModuleGenerator.cs
@@ -179,7 +179,12 @@ public sealed class AutoRegisterModuleGenerator : IIncrementalGenerator
{
var registrations = new List();
- foreach (var attribute in typeSymbol.GetAttributes().OrderBy(GetAttributeOrder))
+ foreach (var attribute in typeSymbol.GetAttributes()
+ // Roslyn 会把 partial 类型上的属性合并到同一个集合中。
+ // 先按语法树标识排序,才能让每个文件内的 Span.Start 成为可比较的稳定顺序键。
+ .OrderBy(GetAttributeSyntaxTreeOrderKey, StringComparer.Ordinal)
+ .ThenBy(GetAttributeOrder)
+ .ThenBy(GetAttributeTypeOrderKey, StringComparer.Ordinal))
{
if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, registerModelAttribute))
{
@@ -352,6 +357,27 @@ public sealed class AutoRegisterModuleGenerator : IIncrementalGenerator
return attribute.ApplicationSyntaxReference?.Span.Start ?? int.MaxValue;
}
+ private static string GetAttributeSyntaxTreeOrderKey(AttributeData attribute)
+ {
+ var syntaxTree = attribute.ApplicationSyntaxReference?.SyntaxTree;
+ if (syntaxTree is null)
+ return string.Empty;
+
+ if (!string.IsNullOrEmpty(syntaxTree.FilePath))
+ return syntaxTree.FilePath;
+
+ // In-memory compilations may not assign file paths. Fall back to the syntax tree text so
+ // attributes from different partial declarations still get a deterministic cross-file order.
+ return syntaxTree.ToString();
+ }
+
+ private static string GetAttributeTypeOrderKey(AttributeData attribute)
+ {
+ return attribute.ConstructorArguments.FirstOrDefault().Value is INamedTypeSymbol componentType
+ ? componentType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ : string.Empty;
+ }
+
private static Location GetAttributeLocation(AttributeData attribute)
{
return attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None;