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;