diff --git a/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs b/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs new file mode 100644 index 00000000..b2d01d72 --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs @@ -0,0 +1,46 @@ +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置表注册选项会在构造阶段建立最小不变量,避免非法路径状态继续向后传播。 +/// +[TestFixture] +public class YamlConfigTableRegistrationOptionsTests +{ + /// + /// 验证构造函数会拒绝空的或仅空白字符的表名。 + /// + /// 待验证的表名。 + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Constructor_Should_Throw_When_Table_Name_Is_Null_Or_Whitespace(string? tableName) + { + var exception = Assert.Throws(() => + _ = new YamlConfigTableRegistrationOptions( + tableName!, + "monster", + static config => config.Length)); + + Assert.That(exception!.ParamName, Is.EqualTo("tableName")); + } + + /// + /// 验证构造函数会拒绝空的或仅空白字符的相对目录路径。 + /// + /// 待验证的相对目录路径。 + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Constructor_Should_Throw_When_Relative_Path_Is_Null_Or_Whitespace(string? relativePath) + { + var exception = Assert.Throws(() => + _ = new YamlConfigTableRegistrationOptions( + "monster", + relativePath!, + static config => config.Length)); + + Assert.That(exception!.ParamName, Is.EqualTo("relativePath")); + } +} \ No newline at end of file diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 6b15d3e5..491505fd 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -161,6 +161,10 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置项主键提取器。 /// 可选主键比较器。 /// 当前加载器实例,以便链式注册。 + /// + /// 当 为 null、空字符串或空白字符串时抛出。 + /// + /// 为 null 时抛出。 public YamlConfigLoader RegisterTable( string tableName, string relativePath, @@ -188,6 +192,11 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置项主键提取器。 /// 可选主键比较器。 /// 当前加载器实例,以便链式注册。 + /// + /// 当 + /// 为 null、空字符串或空白字符串时抛出。 + /// + /// 为 null 时抛出。 public YamlConfigLoader RegisterTable( string tableName, string relativePath, @@ -214,6 +223,11 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置表注册选项。 /// 当前加载器实例,以便链式注册。 /// 为空时抛出。 + /// + /// 当 内的 、 + /// 或 + /// 为 null、空字符串或空白字符串时抛出。 + /// public YamlConfigLoader RegisterTable(YamlConfigTableRegistrationOptions options) where TKey : notnull { diff --git a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs index 16f1a7b7..0f34687d 100644 --- a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs +++ b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs @@ -10,18 +10,34 @@ namespace GFramework.Game.Config; public sealed class YamlConfigTableRegistrationOptions where TKey : notnull { + private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; + private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace."; + /// /// 使用最小必需参数创建配置表注册选项。 /// /// 运行时配置表名称。 /// 相对配置根目录的子目录。 /// 配置项主键提取器。 + /// + /// 当 为 null、空字符串或空白字符串时抛出。 + /// /// 为 null 时抛出。 public YamlConfigTableRegistrationOptions( string tableName, string relativePath, Func keySelector) { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName)); + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath)); + } + ArgumentNullException.ThrowIfNull(keySelector); TableName = tableName; diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 507bf36f..9c777f4c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -91,4 +91,144 @@ public class SchemaConfigGeneratorTests Assert.That(diagnostic.GetMessage(), Does.Contain("array")); }); } + + /// + /// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "drop$item"], + "properties": { + "id": { "type": "integer" }, + "drop$item": { "type": "string" } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_006")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("drop$item")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Drop$item")); + }); + } + + /// + /// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。 + /// + [Test] + public void Run_Should_Assign_Globally_Unique_Reference_Metadata_Member_Names() + { + const string source = """ + using System; + using System.Collections.Generic; + + namespace GFramework.Game.Abstractions.Config + { + public interface IConfigTable + { + Type KeyType { get; } + Type ValueType { get; } + int Count { get; } + } + + public interface IConfigTable : IConfigTable + where TKey : notnull + { + TValue Get(TKey key); + bool TryGet(TKey key, out TValue? value); + bool ContainsKey(TKey key); + IReadOnlyCollection All(); + } + + public interface IConfigRegistry + { + IConfigTable GetTable(string name) + where TKey : notnull; + + bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull; + } + } + + namespace GFramework.Game.Config + { + public sealed class YamlConfigLoader + { + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + string schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + return this; + } + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "integer" }, + "drop-items": { + "type": "array", + "items": { "type": "string" }, + "x-gframework-ref-table": "item" + }, + "drop_items": { + "type": "array", + "items": { "type": "string" }, + "x-gframework-ref-table": "item" + }, + "dropItems1": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var generatedSources = result.Results + .Single() + .GeneratedSources + .ToDictionary( + static sourceResult => sourceResult.HintName, + static sourceResult => sourceResult.SourceText.ToString(), + StringComparer.Ordinal); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + Assert.That(generatedSources.TryGetValue("MonsterConfigBindings.g.cs", out var bindingsSource), Is.True); + Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems =")); + Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 =")); + Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 =")); + } } \ No newline at end of file diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md index 356e4cbe..1f5a1cc8 100644 --- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -20,6 +20,7 @@ GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 7ee0770f..dc33408f 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -253,7 +253,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator var title = TryGetMetadataString(property.Value, "title"); var description = TryGetMetadataString(property.Value, "description"); var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table"); - var propertyName = ToPascalCase(property.Name); + if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic)) + { + return ParsedPropertyResult.FromDiagnostic(diagnostic!); + } switch (schemaType) { @@ -934,26 +937,37 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// 生成期引用元数据集合。 private static IEnumerable CollectReferenceSpecs(SchemaObjectSpec rootObject) { - var memberNameCounts = new Dictionary(StringComparer.Ordinal); + var nextSuffixByBaseMemberName = new Dictionary(StringComparer.Ordinal); + var allocatedMemberNames = new HashSet(StringComparer.Ordinal); foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties)) { var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath); - if (memberNameCounts.TryGetValue(baseMemberName, out var duplicateCount)) + var memberName = baseMemberName; + if (!allocatedMemberNames.Add(memberName)) { - // Reuse the tracked duplicate count so repeated reference paths keep their generated member names stable. - duplicateCount++; - memberNameCounts[baseMemberName] = duplicateCount; - baseMemberName = - $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}"; + // Track globally allocated member names because a suffixed duplicate from one path can collide + // with the unsuffixed base name produced by a later, different path. + var duplicateCount = nextSuffixByBaseMemberName.TryGetValue(baseMemberName, out var nextSuffix) + ? nextSuffix + 1 + : 1; + + memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}"; + while (!allocatedMemberNames.Add(memberName)) + { + duplicateCount++; + memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}"; + } + + nextSuffixByBaseMemberName[baseMemberName] = duplicateCount; } else { - memberNameCounts[baseMemberName] = 0; + nextSuffixByBaseMemberName[baseMemberName] = 0; } yield return new GeneratedReferenceSpec( - baseMemberName, + memberName, referenceSeed.DisplayPath, referenceSeed.ReferencedTableName, referenceSeed.ValueSchemaType, @@ -1165,6 +1179,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine($"{indent}/// "); } + /// + /// 将 schema 字段名转换并验证为生成代码可直接使用的属性标识符。 + /// 生成器会在这里拒绝无法映射为合法 C# 标识符的外部输入,避免生成源码后才在编译阶段失败。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// Schema 原始字段名。 + /// 生成后的属性名。 + /// 字段名非法时生成的诊断。 + /// 是否成功生成合法属性标识符。 + private static bool TryBuildPropertyIdentifier( + string filePath, + string displayPath, + string schemaName, + out string propertyName, + out Diagnostic? diagnostic) + { + propertyName = ToPascalCase(schemaName); + if (SyntaxFacts.IsValidIdentifier(propertyName)) + { + diagnostic = null; + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidGeneratedIdentifier, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + schemaName, + propertyName); + return false; + } + /// /// 从 schema 文件路径提取实体基础名。 /// diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index 9229868f..b03057a1 100644 --- a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -63,4 +63,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 字段名无法安全映射为 C# 标识符。 + /// + public static readonly DiagnosticDescriptor InvalidGeneratedIdentifier = new( + "GF_ConfigSchema_006", + "Config schema property name cannot be converted to a valid C# identifier", + "Property '{1}' in schema file '{0}' uses schema key '{2}', which generates invalid C# identifier '{3}'", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } \ No newline at end of file