From 03580d6836b72d01e6ba77a4805b3b1bfb78d044 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:02:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=8F=8AYAML=20Schema=E6=A0=A1=E9=AA=8C=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现AI-First配表方案,支持怪物、物品、技能等静态内容管理 - 集成YAML配置源文件与JSON Schema结构描述功能 - 提供一对象一文件的目录组织方式和运行时只读查询能力 - 实现Source Generator生成配置类型和表包装类 - 集成VS Code插件提供配置浏览、raw编辑和递归校验功能 - 开发YamlConfigSchemaValidator实现JSON Schema子集校验 - 支持嵌套对象、对象数组、标量数组与深层enum引用约束校验 - 实现跨表引用检测和热重载时依赖表联动校验机制 --- .codex | 0 .../Config/YamlConfigLoaderTests.cs | 299 ++++++ .../Config/YamlConfigSchemaValidator.cs | 888 ++++++++++------ .../SchemaConfigGeneratorSnapshotTests.cs | 34 +- .../Config/SchemaConfigGeneratorTests.cs | 46 + .../SchemaConfigGenerator/MonsterConfig.g.txt | 79 +- .../Config/SchemaConfigGenerator.cs | 780 +++++++++++---- docs/zh-CN/game/config-system.md | 15 +- tools/vscode-config-extension/README.md | 18 +- .../src/configValidation.js | 944 ++++++++++-------- .../vscode-config-extension/src/extension.js | 286 ++++-- .../test/configValidation.test.js | 308 +++--- 12 files changed, 2545 insertions(+), 1152 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index ebdea1c..d5269b8 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -428,6 +428,194 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证嵌套对象中的必填字段同样会按 schema 在运行时生效。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Nested_Object_Is_Missing_Required_Property() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + reward: + gold: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "reward"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "reward": { + "type": "object", + "required": ["gold", "currency"], + "properties": { + "gold": { "type": "integer" }, + "currency": { "type": "string" } + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("reward.currency")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证对象数组中的嵌套字段也会按 schema 递归校验。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Array_Item_Contains_Unknown_Property() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + phases: + - + wave: 1 + monsterId: slime + hpScale: 1.5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "phases"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "phases": { + "type": "array", + "items": { + "type": "object", + "required": ["wave", "monsterId"], + "properties": { + "wave": { "type": "integer" }, + "monsterId": { "type": "string" } + } + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("phases[0].hpScale")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证深层对象数组中的跨表引用也会参与整批加载校验。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Nested_Object_Array_Reference_Target_Is_Missing() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + phases: + - + wave: 1 + dropItemId: potion + - + wave: 2 + dropItemId: bomb + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "phases"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "phases": { + "type": "array", + "items": { + "type": "object", + "required": ["wave", "dropItemId"], + "properties": { + "wave": { "type": "integer" }, + "dropItemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("phases[1].dropItemId")); + Assert.That(exception!.Message, Does.Contain("bomb")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。 /// @@ -949,6 +1137,117 @@ public class YamlConfigLoaderTests public IReadOnlyList DropRates { get; set; } = Array.Empty(); } + /// + /// 用于嵌套对象 schema 校验测试的最小怪物配置类型。 + /// + private sealed class MonsterNestedConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置奖励对象。 + /// + public RewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示嵌套奖励对象的测试桩类型。 + /// + private sealed class RewardConfigStub + { + /// + /// 获取或设置金币数量。 + /// + public int Gold { get; set; } + + /// + /// 获取或设置货币类型。 + /// + public string Currency { get; set; } = string.Empty; + } + + /// + /// 用于对象数组 schema 校验测试的怪物配置类型。 + /// + private sealed class MonsterPhaseArrayConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置阶段数组。 + /// + public IReadOnlyList Phases { get; set; } = Array.Empty(); + } + + /// + /// 表示对象数组中的阶段元素。 + /// + private sealed class PhaseConfigStub + { + /// + /// 获取或设置波次编号。 + /// + public int Wave { get; set; } + + /// + /// 获取或设置怪物主键。 + /// + public string MonsterId { get; set; } = string.Empty; + } + + /// + /// 用于深层跨表引用测试的怪物配置类型。 + /// + private sealed class MonsterPhaseDropConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置阶段数组。 + /// + public List Phases { get; set; } = new(); + } + + /// + /// 表示带有掉落引用的阶段元素。 + /// + private sealed class PhaseDropConfigStub + { + /// + /// 获取或设置波次编号。 + /// + public int Wave { get; set; } + + /// + /// 获取或设置掉落物品主键。 + /// + public string DropItemId { get; set; } = string.Empty; + } + /// /// 用于跨表引用测试的最小物品配置类型。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 857d960..a5efcf4 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -2,8 +2,8 @@ namespace GFramework.Game.Config; /// /// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 -/// 该校验器与当前配置生成器支持的 schema 子集保持一致, -/// 以便在配置进入运行时注册表之前就拒绝缺失字段、未知字段和基础类型错误。 +/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致, +/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 /// internal static class YamlConfigSchemaValidator { @@ -44,56 +44,17 @@ internal static class YamlConfigSchemaValidator { using var document = JsonDocument.Parse(schemaText); var root = document.RootElement; - if (!root.TryGetProperty("type", out var typeElement) || - !string.Equals(typeElement.GetString(), "object", StringComparison.Ordinal)) + var rootNode = ParseNode(schemaPath, "", root, isRoot: true); + if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object) { throw new InvalidOperationException( $"Schema file '{schemaPath}' must declare a root object schema."); } - if (!root.TryGetProperty("properties", out var propertiesElement) || - propertiesElement.ValueKind != JsonValueKind.Object) - { - throw new InvalidOperationException( - $"Schema file '{schemaPath}' must declare an object-valued 'properties' section."); - } + var referencedTableNames = new HashSet(StringComparer.Ordinal); + CollectReferencedTableNames(rootNode, referencedTableNames); - var requiredProperties = new HashSet(StringComparer.Ordinal); - if (root.TryGetProperty("required", out var requiredElement) && - requiredElement.ValueKind == JsonValueKind.Array) - { - foreach (var item in requiredElement.EnumerateArray()) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (item.ValueKind != JsonValueKind.String) - { - continue; - } - - var propertyName = item.GetString(); - if (!string.IsNullOrWhiteSpace(propertyName)) - { - requiredProperties.Add(propertyName); - } - } - } - - var properties = new Dictionary(StringComparer.Ordinal); - foreach (var property in propertiesElement.EnumerateObject()) - { - cancellationToken.ThrowIfCancellationRequested(); - properties.Add(property.Name, ParseProperty(schemaPath, property)); - } - - var referencedTableNames = properties.Values - .Select(static property => property.ReferenceTableName) - .Where(static tableName => !string.IsNullOrWhiteSpace(tableName)) - .Cast() - .Distinct(StringComparer.Ordinal) - .ToArray(); - - return new YamlConfigSchema(schemaPath, properties, requiredProperties, referencedTableNames); + return new YamlConfigSchema(schemaPath, rootNode, referencedTableNames.ToArray()); } catch (JsonException exception) { @@ -149,236 +110,387 @@ internal static class YamlConfigSchemaValidator exception); } - if (yamlStream.Documents.Count != 1 || - yamlStream.Documents[0].RootNode is not YamlMappingNode rootMapping) + if (yamlStream.Documents.Count != 1) { throw new InvalidOperationException( - $"Config file '{yamlPath}' must contain a single root mapping object."); + $"Config file '{yamlPath}' must contain exactly one YAML document."); } var references = new List(); + ValidateNode(yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); + return references; + } + + /// + /// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。 + /// + /// Schema 文件路径。 + /// 当前节点的逻辑属性路径。 + /// Schema JSON 节点。 + /// 是否为根节点。 + /// 可用于运行时校验的节点模型。 + private static YamlConfigSchemaNode ParseNode( + string schemaPath, + string propertyPath, + JsonElement element, + bool isRoot = false) + { + if (!element.TryGetProperty("type", out var typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'type'."); + } + + var typeName = typeElement.GetString() ?? string.Empty; + var referenceTableName = TryGetReferenceTableName(schemaPath, propertyPath, element); + + switch (typeName) + { + case "object": + EnsureReferenceKeywordIsSupported(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Object, + referenceTableName); + return ParseObjectNode(schemaPath, propertyPath, element, isRoot); + + case "array": + return ParseArrayNode(schemaPath, propertyPath, element, referenceTableName); + + case "integer": + return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, element, + referenceTableName); + + case "number": + return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, element, + referenceTableName); + + case "boolean": + return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, element, + referenceTableName); + + case "string": + return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, element, + referenceTableName); + + default: + throw new InvalidOperationException( + $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'."); + } + } + + /// + /// 解析对象节点,保留属性字典与必填集合,以便后续递归校验时逐层定位错误。 + /// + /// Schema 文件路径。 + /// 对象属性路径。 + /// 对象 schema 节点。 + /// 是否为根节点。 + /// 对象节点模型。 + private static YamlConfigSchemaNode ParseObjectNode( + string schemaPath, + string propertyPath, + JsonElement element, + bool isRoot) + { + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + var subject = isRoot ? "root schema" : $"object property '{propertyPath}'"; + throw new InvalidOperationException( + $"The {subject} in schema file '{schemaPath}' must declare an object-valued 'properties' section."); + } + + var requiredProperties = new HashSet(StringComparer.Ordinal); + if (element.TryGetProperty("required", out var requiredElement) && + requiredElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in requiredElement.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + { + continue; + } + + var requiredPropertyName = item.GetString(); + if (!string.IsNullOrWhiteSpace(requiredPropertyName)) + { + requiredProperties.Add(requiredPropertyName); + } + } + } + + var properties = new Dictionary(StringComparer.Ordinal); + foreach (var property in propertiesElement.EnumerateObject()) + { + properties[property.Name] = ParseNode( + schemaPath, + CombineSchemaPath(propertyPath, property.Name), + property.Value); + } + + return new YamlConfigSchemaNode( + YamlConfigSchemaPropertyType.Object, + properties, + requiredProperties, + itemNode: null, + referenceTableName: null, + allowedValues: null, + schemaPath); + } + + /// + /// 解析数组节点。 + /// 当前子集支持标量数组和对象数组,不支持数组嵌套数组。 + /// 当数组声明跨表引用时,会把引用语义挂到元素节点上,便于后续逐项校验。 + /// + /// Schema 文件路径。 + /// 数组属性路径。 + /// 数组 schema 节点。 + /// 声明在数组节点上的目标引用表。 + /// 数组节点模型。 + private static YamlConfigSchemaNode ParseArrayNode( + string schemaPath, + string propertyPath, + JsonElement element, + string? referenceTableName) + { + if (!element.TryGetProperty("items", out var itemsElement) || + itemsElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Array property '{propertyPath}' in schema file '{schemaPath}' must declare an object-valued 'items' schema."); + } + + var itemNode = ParseNode(schemaPath, $"{propertyPath}[]", itemsElement); + if (!string.IsNullOrWhiteSpace(referenceTableName)) + { + if (itemNode.NodeType != YamlConfigSchemaPropertyType.String && + itemNode.NodeType != YamlConfigSchemaPropertyType.Integer) + { + throw new InvalidOperationException( + $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references."); + } + + itemNode = itemNode.WithReferenceTable(referenceTableName); + } + + if (itemNode.NodeType == YamlConfigSchemaPropertyType.Array) + { + throw new InvalidOperationException( + $"Array property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array items."); + } + + return new YamlConfigSchemaNode( + YamlConfigSchemaPropertyType.Array, + properties: null, + requiredProperties: null, + itemNode, + referenceTableName: null, + allowedValues: null, + schemaPath); + } + + /// + /// 创建标量节点,并在解析阶段就完成 enum 与引用约束的兼容性检查。 + /// + /// Schema 文件路径。 + /// 标量属性路径。 + /// 标量类型。 + /// 标量 schema 节点。 + /// 目标引用表名称。 + /// 标量节点模型。 + private static YamlConfigSchemaNode CreateScalarNode( + string schemaPath, + string propertyPath, + YamlConfigSchemaPropertyType nodeType, + JsonElement element, + string? referenceTableName) + { + EnsureReferenceKeywordIsSupported(schemaPath, propertyPath, nodeType, referenceTableName); + return new YamlConfigSchemaNode( + nodeType, + properties: null, + requiredProperties: null, + itemNode: null, + referenceTableName, + ParseEnumValues(schemaPath, propertyPath, element, nodeType, "enum"), + schemaPath); + } + + /// + /// 递归校验 YAML 节点。 + /// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。 + /// + /// YAML 文件路径。 + /// 当前字段路径;根节点时为空。 + /// 实际 YAML 节点。 + /// 对应的 schema 节点。 + /// 已收集的跨表引用。 + private static void ValidateNode( + string yamlPath, + string displayPath, + YamlNode node, + YamlConfigSchemaNode schemaNode, + ICollection references) + { + switch (schemaNode.NodeType) + { + case YamlConfigSchemaPropertyType.Object: + ValidateObjectNode(yamlPath, displayPath, node, schemaNode, references); + return; + + case YamlConfigSchemaPropertyType.Array: + ValidateArrayNode(yamlPath, displayPath, node, schemaNode, references); + return; + + case YamlConfigSchemaPropertyType.Integer: + case YamlConfigSchemaPropertyType.Number: + case YamlConfigSchemaPropertyType.Boolean: + case YamlConfigSchemaPropertyType.String: + ValidateScalarNode(yamlPath, displayPath, node, schemaNode, references); + return; + + default: + throw new InvalidOperationException( + $"Schema node '{displayPath}' uses unsupported runtime node type '{schemaNode.NodeType}'."); + } + } + + /// + /// 校验对象节点,同时处理重复字段、未知字段和深层必填字段。 + /// + /// YAML 文件路径。 + /// 当前对象的逻辑字段路径。 + /// 实际 YAML 节点。 + /// 对象 schema 节点。 + /// 已收集的跨表引用。 + private static void ValidateObjectNode( + string yamlPath, + string displayPath, + YamlNode node, + YamlConfigSchemaNode schemaNode, + ICollection references) + { + if (node is not YamlMappingNode mappingNode) + { + var subject = displayPath.Length == 0 ? "Root object" : $"Property '{displayPath}'"; + throw new InvalidOperationException( + $"{subject} in config file '{yamlPath}' must be an object."); + } + var seenProperties = new HashSet(StringComparer.Ordinal); - foreach (var entry in rootMapping.Children) + foreach (var entry in mappingNode.Children) { if (entry.Key is not YamlScalarNode keyNode || string.IsNullOrWhiteSpace(keyNode.Value)) { + var subject = displayPath.Length == 0 ? "root object" : $"object property '{displayPath}'"; throw new InvalidOperationException( - $"Config file '{yamlPath}' contains a non-scalar or empty top-level property name."); + $"Config file '{yamlPath}' contains a non-scalar or empty property name inside {subject}."); } var propertyName = keyNode.Value; + var propertyPath = CombineDisplayPath(displayPath, propertyName); if (!seenProperties.Add(propertyName)) { throw new InvalidOperationException( - $"Config file '{yamlPath}' contains duplicate property '{propertyName}'."); + $"Config file '{yamlPath}' contains duplicate property '{propertyPath}'."); } - if (!schema.Properties.TryGetValue(propertyName, out var property)) + if (schemaNode.Properties is null || + !schemaNode.Properties.TryGetValue(propertyName, out var propertySchema)) { throw new InvalidOperationException( - $"Config file '{yamlPath}' contains unknown property '{propertyName}' that is not declared in schema '{schema.SchemaPath}'."); + $"Config file '{yamlPath}' contains unknown property '{propertyPath}' that is not declared in schema '{schemaNode.SchemaPathHint}'."); } - ValidateNode(yamlPath, propertyName, entry.Value, property, references); + ValidateNode(yamlPath, propertyPath, entry.Value, propertySchema, references); } - foreach (var requiredProperty in schema.RequiredProperties) + if (schemaNode.RequiredProperties is null) { - if (!seenProperties.Contains(requiredProperty)) + return; + } + + foreach (var requiredProperty in schemaNode.RequiredProperties) + { + if (seenProperties.Contains(requiredProperty)) { - throw new InvalidOperationException( - $"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'."); + continue; } - } - return references; - } - - private static YamlConfigSchemaProperty ParseProperty(string schemaPath, JsonProperty property) - { - if (!property.Value.TryGetProperty("type", out var typeElement) || - typeElement.ValueKind != JsonValueKind.String) - { throw new InvalidOperationException( - $"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'type'."); + $"Config file '{yamlPath}' is missing required property '{CombineDisplayPath(displayPath, requiredProperty)}' defined by schema '{schemaNode.SchemaPathHint}'."); } - - var typeName = typeElement.GetString() ?? string.Empty; - var propertyType = typeName switch - { - "integer" => YamlConfigSchemaPropertyType.Integer, - "number" => YamlConfigSchemaPropertyType.Number, - "boolean" => YamlConfigSchemaPropertyType.Boolean, - "string" => YamlConfigSchemaPropertyType.String, - "array" => YamlConfigSchemaPropertyType.Array, - _ => throw new InvalidOperationException( - $"Property '{property.Name}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.") - }; - - string? referenceTableName = null; - if (property.Value.TryGetProperty("x-gframework-ref-table", out var referenceTableElement)) - { - if (referenceTableElement.ValueKind != JsonValueKind.String) - { - throw new InvalidOperationException( - $"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value."); - } - - referenceTableName = referenceTableElement.GetString(); - if (string.IsNullOrWhiteSpace(referenceTableName)) - { - throw new InvalidOperationException( - $"Property '{property.Name}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value."); - } - } - - if (propertyType != YamlConfigSchemaPropertyType.Array) - { - EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, null, referenceTableName); - return new YamlConfigSchemaProperty( - property.Name, - propertyType, - null, - referenceTableName, - ParseEnumValues(schemaPath, property.Name, property.Value, propertyType, "enum"), - null); - } - - if (!property.Value.TryGetProperty("items", out var itemsElement) || - itemsElement.ValueKind != JsonValueKind.Object || - !itemsElement.TryGetProperty("type", out var itemTypeElement) || - itemTypeElement.ValueKind != JsonValueKind.String) - { - throw new InvalidOperationException( - $"Array property '{property.Name}' in schema file '{schemaPath}' must declare an item type."); - } - - var itemTypeName = itemTypeElement.GetString() ?? string.Empty; - var itemType = itemTypeName switch - { - "integer" => YamlConfigSchemaPropertyType.Integer, - "number" => YamlConfigSchemaPropertyType.Number, - "boolean" => YamlConfigSchemaPropertyType.Boolean, - "string" => YamlConfigSchemaPropertyType.String, - _ => throw new InvalidOperationException( - $"Array property '{property.Name}' in schema file '{schemaPath}' uses unsupported item type '{itemTypeName}'.") - }; - - EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, itemType, referenceTableName); - return new YamlConfigSchemaProperty( - property.Name, - propertyType, - itemType, - referenceTableName, - null, - ParseEnumValues(schemaPath, property.Name, itemsElement, itemType, "items.enum")); } - private static void EnsureReferenceKeywordIsSupported( - string schemaPath, - string propertyName, - YamlConfigSchemaPropertyType propertyType, - YamlConfigSchemaPropertyType? itemType, - string? referenceTableName) - { - if (referenceTableName == null) - { - return; - } - - if (propertyType == YamlConfigSchemaPropertyType.String || - propertyType == YamlConfigSchemaPropertyType.Integer) - { - return; - } - - if (propertyType == YamlConfigSchemaPropertyType.Array && - (itemType == YamlConfigSchemaPropertyType.String || itemType == YamlConfigSchemaPropertyType.Integer)) - { - return; - } - - throw new InvalidOperationException( - $"Property '{propertyName}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references."); - } - - private static void ValidateNode( + /// + /// 校验数组节点,并递归验证每个元素。 + /// + /// YAML 文件路径。 + /// 数组字段路径。 + /// 实际 YAML 节点。 + /// 数组 schema 节点。 + /// 已收集的跨表引用。 + private static void ValidateArrayNode( string yamlPath, - string propertyName, + string displayPath, YamlNode node, - YamlConfigSchemaProperty property, + YamlConfigSchemaNode schemaNode, ICollection references) { - if (property.PropertyType == YamlConfigSchemaPropertyType.Array) + if (node is not YamlSequenceNode sequenceNode) { - if (node is not YamlSequenceNode sequenceNode) - { - throw new InvalidOperationException( - $"Property '{propertyName}' in config file '{yamlPath}' must be an array."); - } - - for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) - { - ValidateScalarNode( - yamlPath, - propertyName, - sequenceNode.Children[itemIndex], - property.ItemType!.Value, - property.ReferenceTableName, - property.ItemAllowedValues, - references, - isArrayItem: true, - itemIndex); - } - - return; + throw new InvalidOperationException( + $"Property '{displayPath}' in config file '{yamlPath}' must be an array."); } - ValidateScalarNode( - yamlPath, - propertyName, - node, - property.PropertyType, - property.ReferenceTableName, - property.AllowedValues, - references, - isArrayItem: false, - itemIndex: null); + if (schemaNode.ItemNode is null) + { + throw new InvalidOperationException( + $"Schema node '{displayPath}' is missing array item information."); + } + + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) + { + ValidateNode( + yamlPath, + $"{displayPath}[{itemIndex}]", + sequenceNode.Children[itemIndex], + schemaNode.ItemNode, + references); + } } + /// + /// 校验标量节点,并在值有效时收集跨表引用。 + /// + /// YAML 文件路径。 + /// 标量字段路径。 + /// 实际 YAML 节点。 + /// 标量 schema 节点。 + /// 已收集的跨表引用。 private static void ValidateScalarNode( string yamlPath, - string propertyName, + string displayPath, YamlNode node, - YamlConfigSchemaPropertyType expectedType, - string? referenceTableName, - IReadOnlyCollection? allowedValues, - ICollection references, - bool isArrayItem, - int? itemIndex) + YamlConfigSchemaNode schemaNode, + ICollection references) { if (node is not YamlScalarNode scalarNode) { - var subject = isArrayItem - ? $"Array item in property '{propertyName}'" - : $"Property '{propertyName}'"; throw new InvalidOperationException( - $"{subject} in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(expectedType)}'."); + $"Property '{displayPath}' in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(schemaNode.NodeType)}'."); } var value = scalarNode.Value; if (value is null) { - var subject = isArrayItem - ? $"Array item in property '{propertyName}'" - : $"Property '{propertyName}'"; throw new InvalidOperationException( - $"{subject} in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(expectedType)}'."); + $"Property '{displayPath}' in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(schemaNode.NodeType)}'."); } var tag = scalarNode.Tag.ToString(); - var isValid = expectedType switch + var isValid = schemaNode.NodeType switch { YamlConfigSchemaPropertyType.String => IsStringScalar(tag), YamlConfigSchemaPropertyType.Integer => long.TryParse( @@ -395,44 +507,44 @@ internal static class YamlConfigSchemaValidator _ => false }; - if (isValid) + if (!isValid) { - var normalizedValue = NormalizeScalarValue(expectedType, value); - if (allowedValues is { Count: > 0 } && - !allowedValues.Contains(normalizedValue, StringComparer.Ordinal)) - { - var enumSubject = isArrayItem - ? $"Array item in property '{propertyName}'" - : $"Property '{propertyName}'"; - throw new InvalidOperationException( - $"{enumSubject} in config file '{yamlPath}' must be one of [{string.Join(", ", allowedValues)}], but the current YAML scalar value is '{value}'."); - } - - if (referenceTableName != null) - { - references.Add( - new YamlConfigReferenceUsage( - yamlPath, - propertyName, - itemIndex, - normalizedValue, - referenceTableName, - expectedType)); - } - - return; + throw new InvalidOperationException( + $"Property '{displayPath}' in config file '{yamlPath}' must be of type '{GetTypeName(schemaNode.NodeType)}', but the current YAML scalar value is '{value}'."); } - var subjectName = isArrayItem - ? $"Array item in property '{propertyName}'" - : $"Property '{propertyName}'"; - throw new InvalidOperationException( - $"{subjectName} in config file '{yamlPath}' must be of type '{GetTypeName(expectedType)}', but the current YAML scalar value is '{value}'."); + var normalizedValue = NormalizeScalarValue(schemaNode.NodeType, value); + if (schemaNode.AllowedValues is { Count: > 0 } && + !schemaNode.AllowedValues.Contains(normalizedValue, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"Property '{displayPath}' in config file '{yamlPath}' must be one of [{string.Join(", ", schemaNode.AllowedValues)}], but the current YAML scalar value is '{value}'."); + } + + if (schemaNode.ReferenceTableName != null) + { + references.Add( + new YamlConfigReferenceUsage( + yamlPath, + displayPath, + normalizedValue, + schemaNode.ReferenceTableName, + schemaNode.NodeType)); + } } + /// + /// 解析 enum,并在读取阶段验证枚举值与字段类型的兼容性。 + /// + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 期望的标量类型。 + /// 当前读取的关键字名称。 + /// 归一化后的枚举值集合;未声明时返回空。 private static IReadOnlyCollection? ParseEnumValues( string schemaPath, - string propertyName, + string propertyPath, JsonElement element, YamlConfigSchemaPropertyType expectedType, string keywordName) @@ -445,21 +557,119 @@ internal static class YamlConfigSchemaValidator if (enumElement.ValueKind != JsonValueKind.Array) { throw new InvalidOperationException( - $"Property '{propertyName}' in schema file '{schemaPath}' must declare '{keywordName}' as an array."); + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as an array."); } var allowedValues = new List(); foreach (var item in enumElement.EnumerateArray()) { - allowedValues.Add(NormalizeEnumValue(schemaPath, propertyName, keywordName, expectedType, item)); + allowedValues.Add(NormalizeEnumValue(schemaPath, propertyPath, keywordName, expectedType, item)); } return allowedValues; } + /// + /// 解析跨表引用目标表名称。 + /// + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 目标表名称;未声明时返回空。 + private static string? TryGetReferenceTableName( + string schemaPath, + string propertyPath, + JsonElement element) + { + if (!element.TryGetProperty("x-gframework-ref-table", out var referenceTableElement)) + { + return null; + } + + if (referenceTableElement.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value."); + } + + var referenceTableName = referenceTableElement.GetString(); + if (string.IsNullOrWhiteSpace(referenceTableName)) + { + throw new InvalidOperationException( + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value."); + } + + return referenceTableName; + } + + /// + /// 验证哪些 schema 类型允许声明跨表引用。 + /// + /// Schema 文件路径。 + /// 字段路径。 + /// 字段类型。 + /// 目标表名称。 + private static void EnsureReferenceKeywordIsSupported( + string schemaPath, + string propertyPath, + YamlConfigSchemaPropertyType propertyType, + string? referenceTableName) + { + if (referenceTableName == null) + { + return; + } + + if (propertyType == YamlConfigSchemaPropertyType.String || + propertyType == YamlConfigSchemaPropertyType.Integer) + { + return; + } + + throw new InvalidOperationException( + $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references."); + } + + /// + /// 递归收集 schema 中声明的目标表名称。 + /// + /// 当前 schema 节点。 + /// 输出集合。 + private static void CollectReferencedTableNames( + YamlConfigSchemaNode node, + ISet referencedTableNames) + { + if (node.ReferenceTableName != null) + { + referencedTableNames.Add(node.ReferenceTableName); + } + + if (node.Properties is not null) + { + foreach (var property in node.Properties.Values) + { + CollectReferencedTableNames(property, referencedTableNames); + } + } + + if (node.ItemNode is not null) + { + CollectReferencedTableNames(node.ItemNode, referencedTableNames); + } + } + + /// + /// 将 schema 中的 enum 单值归一化到运行时比较字符串。 + /// + /// Schema 文件路径。 + /// 字段路径。 + /// 关键字名称。 + /// 期望的标量类型。 + /// 当前枚举值节点。 + /// 归一化后的字符串值。 private static string NormalizeEnumValue( string schemaPath, - string propertyName, + string propertyPath, string keywordName, YamlConfigSchemaPropertyType expectedType, JsonElement item) @@ -484,10 +694,16 @@ internal static class YamlConfigSchemaValidator catch { throw new InvalidOperationException( - $"Property '{propertyName}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'."); + $"Property '{propertyPath}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'."); } } + /// + /// 将 YAML 标量值规范化成运行时比较格式。 + /// + /// 期望的标量类型。 + /// 原始字符串值。 + /// 归一化后的字符串。 private static string NormalizeScalarValue(YamlConfigSchemaPropertyType expectedType, string value) { return expectedType switch @@ -506,6 +722,11 @@ internal static class YamlConfigSchemaValidator }; } + /// + /// 获取 schema 类型的可读名称,用于错误信息。 + /// + /// Schema 节点类型。 + /// 可读类型名。 private static string GetTypeName(YamlConfigSchemaPropertyType type) { return type switch @@ -515,10 +736,39 @@ internal static class YamlConfigSchemaValidator YamlConfigSchemaPropertyType.Boolean => "boolean", YamlConfigSchemaPropertyType.String => "string", YamlConfigSchemaPropertyType.Array => "array", + YamlConfigSchemaPropertyType.Object => "object", _ => type.ToString() }; } + /// + /// 组合 schema 中的逻辑路径,便于诊断时指出深层字段。 + /// + /// 父级路径。 + /// 当前属性名。 + /// 组合后的路径。 + private static string CombineSchemaPath(string parentPath, string propertyName) + { + return parentPath == "" ? propertyName : $"{parentPath}.{propertyName}"; + } + + /// + /// 组合 YAML 诊断展示路径。 + /// + /// 父级路径。 + /// 当前属性名。 + /// 组合后的路径。 + private static string CombineDisplayPath(string parentPath, string propertyName) + { + return string.IsNullOrWhiteSpace(parentPath) ? propertyName : $"{parentPath}.{propertyName}"; + } + + /// + /// 判断当前标量是否应按字符串处理。 + /// 这里显式排除 YAML 的数字、布尔和 null 标签,避免未加引号的值被当成字符串混入运行时。 + /// + /// YAML 标量标签。 + /// 是否为字符串标量。 private static bool IsStringScalar(string tag) { if (string.IsNullOrWhiteSpace(tag)) @@ -535,7 +785,7 @@ internal static class YamlConfigSchemaValidator /// /// 表示已解析并可用于运行时校验的 JSON Schema。 -/// 该模型只保留当前运行时加载器真正需要的最小信息,以避免在游戏运行时引入完整 schema 引擎。 +/// 该模型保留根节点与引用依赖集合,避免运行时引入完整 schema 引擎。 /// internal sealed class YamlConfigSchema { @@ -543,23 +793,19 @@ internal sealed class YamlConfigSchema /// 初始化一个可用于运行时校验的 schema 模型。 /// /// Schema 文件路径。 - /// Schema 属性定义。 - /// 必填属性集合。 + /// 根节点模型。 /// Schema 声明的目标引用表名称集合。 public YamlConfigSchema( string schemaPath, - IReadOnlyDictionary properties, - IReadOnlyCollection requiredProperties, + YamlConfigSchemaNode rootNode, IReadOnlyCollection referencedTableNames) { ArgumentNullException.ThrowIfNull(schemaPath); - ArgumentNullException.ThrowIfNull(properties); - ArgumentNullException.ThrowIfNull(requiredProperties); + ArgumentNullException.ThrowIfNull(rootNode); ArgumentNullException.ThrowIfNull(referencedTableNames); SchemaPath = schemaPath; - Properties = properties; - RequiredProperties = requiredProperties; + RootNode = rootNode; ReferencedTableNames = referencedTableNames; } @@ -569,14 +815,9 @@ internal sealed class YamlConfigSchema public string SchemaPath { get; } /// - /// 获取按属性名索引的 schema 属性定义。 + /// 获取根节点模型。 /// - public IReadOnlyDictionary Properties { get; } - - /// - /// 获取 schema 声明的必填属性集合。 - /// - public IReadOnlyCollection RequiredProperties { get; } + public YamlConfigSchemaNode RootNode { get; } /// /// 获取 schema 声明的目标引用表名称集合。 @@ -586,51 +827,58 @@ internal sealed class YamlConfigSchema } /// -/// 表示单个 schema 属性的最小运行时描述。 +/// 表示单个 schema 节点的最小运行时描述。 +/// 同一个模型同时覆盖对象、数组和标量,便于递归校验逻辑只依赖一种树结构。 /// -internal sealed class YamlConfigSchemaProperty +internal sealed class YamlConfigSchemaNode { /// - /// 初始化一个 schema 属性描述。 + /// 初始化一个 schema 节点描述。 /// - /// 属性名称。 - /// 属性类型。 - /// 数组元素类型;仅当属性类型为数组时有效。 - /// 目标引用表名称;未声明跨表引用时为空。 - /// 标量允许值集合;未声明 enum 时为空。 - /// 数组元素允许值集合;未声明 items.enum 时为空。 - public YamlConfigSchemaProperty( - string name, - YamlConfigSchemaPropertyType propertyType, - YamlConfigSchemaPropertyType? itemType, + /// 节点类型。 + /// 对象属性集合。 + /// 对象必填属性集合。 + /// 数组元素节点。 + /// 目标引用表名称。 + /// 标量允许值集合。 + /// 用于错误信息的 schema 文件路径提示。 + public YamlConfigSchemaNode( + YamlConfigSchemaPropertyType nodeType, + IReadOnlyDictionary? properties, + IReadOnlyCollection? requiredProperties, + YamlConfigSchemaNode? itemNode, string? referenceTableName, IReadOnlyCollection? allowedValues, - IReadOnlyCollection? itemAllowedValues) + string schemaPathHint) { - ArgumentNullException.ThrowIfNull(name); - - Name = name; - PropertyType = propertyType; - ItemType = itemType; + NodeType = nodeType; + Properties = properties; + RequiredProperties = requiredProperties; + ItemNode = itemNode; ReferenceTableName = referenceTableName; AllowedValues = allowedValues; - ItemAllowedValues = itemAllowedValues; + SchemaPathHint = schemaPathHint; } /// - /// 获取属性名称。 + /// 获取节点类型。 /// - public string Name { get; } + public YamlConfigSchemaPropertyType NodeType { get; } /// - /// 获取属性类型。 + /// 获取对象属性集合;非对象节点时返回空。 /// - public YamlConfigSchemaPropertyType PropertyType { get; } + public IReadOnlyDictionary? Properties { get; } /// - /// 获取数组元素类型;非数组属性时返回空。 + /// 获取对象必填属性集合;非对象节点时返回空。 /// - public YamlConfigSchemaPropertyType? ItemType { get; } + public IReadOnlyCollection? RequiredProperties { get; } + + /// + /// 获取数组元素节点;非数组节点时返回空。 + /// + public YamlConfigSchemaNode? ItemNode { get; } /// /// 获取目标引用表名称;未声明跨表引用时返回空。 @@ -643,9 +891,28 @@ internal sealed class YamlConfigSchemaProperty public IReadOnlyCollection? AllowedValues { get; } /// - /// 获取数组元素允许值集合;未声明 items.enum 时返回空。 + /// 获取用于诊断显示的 schema 路径提示。 + /// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。 /// - public IReadOnlyCollection? ItemAllowedValues { get; } + public string SchemaPathHint { get; } + + /// + /// 基于当前节点复制一个只替换引用表名称的新节点。 + /// 该方法用于把数组级别的 ref-table 语义挂接到元素节点上。 + /// + /// 新的目标引用表名称。 + /// 复制后的节点。 + public YamlConfigSchemaNode WithReferenceTable(string referenceTableName) + { + return new YamlConfigSchemaNode( + NodeType, + Properties, + RequiredProperties, + ItemNode, + referenceTableName, + AllowedValues, + SchemaPathHint); + } } /// @@ -658,27 +925,24 @@ internal sealed class YamlConfigReferenceUsage /// 初始化一个跨表引用使用记录。 /// /// 源 YAML 文件路径。 - /// 声明引用的属性名。 - /// 数组元素索引;标量属性时为空。 + /// 声明引用的字段路径。 /// YAML 中的原始标量值。 /// 目标配置表名称。 /// 引用值的 schema 标量类型。 public YamlConfigReferenceUsage( string yamlPath, - string propertyName, - int? itemIndex, + string propertyPath, string rawValue, string referencedTableName, YamlConfigSchemaPropertyType valueType) { ArgumentNullException.ThrowIfNull(yamlPath); - ArgumentNullException.ThrowIfNull(propertyName); + ArgumentNullException.ThrowIfNull(propertyPath); ArgumentNullException.ThrowIfNull(rawValue); ArgumentNullException.ThrowIfNull(referencedTableName); YamlPath = yamlPath; - PropertyName = propertyName; - ItemIndex = itemIndex; + PropertyPath = propertyPath; RawValue = rawValue; ReferencedTableName = referencedTableName; ValueType = valueType; @@ -690,14 +954,9 @@ internal sealed class YamlConfigReferenceUsage public string YamlPath { get; } /// - /// 获取声明引用的属性名。 + /// 获取声明引用的字段路径。 /// - public string PropertyName { get; } - - /// - /// 获取数组元素索引;标量属性时返回空。 - /// - public int? ItemIndex { get; } + public string PropertyPath { get; } /// /// 获取 YAML 中的原始标量值。 @@ -717,7 +976,7 @@ internal sealed class YamlConfigReferenceUsage /// /// 获取便于诊断显示的字段路径。 /// - public string DisplayPath => ItemIndex.HasValue ? $"{PropertyName}[{ItemIndex.Value}]" : PropertyName; + public string DisplayPath => PropertyPath; } /// @@ -725,6 +984,11 @@ internal sealed class YamlConfigReferenceUsage /// internal enum YamlConfigSchemaPropertyType { + /// + /// 对象类型。 + /// + Object, + /// /// 整数类型。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 242bfe9..561555d 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -43,7 +43,7 @@ public class SchemaConfigGeneratorSnapshotTests "title": "Monster Config", "description": "Represents one monster entry generated from schema metadata.", "type": "object", - "required": ["id", "name"], + "required": ["id", "name", "reward", "phases"], "properties": { "id": { "type": "integer", @@ -69,6 +69,38 @@ public class SchemaConfigGeneratorSnapshotTests }, "default": ["potion"], "x-gframework-ref-table": "item" + }, + "reward": { + "type": "object", + "description": "Reward payload.", + "required": ["gold", "currency"], + "properties": { + "gold": { + "type": "integer", + "default": 10 + }, + "currency": { + "type": "string", + "enum": ["coin", "gem"] + } + } + }, + "phases": { + "type": "array", + "description": "Encounter phases.", + "items": { + "type": "object", + "required": ["wave", "monsterId"], + "properties": { + "wave": { + "type": "integer" + }, + "monsterId": { + "type": "string", + "description": "Monster reference id." + } + } + } } } } diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 6a71777..507bf36 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -45,4 +45,50 @@ public class SchemaConfigGeneratorTests Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json")); }); } + + /// + /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Nested_Array_Type_Is_Not_Supported() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "integer" }, + "waves": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "integer" } + } + } + } + } + """; + + 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_004")); + Assert.That(diagnostic.GetMessage(), Does.Contain("waves")); + Assert.That(diagnostic.GetMessage(), Does.Contain("array")); + }); + } } \ 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 index 6b4a36a..d4eda3c 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -13,7 +13,7 @@ public sealed partial class MonsterConfig /// Unique monster identifier. /// /// - /// Schema property: 'id'. + /// Schema property path: 'id'. /// public int Id { get; set; } @@ -21,7 +21,7 @@ public sealed partial class MonsterConfig /// Localized monster display name. /// /// - /// Schema property: 'name'. + /// Schema property path: 'name'. /// Display title: 'Monster Name'. /// Allowed values: Slime, Goblin. /// Generated default initializer: = "Slime"; @@ -29,10 +29,10 @@ public sealed partial class MonsterConfig public string Name { get; set; } = "Slime"; /// - /// Gets or sets the value mapped from schema property 'hp'. + /// Gets or sets the value mapped from schema property path 'hp'. /// /// - /// Schema property: 'hp'. + /// Schema property path: 'hp'. /// Generated default initializer: = 10; /// public int? Hp { get; set; } = 10; @@ -41,11 +41,80 @@ public sealed partial class MonsterConfig /// Referenced drop ids. /// /// - /// Schema property: 'dropItems'. + /// Schema property path: 'dropItems'. /// Allowed values: potion, slime_gel. /// References config table: 'item'. /// Generated default initializer: = new string[] { "potion" }; /// public global::System.Collections.Generic.IReadOnlyList DropItems { get; set; } = new string[] { "potion" }; + /// + /// Reward payload. + /// + /// + /// Schema property path: 'reward'. + /// Generated default initializer: = new(); + /// + public RewardConfig Reward { get; set; } = new(); + + /// + /// Encounter phases. + /// + /// + /// Schema property path: 'phases'. + /// Generated default initializer: = global::System.Array.Empty<PhasesItemConfig>(); + /// + public global::System.Collections.Generic.IReadOnlyList Phases { get; set; } = global::System.Array.Empty(); + + /// + /// Auto-generated nested config type for schema property path 'reward'. + /// Reward payload. + /// + public sealed partial class RewardConfig + { + /// + /// Gets or sets the value mapped from schema property path 'reward.gold'. + /// + /// + /// Schema property path: 'reward.gold'. + /// Generated default initializer: = 10; + /// + public int Gold { get; set; } = 10; + + /// + /// Gets or sets the value mapped from schema property path 'reward.currency'. + /// + /// + /// Schema property path: 'reward.currency'. + /// Allowed values: coin, gem. + /// Generated default initializer: = string.Empty; + /// + public string Currency { get; set; } = string.Empty; + + } + + /// + /// Auto-generated nested config type for schema property path 'phases[]'. + /// This nested type is generated so object-valued schema fields remain strongly typed in consumer code. + /// + public sealed partial class PhasesItemConfig + { + /// + /// Gets or sets the value mapped from schema property path 'phases[].wave'. + /// + /// + /// Schema property path: 'phases[].wave'. + /// + public int Wave { get; set; } + + /// + /// Monster reference id. + /// + /// + /// Schema property path: 'phases[].monsterId'. + /// Generated default initializer: = string.Empty; + /// + public string MonsterId { get; set; } = string.Empty; + + } } diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 174f0ed..663f531 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -8,7 +8,8 @@ namespace GFramework.SourceGenerators.Config; /// /// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 -/// 当前实现聚焦 Runtime MVP 需要的最小能力:单 schema 对应单配置类型,并约定使用必填的 id 字段作为表主键。 +/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集, +/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。 /// [Generator] public sealed class SchemaConfigGenerator : IIncrementalGenerator @@ -92,46 +93,20 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator Path.GetFileName(file.Path))); } - if (!root.TryGetProperty("properties", out var propertiesElement) || - propertiesElement.ValueKind != JsonValueKind.Object) + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); + var rootObject = ParseObjectSpec( + file.Path, + root, + "", + $"{entityName}Config", + isRoot: true); + if (rootObject.Diagnostic is not null) { - return SchemaParseResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.RootObjectSchemaRequired, - CreateFileLocation(file.Path), - Path.GetFileName(file.Path))); + return SchemaParseResult.FromDiagnostic(rootObject.Diagnostic); } - 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 => + var schemaObject = rootObject.Object!; + var idProperty = schemaObject.Properties.FirstOrDefault(static property => string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase)); if (idProperty is null || !idProperty.IsRequired) { @@ -142,28 +117,26 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator Path.GetFileName(file.Path))); } - if (!string.Equals(idProperty.SchemaType, "integer", StringComparison.Ordinal) && - !string.Equals(idProperty.SchemaType, "string", StringComparison.Ordinal)) + if (idProperty.TypeSpec.SchemaType != "integer" && + idProperty.TypeSpec.SchemaType != "string") { return SchemaParseResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedKeyType, CreateFileLocation(file.Path), Path.GetFileName(file.Path), - idProperty.SchemaType)); + idProperty.TypeSpec.SchemaType)); } - var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var schema = new SchemaFileSpec( Path.GetFileName(file.Path), - entityName, - $"{entityName}Config", + schemaObject.ClassName, $"{entityName}Table", GeneratedNamespace, - idProperty.ClrType, + idProperty.TypeSpec.ClrType.TrimEnd('?'), TryGetMetadataString(root, "title"), TryGetMetadataString(root, "description"), - properties); + schemaObject); return SchemaParseResult.FromSchema(schema); } @@ -178,17 +151,86 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + /// + /// 解析对象 schema,并递归构建子属性模型。 + /// + /// Schema 文件路径。 + /// 对象 schema 节点。 + /// 当前对象的逻辑字段路径。 + /// 要生成的 CLR 类型名。 + /// 是否为根对象。 + /// 对象模型或诊断。 + private static ParsedObjectResult ParseObjectSpec( + string filePath, + JsonElement element, + string displayPath, + string className, + bool isRoot = false) + { + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + return ParsedObjectResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(filePath), + Path.GetFileName(filePath))); + } + + var requiredProperties = new HashSet(StringComparer.OrdinalIgnoreCase); + if (element.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( + filePath, + property, + requiredProperties.Contains(property.Name), + CombinePath(displayPath, property.Name)); + if (parsedProperty.Diagnostic is not null) + { + return ParsedObjectResult.FromDiagnostic(parsedProperty.Diagnostic); + } + + properties.Add(parsedProperty.Property!); + } + + return ParsedObjectResult.FromObject(new SchemaObjectSpec( + displayPath, + className, + TryGetMetadataString(element, "title"), + TryGetMetadataString(element, "description"), + properties)); + } + /// /// 解析单个 schema 属性定义。 /// - /// schema 文件路径。 + /// Schema 文件路径。 /// 属性 JSON 节点。 /// 属性是否必填。 + /// 逻辑字段路径。 /// 解析后的属性信息或诊断。 private static ParsedPropertyResult ParseProperty( string filePath, JsonProperty property, - bool isRequired) + bool isRequired, + string displayPath) { if (!property.Value.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) @@ -198,7 +240,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), - property.Name, + displayPath, "")); } @@ -206,109 +248,126 @@ 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); switch (schemaType) { case "integer": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, - ToPascalCase(property.Name), - "integer", - isRequired ? "int" : "int?", + displayPath, + propertyName, isRequired, - TryBuildScalarInitializer(property.Value, "integer"), title, description, - TryBuildEnumDocumentation(property.Value, "integer"), - refTableName)); + new SchemaTypeSpec( + SchemaNodeKind.Scalar, + "integer", + isRequired ? "int" : "int?", + TryBuildScalarInitializer(property.Value, "integer"), + TryBuildEnumDocumentation(property.Value, "integer"), + refTableName, + null, + null))); case "number": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, - ToPascalCase(property.Name), - "number", - isRequired ? "double" : "double?", + displayPath, + propertyName, isRequired, - TryBuildScalarInitializer(property.Value, "number"), title, description, - TryBuildEnumDocumentation(property.Value, "number"), - refTableName)); + new SchemaTypeSpec( + SchemaNodeKind.Scalar, + "number", + isRequired ? "double" : "double?", + TryBuildScalarInitializer(property.Value, "number"), + TryBuildEnumDocumentation(property.Value, "number"), + refTableName, + null, + null))); case "boolean": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, - ToPascalCase(property.Name), - "boolean", - isRequired ? "bool" : "bool?", + displayPath, + propertyName, isRequired, - TryBuildScalarInitializer(property.Value, "boolean"), title, description, - TryBuildEnumDocumentation(property.Value, "boolean"), - refTableName)); + new SchemaTypeSpec( + SchemaNodeKind.Scalar, + "boolean", + isRequired ? "bool" : "bool?", + TryBuildScalarInitializer(property.Value, "boolean"), + TryBuildEnumDocumentation(property.Value, "boolean"), + refTableName, + null, + null))); case "string": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, - ToPascalCase(property.Name), - "string", - isRequired ? "string" : "string?", + displayPath, + propertyName, isRequired, - TryBuildScalarInitializer(property.Value, "string") ?? - (isRequired ? " = string.Empty;" : null), title, description, - TryBuildEnumDocumentation(property.Value, "string"), - refTableName)); + new SchemaTypeSpec( + SchemaNodeKind.Scalar, + "string", + isRequired ? "string" : "string?", + TryBuildScalarInitializer(property.Value, "string") ?? + (isRequired ? " = string.Empty;" : null), + TryBuildEnumDocumentation(property.Value, "string"), + refTableName, + null, + null))); - case "array": - if (!property.Value.TryGetProperty("items", out var itemsElement) || - !itemsElement.TryGetProperty("type", out var itemTypeElement) || - itemTypeElement.ValueKind != JsonValueKind.String) + case "object": + if (!string.IsNullOrWhiteSpace(refTableName)) { return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), - property.Name, - "array")); + displayPath, + "object-ref")); } - var itemType = itemTypeElement.GetString() ?? string.Empty; - var itemClrType = itemType switch + var objectResult = ParseObjectSpec( + filePath, + property.Value, + displayPath, + $"{propertyName}Config"); + if (objectResult.Diagnostic is not null) { - "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.FromDiagnostic(objectResult.Diagnostic); } + var objectSpec = objectResult.Object!; return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, - ToPascalCase(property.Name), - "array", - $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", + displayPath, + propertyName, isRequired, - TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? - " = global::System.Array.Empty<" + itemClrType + ">();", title, description, - TryBuildEnumDocumentation(itemsElement, itemType), - refTableName)); + new SchemaTypeSpec( + SchemaNodeKind.Object, + "object", + isRequired ? objectSpec.ClassName : $"{objectSpec.ClassName}?", + isRequired ? " = new();" : null, + null, + null, + objectSpec, + null))); + + case "array": + return ParseArrayProperty(filePath, property, isRequired, displayPath, propertyName, title, + description, refTableName); default: return ParsedPropertyResult.FromDiagnostic( @@ -316,11 +375,147 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), - property.Name, + displayPath, schemaType)); } } + /// + /// 解析数组属性,支持标量数组与对象数组。 + /// + /// Schema 文件路径。 + /// 属性 JSON 节点。 + /// 属性是否必填。 + /// 逻辑字段路径。 + /// CLR 属性名。 + /// 标题元数据。 + /// 说明元数据。 + /// 目标引用表名称。 + /// 解析后的属性信息或诊断。 + private static ParsedPropertyResult ParseArrayProperty( + string filePath, + JsonProperty property, + bool isRequired, + string displayPath, + string propertyName, + string? title, + string? description, + string? refTableName) + { + if (!property.Value.TryGetProperty("items", out var itemsElement) || + itemsElement.ValueKind != JsonValueKind.Object || + !itemsElement.TryGetProperty("type", out var itemTypeElement) || + itemTypeElement.ValueKind != JsonValueKind.String) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "array")); + } + + var itemType = itemTypeElement.GetString() ?? string.Empty; + switch (itemType) + { + case "integer": + case "number": + case "boolean": + case "string": + var itemClrType = itemType switch + { + "integer" => "int", + "number" => "double", + "boolean" => "bool", + _ => "string" + }; + + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + displayPath, + propertyName, + isRequired, + title, + description, + new SchemaTypeSpec( + SchemaNodeKind.Array, + "array", + $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", + TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? + $" = global::System.Array.Empty<{itemClrType}>();", + TryBuildEnumDocumentation(itemsElement, itemType), + refTableName, + null, + new SchemaTypeSpec( + SchemaNodeKind.Scalar, + itemType, + itemClrType, + null, + TryBuildEnumDocumentation(itemsElement, itemType), + refTableName, + null, + null)))); + + case "object": + if (!string.IsNullOrWhiteSpace(refTableName)) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "array-ref")); + } + + var objectResult = ParseObjectSpec( + filePath, + itemsElement, + $"{displayPath}[]", + $"{propertyName}ItemConfig"); + if (objectResult.Diagnostic is not null) + { + return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic); + } + + var objectSpec = objectResult.Object!; + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + displayPath, + propertyName, + isRequired, + title, + description, + new SchemaTypeSpec( + SchemaNodeKind.Array, + "array", + $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", + $" = global::System.Array.Empty<{objectSpec.ClassName}>();", + null, + null, + null, + new SchemaTypeSpec( + SchemaNodeKind.Object, + "object", + objectSpec.ClassName, + null, + null, + null, + objectSpec, + null)))); + + default: + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"array<{itemType}>")); + } + } + /// /// 生成配置类型源码。 /// @@ -334,34 +529,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); builder.AppendLine($"namespace {schema.Namespace};"); builder.AppendLine(); - builder.AppendLine("/// "); - builder.AppendLine( - $"/// Auto-generated config type for schema file '{schema.FileName}'."); - builder.AppendLine( - $"/// {EscapeXmlDocumentation(schema.Description ?? schema.Title ?? "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) - { - AppendPropertyDocumentation(builder, property); - builder.Append($" public {property.ClrType} {property.PropertyName} {{ get; set; }}"); - if (!string.IsNullOrEmpty(property.Initializer)) - { - builder.Append(property.Initializer); - } - - builder.AppendLine(); - builder.AppendLine(); - } - - builder.AppendLine("}"); + AppendObjectType(builder, schema.RootObject, schema.FileName, schema.Title, schema.Description, isRoot: true, + indentationLevel: 0); return builder.ToString().TrimEnd(); } /// - /// 生成配置表包装源码。 + /// 生成表包装源码。 /// /// 已解析的 schema 模型。 /// 配置表包装源码。 @@ -432,11 +607,161 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return builder.ToString().TrimEnd(); } + /// + /// 递归生成配置对象类型。 + /// + /// 输出缓冲区。 + /// 要生成的对象类型。 + /// Schema 文件名。 + /// 对象标题元数据。 + /// 对象说明元数据。 + /// 是否为根配置类型。 + /// 缩进层级。 + private static void AppendObjectType( + StringBuilder builder, + SchemaObjectSpec objectSpec, + string fileName, + string? title, + string? description, + bool isRoot, + int indentationLevel) + { + var indent = new string(' ', indentationLevel * 4); + builder.AppendLine($"{indent}/// "); + if (isRoot) + { + builder.AppendLine( + $"{indent}/// Auto-generated config type for schema file '{fileName}'."); + builder.AppendLine( + $"{indent}/// {EscapeXmlDocumentation(description ?? title ?? "This type is generated from JSON schema so runtime loading and editor tooling can share the same contract.")}"); + } + else + { + builder.AppendLine( + $"{indent}/// Auto-generated nested config type for schema property path '{EscapeXmlDocumentation(objectSpec.DisplayPath)}'."); + builder.AppendLine( + $"{indent}/// {EscapeXmlDocumentation(description ?? title ?? "This nested type is generated so object-valued schema fields remain strongly typed in consumer code.")}"); + } + + builder.AppendLine($"{indent}/// "); + builder.AppendLine($"{indent}public sealed partial class {objectSpec.ClassName}"); + builder.AppendLine($"{indent}{{"); + + for (var index = 0; index < objectSpec.Properties.Count; index++) + { + var property = objectSpec.Properties[index]; + AppendPropertyDocumentation(builder, property, indentationLevel + 1); + + var propertyIndent = new string(' ', (indentationLevel + 1) * 4); + builder.Append( + $"{propertyIndent}public {property.TypeSpec.ClrType} {property.PropertyName} {{ get; set; }}"); + if (!string.IsNullOrEmpty(property.TypeSpec.Initializer)) + { + builder.Append(property.TypeSpec.Initializer); + } + + builder.AppendLine(); + builder.AppendLine(); + } + + var nestedTypes = CollectNestedTypes(objectSpec.Properties).ToArray(); + for (var index = 0; index < nestedTypes.Length; index++) + { + var nestedType = nestedTypes[index]; + AppendObjectType( + builder, + nestedType, + fileName, + nestedType.Title, + nestedType.Description, + isRoot: false, + indentationLevel: indentationLevel + 1); + + if (index < nestedTypes.Length - 1) + { + builder.AppendLine(); + } + } + + builder.AppendLine($"{indent}}}"); + } + + /// + /// 枚举一个对象直接拥有的嵌套类型。 + /// + /// 对象属性集合。 + /// 嵌套对象类型序列。 + private static IEnumerable CollectNestedTypes(IEnumerable properties) + { + foreach (var property in properties) + { + if (property.TypeSpec.Kind == SchemaNodeKind.Object && property.TypeSpec.NestedObject is not null) + { + yield return property.TypeSpec.NestedObject; + continue; + } + + if (property.TypeSpec.Kind == SchemaNodeKind.Array && + property.TypeSpec.ItemTypeSpec?.Kind == SchemaNodeKind.Object && + property.TypeSpec.ItemTypeSpec.NestedObject is not null) + { + yield return property.TypeSpec.ItemTypeSpec.NestedObject; + } + } + } + + /// + /// 为生成属性输出 XML 文档。 + /// + /// 输出缓冲区。 + /// 属性模型。 + /// 缩进层级。 + private static void AppendPropertyDocumentation( + StringBuilder builder, + SchemaPropertySpec property, + int indentationLevel) + { + var indent = new string(' ', indentationLevel * 4); + builder.AppendLine($"{indent}/// "); + builder.AppendLine( + $"{indent}/// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property path '{property.DisplayPath}'.")}"); + builder.AppendLine($"{indent}/// "); + builder.AppendLine($"{indent}/// "); + builder.AppendLine( + $"{indent}/// Schema property path: '{EscapeXmlDocumentation(property.DisplayPath)}'."); + + if (!string.IsNullOrWhiteSpace(property.Title)) + { + builder.AppendLine( + $"{indent}/// Display title: '{EscapeXmlDocumentation(property.Title!)}'."); + } + + if (!string.IsNullOrWhiteSpace(property.TypeSpec.EnumDocumentation)) + { + builder.AppendLine( + $"{indent}/// Allowed values: {EscapeXmlDocumentation(property.TypeSpec.EnumDocumentation!)}."); + } + + if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName)) + { + builder.AppendLine( + $"{indent}/// References config table: '{EscapeXmlDocumentation(property.TypeSpec.RefTableName!)}'."); + } + + if (!string.IsNullOrWhiteSpace(property.TypeSpec.Initializer)) + { + builder.AppendLine( + $"{indent}/// Generated default initializer: {EscapeXmlDocumentation(property.TypeSpec.Initializer!.Trim())}"); + } + + builder.AppendLine($"{indent}/// "); + } + /// /// 从 schema 文件路径提取实体基础名。 /// - /// schema 文件路径。 - /// 去掉扩展名和 `.schema` 后缀的实体基础名。 + /// Schema 文件路径。 + /// 去掉扩展名和 .schema 后缀的实体基础名。 private static string GetSchemaBaseName(string path) { var fileName = Path.GetFileName(path); @@ -477,6 +802,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); } + /// + /// 读取字符串元数据。 + /// + /// Schema 节点。 + /// 元数据字段名。 + /// 非空字符串值;不存在时返回空。 private static string? TryGetMetadataString(JsonElement element, string propertyName) { if (!element.TryGetProperty(propertyName, out var metadataElement) || @@ -489,6 +820,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return string.IsNullOrWhiteSpace(value) ? null : value; } + /// + /// 为标量字段构建可直接生成到属性上的默认值初始化器。 + /// + /// Schema 节点。 + /// 标量类型。 + /// 初始化器源码;不兼容时返回空。 private static string? TryBuildScalarInitializer(JsonElement element, string schemaType) { if (!element.TryGetProperty("default", out var defaultElement)) @@ -511,6 +848,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator }; } + /// + /// 为标量数组构建默认值初始化器。 + /// + /// Schema 节点。 + /// 元素类型。 + /// 元素 CLR 类型。 + /// 初始化器源码;不兼容时返回空。 private static string? TryBuildArrayInitializer(JsonElement element, string itemType, string itemClrType) { if (!element.TryGetProperty("default", out var defaultElement) || @@ -546,6 +890,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return $" = new {itemClrType}[] {{ {string.Join(", ", items)} }};"; } + /// + /// 将 enum 值整理成 XML 文档可读字符串。 + /// + /// Schema 节点。 + /// 标量类型。 + /// 格式化后的枚举说明。 private static string? TryBuildEnumDocumentation(JsonElement element, string schemaType) { if (!element.TryGetProperty("enum", out var enumElement) || @@ -578,43 +928,22 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return values.Count > 0 ? string.Join(", ", values) : null; } - private static void AppendPropertyDocumentation(StringBuilder builder, SchemaPropertySpec property) + /// + /// 组合逻辑字段路径。 + /// + /// 父路径。 + /// 当前属性名。 + /// 组合后的路径。 + private static string CombinePath(string parentPath, string propertyName) { - builder.AppendLine(" /// "); - builder.AppendLine( - $" /// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property '{property.SchemaName}'.")}"); - builder.AppendLine(" /// "); - builder.AppendLine(" /// "); - builder.AppendLine( - $" /// Schema property: '{EscapeXmlDocumentation(property.SchemaName)}'."); - - if (!string.IsNullOrWhiteSpace(property.Title)) - { - builder.AppendLine( - $" /// Display title: '{EscapeXmlDocumentation(property.Title!)}'."); - } - - if (!string.IsNullOrWhiteSpace(property.EnumDocumentation)) - { - builder.AppendLine( - $" /// Allowed values: {EscapeXmlDocumentation(property.EnumDocumentation!)}."); - } - - if (!string.IsNullOrWhiteSpace(property.ReferenceTableName)) - { - builder.AppendLine( - $" /// References config table: '{EscapeXmlDocumentation(property.ReferenceTableName!)}'."); - } - - if (!string.IsNullOrWhiteSpace(property.Initializer)) - { - builder.AppendLine( - $" /// Generated default initializer: {EscapeXmlDocumentation(property.Initializer!.Trim())}"); - } - - builder.AppendLine(" /// "); + return parentPath == "" ? propertyName : $"{parentPath}.{propertyName}"; } + /// + /// 转义 XML 文档文本。 + /// + /// 原始字符串。 + /// 已转义的字符串。 private static string EscapeXmlDocumentation(string value) { return value @@ -624,81 +953,148 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 表示单个 schema 文件的解析结果。 + /// 解析结果包装。 /// - /// 成功解析出的 schema 模型。 - /// 解析阶段产生的诊断。 + /// 解析出的 schema。 + /// 生成过程中收集的诊断。 private sealed record SchemaParseResult( SchemaFileSpec? Schema, - ImmutableArray Diagnostics) + IReadOnlyList Diagnostics) { - /// - /// 从成功解析的 schema 模型创建结果。 - /// public static SchemaParseResult FromSchema(SchemaFileSpec schema) { - return new SchemaParseResult(schema, ImmutableArray.Empty); + return new SchemaParseResult(schema, Array.Empty()); } - /// - /// 从单个诊断创建结果。 - /// public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic) { - return new SchemaParseResult(null, ImmutableArray.Create(diagnostic)); + return new SchemaParseResult(null, new[] { diagnostic }); } } /// - /// 表示已解析的 schema 文件模型。 + /// 对象解析结果包装。 /// + /// 解析出的对象类型。 + /// 错误诊断。 + private sealed record ParsedObjectResult( + SchemaObjectSpec? Object, + Diagnostic? Diagnostic) + { + public static ParsedObjectResult FromObject(SchemaObjectSpec schemaObject) + { + return new ParsedObjectResult(schemaObject, null); + } + + public static ParsedObjectResult FromDiagnostic(Diagnostic diagnostic) + { + return new ParsedObjectResult(null, diagnostic); + } + } + + /// + /// 生成器级 schema 模型。 + /// + /// Schema 文件名。 + /// 根配置类型名。 + /// 配置表包装类型名。 + /// 目标命名空间。 + /// 主键 CLR 类型。 + /// 根标题元数据。 + /// 根描述元数据。 + /// 根对象模型。 private sealed record SchemaFileSpec( string FileName, - string EntityName, string ClassName, string TableName, string Namespace, string KeyClrType, string? Title, string? Description, + SchemaObjectSpec RootObject); + + /// + /// 生成器内部的对象类型模型。 + /// + /// 对象字段路径。 + /// 要生成的 CLR 类型名。 + /// 对象标题元数据。 + /// 对象描述元数据。 + /// 对象属性集合。 + private sealed record SchemaObjectSpec( + string DisplayPath, + string ClassName, + string? Title, + string? Description, IReadOnlyList Properties); /// - /// 表示已解析的 schema 属性。 + /// 单个配置属性模型。 /// + /// Schema 原始字段名。 + /// 逻辑字段路径。 + /// CLR 属性名。 + /// 是否必填。 + /// 字段标题元数据。 + /// 字段描述元数据。 + /// 字段类型模型。 private sealed record SchemaPropertySpec( string SchemaName, + string DisplayPath, string PropertyName, - string SchemaType, - string ClrType, bool IsRequired, - string? Initializer, string? Title, string? Description, - string? EnumDocumentation, - string? ReferenceTableName); + SchemaTypeSpec TypeSpec); /// - /// 表示单个属性的解析结果。 + /// 类型模型,覆盖标量、对象和数组。 /// + /// 节点种类。 + /// Schema 类型名。 + /// CLR 类型名。 + /// 属性初始化器。 + /// 枚举文档说明。 + /// 目标引用表名称。 + /// 对象节点对应的嵌套类型。 + /// 数组元素类型模型。 + private sealed record SchemaTypeSpec( + SchemaNodeKind Kind, + string SchemaType, + string ClrType, + string? Initializer, + string? EnumDocumentation, + string? RefTableName, + SchemaObjectSpec? NestedObject, + SchemaTypeSpec? ItemTypeSpec); + + /// + /// 属性解析结果包装。 + /// + /// 解析出的属性模型。 + /// 错误诊断。 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); } } + + /// + /// 类型节点种类。 + /// + private enum SchemaNodeKind + { + Scalar, + Object, + Array + } } \ No newline at end of file diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 283d30f..f84314f 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -13,7 +13,7 @@ - 一对象一文件的目录组织 - 运行时只读查询 - Source Generator 生成配置类型和表包装 -- VS Code 插件提供配置浏览、raw 编辑、schema 打开和轻量校验入口 +- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 ## 推荐目录结构 @@ -113,6 +113,8 @@ var slime = monsterTable.Get(1); - 未在 schema 中声明的未知字段 - 标量类型不匹配 - 数组元素类型不匹配 +- 嵌套对象字段类型不匹配 +- 对象数组元素结构不匹配 - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -198,19 +200,22 @@ var hotReload = loader.EnableHotReload( - 浏览 `config/` 目录 - 打开 raw YAML 文件 - 打开匹配的 schema 文件 -- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验 -- 对顶层标量字段和顶层标量数组提供轻量表单入口 +- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 +- 对嵌套对象字段、顶层标量字段和顶层标量数组提供轻量表单入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 - 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 -当前批量编辑入口适合对同域文件统一改动顶层标量字段和顶层标量数组;复杂数组、嵌套对象仍建议放在 raw YAML 中完成。 +当前表单入口适合编辑嵌套对象中的标量字段和标量数组;对象数组仍建议放在 raw YAML 中完成。 + +当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。 ## 当前限制 以下能力尚未完全完成: - 更完整的 JSON Schema 支持 -- 更强的 VS Code 嵌套对象与复杂数组编辑器 +- VS Code 中对象数组的安全表单编辑器 +- 更强的复杂数组与更深 schema 关键字支持 因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 diff --git a/tools/vscode-config-extension/README.md b/tools/vscode-config-extension/README.md index 8dc730a..96c56ab 100644 --- a/tools/vscode-config-extension/README.md +++ b/tools/vscode-config-extension/README.md @@ -7,8 +7,9 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow. - Browse config files from the workspace `config/` directory - Open raw YAML files - Open matching schema files from `schemas/` -- Run lightweight schema validation for required fields, unknown top-level fields, scalar types, and scalar array items -- Open a lightweight form preview for top-level scalar fields and top-level scalar arrays +- Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and + arrays of objects +- Open a lightweight form preview for nested object fields, top-level scalar fields, and scalar arrays - Batch edit one config domain across multiple files for top-level scalar and scalar-array fields - Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the lightweight editors @@ -17,13 +18,14 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow. The extension currently validates the repository's minimal config-schema subset: -- required top-level properties -- unknown top-level properties +- required properties in nested objects +- unknown properties in nested objects - scalar compatibility for `integer`, `number`, `boolean`, and `string` -- top-level scalar arrays with scalar item type checks +- scalar arrays with scalar item type checks +- arrays of objects whose items use the same supported subset recursively - scalar `enum` constraints and scalar-array item `enum` constraints -Nested objects and complex arrays should still be reviewed in raw YAML. +Object-array editing should still be reviewed in raw YAML. ## Local Testing @@ -36,8 +38,8 @@ node --test ./test/*.test.js - Multi-root workspaces use the first workspace folder - Validation only covers a minimal subset of JSON Schema -- Form and batch editing currently support top-level scalar fields and top-level scalar arrays -- Nested objects and complex arrays should still be edited in raw YAML +- Form preview supports nested objects and scalar arrays, but object arrays remain raw-YAML-only for edits +- Batch editing remains limited to top-level scalar fields and top-level scalar arrays ## Workspace Settings diff --git a/tools/vscode-config-extension/src/configValidation.js b/tools/vscode-config-extension/src/configValidation.js index 4bd6b24..320e242 100644 --- a/tools/vscode-config-extension/src/configValidation.js +++ b/tools/vscode-config-extension/src/configValidation.js @@ -1,103 +1,39 @@ /** - * Parse a minimal JSON schema document used by the config extension. - * The parser intentionally supports the same schema subset that the current - * runtime validator and source generator depend on. + * Parse the repository's minimal config-schema subset into a recursive tree. + * The parser intentionally mirrors the same high-level contract used by the + * runtime validator and source generator so tooling diagnostics stay aligned. * * @param {string} content Raw schema JSON text. - * @returns {{required: string[], properties: Record}} Parsed schema info. + * @returns {{ + * type: "object", + * required: string[], + * properties: Record + * }} Parsed schema info. */ function parseSchemaContent(content) { const parsed = JSON.parse(content); - const required = Array.isArray(parsed.required) - ? parsed.required.filter((value) => typeof value === "string") - : []; - const properties = {}; - const propertyBag = parsed.properties || {}; - - for (const [key, value] of Object.entries(propertyBag)) { - if (!value || typeof value !== "object" || typeof value.type !== "string") { - continue; - } - - const metadata = { - title: typeof value.title === "string" ? value.title : undefined, - description: typeof value.description === "string" ? value.description : undefined, - defaultValue: formatSchemaDefaultValue(value.default), - enumValues: normalizeSchemaEnumValues(value.enum), - refTable: typeof value["x-gframework-ref-table"] === "string" - ? value["x-gframework-ref-table"] - : undefined - }; - - if (value.type === "array" && - value.items && - typeof value.items === "object" && - typeof value.items.type === "string") { - properties[key] = { - type: "array", - itemType: value.items.type, - title: metadata.title, - description: metadata.description, - defaultValue: metadata.defaultValue, - refTable: metadata.refTable, - itemEnumValues: normalizeSchemaEnumValues(value.items.enum) - }; - continue; - } - - properties[key] = { - type: value.type, - title: metadata.title, - description: metadata.description, - defaultValue: metadata.defaultValue, - enumValues: metadata.enumValues, - refTable: metadata.refTable - }; - } - - return { - required, - properties - }; + return parseSchemaNode(parsed, ""); } /** - * Collect top-level schema fields that the current tooling can edit in bulk. - * The bulk editor intentionally stays aligned with the lightweight form editor: - * top-level scalars and scalar arrays are supported, while nested objects and - * complex array items remain raw-YAML-only. + * Collect top-level schema fields that the current batch editor can update + * safely. Batch editing intentionally remains conservative even though the form + * preview can now navigate nested object structures. * - * @param {{required: string[], properties: Record}} schemaInfo Parsed schema info. + * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. * @returns {Array<{ - * key: string, - * type: string, - * itemType?: string, - * title?: string, - * description?: string, - * defaultValue?: string, - * enumValues?: string[], - * itemEnumValues?: string[], - * refTable?: string, - * inputKind: "scalar" | "array", - * required: boolean + * key: string, + * path: string, + * type: string, + * itemType?: string, + * title?: string, + * description?: string, + * defaultValue?: string, + * enumValues?: string[], + * itemEnumValues?: string[], + * refTable?: string, + * inputKind: "scalar" | "array", + * required: boolean * }>} Editable field descriptors. */ function getEditableSchemaFields(schemaInfo) { @@ -108,6 +44,7 @@ function getEditableSchemaFields(schemaInfo) { if (isEditableScalarType(property.type)) { editableFields.push({ key, + path: key, type: property.type, title: property.title, description: property.description, @@ -120,15 +57,16 @@ function getEditableSchemaFields(schemaInfo) { continue; } - if (property.type === "array" && isEditableScalarType(property.itemType || "")) { + if (property.type === "array" && property.items && isEditableScalarType(property.items.type)) { editableFields.push({ key, + path: key, type: property.type, - itemType: property.itemType, + itemType: property.items.type, title: property.title, description: property.description, defaultValue: property.defaultValue, - itemEnumValues: property.itemEnumValues, + itemEnumValues: property.items.enumValues, refTable: property.refTable, inputKind: "array", required: requiredSet.has(key) @@ -140,202 +78,42 @@ function getEditableSchemaFields(schemaInfo) { } /** - * Parse a minimal top-level YAML structure for config validation and form - * preview. This parser intentionally focuses on the repository's current - * config conventions: one root mapping object per file, top-level scalar - * fields, and top-level scalar arrays. + * Parse YAML into a recursive object/array/scalar tree. + * The parser covers the config system's intended subset: root mappings, + * indentation-based nested objects, scalar arrays, and arrays of objects. * * @param {string} text YAML text. - * @returns {{entries: Map}>, keys: Set}} Parsed YAML. + * @returns {YamlNode} Parsed YAML tree. */ function parseTopLevelYaml(text) { - const entries = new Map(); - const keys = new Set(); - const lines = text.split(/\r?\n/u); - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - if (!line || line.trim().length === 0 || line.trim().startsWith("#")) { - continue; - } - - if (/^\s/u.test(line)) { - continue; - } - - const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); - if (!match) { - continue; - } - - const key = match[1]; - const rawValue = match[2] || ""; - keys.add(key); - - if (rawValue.length > 0 && !rawValue.startsWith("|") && !rawValue.startsWith(">")) { - entries.set(key, { - kind: "scalar", - value: rawValue.trim() - }); - continue; - } - - const childLines = []; - let cursor = index + 1; - while (cursor < lines.length) { - const childLine = lines[cursor]; - if (childLine.trim().length === 0 || childLine.trim().startsWith("#")) { - cursor += 1; - continue; - } - - if (!/^\s/u.test(childLine)) { - break; - } - - childLines.push(childLine); - cursor += 1; - } - - if (childLines.length === 0) { - entries.set(key, { - kind: "empty" - }); - continue; - } - - const arrayItems = parseTopLevelArray(childLines); - if (arrayItems) { - entries.set(key, { - kind: "array", - items: arrayItems - }); - index = cursor - 1; - continue; - } - - entries.set(key, { - kind: "object" - }); - index = cursor - 1; + const tokens = tokenizeYaml(text); + if (tokens.length === 0) { + return createObjectNode(); } - return { - entries, - keys - }; + const state = {index: 0}; + return parseBlock(tokens, state, tokens[0].indent); } /** * Produce extension-facing validation diagnostics from schema and parsed YAML. * - * @param {{required: string[], properties: Record}} schemaInfo Parsed schema info. - * @param {{entries: Map}>, keys: Set}} parsedYaml Parsed YAML. + * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. + * @param {YamlNode} parsedYaml Parsed YAML tree. * @returns {Array<{severity: "error" | "warning", message: string}>} Validation diagnostics. */ function validateParsedConfig(schemaInfo, parsedYaml) { const diagnostics = []; - - for (const requiredProperty of schemaInfo.required) { - if (!parsedYaml.keys.has(requiredProperty)) { - diagnostics.push({ - severity: "error", - message: `Required property '${requiredProperty}' is missing.` - }); - } - } - - for (const key of parsedYaml.keys) { - if (!Object.prototype.hasOwnProperty.call(schemaInfo.properties, key)) { - diagnostics.push({ - severity: "error", - message: `Property '${key}' is not declared in the matching schema.` - }); - } - } - - for (const [propertyName, propertySchema] of Object.entries(schemaInfo.properties)) { - if (!parsedYaml.entries.has(propertyName)) { - continue; - } - - const entry = parsedYaml.entries.get(propertyName); - if (propertySchema.type === "array") { - if (entry.kind !== "array") { - diagnostics.push({ - severity: "error", - message: `Property '${propertyName}' is expected to be an array.` - }); - continue; - } - - for (const item of entry.items || []) { - if (item.isComplex || !isScalarCompatible(propertySchema.itemType || "", item.raw)) { - diagnostics.push({ - severity: "error", - message: `Array item in property '${propertyName}' is expected to be '${propertySchema.itemType}', but the current value is incompatible.` - }); - break; - } - - if (Array.isArray(propertySchema.itemEnumValues) && - propertySchema.itemEnumValues.length > 0 && - !propertySchema.itemEnumValues.includes(unquoteScalar(item.raw))) { - diagnostics.push({ - severity: "error", - message: `Array item in property '${propertyName}' must be one of: ${propertySchema.itemEnumValues.join(", ")}.` - }); - break; - } - } - - continue; - } - - if (entry.kind !== "scalar") { - diagnostics.push({ - severity: "error", - message: `Property '${propertyName}' is expected to be '${propertySchema.type}', but the current YAML shape is '${entry.kind}'.` - }); - continue; - } - - if (!isScalarCompatible(propertySchema.type, entry.value || "")) { - diagnostics.push({ - severity: "error", - message: `Property '${propertyName}' is expected to be '${propertySchema.type}', but the current scalar value is incompatible.` - }); - continue; - } - - if (Array.isArray(propertySchema.enumValues) && - propertySchema.enumValues.length > 0 && - !propertySchema.enumValues.includes(unquoteScalar(entry.value || ""))) { - diagnostics.push({ - severity: "error", - message: `Property '${propertyName}' must be one of: ${propertySchema.enumValues.join(", ")}.` - }); - } - } - + validateNode(schemaInfo, parsedYaml, "", diagnostics); return diagnostics; } /** - * Determine whether the current schema type can be edited through the - * lightweight form or batch-edit tooling. + * Determine whether the current schema type can be edited through the batch + * editor. The richer form preview handles nested objects separately. * * @param {string} schemaType Schema type. - * @returns {boolean} True when the type is supported by the lightweight editors. + * @returns {boolean} True when the type is batch-editable. */ function isEditableScalarType(schemaType) { return schemaType === "string" || @@ -352,7 +130,7 @@ function isEditableScalarType(schemaType) { * @returns {boolean} True when compatible. */ function isScalarCompatible(expectedType, scalarValue) { - const value = unquoteScalar(scalarValue); + const value = unquoteScalar(String(scalarValue)); switch (expectedType) { case "integer": return /^-?\d+$/u.test(value); @@ -368,78 +146,32 @@ function isScalarCompatible(expectedType, scalarValue) { } /** - * Apply form field updates back into the original YAML text. - * The current form editor supports top-level scalar fields and top-level scalar - * arrays, while nested objects and complex arrays remain raw-YAML-only. + * Apply form updates back into YAML. The implementation rewrites the YAML tree + * from the parsed structure so nested object edits can be saved safely. * * @param {string} originalYaml Original YAML content. * @param {{scalars?: Record, arrays?: Record}} updates Updated form values. * @returns {string} Updated YAML content. */ function applyFormUpdates(originalYaml, updates) { - const lines = originalYaml.split(/\r?\n/u); + const root = normalizeRootNode(parseTopLevelYaml(originalYaml)); const scalarUpdates = updates.scalars || {}; const arrayUpdates = updates.arrays || {}; - const touchedScalarKeys = new Set(); - const touchedArrayKeys = new Set(); - const blocks = findTopLevelBlocks(lines); - const updatedLines = []; - let cursor = 0; - for (const block of blocks) { - while (cursor < block.start) { - updatedLines.push(lines[cursor]); - cursor += 1; - } - - if (Object.prototype.hasOwnProperty.call(scalarUpdates, block.key)) { - touchedScalarKeys.add(block.key); - updatedLines.push(renderScalarLine(block.key, scalarUpdates[block.key])); - cursor = block.end + 1; - continue; - } - - if (Object.prototype.hasOwnProperty.call(arrayUpdates, block.key)) { - touchedArrayKeys.add(block.key); - updatedLines.push(...renderArrayBlock(block.key, arrayUpdates[block.key])); - cursor = block.end + 1; - continue; - } - - while (cursor <= block.end) { - updatedLines.push(lines[cursor]); - cursor += 1; - } + for (const [path, value] of Object.entries(scalarUpdates)) { + setNodeAtPath(root, path.split("."), createScalarNode(String(value))); } - while (cursor < lines.length) { - updatedLines.push(lines[cursor]); - cursor += 1; + for (const [path, values] of Object.entries(arrayUpdates)) { + setNodeAtPath(root, path.split("."), createArrayNode( + (values || []).map((item) => createScalarNode(String(item))))); } - for (const [key, value] of Object.entries(scalarUpdates)) { - if (touchedScalarKeys.has(key)) { - continue; - } - - updatedLines.push(renderScalarLine(key, value)); - } - - for (const [key, value] of Object.entries(arrayUpdates)) { - if (touchedArrayKeys.has(key)) { - continue; - } - - updatedLines.push(...renderArrayBlock(key, value)); - } - - return updatedLines.join("\n"); + return renderYaml(root).join("\n"); } /** - * Apply only scalar updates back into the original YAML text. - * This helper is preserved for compatibility with existing tests and callers - * that only edit top-level scalar fields. + * Apply only scalar updates back into YAML. * * @param {string} originalYaml Original YAML content. * @param {Record} updates Updated scalar values. @@ -505,6 +237,10 @@ function formatSchemaDefaultValue(value) { return normalized.length > 0 ? normalized.join(", ") : undefined; } + if (typeof value === "object") { + return JSON.stringify(value); + } + return undefined; } @@ -542,126 +278,528 @@ function unquoteScalar(value) { } /** - * Parse a sequence of child lines as a top-level scalar array. + * Parse one schema node recursively. * - * @param {string[]} childLines Indented child lines. - * @returns {Array<{raw: string, isComplex: boolean}> | null} Parsed array items or null when the block is not an array. + * @param {unknown} rawNode Raw schema node. + * @param {string} displayPath Logical property path. + * @returns {SchemaNode} Parsed schema node. */ -function parseTopLevelArray(childLines) { +function parseSchemaNode(rawNode, displayPath) { + const value = rawNode && typeof rawNode === "object" ? rawNode : {}; + const type = typeof value.type === "string" ? value.type : "object"; + const metadata = { + title: typeof value.title === "string" ? value.title : undefined, + description: typeof value.description === "string" ? value.description : undefined, + defaultValue: formatSchemaDefaultValue(value.default), + refTable: typeof value["x-gframework-ref-table"] === "string" + ? value["x-gframework-ref-table"] + : undefined + }; + + if (type === "object") { + const required = Array.isArray(value.required) + ? value.required.filter((item) => typeof item === "string") + : []; + const properties = {}; + for (const [key, propertyNode] of Object.entries(value.properties || {})) { + properties[key] = parseSchemaNode(propertyNode, combinePath(displayPath, key)); + } + + return { + type: "object", + displayPath, + required, + properties, + title: metadata.title, + description: metadata.description, + defaultValue: metadata.defaultValue + }; + } + + if (type === "array") { + const itemNode = parseSchemaNode(value.items || {}, `${displayPath}[]`); + return { + type: "array", + displayPath, + title: metadata.title, + description: metadata.description, + defaultValue: metadata.defaultValue, + refTable: metadata.refTable, + items: itemNode + }; + } + + return { + type, + displayPath, + title: metadata.title, + description: metadata.description, + defaultValue: metadata.defaultValue, + enumValues: normalizeSchemaEnumValues(value.enum), + refTable: metadata.refTable + }; +} + +/** + * Validate one schema node against one YAML node. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @param {string} displayPath Current logical path. + * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. + */ +function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { + if (schemaNode.type === "object") { + validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics); + return; + } + + if (schemaNode.type === "array") { + if (!yamlNode || yamlNode.kind !== "array") { + diagnostics.push({ + severity: "error", + message: `Property '${displayPath}' is expected to be an array.` + }); + return; + } + + for (let index = 0; index < yamlNode.items.length; index += 1) { + validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics); + } + return; + } + + if (!yamlNode || yamlNode.kind !== "scalar") { + diagnostics.push({ + severity: "error", + message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current YAML shape is '${yamlNode ? yamlNode.kind : "missing"}'.` + }); + return; + } + + if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { + diagnostics.push({ + severity: "error", + message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current scalar value is incompatible.` + }); + return; + } + + if (Array.isArray(schemaNode.enumValues) && + schemaNode.enumValues.length > 0 && + !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { + diagnostics.push({ + severity: "error", + message: `Property '${displayPath}' must be one of: ${schemaNode.enumValues.join(", ")}.` + }); + } +} + +/** + * Validate an object node recursively. + * + * @param {Extract} schemaNode Object schema node. + * @param {YamlNode} yamlNode YAML node. + * @param {string} displayPath Current logical path. + * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. + */ +function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) { + if (!yamlNode || yamlNode.kind !== "object") { + const subject = displayPath.length === 0 ? "Root object" : `Property '${displayPath}'`; + diagnostics.push({ + severity: "error", + message: `${subject} is expected to be an object.` + }); + return; + } + + for (const requiredProperty of schemaNode.required) { + if (!yamlNode.map.has(requiredProperty)) { + diagnostics.push({ + severity: "error", + message: `Required property '${combinePath(displayPath, requiredProperty)}' is missing.` + }); + } + } + + for (const entry of yamlNode.entries) { + if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { + diagnostics.push({ + severity: "error", + message: `Property '${combinePath(displayPath, entry.key)}' is not declared in the matching schema.` + }); + continue; + } + + validateNode( + schemaNode.properties[entry.key], + entry.node, + combinePath(displayPath, entry.key), + diagnostics); + } +} + +/** + * Tokenize YAML lines into indentation-aware units. + * + * @param {string} text YAML text. + * @returns {Array<{indent: number, text: string}>} Tokens. + */ +function tokenizeYaml(text) { + const tokens = []; + const lines = String(text).split(/\r?\n/u); + + for (const line of lines) { + if (!line || line.trim().length === 0 || line.trimStart().startsWith("#")) { + continue; + } + + const indentMatch = /^(\s*)/u.exec(line); + const indent = indentMatch ? indentMatch[1].length : 0; + const trimmed = line.slice(indent); + tokens.push({indent, text: trimmed}); + } + + return tokens; +} + +/** + * Parse the next YAML block from the token stream. + * + * @param {Array<{indent: number, text: string}>} tokens Token array. + * @param {{index: number}} state Mutable parser state. + * @param {number} indent Expected indentation. + * @returns {YamlNode} Parsed node. + */ +function parseBlock(tokens, state, indent) { + if (state.index >= tokens.length) { + return createObjectNode(); + } + + const token = tokens[state.index]; + if (token.text.startsWith("-")) { + return parseSequence(tokens, state, indent); + } + + return parseMapping(tokens, state, indent); +} + +/** + * Parse a mapping block. + * + * @param {Array<{indent: number, text: string}>} tokens Token array. + * @param {{index: number}} state Mutable parser state. + * @param {number} indent Expected indentation. + * @returns {YamlNode} Parsed object node. + */ +function parseMapping(tokens, state, indent) { + const entries = []; + const map = new Map(); + + while (state.index < tokens.length) { + const token = tokens[state.index]; + if (token.indent < indent || token.text.startsWith("-")) { + break; + } + + if (token.indent > indent) { + state.index += 1; + continue; + } + + const match = /^([A-Za-z0-9_]+):(.*)$/u.exec(token.text); + if (!match) { + state.index += 1; + continue; + } + + const key = match[1]; + const rawValue = match[2].trim(); + state.index += 1; + + let node; + if (rawValue.length > 0 && !rawValue.startsWith("|") && !rawValue.startsWith(">")) { + node = createScalarNode(rawValue); + } else if (state.index < tokens.length && tokens[state.index].indent > indent) { + node = parseBlock(tokens, state, tokens[state.index].indent); + } else { + node = createScalarNode(""); + } + + entries.push({key, node}); + map.set(key, node); + } + + return {kind: "object", entries, map}; +} + +/** + * Parse a sequence block. + * + * @param {Array<{indent: number, text: string}>} tokens Token array. + * @param {{index: number}} state Mutable parser state. + * @param {number} indent Expected indentation. + * @returns {YamlNode} Parsed array node. + */ +function parseSequence(tokens, state, indent) { const items = []; - for (const line of childLines) { - if (line.trim().length === 0 || line.trim().startsWith("#")) { + while (state.index < tokens.length) { + const token = tokens[state.index]; + if (token.indent !== indent || !token.text.startsWith("-")) { + break; + } + + const rest = token.text.slice(1).trim(); + state.index += 1; + + if (rest.length === 0) { + if (state.index < tokens.length && tokens[state.index].indent > indent) { + items.push(parseBlock(tokens, state, tokens[state.index].indent)); + } else { + items.push(createScalarNode("")); + } continue; } - const trimmed = line.trimStart(); - if (!trimmed.startsWith("-")) { - return null; + if (/^[A-Za-z0-9_]+:/u.test(rest)) { + items.push(parseInlineObjectItem(tokens, state, indent, rest)); + continue; } - const raw = trimmed.slice(1).trim(); - items.push({ - raw, - isComplex: raw.length === 0 || raw.startsWith("{") || raw.startsWith("[") || /^[A-Za-z0-9_]+:\s*/u.test(raw) - }); + items.push(createScalarNode(rest)); } - return items; + return createArrayNode(items); } /** - * Find top-level YAML blocks so form updates can replace whole entries without - * touching unrelated domains in the file. + * Parse an array item written as an inline mapping head followed by nested + * child lines, for example `- wave: 1`. * - * @param {string[]} lines YAML lines. - * @returns {Array<{key: string, start: number, end: number}>} Top-level blocks. + * @param {Array<{indent: number, text: string}>} tokens Token array. + * @param {{index: number}} state Mutable parser state. + * @param {number} parentIndent Array indentation. + * @param {string} firstEntry Inline first entry text. + * @returns {YamlNode} Parsed object node. */ -function findTopLevelBlocks(lines) { - const blocks = []; - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - if (!line || line.trim().length === 0 || line.trim().startsWith("#") || /^\s/u.test(line)) { - continue; - } - - const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); - if (!match) { - continue; - } - - let cursor = index + 1; - while (cursor < lines.length) { - const nextLine = lines[cursor]; - if (nextLine.trim().length === 0 || nextLine.trim().startsWith("#")) { - cursor += 1; - continue; - } - - if (!/^\s/u.test(nextLine)) { - break; - } - - cursor += 1; - } - - blocks.push({ - key: match[1], - start: index, - end: cursor - 1 - }); - index = cursor - 1; +function parseInlineObjectItem(tokens, state, parentIndent, firstEntry) { + const syntheticTokens = [{indent: parentIndent + 2, text: firstEntry}]; + while (state.index < tokens.length && tokens[state.index].indent > parentIndent) { + syntheticTokens.push(tokens[state.index]); + state.index += 1; } - return blocks; + return parseBlock(syntheticTokens, {index: 0}, parentIndent + 2); } /** - * Render a top-level scalar line. + * Ensure the root node is an object, creating one if the YAML was empty or not + * object-shaped enough for structured edits. * - * @param {string} key Property name. - * @param {string} value Scalar value. - * @returns {string} Rendered YAML line. + * @param {YamlNode} node Parsed node. + * @returns {YamlObjectNode} Root object node. */ -function renderScalarLine(key, value) { - return `${key}: ${formatYamlScalar(value)}`; +function normalizeRootNode(node) { + return node && node.kind === "object" ? node : createObjectNode(); } /** - * Render a top-level scalar array block. + * Replace or create a node at a dot-separated object path. * - * @param {string} key Property name. - * @param {string[]} items Array items. - * @returns {string[]} Rendered YAML lines. + * @param {YamlObjectNode} root Root object node. + * @param {string[]} segments Path segments. + * @param {YamlNode} valueNode Value node. */ -function renderArrayBlock(key, items) { - const normalizedItems = Array.isArray(items) - ? items - .map((item) => String(item).trim()) - .filter((item) => item.length > 0) - : []; +function setNodeAtPath(root, segments, valueNode) { + let current = root; - const lines = [`${key}:`]; - for (const item of normalizedItems) { - lines.push(` - ${formatYamlScalar(item)}`); + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (!segment) { + continue; + } + + if (index === segments.length - 1) { + setObjectEntry(current, segment, valueNode); + return; + } + + let nextNode = current.map.get(segment); + if (!nextNode || nextNode.kind !== "object") { + nextNode = createObjectNode(); + setObjectEntry(current, segment, nextNode); + } + + current = nextNode; + } +} + +/** + * Insert or replace one mapping entry while preserving insertion order. + * + * @param {YamlObjectNode} objectNode Target object node. + * @param {string} key Mapping key. + * @param {YamlNode} valueNode Value node. + */ +function setObjectEntry(objectNode, key, valueNode) { + const existingIndex = objectNode.entries.findIndex((entry) => entry.key === key); + if (existingIndex >= 0) { + objectNode.entries[existingIndex] = {key, node: valueNode}; + } else { + objectNode.entries.push({key, node: valueNode}); + } + + objectNode.map.set(key, valueNode); +} + +/** + * Render a YAML node back to text lines. + * + * @param {YamlNode} node YAML node. + * @param {number} indent Current indentation. + * @returns {string[]} YAML lines. + */ +function renderYaml(node, indent = 0) { + if (node.kind === "object") { + return renderObjectNode(node, indent); + } + + if (node.kind === "array") { + return renderArrayNode(node, indent); + } + + return [`${" ".repeat(indent)}${formatYamlScalar(node.value)}`]; +} + +/** + * Render an object node. + * + * @param {YamlObjectNode} node Object node. + * @param {number} indent Current indentation. + * @returns {string[]} YAML lines. + */ +function renderObjectNode(node, indent) { + const lines = []; + for (const entry of node.entries) { + if (entry.node.kind === "scalar") { + lines.push(`${" ".repeat(indent)}${entry.key}: ${formatYamlScalar(entry.node.value)}`); + continue; + } + + lines.push(`${" ".repeat(indent)}${entry.key}:`); + lines.push(...renderYaml(entry.node, indent + 2)); } return lines; } +/** + * Render an array node. + * + * @param {YamlArrayNode} node Array node. + * @param {number} indent Current indentation. + * @returns {string[]} YAML lines. + */ +function renderArrayNode(node, indent) { + const lines = []; + for (const item of node.items) { + if (item.kind === "scalar") { + lines.push(`${" ".repeat(indent)}- ${formatYamlScalar(item.value)}`); + continue; + } + + lines.push(`${" ".repeat(indent)}-`); + lines.push(...renderYaml(item, indent + 2)); + } + + return lines; +} + +/** + * Create a scalar node. + * + * @param {string} value Scalar value. + * @returns {YamlScalarNode} Scalar node. + */ +function createScalarNode(value) { + return {kind: "scalar", value}; +} + +/** + * Create an array node. + * + * @param {YamlNode[]} items Array items. + * @returns {YamlArrayNode} Array node. + */ +function createArrayNode(items) { + return {kind: "array", items}; +} + +/** + * Create an object node. + * + * @returns {YamlObjectNode} Object node. + */ +function createObjectNode() { + return {kind: "object", entries: [], map: new Map()}; +} + +/** + * Combine a parent path with one child segment. + * + * @param {string} parentPath Parent path. + * @param {string} key Child key. + * @returns {string} Combined path. + */ +function combinePath(parentPath, key) { + return parentPath && parentPath !== "" ? `${parentPath}.${key}` : key; +} + module.exports = { applyFormUpdates, applyScalarUpdates, - findTopLevelBlocks, - formatYamlScalar, getEditableSchemaFields, + isEditableScalarType, isScalarCompatible, - normalizeSchemaEnumValues, parseBatchArrayValue, parseSchemaContent, parseTopLevelYaml, unquoteScalar, - validateParsedConfig, - formatSchemaDefaultValue + validateParsedConfig }; + +/** + * @typedef {{ + * type: "object", + * displayPath: string, + * required: string[], + * properties: Record, + * title?: string, + * description?: string, + * defaultValue?: string + * } | { + * type: "array", + * displayPath: string, + * title?: string, + * description?: string, + * defaultValue?: string, + * refTable?: string, + * items: SchemaNode + * } | { + * type: "string" | "integer" | "number" | "boolean", + * displayPath: string, + * title?: string, + * description?: string, + * defaultValue?: string, + * enumValues?: string[], + * refTable?: string + * }} SchemaNode + */ + +/** + * @typedef {{kind: "scalar", value: string}} YamlScalarNode + * @typedef {{kind: "array", items: YamlNode[]}} YamlArrayNode + * @typedef {{kind: "object", entries: Array<{key: string, node: YamlNode}>, map: Map}} YamlObjectNode + * @typedef {YamlScalarNode | YamlArrayNode | YamlObjectNode} YamlNode + */ diff --git a/tools/vscode-config-extension/src/extension.js b/tools/vscode-config-extension/src/extension.js index 0217a98..32d66a4 100644 --- a/tools/vscode-config-extension/src/extension.js +++ b/tools/vscode-config-extension/src/extension.js @@ -252,9 +252,9 @@ async function openSchemaFile(item) { } /** - * Open a lightweight form preview for top-level scalar fields and scalar - * arrays. Nested objects and more complex array shapes still use raw YAML as - * the escape hatch. + * Open a lightweight form preview for schema-bound config fields. + * The preview now walks nested object structures recursively, while complex + * object-array editing still falls back to raw YAML for safety. * * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. @@ -284,7 +284,8 @@ async function openFormPreview(item, diagnostics) { panel.webview.onDidReceiveMessage(async (message) => { if (message.type === "save") { - const updatedYaml = applyFormUpdates(yamlText, { + const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + const updatedYaml = applyFormUpdates(latestYamlText, { scalars: message.scalars || {}, arrays: parseArrayFieldPayload(message.arrays || {}) }); @@ -544,6 +545,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { return { exists: true, schemaPath, + type: parsed.type, required: parsed.required, properties: parsed.properties }; @@ -561,86 +563,67 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { * Render the form-preview webview HTML. * * @param {string} fileName File name. - * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record}} schemaInfo Schema info. - * @param {{entries: Map}>, keys: Set}} parsedYaml Parsed YAML data. + * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record, type?: string}} schemaInfo Schema info. + * @param {unknown} parsedYaml Parsed YAML data. * @returns {string} HTML string. */ function renderFormHtml(fileName, schemaInfo, parsedYaml) { - const scalarFields = Array.from(parsedYaml.entries.entries()) - .filter(([, entry]) => entry.kind === "scalar") - .map(([key, entry]) => { - const propertySchema = schemaInfo.properties[key] || {}; - const displayName = propertySchema.title || key; - const escapedKey = escapeHtml(key); - const escapedDisplayName = escapeHtml(displayName); - const escapedValue = escapeHtml(unquoteScalar(entry.value || "")); - const required = schemaInfo.required.includes(key) ? "required" : ""; - const metadataHint = renderFieldHint(propertySchema, false); - const enumValues = Array.isArray(propertySchema.enumValues) ? propertySchema.enumValues : []; + const formModel = buildFormModel(schemaInfo, parsedYaml); + const renderedFields = formModel.fields + .map((field) => { + if (field.kind === "section") { + return ` +
+
${escapeHtml(field.label)} ${field.required ? "required" : ""}
+
${escapeHtml(field.path)}
+ ${field.description ? `${escapeHtml(field.description)}` : ""} +
+ `; + } + + if (field.kind === "array") { + const itemType = field.itemType + ? `array<${escapeHtml(field.itemType)}>` + : "array"; + return ` + + `; + } + + const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : []; const inputControl = enumValues.length > 0 ? ` - ${enumValues.map((value) => { const escapedOption = escapeHtml(value); - const selected = value === unquoteScalar(entry.value || "") ? " selected" : ""; + const selected = value === field.value ? " selected" : ""; return ``; }).join("\n")} ` - : ``; + : ``; + return ` -