From c9d230629564fc1eaf7768daf5de44cec45dee35 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:29:31 +0800
Subject: [PATCH] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0JSON=20sch?=
=?UTF-8?q?ema=E9=85=8D=E7=BD=AE=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现SchemaConfigGenerator源代码生成器
- 支持从JSON schema文件生成配置类型和表包装类
- 添加ConfigSchemaDiagnostics诊断系统
- 集成System.Text.Json包依赖
- 生成强类型的配置访问接口
- 支持多种数据类型包括整数、浮点数、布尔值、字符串和数组
- 实现id字段作为表主键的约束验证
- 添加完整的单元测试和快照验证
---
.../SchemaConfigGeneratorSnapshotTests.cs | 125 +++++
.../Config/SchemaConfigGeneratorTests.cs | 48 ++
.../Config/SchemaGeneratorTestDriver.cs | 87 +++
.../SchemaConfigGenerator/MonsterConfig.g.txt | 32 ++
.../SchemaConfigGenerator/MonsterTable.g.txt | 55 ++
.../AnalyzerReleases.Unshipped.md | 41 +-
.../Config/SchemaConfigGenerator.cs | 526 ++++++++++++++++++
.../Diagnostics/ConfigSchemaDiagnostics.cs | 66 +++
.../GFramework.SourceGenerators.csproj | 1 +
9 files changed, 963 insertions(+), 18 deletions(-)
create mode 100644 GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
create mode 100644 GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
create mode 100644 GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs
create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
create mode 100644 GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
create mode 100644 GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
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
+