diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs new file mode 100644 index 0000000..8c5d327 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -0,0 +1,125 @@ +using System.IO; + +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 验证 schema 配置生成器的生成快照。 +/// +[TestFixture] +public class SchemaConfigGeneratorSnapshotTests +{ + /// + /// 验证一个最小 monster schema 能生成配置类型和表包装。 + /// + [Test] + public async Task Snapshot_SchemaConfigGenerator() + { + 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(); + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" }, + "dropItems": { + "type": "array", + "items": { "type": "string" } + } + } + } + """; + + 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); + + var snapshotFolder = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", + "..", + "..", + "Config", + "snapshots", + "SchemaConfigGenerator"); + snapshotFolder = Path.GetFullPath(snapshotFolder); + + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt"); + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt"); + } + + /// + /// 对单个生成文件执行快照断言。 + /// + /// 生成结果字典。 + /// 快照目录。 + /// 快照文件名。 + private static async Task AssertSnapshotAsync( + IReadOnlyDictionary generatedSources, + string snapshotFolder, + string generatedFileName, + string snapshotFileName) + { + if (!generatedSources.TryGetValue(generatedFileName, out var actual)) + { + Assert.Fail($"Generated source '{generatedFileName}' was not found."); + return; + } + + var path = Path.Combine(snapshotFolder, snapshotFileName); + if (!File.Exists(path)) + { + Directory.CreateDirectory(snapshotFolder); + await File.WriteAllTextAsync(path, actual); + Assert.Fail($"Snapshot not found. Generated new snapshot at:\n{path}"); + } + + var expected = await File.ReadAllTextAsync(path); + Assert.That( + Normalize(expected), + Is.EqualTo(Normalize(actual)), + $"Snapshot mismatch: {generatedFileName}"); + } + + /// + /// 标准化快照文本以避免平台换行差异。 + /// + /// 原始文本。 + /// 标准化后的文本。 + private static string Normalize(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs new file mode 100644 index 0000000..6a71777 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -0,0 +1,48 @@ +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 验证 schema 配置生成器的错误诊断行为。 +/// +[TestFixture] +public class SchemaConfigGeneratorTests +{ + /// + /// 验证缺失必填 id 字段时会产生命名明确的诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Id_Property_Is_Missing() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostics = result.Results.Single().Diagnostics; + var diagnostic = diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_003")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json")); + }); + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs new file mode 100644 index 0000000..0c33647 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs @@ -0,0 +1,87 @@ +using System.Collections.Immutable; +using System.IO; +using GFramework.SourceGenerators.Config; + +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 为 schema 配置生成器提供测试驱动。 +/// 该驱动直接使用 Roslyn GeneratorDriver 运行 AdditionalFiles 场景, +/// 以便测试基于 schema 文件的代码生成行为。 +/// +public static class SchemaGeneratorTestDriver +{ + /// + /// 运行 schema 配置生成器,并返回生成结果。 + /// + /// 测试用源码。 + /// AdditionalFiles 集合。 + /// 生成器运行结果。 + public static GeneratorDriverRunResult Run( + string source, + params (string path, string content)[] additionalFiles) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + "SchemaConfigGeneratorTests", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var additionalTexts = additionalFiles + .Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content)) + .ToImmutableArray(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new[] { new SchemaConfigGenerator().AsSourceGenerator() }, + additionalTexts: additionalTexts, + parseOptions: (CSharpParseOptions)syntaxTree.Options); + + driver = driver.RunGenerators(compilation); + return driver.GetRunResult(); + } + + /// + /// 获取测试编译所需的运行时元数据引用。 + /// + /// 元数据引用集合。 + private static IEnumerable GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))? + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + ?? Array.Empty(); + + return trustedPlatformAssemblies + .Select(static path => MetadataReference.CreateFromFile(path)); + } + + /// + /// 用于测试 AdditionalFiles 的内存实现。 + /// + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _text; + + /// + /// 创建内存 AdditionalText。 + /// + /// 虚拟文件路径。 + /// 文件内容。 + public InMemoryAdditionalText( + string path, + string content) + { + Path = path; + _text = SourceText.From(content); + } + + /// + public override string Path { get; } + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt new file mode 100644 index 0000000..8d05f36 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -0,0 +1,32 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated config type for schema file 'monster.schema.json'. +/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract. +/// +public sealed partial class MonsterConfig +{ + /// + /// Gets or sets the value mapped from schema property 'id'. + /// + public int Id { get; set; } + + /// + /// Gets or sets the value mapped from schema property 'name'. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the value mapped from schema property 'hp'. + /// + public int? Hp { get; set; } + + /// + /// Gets or sets the value mapped from schema property 'dropItems'. + /// + public global::System.Collections.Generic.IReadOnlyList DropItems { get; set; } = global::System.Array.Empty(); + +} diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt new file mode 100644 index 0000000..2e1a442 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt @@ -0,0 +1,55 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated table wrapper for schema file 'monster.schema.json'. +/// The wrapper keeps generated call sites strongly typed while delegating actual storage to the runtime config table implementation. +/// +public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.Config.IConfigTable +{ + private readonly global::GFramework.Game.Abstractions.Config.IConfigTable _inner; + + /// + /// Creates a generated table wrapper around the runtime config table instance. + /// + /// The runtime config table instance. + public MonsterTable(global::GFramework.Game.Abstractions.Config.IConfigTable inner) + { + _inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner)); + } + + /// + public global::System.Type KeyType => _inner.KeyType; + + /// + public global::System.Type ValueType => _inner.ValueType; + + /// + public int Count => _inner.Count; + + /// + public MonsterConfig Get(int key) + { + return _inner.Get(key); + } + + /// + public bool TryGet(int key, out MonsterConfig? value) + { + return _inner.TryGet(key, out value); + } + + /// + public bool ContainsKey(int key) + { + return _inner.ContainsKey(key); + } + + /// + public global::System.Collections.Generic.IReadOnlyCollection All() + { + return _inner.All(); + } +} diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md index 8b25a37..356e4cb 100644 --- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -3,21 +3,26 @@ ### New Rules - Rule ID | Category | Severity | Notes ------------------------|----------------------------------|----------|------------------------ - GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics - GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic - GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics - GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics - GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic - GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer + Rule ID | Category | Severity | Notes +-----------------------|------------------------------------|----------|------------------------- + GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics + GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic + GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics + GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics + GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + 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_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic + GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs new file mode 100644 index 0000000..4faf0e7 --- /dev/null +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -0,0 +1,526 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using GFramework.SourceGenerators.Diagnostics; + +namespace GFramework.SourceGenerators.Config; + +/// +/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 +/// 当前实现聚焦 Runtime MVP 需要的最小能力:单 schema 对应单配置类型,并约定使用必填的 id 字段作为表主键。 +/// +[Generator] +public sealed class SchemaConfigGenerator : IIncrementalGenerator +{ + private const string GeneratedNamespace = "GFramework.Game.Config.Generated"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var schemaFiles = context.AdditionalTextsProvider + .Where(static file => file.Path.EndsWith(".schema.json", StringComparison.OrdinalIgnoreCase)) + .Select(static (file, cancellationToken) => ParseSchema(file, cancellationToken)); + + context.RegisterSourceOutput(schemaFiles, static (productionContext, result) => + { + foreach (var diagnostic in result.Diagnostics) + { + productionContext.ReportDiagnostic(diagnostic); + } + + if (result.Schema is null) + { + return; + } + + productionContext.AddSource( + $"{result.Schema.ClassName}.g.cs", + SourceText.From(GenerateConfigClass(result.Schema), Encoding.UTF8)); + productionContext.AddSource( + $"{result.Schema.TableName}.g.cs", + SourceText.From(GenerateTableClass(result.Schema), Encoding.UTF8)); + }); + } + + /// + /// 解析单个 schema 文件。 + /// + /// AdditionalFiles 中的 schema 文件。 + /// 取消令牌。 + /// 解析结果,包含 schema 模型或诊断。 + private static SchemaParseResult ParseSchema( + AdditionalText file, + CancellationToken cancellationToken) + { + SourceText? text; + try + { + text = file.GetText(cancellationToken); + } + catch (Exception exception) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + exception.Message)); + } + + if (text is null) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + "File content could not be read.")); + } + + try + { + using var document = JsonDocument.Parse(text.ToString()); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var rootTypeElement) || + !string.Equals(rootTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + if (!root.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + var requiredProperties = new HashSet(StringComparer.OrdinalIgnoreCase); + if (root.TryGetProperty("required", out var requiredElement) && + requiredElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in requiredElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + requiredProperties.Add(value!); + } + } + } + } + + var properties = new List(); + foreach (var property in propertiesElement.EnumerateObject()) + { + var parsedProperty = ParseProperty(file.Path, property, requiredProperties.Contains(property.Name)); + if (parsedProperty.Diagnostic is not null) + { + return SchemaParseResult.FromDiagnostic(parsedProperty.Diagnostic); + } + + properties.Add(parsedProperty.Property!); + } + + var idProperty = properties.FirstOrDefault(static property => + string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase)); + if (idProperty is null || !idProperty.IsRequired) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.IdPropertyRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + if (!string.Equals(idProperty.SchemaType, "integer", StringComparison.Ordinal) && + !string.Equals(idProperty.SchemaType, "string", StringComparison.Ordinal)) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedKeyType, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + idProperty.SchemaType)); + } + + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); + var schema = new SchemaFileSpec( + Path.GetFileName(file.Path), + entityName, + $"{entityName}Config", + $"{entityName}Table", + GeneratedNamespace, + idProperty.ClrType, + properties); + + return SchemaParseResult.FromSchema(schema); + } + catch (JsonException exception) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + exception.Message)); + } + } + + /// + /// 解析单个 schema 属性定义。 + /// + /// schema 文件路径。 + /// 属性 JSON 节点。 + /// 属性是否必填。 + /// 解析后的属性信息或诊断。 + private static ParsedPropertyResult ParseProperty( + string filePath, + JsonProperty property, + bool isRequired) + { + if (!property.Value.TryGetProperty("type", out var typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + "")); + } + + var schemaType = typeElement.GetString() ?? string.Empty; + switch (schemaType) + { + case "integer": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "integer", + isRequired ? "int" : "int?", + isRequired, + null)); + + case "number": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "number", + isRequired ? "double" : "double?", + isRequired, + null)); + + case "boolean": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "boolean", + isRequired ? "bool" : "bool?", + isRequired, + null)); + + case "string": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "string", + isRequired ? "string" : "string?", + isRequired, + isRequired ? " = string.Empty;" : null)); + + case "array": + if (!property.Value.TryGetProperty("items", out var itemsElement) || + !itemsElement.TryGetProperty("type", out var itemTypeElement) || + itemTypeElement.ValueKind != JsonValueKind.String) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + "array")); + } + + var itemType = itemTypeElement.GetString() ?? string.Empty; + var itemClrType = itemType switch + { + "integer" => "int", + "number" => "double", + "boolean" => "bool", + "string" => "string", + _ => string.Empty + }; + + if (string.IsNullOrEmpty(itemClrType)) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + $"array<{itemType}>")); + } + + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "array", + $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", + isRequired, + " = global::System.Array.Empty<" + itemClrType + ">();")); + + default: + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + schemaType)); + } + } + + /// + /// 生成配置类型源码。 + /// + /// 已解析的 schema 模型。 + /// 配置类型源码。 + private static string GenerateConfigClass(SchemaFileSpec schema) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + $"/// Auto-generated config type for schema file '{schema.FileName}'."); + builder.AppendLine( + "/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract."); + builder.AppendLine("/// "); + builder.AppendLine($"public sealed partial class {schema.ClassName}"); + builder.AppendLine("{"); + + foreach (var property in schema.Properties) + { + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// Gets or sets the value mapped from schema property '{property.SchemaName}'."); + builder.AppendLine(" /// "); + builder.Append($" public {property.ClrType} {property.PropertyName} {{ get; set; }}"); + if (!string.IsNullOrEmpty(property.Initializer)) + { + builder.Append(property.Initializer); + } + + builder.AppendLine(); + builder.AppendLine(); + } + + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 生成配置表包装源码。 + /// + /// 已解析的 schema 模型。 + /// 配置表包装源码。 + private static string GenerateTableClass(SchemaFileSpec schema) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + $"/// Auto-generated table wrapper for schema file '{schema.FileName}'."); + builder.AppendLine( + "/// The wrapper keeps generated call sites strongly typed while delegating actual storage to the runtime config table implementation."); + builder.AppendLine("/// "); + builder.AppendLine( + $"public sealed partial class {schema.TableName} : global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}>"); + builder.AppendLine("{"); + builder.AppendLine( + $" private readonly global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> _inner;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Creates a generated table wrapper around the runtime config table instance."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The runtime config table instance."); + builder.AppendLine( + $" public {schema.TableName}(global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> inner)"); + builder.AppendLine(" {"); + builder.AppendLine(" _inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public global::System.Type KeyType => _inner.KeyType;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public global::System.Type ValueType => _inner.ValueType;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public int Count => _inner.Count;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public {schema.ClassName} Get({schema.KeyClrType} key)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.Get(key);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public bool TryGet({schema.KeyClrType} key, out {schema.ClassName}? value)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.TryGet(key, out value);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public bool ContainsKey({schema.KeyClrType} key)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.ContainsKey(key);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public global::System.Collections.Generic.IReadOnlyCollection<{schema.ClassName}> All()"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.All();"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 从 schema 文件路径提取实体基础名。 + /// + /// schema 文件路径。 + /// 去掉扩展名和 `.schema` 后缀的实体基础名。 + private static string GetSchemaBaseName(string path) + { + var fileName = Path.GetFileName(path); + if (fileName.EndsWith(".schema.json", StringComparison.OrdinalIgnoreCase)) + { + return fileName.Substring(0, fileName.Length - ".schema.json".Length); + } + + return Path.GetFileNameWithoutExtension(fileName); + } + + /// + /// 将 schema 名称转换为 PascalCase 标识符。 + /// + /// 原始名称。 + /// PascalCase 标识符。 + private static string ToPascalCase(string value) + { + var tokens = value + .Split(new[] { '-', '_', '.', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Select(static token => + char.ToUpperInvariant(token[0]) + token.Substring(1)) + .ToArray(); + + return tokens.Length == 0 ? "Config" : string.Concat(tokens); + } + + /// + /// 为 AdditionalFiles 诊断创建文件位置。 + /// + /// 文件路径。 + /// 指向文件开头的位置。 + private static Location CreateFileLocation(string path) + { + return Location.Create( + path, + TextSpan.FromBounds(0, 0), + new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); + } + + /// + /// 表示单个 schema 文件的解析结果。 + /// + /// 成功解析出的 schema 模型。 + /// 解析阶段产生的诊断。 + private sealed record SchemaParseResult( + SchemaFileSpec? Schema, + ImmutableArray Diagnostics) + { + /// + /// 从成功解析的 schema 模型创建结果。 + /// + public static SchemaParseResult FromSchema(SchemaFileSpec schema) + { + return new SchemaParseResult(schema, ImmutableArray.Empty); + } + + /// + /// 从单个诊断创建结果。 + /// + public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic) + { + return new SchemaParseResult(null, ImmutableArray.Create(diagnostic)); + } + } + + /// + /// 表示已解析的 schema 文件模型。 + /// + private sealed record SchemaFileSpec( + string FileName, + string EntityName, + string ClassName, + string TableName, + string Namespace, + string KeyClrType, + IReadOnlyList Properties); + + /// + /// 表示已解析的 schema 属性。 + /// + private sealed record SchemaPropertySpec( + string SchemaName, + string PropertyName, + string SchemaType, + string ClrType, + bool IsRequired, + string? Initializer); + + /// + /// 表示单个属性的解析结果。 + /// + private sealed record ParsedPropertyResult( + SchemaPropertySpec? Property, + Diagnostic? Diagnostic) + { + /// + /// 从属性模型创建成功结果。 + /// + public static ParsedPropertyResult FromProperty(SchemaPropertySpec property) + { + return new ParsedPropertyResult(property, null); + } + + /// + /// 从诊断创建失败结果。 + /// + public static ParsedPropertyResult FromDiagnostic(Diagnostic diagnostic) + { + return new ParsedPropertyResult(null, diagnostic); + } + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs new file mode 100644 index 0000000..9229868 --- /dev/null +++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -0,0 +1,66 @@ +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.SourceGenerators.Diagnostics; + +/// +/// 提供配置 schema 代码生成相关诊断。 +/// +public static class ConfigSchemaDiagnostics +{ + private const string SourceGeneratorsConfigCategory = $"{PathContests.SourceGeneratorsPath}.Config"; + + /// + /// schema JSON 无法解析。 + /// + public static readonly DiagnosticDescriptor InvalidSchemaJson = new( + "GF_ConfigSchema_001", + "Config schema JSON is invalid", + "Schema file '{0}' could not be parsed: {1}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 顶层必须是 object。 + /// + public static readonly DiagnosticDescriptor RootObjectSchemaRequired = new( + "GF_ConfigSchema_002", + "Config schema root must describe an object", + "Schema file '{0}' must declare a root object schema", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 必须声明 id 字段作为主键。 + /// + public static readonly DiagnosticDescriptor IdPropertyRequired = new( + "GF_ConfigSchema_003", + "Config schema must declare an id property", + "Schema file '{0}' must declare a required 'id' property for table generation", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 包含暂不支持的字段类型。 + /// + public static readonly DiagnosticDescriptor UnsupportedPropertyType = new( + "GF_ConfigSchema_004", + "Config schema contains an unsupported property type", + "Property '{1}' in schema file '{0}' uses unsupported type '{2}'", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 的 id 字段类型不支持作为主键。 + /// + public static readonly DiagnosticDescriptor UnsupportedKeyType = new( + "GF_ConfigSchema_005", + "Config schema uses an unsupported key type", + "Schema file '{0}' uses unsupported id type '{1}'. Supported key types are 'integer' and 'string'.", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj b/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj index 99240bd..540ecde 100644 --- a/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj +++ b/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj @@ -24,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers +