From c0aa8ba70e7cf121f28a0c9b2ba7fd7d2cec4938 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:18:54 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了 ConfigRegistry 配置注册表,支持按名称注册和类型安全查询 - 创建了 InMemoryConfigTable 内存配置表,提供基于字典的只读配置存储 - 定义了 IConfigLoader、IConfigRegistry 和 IConfigTable 接口契约 - 添加了完整的单元测试验证配置表的注册、查询和类型检查功能 - 在项目文件中添加了新的代码文件夹结构 - 实现了配置表的覆盖策略以支持开发期热重载需求 --- .../Config/IConfigLoader.cs | 19 ++ .../Config/IConfigRegistry.cs | 75 ++++++++ .../Config/IConfigTable.cs | 65 +++++++ .../Config/ConfigRegistryTests.cs | 130 ++++++++++++++ .../Config/InMemoryConfigTableTests.cs | 72 ++++++++ GFramework.Game/Config/ConfigRegistry.cs | 162 ++++++++++++++++++ GFramework.Game/Config/InMemoryConfigTable.cs | 118 +++++++++++++ GFramework.csproj | 1 + 8 files changed, 642 insertions(+) create mode 100644 GFramework.Game.Abstractions/Config/IConfigLoader.cs create mode 100644 GFramework.Game.Abstractions/Config/IConfigRegistry.cs create mode 100644 GFramework.Game.Abstractions/Config/IConfigTable.cs create mode 100644 GFramework.Game.Tests/Config/ConfigRegistryTests.cs create mode 100644 GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs create mode 100644 GFramework.Game/Config/ConfigRegistry.cs create mode 100644 GFramework.Game/Config/InMemoryConfigTable.cs diff --git a/GFramework.Game.Abstractions/Config/IConfigLoader.cs b/GFramework.Game.Abstractions/Config/IConfigLoader.cs new file mode 100644 index 0000000..50abd08 --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigLoader.cs @@ -0,0 +1,19 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置加载器契约。 +/// 具体实现负责从文件系统、资源包或其他配置源加载文本配置,并将解析结果注册到配置注册表。 +/// +public interface IConfigLoader : IUtility +{ + /// + /// 执行配置加载并将结果写入注册表。 + /// 实现应在同一次加载过程中保证注册结果的一致性,避免只加载部分配置后就暴露给运行时消费。 + /// + /// 用于接收配置表的注册表。 + /// 取消令牌。 + /// 表示异步加载流程的任务。 + Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/IConfigRegistry.cs b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs new file mode 100644 index 0000000..d40c918 --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs @@ -0,0 +1,75 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置注册表契约,用于统一保存和解析按名称注册的配置表。 +/// 注册表是运行时配置系统的入口,负责在加载阶段收集配置表,并在消费阶段提供类型安全查询。 +/// +public interface IConfigRegistry : IUtility +{ + /// + /// 获取当前已注册配置表数量。 + /// + int Count { get; } + + /// + /// 获取所有已注册配置表名称。 + /// + /// 配置表名称集合。 + IReadOnlyCollection GetTableNames(); + + /// + /// 注册指定名称的配置表。 + /// 若名称已存在,则替换旧表,以便开发期热重载使用同一入口刷新配置。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 要注册的配置表实例。 + void RegisterTable(string name, IConfigTable table) + where TKey : notnull; + + /// + /// 获取指定名称的配置表。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 匹配的强类型配置表实例。 + /// 当配置表名称不存在时抛出。 + /// 当请求类型与已注册配置表类型不匹配时抛出。 + IConfigTable GetTable(string name) + where TKey : notnull; + + /// + /// 尝试获取指定名称的配置表。 + /// 当名称存在但类型不匹配时返回 false,避免消费端将类型错误误判为加载成功。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 匹配的强类型配置表;未找到或类型不匹配时返回空。 + /// 找到且类型匹配时返回 true,否则返回 false + bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull; + + /// + /// 检查指定名称的配置表是否存在。 + /// + /// 配置表名称。 + /// 存在时返回 true,否则返回 false + bool HasTable(string name); + + /// + /// 移除指定名称的配置表。 + /// + /// 配置表名称。 + /// 移除成功时返回 true,否则返回 false + bool RemoveTable(string name); + + /// + /// 清空所有已注册配置表。 + /// + void Clear(); +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/IConfigTable.cs b/GFramework.Game.Abstractions/Config/IConfigTable.cs new file mode 100644 index 0000000..1cd781a --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigTable.cs @@ -0,0 +1,65 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置表的非泛型公共契约,用于在注册表中保存异构配置表实例。 +/// 该接口只暴露运行时发现和诊断所需的元数据,不提供具体类型访问能力。 +/// +public interface IConfigTable : IUtility +{ + /// + /// 获取配置表主键类型。 + /// + Type KeyType { get; } + + /// + /// 获取配置项值类型。 + /// + Type ValueType { get; } + + /// + /// 获取当前配置表中的条目数量。 + /// + int Count { get; } +} + +/// +/// 定义强类型只读配置表契约。 +/// 运行时配置表应通过主键执行只读查询,而不是暴露可变集合接口, +/// 以保持配置数据在加载完成后的稳定性和可预测性。 +/// +/// 配置表主键类型。 +/// 配置项值类型。 +public interface IConfigTable : IConfigTable + where TKey : notnull +{ + /// + /// 获取指定主键的配置项。 + /// + /// 配置项主键。 + /// 找到的配置项。 + /// 当主键不存在时抛出。 + TValue Get(TKey key); + + /// + /// 尝试获取指定主键的配置项。 + /// + /// 配置项主键。 + /// 找到的配置项;未找到时返回默认值。 + /// 找到配置项时返回 true,否则返回 false + bool TryGet(TKey key, out TValue? value); + + /// + /// 检查指定主键是否存在。 + /// + /// 配置项主键。 + /// 主键存在时返回 true,否则返回 false + bool ContainsKey(TKey key); + + /// + /// 获取配置表中的所有配置项快照。 + /// + /// 只读配置项集合。 + IReadOnlyCollection All(); +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/ConfigRegistryTests.cs b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs new file mode 100644 index 0000000..437e968 --- /dev/null +++ b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs @@ -0,0 +1,130 @@ +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证配置注册表的注册、覆盖和类型检查行为。 +/// +[TestFixture] +public class ConfigRegistryTests +{ + /// + /// 验证注册后的配置表可以按名称和类型成功解析。 + /// + [Test] + public void RegisterTable_Then_GetTable_Should_Return_Registered_Instance() + { + var registry = new ConfigRegistry(); + var table = CreateMonsterTable(); + + registry.RegisterTable("monster", table); + + var resolved = registry.GetTable("monster"); + + Assert.That(resolved, Is.SameAs(table)); + } + + /// + /// 验证同名注册会覆盖旧表,用于后续热重载场景。 + /// + [Test] + public void RegisterTable_Should_Replace_Previous_Table_With_Same_Name() + { + var registry = new ConfigRegistry(); + var oldTable = CreateMonsterTable(); + var newTable = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(3, "Orc") + }, + static config => config.Id); + + registry.RegisterTable("monster", oldTable); + registry.RegisterTable("monster", newTable); + + var resolved = registry.GetTable("monster"); + + Assert.That(resolved, Is.SameAs(newTable)); + Assert.That(resolved.Count, Is.EqualTo(1)); + } + + /// + /// 验证请求类型与实际注册类型不匹配时会抛出异常,避免消费端默默读取错误表。 + /// + [Test] + public void GetTable_Should_Throw_When_Requested_Type_Does_Not_Match_Registered_Table() + { + var registry = new ConfigRegistry(); + registry.RegisterTable("monster", CreateMonsterTable()); + + Assert.Throws(() => registry.GetTable("monster")); + } + + /// + /// 验证移除和清空操作会更新注册表状态。 + /// + [Test] + public void RemoveTable_And_Clear_Should_Update_Registry_State() + { + var registry = new ConfigRegistry(); + registry.RegisterTable("monster", CreateMonsterTable()); + registry.RegisterTable("npc", CreateNpcTable()); + + var removed = registry.RemoveTable("monster"); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(registry.HasTable("monster"), Is.False); + Assert.That(registry.Count, Is.EqualTo(1)); + }); + + registry.Clear(); + + Assert.That(registry.Count, Is.EqualTo(0)); + } + + /// + /// 创建怪物配置表测试实例。 + /// + /// 怪物配置表。 + private static IConfigTable CreateMonsterTable() + { + return new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + } + + /// + /// 创建 NPC 配置表测试实例。 + /// + /// NPC 配置表。 + private static IConfigTable CreateNpcTable() + { + return new InMemoryConfigTable( + new[] + { + new NpcConfigStub(Guid.NewGuid(), "Guide") + }, + static config => config.Id); + } + + /// + /// 用于怪物配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record MonsterConfigStub(int Id, string Name); + + /// + /// 用于 NPC 配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record NpcConfigStub(Guid Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs b/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs new file mode 100644 index 0000000..0d5a7f4 --- /dev/null +++ b/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs @@ -0,0 +1,72 @@ +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证内存配置表的基础只读查询行为。 +/// +[TestFixture] +public class InMemoryConfigTableTests +{ + /// + /// 验证已存在主键可以被正确查询。 + /// + [Test] + public void Get_Should_Return_Config_When_Key_Exists() + { + var table = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + + var result = table.Get(2); + + Assert.That(result.Name, Is.EqualTo("Goblin")); + } + + /// + /// 验证重复主键会在加载期被拒绝,避免运行期覆盖旧值。 + /// + [Test] + public void Constructor_Should_Throw_When_Duplicate_Key_Is_Detected() + { + Assert.Throws(() => + new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(1, "Goblin") + }, + static config => config.Id)); + } + + /// + /// 验证 All 返回的集合包含完整快照。 + /// + [Test] + public void All_Should_Return_All_Configs() + { + var table = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + + var all = table.All(); + + Assert.That(all, Has.Count.EqualTo(2)); + Assert.That(all.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" })); + } + + /// + /// 用于配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record MonsterConfigStub(int Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game/Config/ConfigRegistry.cs b/GFramework.Game/Config/ConfigRegistry.cs new file mode 100644 index 0000000..db80be1 --- /dev/null +++ b/GFramework.Game/Config/ConfigRegistry.cs @@ -0,0 +1,162 @@ +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 默认配置注册表实现。 +/// 该类型负责统一管理按名称注册的配置表,并在消费端提供类型安全的解析入口。 +/// 为了支持开发期热重载,注册行为采用覆盖策略而不是拒绝重复名称。 +/// +public sealed class ConfigRegistry : IConfigRegistry +{ + private const string NameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; + + private readonly ConcurrentDictionary _tables = new(StringComparer.Ordinal); + + /// + /// 获取已注册的配置表数量。 + /// + public int Count => _tables.Count; + + /// + /// 获取所有已注册配置表的名称集合,按字典序排序。 + /// + /// 返回只读的配置表名称集合。 + public IReadOnlyCollection GetTableNames() + { + return _tables.Keys.OrderBy(static key => key, StringComparer.Ordinal).ToArray(); + } + + /// + /// 注册一个配置表到注册表中。 + /// 如果同名的配置表已存在,则会覆盖原有注册以支持热重载。 + /// + /// 配置表主键的类型,必须为非空类型。 + /// 配置表值的类型。 + /// 配置表的注册名称,用于后续查找。 + /// 要注册的配置表实例。 + /// 为 null、空或仅包含空白字符时抛出。 + /// 为 null 时抛出。 + public void RegisterTable(string name, IConfigTable table) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + ArgumentNullException.ThrowIfNull(table); + + _tables[name] = table; + } + + /// + /// 根据名称获取已注册的配置表,并进行类型验证。 + /// + /// 期望的主键类型,必须为非空类型。 + /// 期望的值类型。 + /// 要查找的配置表名称。 + /// 返回类型匹配的配置表实例。 + /// 为 null、空或仅包含空白字符时抛出。 + /// 当指定名称的配置表不存在时抛出。 + /// + /// 当找到的配置表类型与请求的类型不匹配时抛出。 + /// + public IConfigTable GetTable(string name) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + if (!_tables.TryGetValue(name, out var table)) + { + throw new KeyNotFoundException($"Config table '{name}' was not found."); + } + + if (table is IConfigTable typedTable) + { + return typedTable; + } + + throw new InvalidOperationException( + $"Config table '{name}' was registered as '{table.KeyType.Name} -> {table.ValueType.Name}', " + + $"but the caller requested '{typeof(TKey).Name} -> {typeof(TValue).Name}'."); + } + + /// + /// 尝试根据名称获取配置表,操作失败时不会抛出异常。 + /// + /// 期望的主键类型,必须为非空类型。 + /// 期望的值类型。 + /// 要查找的配置表名称。 + /// + /// 输出参数,如果查找成功则返回类型匹配的配置表实例,否则为 null。 + /// + /// 如果找到指定名称且类型匹配的配置表则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull + { + table = default; + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + if (!_tables.TryGetValue(name, out var rawTable)) + { + return false; + } + + if (rawTable is not IConfigTable typedTable) + { + return false; + } + + table = typedTable; + return true; + } + + /// + /// 检查指定名称的配置表是否已注册。 + /// + /// 要检查的配置表名称。 + /// 如果配置表已注册则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool HasTable(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + return _tables.ContainsKey(name); + } + + /// + /// 从注册表中移除指定名称的配置表。 + /// + /// 要移除的配置表名称。 + /// 如果配置表存在并被成功移除则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool RemoveTable(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + return _tables.TryRemove(name, out _); + } + + /// + /// 清空注册表中的所有配置表。 + /// + public void Clear() + { + _tables.Clear(); + } +} \ No newline at end of file diff --git a/GFramework.Game/Config/InMemoryConfigTable.cs b/GFramework.Game/Config/InMemoryConfigTable.cs new file mode 100644 index 0000000..bf6bd28 --- /dev/null +++ b/GFramework.Game/Config/InMemoryConfigTable.cs @@ -0,0 +1,118 @@ +using System.Collections.ObjectModel; +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 基于内存字典的只读配置表实现。 +/// 该实现用于 Runtime MVP 阶段,为加载器和注册表提供稳定的只读查询对象。 +/// +/// 配置表主键类型。 +/// 配置项值类型。 +public sealed class InMemoryConfigTable : IConfigTable + where TKey : notnull +{ + private readonly IReadOnlyCollection _allValues; + private readonly IReadOnlyDictionary _entries; + + /// + /// 使用配置项序列和主键选择器创建内存配置表。 + /// + /// 配置项序列。 + /// 用于提取主键的委托。 + /// 可选的主键比较器。 + /// 为空时抛出。 + /// 当配置项主键重复时抛出。 + public InMemoryConfigTable( + IEnumerable values, + Func keySelector, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(keySelector); + + var dictionary = new Dictionary(comparer); + var allValues = new List(); + + foreach (var value in values) + { + var key = keySelector(value); + + // 配置表必须在加载期拒绝重复主键,否则运行期查询结果将不可预测。 + if (!dictionary.TryAdd(key, value)) + { + throw new InvalidOperationException( + $"Duplicate config key '{key}' was detected for table value type '{typeof(TValue).Name}'."); + } + + allValues.Add(value); + } + + _entries = new ReadOnlyDictionary(dictionary); + _allValues = new ReadOnlyCollection(allValues); + } + + /// + /// 获取配置表的主键类型。 + /// + public Type KeyType => typeof(TKey); + + /// + /// 获取配置表的值类型。 + /// + public Type ValueType => typeof(TValue); + + /// + /// 获取配置表中配置项的数量。 + /// + public int Count => _entries.Count; + + /// + /// 根据主键获取配置项的值。 + /// + /// 要查找的配置项主键。 + /// 返回对应主键的配置项值。 + /// 当指定主键的配置项不存在时抛出。 + public TValue Get(TKey key) + { + if (!_entries.TryGetValue(key, out var value)) + { + throw new KeyNotFoundException( + $"Config key '{key}' was not found in table '{typeof(TValue).Name}'."); + } + + return value; + } + + /// + /// 尝试根据主键获取配置项的值,操作失败时不会抛出异常。 + /// + /// 要查找的配置项主键。 + /// + /// 输出参数,如果查找成功则返回对应的配置项值,否则为默认值。 + /// + /// 如果找到指定主键的配置项则返回 true,否则返回 false。 + public bool TryGet(TKey key, out TValue? value) + { + return _entries.TryGetValue(key, out value); + } + + /// + /// 检查指定主键的配置项是否存在于配置表中。 + /// + /// 要检查的配置项主键。 + /// 如果配置项已存在则返回 true,否则返回 false。 + public bool ContainsKey(TKey key) + { + return _entries.ContainsKey(key); + } + + /// + /// 获取配置表中所有配置项的集合。 + /// + /// 返回所有配置项值的只读集合。 + public IReadOnlyCollection All() + { + return _allValues; + } +} \ No newline at end of file diff --git a/GFramework.csproj b/GFramework.csproj index 1594e9a..e1d069a 100644 --- a/GFramework.csproj +++ b/GFramework.csproj @@ -134,6 +134,7 @@ + From 5fa12dcd37f3d19e219bb8a4b3454f6d1cd3747a Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:13:26 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增YamlConfigLoader类支持基于目录的YAML配置加载 - 添加对.yaml和.yml文件格式的自动识别和解析 - 实现异步加载任务支持取消令牌 - 集成YamlDotNet库进行YAML反序列化处理 - 支持驼峰命名约定和忽略未匹配属性 - 实现配置表注册的链式API设计 - 添加详细的加载过程异常处理和错误信息 - 提供完整的单元测试覆盖各种加载场景 - 更新项目依赖添加YamlDotNet包引用16.3.0版本 --- .../Config/YamlConfigLoaderTests.cs | 202 +++++++++++++ GFramework.Game/Config/YamlConfigLoader.cs | 269 ++++++++++++++++++ GFramework.Game/GFramework.Game.csproj | 1 + 3 files changed, 472 insertions(+) create mode 100644 GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs create mode 100644 GFramework.Game/Config/YamlConfigLoader.cs diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs new file mode 100644 index 0000000..f551eb6 --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -0,0 +1,202 @@ +using System.IO; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器的目录扫描与注册行为。 +/// +[TestFixture] +public class YamlConfigLoaderTests +{ + /// + /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理测试期间创建的临时目录。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + private string _rootPath = null!; + + /// + /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 + /// + [Test] + public async Task LoadAsync_Should_Register_Table_From_Yaml_Files() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateConfigFile( + "monster/goblin.yml", + """ + id: 2 + name: Goblin + hp: 30 + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(2).Hp, Is.EqualTo(30)); + }); + } + + /// + /// 验证注册的配置目录不存在时会抛出清晰错误。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Config_Directory_Does_Not_Exist() + { + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", 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("monster")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证某个配置表加载失败时,注册表不会留下部分成功的中间状态。 + /// + [Test] + public void LoadAsync_Should_Not_Mutate_Registry_When_A_Later_Table_Fails() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + + var registry = new ConfigRegistry(); + registry.RegisterTable( + "existing", + new InMemoryConfigTable( + new[] + { + new ExistingConfigStub(100, "Original") + }, + static config => config.Id)); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id) + .RegisterTable("broken", "broken", static config => config.Id); + + Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(registry.Count, Is.EqualTo(1)); + Assert.That(registry.HasTable("monster"), Is.False); + Assert.That(registry.GetTable("existing").Get(100).Name, Is.EqualTo("Original")); + }); + } + + /// + /// 验证非法 YAML 会被包装成带文件路径的反序列化错误。 + /// + [Test] + public void LoadAsync_Should_Throw_With_File_Path_When_Yaml_Is_Invalid() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: [1 + name: Slime + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", 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("slime.yaml")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 创建测试用配置文件。 + /// + /// 相对根目录的文件路径。 + /// 文件内容。 + private void CreateConfigFile(string relativePath, string content) + { + var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(fullPath, content); + } + + /// + /// 用于 YAML 加载测试的最小怪物配置类型。 + /// + private sealed class MonsterConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置生命值。 + /// + public int Hp { get; set; } + } + + /// + /// 用于验证注册表一致性的现有配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record ExistingConfigStub(int Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs new file mode 100644 index 0000000..f26308d --- /dev/null +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -0,0 +1,269 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace GFramework.Game.Config; + +/// +/// 基于文件目录的 YAML 配置加载器。 +/// 该实现用于 Runtime MVP 的文本配置接入阶段,通过显式注册表定义描述要加载的配置域, +/// 再在一次加载流程中统一解析并写入配置注册表。 +/// +public sealed class YamlConfigLoader : IConfigLoader +{ + private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace."; + private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; + private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace."; + + private readonly IDeserializer _deserializer; + private readonly List _registrations = new(); + private readonly string _rootPath; + + /// + /// 使用指定配置根目录创建 YAML 配置加载器。 + /// + /// 配置根目录。 + /// 为空时抛出。 + public YamlConfigLoader(string rootPath) + { + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new ArgumentException(RootPathCannotBeNullOrWhiteSpaceMessage, nameof(rootPath)); + } + + _rootPath = rootPath; + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + } + + /// + /// 获取配置根目录。 + /// + public string RootPath => _rootPath; + + /// + /// 获取当前已注册的配置表定义数量。 + /// + public int RegistrationCount => _registrations.Count; + + /// + public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(registry); + + var loadedTables = new List<(string name, IConfigTable table)>(_registrations.Count); + + foreach (var registration in _registrations) + { + cancellationToken.ThrowIfCancellationRequested(); + loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken)); + } + + // 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。 + foreach (var (name, table) in loadedTables) + { + RegistrationDispatcher.Register(registry, name, table); + } + } + + /// + /// 注册一个 YAML 配置表定义。 + /// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。 + /// + /// 配置主键类型。 + /// 配置值类型。 + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + /// 当前加载器实例,以便链式注册。 + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName)); + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath)); + } + + ArgumentNullException.ThrowIfNull(keySelector); + + _registrations.Add(new YamlTableRegistration(tableName, relativePath, keySelector, comparer)); + return this; + } + + /// + /// 负责在非泛型配置表与泛型注册表方法之间做分派。 + /// 该静态助手将运行时反射局部封装在加载器内部,避免向外暴露弱类型注册 API。 + /// + private static class RegistrationDispatcher + { + /// + /// 将强类型配置表写入注册表。 + /// + /// 目标配置注册表。 + /// 配置表名称。 + /// 已加载的配置表实例。 + /// 当传入表未实现强类型配置表契约时抛出。 + public static void Register(IConfigRegistry registry, string name, IConfigTable table) + { + var tableInterface = table.GetType() + .GetInterfaces() + .FirstOrDefault(static type => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>)); + + if (tableInterface == null) + { + throw new InvalidOperationException( + $"Loaded config table '{name}' does not implement '{typeof(IConfigTable<,>).Name}'."); + } + + var genericArguments = tableInterface.GetGenericArguments(); + var method = typeof(IConfigRegistry) + .GetMethod(nameof(IConfigRegistry.RegisterTable))! + .MakeGenericMethod(genericArguments[0], genericArguments[1]); + + method.Invoke(registry, new object[] { name, table }); + } + } + + /// + /// 定义 YAML 配置表注册项的统一内部契约。 + /// + private interface IYamlTableRegistration + { + /// + /// 从指定根目录加载配置表。 + /// + /// 配置根目录。 + /// YAML 反序列化器。 + /// 取消令牌。 + /// 已加载的配置表名称与配置表实例。 + Task<(string name, IConfigTable table)> LoadAsync( + string rootPath, + IDeserializer deserializer, + CancellationToken cancellationToken); + } + + /// + /// YAML 配置表注册项。 + /// + /// 配置主键类型。 + /// 配置项值类型。 + private sealed class YamlTableRegistration : IYamlTableRegistration + where TKey : notnull + { + private readonly IEqualityComparer? _comparer; + private readonly Func _keySelector; + + /// + /// 初始化 YAML 配置表注册项。 + /// + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + public YamlTableRegistration( + string name, + string relativePath, + Func keySelector, + IEqualityComparer? comparer) + { + Name = name; + RelativePath = relativePath; + _keySelector = keySelector; + _comparer = comparer; + } + + /// + /// 获取配置表名称。 + /// + public string Name { get; } + + /// + /// 获取相对配置根目录的子目录。 + /// + public string RelativePath { get; } + + /// + public async Task<(string name, IConfigTable table)> LoadAsync( + string rootPath, + IDeserializer deserializer, + CancellationToken cancellationToken) + { + var directoryPath = Path.Combine(rootPath, RelativePath); + if (!Directory.Exists(directoryPath)) + { + throw new DirectoryNotFoundException( + $"Config directory '{directoryPath}' was not found for table '{Name}'."); + } + + var values = new List(); + var files = Directory + .EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly) + .Where(static path => + path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string yaml; + try + { + yaml = await File.ReadAllTextAsync(file, cancellationToken); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to read config file '{file}' for table '{Name}'.", + exception); + } + + try + { + var value = deserializer.Deserialize(yaml); + + if (value == null) + { + throw new InvalidOperationException("YAML content was deserialized to null."); + } + + values.Add(value); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.", + exception); + } + } + + try + { + var table = new InMemoryConfigTable(values, _keySelector, _comparer); + return (Name, table); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to build config table '{Name}' from directory '{directoryPath}'.", + exception); + } + } + } +} \ No newline at end of file diff --git a/GFramework.Game/GFramework.Game.csproj b/GFramework.Game/GFramework.Game.csproj index 5bb94d9..6a81f01 100644 --- a/GFramework.Game/GFramework.Game.csproj +++ b/GFramework.Game/GFramework.Game.csproj @@ -14,5 +14,6 @@ + From c9d230629564fc1eaf7768daf5de44cec45dee35 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:29:31 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0JSON?= =?UTF-8?q?=20schema=E9=85=8D=E7=BD=AE=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现SchemaConfigGenerator源代码生成器 - 支持从JSON schema文件生成配置类型和表包装类 - 添加ConfigSchemaDiagnostics诊断系统 - 集成System.Text.Json包依赖 - 生成强类型的配置访问接口 - 支持多种数据类型包括整数、浮点数、布尔值、字符串和数组 - 实现id字段作为表主键的约束验证 - 添加完整的单元测试和快照验证 --- .../SchemaConfigGeneratorSnapshotTests.cs | 125 +++++ .../Config/SchemaConfigGeneratorTests.cs | 48 ++ .../Config/SchemaGeneratorTestDriver.cs | 87 +++ .../SchemaConfigGenerator/MonsterConfig.g.txt | 32 ++ .../SchemaConfigGenerator/MonsterTable.g.txt | 55 ++ .../AnalyzerReleases.Unshipped.md | 41 +- .../Config/SchemaConfigGenerator.cs | 526 ++++++++++++++++++ .../Diagnostics/ConfigSchemaDiagnostics.cs | 66 +++ .../GFramework.SourceGenerators.csproj | 1 + 9 files changed, 963 insertions(+), 18 deletions(-) create mode 100644 GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs create mode 100644 GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs create mode 100644 GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt create mode 100644 GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs create mode 100644 GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs new file mode 100644 index 0000000..8c5d327 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -0,0 +1,125 @@ +using System.IO; + +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 验证 schema 配置生成器的生成快照。 +/// +[TestFixture] +public class SchemaConfigGeneratorSnapshotTests +{ + /// + /// 验证一个最小 monster schema 能生成配置类型和表包装。 + /// + [Test] + public async Task Snapshot_SchemaConfigGenerator() + { + const string source = """ + using System; + using System.Collections.Generic; + + namespace GFramework.Game.Abstractions.Config + { + public interface IConfigTable + { + Type KeyType { get; } + Type ValueType { get; } + int Count { get; } + } + + public interface IConfigTable : IConfigTable + where TKey : notnull + { + TValue Get(TKey key); + bool TryGet(TKey key, out TValue? value); + bool ContainsKey(TKey key); + IReadOnlyCollection All(); + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" }, + "dropItems": { + "type": "array", + "items": { "type": "string" } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var generatedSources = result.Results + .Single() + .GeneratedSources + .ToDictionary( + static sourceResult => sourceResult.HintName, + static sourceResult => sourceResult.SourceText.ToString(), + StringComparer.Ordinal); + + var snapshotFolder = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", + "..", + "..", + "Config", + "snapshots", + "SchemaConfigGenerator"); + snapshotFolder = Path.GetFullPath(snapshotFolder); + + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt"); + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt"); + } + + /// + /// 对单个生成文件执行快照断言。 + /// + /// 生成结果字典。 + /// 快照目录。 + /// 快照文件名。 + private static async Task AssertSnapshotAsync( + IReadOnlyDictionary generatedSources, + string snapshotFolder, + string generatedFileName, + string snapshotFileName) + { + if (!generatedSources.TryGetValue(generatedFileName, out var actual)) + { + Assert.Fail($"Generated source '{generatedFileName}' was not found."); + return; + } + + var path = Path.Combine(snapshotFolder, snapshotFileName); + if (!File.Exists(path)) + { + Directory.CreateDirectory(snapshotFolder); + await File.WriteAllTextAsync(path, actual); + Assert.Fail($"Snapshot not found. Generated new snapshot at:\n{path}"); + } + + var expected = await File.ReadAllTextAsync(path); + Assert.That( + Normalize(expected), + Is.EqualTo(Normalize(actual)), + $"Snapshot mismatch: {generatedFileName}"); + } + + /// + /// 标准化快照文本以避免平台换行差异。 + /// + /// 原始文本。 + /// 标准化后的文本。 + private static string Normalize(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs new file mode 100644 index 0000000..6a71777 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -0,0 +1,48 @@ +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 验证 schema 配置生成器的错误诊断行为。 +/// +[TestFixture] +public class SchemaConfigGeneratorTests +{ + /// + /// 验证缺失必填 id 字段时会产生命名明确的诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Id_Property_Is_Missing() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostics = result.Results.Single().Diagnostics; + var diagnostic = diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_003")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json")); + }); + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs new file mode 100644 index 0000000..0c33647 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs @@ -0,0 +1,87 @@ +using System.Collections.Immutable; +using System.IO; +using GFramework.SourceGenerators.Config; + +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 为 schema 配置生成器提供测试驱动。 +/// 该驱动直接使用 Roslyn GeneratorDriver 运行 AdditionalFiles 场景, +/// 以便测试基于 schema 文件的代码生成行为。 +/// +public static class SchemaGeneratorTestDriver +{ + /// + /// 运行 schema 配置生成器,并返回生成结果。 + /// + /// 测试用源码。 + /// AdditionalFiles 集合。 + /// 生成器运行结果。 + public static GeneratorDriverRunResult Run( + string source, + params (string path, string content)[] additionalFiles) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + "SchemaConfigGeneratorTests", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var additionalTexts = additionalFiles + .Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content)) + .ToImmutableArray(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new[] { new SchemaConfigGenerator().AsSourceGenerator() }, + additionalTexts: additionalTexts, + parseOptions: (CSharpParseOptions)syntaxTree.Options); + + driver = driver.RunGenerators(compilation); + return driver.GetRunResult(); + } + + /// + /// 获取测试编译所需的运行时元数据引用。 + /// + /// 元数据引用集合。 + private static IEnumerable GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))? + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + ?? Array.Empty(); + + return trustedPlatformAssemblies + .Select(static path => MetadataReference.CreateFromFile(path)); + } + + /// + /// 用于测试 AdditionalFiles 的内存实现。 + /// + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _text; + + /// + /// 创建内存 AdditionalText。 + /// + /// 虚拟文件路径。 + /// 文件内容。 + public InMemoryAdditionalText( + string path, + string content) + { + Path = path; + _text = SourceText.From(content); + } + + /// + public override string Path { get; } + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt new file mode 100644 index 0000000..8d05f36 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -0,0 +1,32 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated config type for schema file 'monster.schema.json'. +/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract. +/// +public sealed partial class MonsterConfig +{ + /// + /// Gets or sets the value mapped from schema property 'id'. + /// + public int Id { get; set; } + + /// + /// Gets or sets the value mapped from schema property 'name'. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the value mapped from schema property 'hp'. + /// + public int? Hp { get; set; } + + /// + /// Gets or sets the value mapped from schema property 'dropItems'. + /// + public global::System.Collections.Generic.IReadOnlyList DropItems { get; set; } = global::System.Array.Empty(); + +} diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt new file mode 100644 index 0000000..2e1a442 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt @@ -0,0 +1,55 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated table wrapper for schema file 'monster.schema.json'. +/// The wrapper keeps generated call sites strongly typed while delegating actual storage to the runtime config table implementation. +/// +public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.Config.IConfigTable +{ + private readonly global::GFramework.Game.Abstractions.Config.IConfigTable _inner; + + /// + /// Creates a generated table wrapper around the runtime config table instance. + /// + /// The runtime config table instance. + public MonsterTable(global::GFramework.Game.Abstractions.Config.IConfigTable inner) + { + _inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner)); + } + + /// + public global::System.Type KeyType => _inner.KeyType; + + /// + public global::System.Type ValueType => _inner.ValueType; + + /// + public int Count => _inner.Count; + + /// + public MonsterConfig Get(int key) + { + return _inner.Get(key); + } + + /// + public bool TryGet(int key, out MonsterConfig? value) + { + return _inner.TryGet(key, out value); + } + + /// + public bool ContainsKey(int key) + { + return _inner.ContainsKey(key); + } + + /// + public global::System.Collections.Generic.IReadOnlyCollection All() + { + return _inner.All(); + } +} diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md index 8b25a37..356e4cb 100644 --- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -3,21 +3,26 @@ ### New Rules - Rule ID | Category | Severity | Notes ------------------------|----------------------------------|----------|------------------------ - GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics - GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic - GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics - GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics - GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic - GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer + Rule ID | Category | Severity | Notes +-----------------------|------------------------------------|----------|------------------------- + GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics + GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic + GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics + GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics + GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic + GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs new file mode 100644 index 0000000..4faf0e7 --- /dev/null +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -0,0 +1,526 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using GFramework.SourceGenerators.Diagnostics; + +namespace GFramework.SourceGenerators.Config; + +/// +/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 +/// 当前实现聚焦 Runtime MVP 需要的最小能力:单 schema 对应单配置类型,并约定使用必填的 id 字段作为表主键。 +/// +[Generator] +public sealed class SchemaConfigGenerator : IIncrementalGenerator +{ + private const string GeneratedNamespace = "GFramework.Game.Config.Generated"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var schemaFiles = context.AdditionalTextsProvider + .Where(static file => file.Path.EndsWith(".schema.json", StringComparison.OrdinalIgnoreCase)) + .Select(static (file, cancellationToken) => ParseSchema(file, cancellationToken)); + + context.RegisterSourceOutput(schemaFiles, static (productionContext, result) => + { + foreach (var diagnostic in result.Diagnostics) + { + productionContext.ReportDiagnostic(diagnostic); + } + + if (result.Schema is null) + { + return; + } + + productionContext.AddSource( + $"{result.Schema.ClassName}.g.cs", + SourceText.From(GenerateConfigClass(result.Schema), Encoding.UTF8)); + productionContext.AddSource( + $"{result.Schema.TableName}.g.cs", + SourceText.From(GenerateTableClass(result.Schema), Encoding.UTF8)); + }); + } + + /// + /// 解析单个 schema 文件。 + /// + /// AdditionalFiles 中的 schema 文件。 + /// 取消令牌。 + /// 解析结果,包含 schema 模型或诊断。 + private static SchemaParseResult ParseSchema( + AdditionalText file, + CancellationToken cancellationToken) + { + SourceText? text; + try + { + text = file.GetText(cancellationToken); + } + catch (Exception exception) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + exception.Message)); + } + + if (text is null) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + "File content could not be read.")); + } + + try + { + using var document = JsonDocument.Parse(text.ToString()); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var rootTypeElement) || + !string.Equals(rootTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + if (!root.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + var requiredProperties = new HashSet(StringComparer.OrdinalIgnoreCase); + if (root.TryGetProperty("required", out var requiredElement) && + requiredElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in requiredElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + requiredProperties.Add(value!); + } + } + } + } + + var properties = new List(); + foreach (var property in propertiesElement.EnumerateObject()) + { + var parsedProperty = ParseProperty(file.Path, property, requiredProperties.Contains(property.Name)); + if (parsedProperty.Diagnostic is not null) + { + return SchemaParseResult.FromDiagnostic(parsedProperty.Diagnostic); + } + + properties.Add(parsedProperty.Property!); + } + + var idProperty = properties.FirstOrDefault(static property => + string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase)); + if (idProperty is null || !idProperty.IsRequired) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.IdPropertyRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + if (!string.Equals(idProperty.SchemaType, "integer", StringComparison.Ordinal) && + !string.Equals(idProperty.SchemaType, "string", StringComparison.Ordinal)) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedKeyType, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + idProperty.SchemaType)); + } + + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); + var schema = new SchemaFileSpec( + Path.GetFileName(file.Path), + entityName, + $"{entityName}Config", + $"{entityName}Table", + GeneratedNamespace, + idProperty.ClrType, + properties); + + return SchemaParseResult.FromSchema(schema); + } + catch (JsonException exception) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + exception.Message)); + } + } + + /// + /// 解析单个 schema 属性定义。 + /// + /// schema 文件路径。 + /// 属性 JSON 节点。 + /// 属性是否必填。 + /// 解析后的属性信息或诊断。 + private static ParsedPropertyResult ParseProperty( + string filePath, + JsonProperty property, + bool isRequired) + { + if (!property.Value.TryGetProperty("type", out var typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + "")); + } + + var schemaType = typeElement.GetString() ?? string.Empty; + switch (schemaType) + { + case "integer": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "integer", + isRequired ? "int" : "int?", + isRequired, + null)); + + case "number": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "number", + isRequired ? "double" : "double?", + isRequired, + null)); + + case "boolean": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "boolean", + isRequired ? "bool" : "bool?", + isRequired, + null)); + + case "string": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "string", + isRequired ? "string" : "string?", + isRequired, + isRequired ? " = string.Empty;" : null)); + + case "array": + if (!property.Value.TryGetProperty("items", out var itemsElement) || + !itemsElement.TryGetProperty("type", out var itemTypeElement) || + itemTypeElement.ValueKind != JsonValueKind.String) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + "array")); + } + + var itemType = itemTypeElement.GetString() ?? string.Empty; + var itemClrType = itemType switch + { + "integer" => "int", + "number" => "double", + "boolean" => "bool", + "string" => "string", + _ => string.Empty + }; + + if (string.IsNullOrEmpty(itemClrType)) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + $"array<{itemType}>")); + } + + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "array", + $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", + isRequired, + " = global::System.Array.Empty<" + itemClrType + ">();")); + + default: + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + schemaType)); + } + } + + /// + /// 生成配置类型源码。 + /// + /// 已解析的 schema 模型。 + /// 配置类型源码。 + private static string GenerateConfigClass(SchemaFileSpec schema) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + $"/// Auto-generated config type for schema file '{schema.FileName}'."); + builder.AppendLine( + "/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract."); + builder.AppendLine("/// "); + builder.AppendLine($"public sealed partial class {schema.ClassName}"); + builder.AppendLine("{"); + + foreach (var property in schema.Properties) + { + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// Gets or sets the value mapped from schema property '{property.SchemaName}'."); + builder.AppendLine(" /// "); + builder.Append($" public {property.ClrType} {property.PropertyName} {{ get; set; }}"); + if (!string.IsNullOrEmpty(property.Initializer)) + { + builder.Append(property.Initializer); + } + + builder.AppendLine(); + builder.AppendLine(); + } + + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 生成配置表包装源码。 + /// + /// 已解析的 schema 模型。 + /// 配置表包装源码。 + private static string GenerateTableClass(SchemaFileSpec schema) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + $"/// Auto-generated table wrapper for schema file '{schema.FileName}'."); + builder.AppendLine( + "/// The wrapper keeps generated call sites strongly typed while delegating actual storage to the runtime config table implementation."); + builder.AppendLine("/// "); + builder.AppendLine( + $"public sealed partial class {schema.TableName} : global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}>"); + builder.AppendLine("{"); + builder.AppendLine( + $" private readonly global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> _inner;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Creates a generated table wrapper around the runtime config table instance."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The runtime config table instance."); + builder.AppendLine( + $" public {schema.TableName}(global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> inner)"); + builder.AppendLine(" {"); + builder.AppendLine(" _inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public global::System.Type KeyType => _inner.KeyType;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public global::System.Type ValueType => _inner.ValueType;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public int Count => _inner.Count;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public {schema.ClassName} Get({schema.KeyClrType} key)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.Get(key);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public bool TryGet({schema.KeyClrType} key, out {schema.ClassName}? value)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.TryGet(key, out value);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public bool ContainsKey({schema.KeyClrType} key)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.ContainsKey(key);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public global::System.Collections.Generic.IReadOnlyCollection<{schema.ClassName}> All()"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.All();"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 从 schema 文件路径提取实体基础名。 + /// + /// schema 文件路径。 + /// 去掉扩展名和 `.schema` 后缀的实体基础名。 + private static string GetSchemaBaseName(string path) + { + var fileName = Path.GetFileName(path); + if (fileName.EndsWith(".schema.json", StringComparison.OrdinalIgnoreCase)) + { + return fileName.Substring(0, fileName.Length - ".schema.json".Length); + } + + return Path.GetFileNameWithoutExtension(fileName); + } + + /// + /// 将 schema 名称转换为 PascalCase 标识符。 + /// + /// 原始名称。 + /// PascalCase 标识符。 + private static string ToPascalCase(string value) + { + var tokens = value + .Split(new[] { '-', '_', '.', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Select(static token => + char.ToUpperInvariant(token[0]) + token.Substring(1)) + .ToArray(); + + return tokens.Length == 0 ? "Config" : string.Concat(tokens); + } + + /// + /// 为 AdditionalFiles 诊断创建文件位置。 + /// + /// 文件路径。 + /// 指向文件开头的位置。 + private static Location CreateFileLocation(string path) + { + return Location.Create( + path, + TextSpan.FromBounds(0, 0), + new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); + } + + /// + /// 表示单个 schema 文件的解析结果。 + /// + /// 成功解析出的 schema 模型。 + /// 解析阶段产生的诊断。 + private sealed record SchemaParseResult( + SchemaFileSpec? Schema, + ImmutableArray Diagnostics) + { + /// + /// 从成功解析的 schema 模型创建结果。 + /// + public static SchemaParseResult FromSchema(SchemaFileSpec schema) + { + return new SchemaParseResult(schema, ImmutableArray.Empty); + } + + /// + /// 从单个诊断创建结果。 + /// + public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic) + { + return new SchemaParseResult(null, ImmutableArray.Create(diagnostic)); + } + } + + /// + /// 表示已解析的 schema 文件模型。 + /// + private sealed record SchemaFileSpec( + string FileName, + string EntityName, + string ClassName, + string TableName, + string Namespace, + string KeyClrType, + IReadOnlyList Properties); + + /// + /// 表示已解析的 schema 属性。 + /// + private sealed record SchemaPropertySpec( + string SchemaName, + string PropertyName, + string SchemaType, + string ClrType, + bool IsRequired, + string? Initializer); + + /// + /// 表示单个属性的解析结果。 + /// + private sealed record ParsedPropertyResult( + SchemaPropertySpec? Property, + Diagnostic? Diagnostic) + { + /// + /// 从属性模型创建成功结果。 + /// + public static ParsedPropertyResult FromProperty(SchemaPropertySpec property) + { + return new ParsedPropertyResult(property, null); + } + + /// + /// 从诊断创建失败结果。 + /// + public static ParsedPropertyResult FromDiagnostic(Diagnostic diagnostic) + { + return new ParsedPropertyResult(null, diagnostic); + } + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs new file mode 100644 index 0000000..9229868 --- /dev/null +++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -0,0 +1,66 @@ +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.SourceGenerators.Diagnostics; + +/// +/// 提供配置 schema 代码生成相关诊断。 +/// +public static class ConfigSchemaDiagnostics +{ + private const string SourceGeneratorsConfigCategory = $"{PathContests.SourceGeneratorsPath}.Config"; + + /// + /// schema JSON 无法解析。 + /// + public static readonly DiagnosticDescriptor InvalidSchemaJson = new( + "GF_ConfigSchema_001", + "Config schema JSON is invalid", + "Schema file '{0}' could not be parsed: {1}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 顶层必须是 object。 + /// + public static readonly DiagnosticDescriptor RootObjectSchemaRequired = new( + "GF_ConfigSchema_002", + "Config schema root must describe an object", + "Schema file '{0}' must declare a root object schema", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 必须声明 id 字段作为主键。 + /// + public static readonly DiagnosticDescriptor IdPropertyRequired = new( + "GF_ConfigSchema_003", + "Config schema must declare an id property", + "Schema file '{0}' must declare a required 'id' property for table generation", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 包含暂不支持的字段类型。 + /// + public static readonly DiagnosticDescriptor UnsupportedPropertyType = new( + "GF_ConfigSchema_004", + "Config schema contains an unsupported property type", + "Property '{1}' in schema file '{0}' uses unsupported type '{2}'", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 的 id 字段类型不支持作为主键。 + /// + public static readonly DiagnosticDescriptor UnsupportedKeyType = new( + "GF_ConfigSchema_005", + "Config schema uses an unsupported key type", + "Schema file '{0}' uses unsupported id type '{1}'. Supported key types are 'integer' and 'string'.", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj b/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj index 99240bd..540ecde 100644 --- a/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj +++ b/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj @@ -24,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers + From 9972788c32247d5c840a68401a98fcea6549d8cd Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:37:15 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(vscode):=20=E6=B7=BB=E5=8A=A0=20GFra?= =?UTF-8?q?mework=20=E9=85=8D=E7=BD=AE=E5=B7=A5=E5=85=B7=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置文件浏览器功能,支持工作区 config 目录下的 YAML 文件浏览 - 添加配置文件验证功能,支持基于 JSON Schema 的轻量级验证 - 提供表单预览界面,支持顶层标量字段的编辑功能 - 实现配置文件与匹配模式文件的快速打开功能 - 添加工作区设置选项,可自定义配置和模式目录路径 - 支持实时保存和验证反馈,集成 VSCode 诊断集合显示错误警告 --- tools/vscode-config-extension/README.md | 23 + tools/vscode-config-extension/package.json | 101 +++ .../vscode-config-extension/src/extension.js | 807 ++++++++++++++++++ 3 files changed, 931 insertions(+) create mode 100644 tools/vscode-config-extension/README.md create mode 100644 tools/vscode-config-extension/package.json create mode 100644 tools/vscode-config-extension/src/extension.js diff --git a/tools/vscode-config-extension/README.md b/tools/vscode-config-extension/README.md new file mode 100644 index 0000000..c9e3a58 --- /dev/null +++ b/tools/vscode-config-extension/README.md @@ -0,0 +1,23 @@ +# GFramework Config Tools + +Minimal VS Code extension scaffold for the GFramework AI-First config workflow. + +## Current MVP + +- 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 and simple scalar types +- Open a lightweight form preview for top-level scalar fields + +## Current Constraints + +- Multi-root workspaces use the first workspace folder +- Validation only covers a minimal subset of JSON Schema +- Form editing currently supports top-level scalar fields only +- Arrays and nested objects should still be edited in raw YAML + +## Workspace Settings + +- `gframeworkConfig.configPath` +- `gframeworkConfig.schemasPath` diff --git a/tools/vscode-config-extension/package.json b/tools/vscode-config-extension/package.json new file mode 100644 index 0000000..92c5c71 --- /dev/null +++ b/tools/vscode-config-extension/package.json @@ -0,0 +1,101 @@ +{ + "name": "gframework-config-extension", + "displayName": "GFramework Config Tools", + "description": "Workspace tools for browsing, validating, and editing AI-First config files in GFramework projects.", + "version": "0.0.1", + "publisher": "gewuyou", + "license": "Apache-2.0", + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onView:gframeworkConfigExplorer", + "onCommand:gframeworkConfig.refresh", + "onCommand:gframeworkConfig.openRaw", + "onCommand:gframeworkConfig.openSchema", + "onCommand:gframeworkConfig.openFormPreview", + "onCommand:gframeworkConfig.validateAll" + ], + "main": "./src/extension.js", + "contributes": { + "views": { + "explorer": [ + { + "id": "gframeworkConfigExplorer", + "name": "GFramework Config" + } + ] + }, + "commands": [ + { + "command": "gframeworkConfig.refresh", + "title": "GFramework Config: Refresh" + }, + { + "command": "gframeworkConfig.openRaw", + "title": "GFramework Config: Open Raw File" + }, + { + "command": "gframeworkConfig.openSchema", + "title": "GFramework Config: Open Schema" + }, + { + "command": "gframeworkConfig.openFormPreview", + "title": "GFramework Config: Open Form Preview" + }, + { + "command": "gframeworkConfig.validateAll", + "title": "GFramework Config: Validate All" + } + ], + "menus": { + "view/title": [ + { + "command": "gframeworkConfig.refresh", + "when": "view == gframeworkConfigExplorer", + "group": "navigation" + }, + { + "command": "gframeworkConfig.validateAll", + "when": "view == gframeworkConfigExplorer", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "gframeworkConfig.openRaw", + "when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile", + "group": "inline" + }, + { + "command": "gframeworkConfig.openSchema", + "when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile", + "group": "navigation" + }, + { + "command": "gframeworkConfig.openFormPreview", + "when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile", + "group": "navigation" + } + ] + }, + "configuration": { + "title": "GFramework Config", + "properties": { + "gframeworkConfig.configPath": { + "type": "string", + "default": "config", + "description": "Relative path from the workspace root to the config directory." + }, + "gframeworkConfig.schemasPath": { + "type": "string", + "default": "schemas", + "description": "Relative path from the workspace root to the schema directory." + } + } + } + } +} diff --git a/tools/vscode-config-extension/src/extension.js b/tools/vscode-config-extension/src/extension.js new file mode 100644 index 0000000..633cf6a --- /dev/null +++ b/tools/vscode-config-extension/src/extension.js @@ -0,0 +1,807 @@ +const fs = require("fs"); +const path = require("path"); +const vscode = require("vscode"); + +/** + * Activate the GFramework config extension. + * The initial MVP focuses on workspace file navigation, lightweight validation, + * and a small form-preview entry for top-level scalar values. + * + * @param {vscode.ExtensionContext} context Extension context. + */ +function activate(context) { + const diagnostics = vscode.languages.createDiagnosticCollection("gframeworkConfig"); + const provider = new ConfigTreeDataProvider(); + + context.subscriptions.push(diagnostics); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("gframeworkConfigExplorer", provider), + vscode.commands.registerCommand("gframeworkConfig.refresh", async () => { + provider.refresh(); + await validateAllConfigs(diagnostics); + }), + vscode.commands.registerCommand("gframeworkConfig.openRaw", async (item) => { + await openRawFile(item); + }), + vscode.commands.registerCommand("gframeworkConfig.openSchema", async (item) => { + await openSchemaFile(item); + }), + vscode.commands.registerCommand("gframeworkConfig.openFormPreview", async (item) => { + await openFormPreview(item, diagnostics); + }), + vscode.commands.registerCommand("gframeworkConfig.validateAll", async () => { + await validateAllConfigs(diagnostics); + }), + vscode.workspace.onDidSaveTextDocument(async (document) => { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return; + } + + if (!isConfigFile(document.uri, workspaceRoot)) { + return; + } + + await validateConfigFile(document.uri, diagnostics); + provider.refresh(); + }), + vscode.workspace.onDidChangeWorkspaceFolders(async () => { + provider.refresh(); + await validateAllConfigs(diagnostics); + }) + ); + + void validateAllConfigs(diagnostics); +} + +/** + * Deactivate the extension. + */ +function deactivate() { +} + +/** + * Tree provider for the GFramework config explorer view. + */ +class ConfigTreeDataProvider { + constructor() { + this._emitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this._emitter.event; + } + + /** + * Refresh the tree view. + */ + refresh() { + this._emitter.fire(undefined); + } + + /** + * Resolve a tree item. + * + * @param {ConfigTreeItem} element Tree element. + * @returns {vscode.TreeItem} Tree item. + */ + getTreeItem(element) { + return element; + } + + /** + * Resolve child elements. + * + * @param {ConfigTreeItem | undefined} element Parent element. + * @returns {Thenable} Child items. + */ + async getChildren(element) { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return []; + } + + if (!element) { + return this.getRootItems(workspaceRoot); + } + + if (element.kind !== "domain" || !element.resourceUri) { + return []; + } + + return this.getFileItems(workspaceRoot, element.resourceUri); + } + + /** + * Build root domain items from the config directory. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {Promise} Root items. + */ + async getRootItems(workspaceRoot) { + const configRoot = getConfigRoot(workspaceRoot); + if (!configRoot || !fs.existsSync(configRoot.fsPath)) { + return [ + new ConfigTreeItem( + "No config directory", + "info", + vscode.TreeItemCollapsibleState.None, + undefined, + "Set gframeworkConfig.configPath or create the directory.") + ]; + } + + const entries = fs.readdirSync(configRoot.fsPath, {withFileTypes: true}) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)); + + return entries.map((entry) => { + const domainUri = vscode.Uri.joinPath(configRoot, entry.name); + return new ConfigTreeItem( + entry.name, + "domain", + vscode.TreeItemCollapsibleState.Collapsed, + domainUri, + undefined); + }); + } + + /** + * Build file items for a config domain directory. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @param {vscode.Uri} domainUri Domain directory URI. + * @returns {Promise} File items. + */ + async getFileItems(workspaceRoot, domainUri) { + const entries = fs.readdirSync(domainUri.fsPath, {withFileTypes: true}) + .filter((entry) => entry.isFile() && isYamlPath(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)); + + return entries.map((entry) => { + const fileUri = vscode.Uri.joinPath(domainUri, entry.name); + const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot); + const description = schemaUri && fs.existsSync(schemaUri.fsPath) + ? "schema" + : "schema missing"; + const item = new ConfigTreeItem( + entry.name, + "file", + vscode.TreeItemCollapsibleState.None, + fileUri, + description); + + item.contextValue = "gframeworkConfigFile"; + item.command = { + command: "gframeworkConfig.openRaw", + title: "Open Raw", + arguments: [item] + }; + + return item; + }); + } +} + +/** + * Tree item used by the config explorer. + */ +class ConfigTreeItem extends vscode.TreeItem { + /** + * @param {string} label Display label. + * @param {"domain" | "file" | "info"} kind Item kind. + * @param {vscode.TreeItemCollapsibleState} collapsibleState Collapsible state. + * @param {vscode.Uri | undefined} resourceUri Resource URI. + * @param {string | undefined} description Description. + */ + constructor(label, kind, collapsibleState, resourceUri, description) { + super(label, collapsibleState); + this.kind = kind; + this.resourceUri = resourceUri; + this.description = description; + this.contextValue = kind === "file" ? "gframeworkConfigFile" : kind; + } +} + +/** + * Open the selected raw config file. + * + * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. + * @returns {Promise} Async task. + */ +async function openRawFile(item) { + const uri = item && item.resourceUri; + if (!uri) { + return; + } + + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, {preview: false}); +} + +/** + * Open the matching schema file for a selected config item. + * + * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. + * @returns {Promise} Async task. + */ +async function openSchemaFile(item) { + const workspaceRoot = getWorkspaceRoot(); + const configUri = item && item.resourceUri; + if (!workspaceRoot || !configUri) { + return; + } + + const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); + if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { + void vscode.window.showWarningMessage("Matching schema file was not found."); + return; + } + + const document = await vscode.workspace.openTextDocument(schemaUri); + await vscode.window.showTextDocument(document, {preview: false}); +} + +/** + * Open a lightweight form preview for top-level scalar fields. + * The editor intentionally edits only simple scalar keys and keeps raw YAML as + * the escape hatch for arrays, nested objects, and advanced changes. + * + * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. + * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. + * @returns {Promise} Async task. + */ +async function openFormPreview(item, diagnostics) { + const workspaceRoot = getWorkspaceRoot(); + const configUri = item && item.resourceUri; + if (!workspaceRoot || !configUri) { + return; + } + + const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + const parsedYaml = parseTopLevelYaml(yamlText); + const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); + + const panel = vscode.window.createWebviewPanel( + "gframeworkConfigFormPreview", + `Config Form: ${path.basename(configUri.fsPath)}`, + vscode.ViewColumn.Beside, + {enableScripts: true}); + + panel.webview.html = renderFormHtml( + path.basename(configUri.fsPath), + schemaInfo, + parsedYaml); + + panel.webview.onDidReceiveMessage(async (message) => { + if (message.type === "save") { + const updatedYaml = applyScalarUpdates(yamlText, message.values || {}); + await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8"); + const document = await vscode.workspace.openTextDocument(configUri); + await document.save(); + await validateConfigFile(configUri, diagnostics); + void vscode.window.showInformationMessage("Config file saved from form preview."); + } + + if (message.type === "openRaw") { + await openRawFile({resourceUri: configUri}); + } + }); +} + +/** + * Validate all config files in the configured config directory. + * + * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. + * @returns {Promise} Async task. + */ +async function validateAllConfigs(diagnostics) { + diagnostics.clear(); + + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return; + } + + const configRoot = getConfigRoot(workspaceRoot); + if (!configRoot || !fs.existsSync(configRoot.fsPath)) { + return; + } + + const files = enumerateYamlFiles(configRoot.fsPath); + for (const filePath of files) { + await validateConfigFile(vscode.Uri.file(filePath), diagnostics); + } +} + +/** + * Validate a single config file against its matching schema. + * + * @param {vscode.Uri} configUri Config file URI. + * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. + * @returns {Promise} Async task. + */ +async function validateConfigFile(configUri, diagnostics) { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return; + } + + if (!isConfigFile(configUri, workspaceRoot)) { + return; + } + + const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + const parsedYaml = parseTopLevelYaml(yamlText); + const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); + const fileDiagnostics = []; + + if (!schemaInfo.exists) { + fileDiagnostics.push(new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 1), + `Matching schema file not found: ${schemaInfo.schemaPath}`, + vscode.DiagnosticSeverity.Warning)); + diagnostics.set(configUri, fileDiagnostics); + return; + } + + for (const requiredProperty of schemaInfo.required) { + if (!parsedYaml.keys.has(requiredProperty)) { + fileDiagnostics.push(new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 1), + `Required property '${requiredProperty}' is missing.`, + vscode.DiagnosticSeverity.Error)); + } + } + + for (const [propertyName, expectedType] of Object.entries(schemaInfo.propertyTypes)) { + if (!parsedYaml.scalars.has(propertyName)) { + continue; + } + + const scalarValue = parsedYaml.scalars.get(propertyName); + if (!isScalarCompatible(expectedType, scalarValue)) { + fileDiagnostics.push(new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 1), + `Property '${propertyName}' is expected to be '${expectedType}', but the current scalar value is incompatible.`, + vscode.DiagnosticSeverity.Warning)); + } + } + + diagnostics.set(configUri, fileDiagnostics); +} + +/** + * Load schema info for a config file. + * + * @param {vscode.Uri} configUri Config file URI. + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], propertyTypes: Record}>} Schema info. + */ +async function loadSchemaInfoForConfig(configUri, workspaceRoot) { + const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); + const schemaPath = schemaUri ? schemaUri.fsPath : ""; + if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { + return { + exists: false, + schemaPath, + required: [], + propertyTypes: {} + }; + } + + const content = await fs.promises.readFile(schemaUri.fsPath, "utf8"); + try { + const parsed = JSON.parse(content); + const required = Array.isArray(parsed.required) + ? parsed.required.filter((value) => typeof value === "string") + : []; + const propertyTypes = {}; + const properties = parsed.properties || {}; + + for (const [key, value] of Object.entries(properties)) { + if (!value || typeof value !== "object") { + continue; + } + + if (typeof value.type === "string") { + propertyTypes[key] = value.type; + } + } + + return { + exists: true, + schemaPath, + required, + propertyTypes + }; + } catch (error) { + return { + exists: false, + schemaPath, + required: [], + propertyTypes: {} + }; + } +} + +/** + * Parse top-level YAML keys and scalar values. + * This intentionally supports only the MVP subset needed for lightweight form + * preview and validation. + * + * @param {string} text YAML text. + * @returns {{keys: Set, scalars: Map}} Parsed shape. + */ +function parseTopLevelYaml(text) { + const keys = new Set(); + const scalars = new Map(); + const lines = text.split(/\r?\n/u); + + for (const line of lines) { + 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) { + continue; + } + + if (rawValue.startsWith("|") || rawValue.startsWith(">")) { + continue; + } + + scalars.set(key, rawValue.trim()); + } + + return {keys, scalars}; +} + +/** + * Apply scalar field updates back into the original YAML text. + * + * @param {string} originalYaml Original YAML content. + * @param {Record} updates Updated scalar values. + * @returns {string} Updated YAML content. + */ +function applyScalarUpdates(originalYaml, updates) { + const lines = originalYaml.split(/\r?\n/u); + const touched = new Set(); + + const updatedLines = lines.map((line) => { + if (/^\s/u.test(line)) { + return line; + } + + const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); + if (!match) { + return line; + } + + const key = match[1]; + if (!Object.prototype.hasOwnProperty.call(updates, key)) { + return line; + } + + touched.add(key); + return `${key}: ${formatYamlScalar(updates[key])}`; + }); + + for (const [key, value] of Object.entries(updates)) { + if (touched.has(key)) { + continue; + } + + updatedLines.push(`${key}: ${formatYamlScalar(value)}`); + } + + return updatedLines.join("\n"); +} + +/** + * Render the form-preview webview HTML. + * + * @param {string} fileName File name. + * @param {{exists: boolean, schemaPath: string, required: string[], propertyTypes: Record}} schemaInfo Schema info. + * @param {{keys: Set, scalars: Map}} parsedYaml Parsed YAML data. + * @returns {string} HTML string. + */ +function renderFormHtml(fileName, schemaInfo, parsedYaml) { + const fields = Array.from(parsedYaml.scalars.entries()) + .map(([key, value]) => { + const escapedKey = escapeHtml(key); + const escapedValue = escapeHtml(unquoteScalar(value)); + const required = schemaInfo.required.includes(key) ? "required" : ""; + return ` + + `; + }) + .join("\n"); + + const schemaStatus = schemaInfo.exists + ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}` + : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`; + + const emptyState = fields.length > 0 + ? fields + : "

No editable top-level scalar fields were detected. Use raw YAML for nested objects or arrays.

"; + + return ` + + + + + + + +
+ + +
+
+
File: ${escapeHtml(fileName)}
+
${schemaStatus}
+
+
${emptyState}
+ + +`; +} + +/** + * Determine whether a scalar value matches a minimal schema type. + * + * @param {string} expectedType Schema type. + * @param {string} scalarValue YAML scalar value. + * @returns {boolean} True when compatible. + */ +function isScalarCompatible(expectedType, scalarValue) { + const value = unquoteScalar(scalarValue); + switch (expectedType) { + case "integer": + return /^-?\d+$/u.test(value); + case "number": + return /^-?\d+(?:\.\d+)?$/u.test(value); + case "boolean": + return /^(true|false)$/iu.test(value); + case "string": + return true; + default: + return true; + } +} + +/** + * Format a scalar value for YAML output. + * + * @param {string} value Scalar value. + * @returns {string} YAML-ready scalar. + */ +function formatYamlScalar(value) { + if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) { + return value; + } + + if (value.length === 0 || /[:#\[\]\{\},]|^\s|\s$/u.test(value)) { + return JSON.stringify(value); + } + + return value; +} + +/** + * Remove a simple YAML string quote wrapper. + * + * @param {string} value Scalar value. + * @returns {string} Unquoted value. + */ +function unquoteScalar(value) { + if ((value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + + return value; +} + +/** + * Enumerate all YAML files recursively. + * + * @param {string} rootPath Root path. + * @returns {string[]} YAML file paths. + */ +function enumerateYamlFiles(rootPath) { + const results = []; + + for (const entry of fs.readdirSync(rootPath, {withFileTypes: true})) { + const fullPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + results.push(...enumerateYamlFiles(fullPath)); + continue; + } + + if (entry.isFile() && isYamlPath(entry.name)) { + results.push(fullPath); + } + } + + return results; +} + +/** + * Check whether a path is a YAML file. + * + * @param {string} filePath File path. + * @returns {boolean} True for YAML files. + */ +function isYamlPath(filePath) { + return filePath.endsWith(".yaml") || filePath.endsWith(".yml"); +} + +/** + * Resolve the first workspace root. + * + * @returns {vscode.WorkspaceFolder | undefined} Workspace root. + */ +function getWorkspaceRoot() { + const folders = vscode.workspace.workspaceFolders; + return folders && folders.length > 0 ? folders[0] : undefined; +} + +/** + * Resolve the configured config root. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {vscode.Uri | undefined} Config root URI. + */ +function getConfigRoot(workspaceRoot) { + const relativePath = vscode.workspace.getConfiguration("gframeworkConfig") + .get("configPath", "config"); + return vscode.Uri.joinPath(workspaceRoot.uri, relativePath); +} + +/** + * Resolve the configured schemas root. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {vscode.Uri | undefined} Schema root URI. + */ +function getSchemasRoot(workspaceRoot) { + const relativePath = vscode.workspace.getConfiguration("gframeworkConfig") + .get("schemasPath", "schemas"); + return vscode.Uri.joinPath(workspaceRoot.uri, relativePath); +} + +/** + * Resolve the matching schema URI for a config file. + * + * @param {vscode.Uri} configUri Config file URI. + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {vscode.Uri | undefined} Schema URI. + */ +function getSchemaUriForConfigFile(configUri, workspaceRoot) { + const configRoot = getConfigRoot(workspaceRoot); + const schemaRoot = getSchemasRoot(workspaceRoot); + if (!configRoot || !schemaRoot) { + return undefined; + } + + const relativePath = path.relative(configRoot.fsPath, configUri.fsPath); + const segments = relativePath.split(path.sep); + if (segments.length === 0 || !segments[0]) { + return undefined; + } + + return vscode.Uri.joinPath(schemaRoot, `${segments[0]}.schema.json`); +} + +/** + * Check whether a URI is inside the configured config root. + * + * @param {vscode.Uri} uri File URI. + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {boolean} True when the file belongs to the config tree. + */ +function isConfigFile(uri, workspaceRoot) { + const configRoot = getConfigRoot(workspaceRoot); + if (!configRoot) { + return false; + } + + const relativePath = path.relative(configRoot.fsPath, uri.fsPath); + return !relativePath.startsWith("..") && !path.isAbsolute(relativePath) && isYamlPath(uri.fsPath); +} + +/** + * Escape HTML text. + * + * @param {string} value Raw string. + * @returns {string} Escaped string. + */ +function escapeHtml(value) { + return String(value) + .replace(/&/gu, "&") + .replace(//gu, ">") + .replace(/"/gu, """) + .replace(/'/gu, "'"); +} + +module.exports = { + activate, + deactivate +}; From 91f03754615fcc25d64b515e96b2ad024ceeda64 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:41:52 +0800 Subject: [PATCH 05/14] =?UTF-8?q?refactor(tests):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=A9=B1=E5=8A=A8=E7=A8=8B=E5=BA=8F=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Microsoft.CodeAnalysis.CSharp 命名空间引用 - 优化代码结构以支持 C# 语法分析功能 - 提升测试驱动程序的编译器集成能力 --- .../Config/SchemaGeneratorTestDriver.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs index 0c33647..4da9519 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.IO; using GFramework.SourceGenerators.Config; +using Microsoft.CodeAnalysis.CSharp; namespace GFramework.SourceGenerators.Tests.Config; From b87e51133466f763295883a147422c824266cd4d Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:30:33 +0800 Subject: [PATCH 06/14] =?UTF-8?q?docs(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E6=A8=A1=E5=9D=97=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建了 GFramework.Game 模块的全面文档 - 包含架构模块系统、资产管理、存储系统和序列化系统详解 - 提供了 AbstractModule、AbstractAssetCatalogUtility 等核心组件使用示例 - 添加了分层存储、加密存储和缓存存储的实现方案 - 集成了 JSON 序列化、自定义转换器和版本化数据管理 - 提供了完整的游戏数据管理系统和自动保存系统实现 - 修改了 VitePress 配置文件 --- AGENTS.md | 12 + .../Config/YamlConfigLoaderTests.cs | 211 ++++++++- GFramework.Game/Config/YamlConfigLoader.cs | 76 ++- .../Config/YamlConfigSchemaValidator.cs | 441 ++++++++++++++++++ ...eWuYou.GFramework.SourceGenerators.targets | 18 +- docs/.vitepress/config.mts | 1 + docs/zh-CN/game/config-system.md | 126 +++++ docs/zh-CN/game/index.md | 21 +- docs/zh-CN/source-generators/index.md | 41 ++ 9 files changed, 940 insertions(+), 7 deletions(-) create mode 100644 GFramework.Game/Config/YamlConfigSchemaValidator.cs create mode 100644 docs/zh-CN/game/config-system.md diff --git a/AGENTS.md b/AGENTS.md index 8f4234f..d806c5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,17 @@ bash scripts/validate-csharp-naming.sh - The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`. - Keep code samples, package names, and command examples aligned with the current repository state. - Prefer documenting behavior and design intent, not only API surface. +- When a feature is added, removed, renamed, or substantially refactored, contributors MUST update or create the + corresponding user-facing integration documentation in `docs/zh-CN/` in the same change. +- For integration-oriented features such as the AI-First config system, documentation MUST cover: + - project directory layout and file conventions + - required project or package wiring + - minimal working usage example + - migration or compatibility notes when behavior changes +- If an existing documentation page no longer reflects the current implementation, fixing the code without fixing the + documentation is considered incomplete work. +- Do not rely on “the code is self-explanatory” for framework features that consumers need to adopt; write the + adoption path down so future users do not need to rediscover it from source. ### Documentation Preview @@ -218,3 +229,4 @@ Before considering work complete, confirm: - Relevant tests were added or updated - Sensitive or unsafe behavior was not introduced - User-facing documentation is updated when needed +- Feature adoption docs under `docs/zh-CN/` were added or updated when functionality was added, removed, or refactored diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index f551eb6..c4d5e81 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -9,6 +9,8 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public class YamlConfigLoaderTests { + private string _rootPath = null!; + /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -31,8 +33,6 @@ public class YamlConfigLoaderTests } } - private string _rootPath = null!; - /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// @@ -155,6 +155,182 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证启用 schema 校验后,缺失必填字段会在反序列化前被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Required_Property_Is_Missing_According_To_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + 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("name")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证启用 schema 校验后,类型不匹配的标量字段会被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Property_Type_Does_Not_Match_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: high + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + 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("hp")); + Assert.That(exception!.Message, Does.Contain("integer")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Unknown_Property_Is_Present_In_Schema_Bound_Mode() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + attackPower: 2 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + 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("attackPower")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组字段的元素类型会按 schema 校验。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Item_Type_Does_Not_Match_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 1 + - potion + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "items": { "type": "integer" } + } + } + } + """); + + 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("dropRates")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 创建测试用配置文件。 /// @@ -172,6 +348,16 @@ public class YamlConfigLoaderTests File.WriteAllText(fullPath, content); } + /// + /// 创建测试用 schema 文件。 + /// + /// 相对根目录的文件路径。 + /// 文件内容。 + private void CreateSchemaFile(string relativePath, string content) + { + CreateConfigFile(relativePath, content); + } + /// /// 用于 YAML 加载测试的最小怪物配置类型。 /// @@ -193,6 +379,27 @@ public class YamlConfigLoaderTests public int Hp { get; set; } } + /// + /// 用于数组 schema 校验测试的最小怪物配置类型。 + /// + private sealed class MonsterConfigIntegerArrayStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置掉落率列表。 + /// + public IReadOnlyList DropRates { get; set; } = Array.Empty(); + } + /// /// 用于验证注册表一致性的现有配置类型。 /// diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index f26308d..d6d4f59 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,7 +1,5 @@ using System.IO; using GFramework.Game.Abstractions.Config; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; @@ -16,6 +14,9 @@ public sealed class YamlConfigLoader : IConfigLoader private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace."; + private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage = + "Schema relative path cannot be null or whitespace."; + private readonly IDeserializer _deserializer; private readonly List _registrations = new(); private readonly string _rootPath; @@ -86,6 +87,41 @@ public sealed class YamlConfigLoader : IConfigLoader Func keySelector, IEqualityComparer? comparer = null) where TKey : notnull + { + return RegisterTableCore(tableName, relativePath, null, keySelector, comparer); + } + + /// + /// 注册一个带 schema 校验的 YAML 配置表定义。 + /// 该重载会在 YAML 反序列化之前使用指定 schema 拒绝未知字段、缺失必填字段和基础类型错误, + /// 以避免错误配置以默认值形式悄悄进入运行时。 + /// + /// 配置主键类型。 + /// 配置值类型。 + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 相对配置根目录的 schema 文件路径。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + /// 当前加载器实例,以便链式注册。 + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + string schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer); + } + + private YamlConfigLoader RegisterTableCore( + string tableName, + string relativePath, + string? schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer) + where TKey : notnull { if (string.IsNullOrWhiteSpace(tableName)) { @@ -99,7 +135,20 @@ public sealed class YamlConfigLoader : IConfigLoader ArgumentNullException.ThrowIfNull(keySelector); - _registrations.Add(new YamlTableRegistration(tableName, relativePath, keySelector, comparer)); + if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath)) + { + throw new ArgumentException( + SchemaRelativePathCannotBeNullOrWhiteSpaceMessage, + nameof(schemaRelativePath)); + } + + _registrations.Add( + new YamlTableRegistration( + tableName, + relativePath, + schemaRelativePath, + keySelector, + comparer)); return this; } @@ -172,16 +221,19 @@ public sealed class YamlConfigLoader : IConfigLoader /// /// 配置表名称。 /// 相对配置根目录的子目录。 + /// 相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。 /// 配置项主键提取器。 /// 可选主键比较器。 public YamlTableRegistration( string name, string relativePath, + string? schemaRelativePath, Func keySelector, IEqualityComparer? comparer) { Name = name; RelativePath = relativePath; + SchemaRelativePath = schemaRelativePath; _keySelector = keySelector; _comparer = comparer; } @@ -196,6 +248,11 @@ public sealed class YamlConfigLoader : IConfigLoader /// public string RelativePath { get; } + /// + /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。 + /// + public string? SchemaRelativePath { get; } + /// public async Task<(string name, IConfigTable table)> LoadAsync( string rootPath, @@ -209,6 +266,13 @@ public sealed class YamlConfigLoader : IConfigLoader $"Config directory '{directoryPath}' was not found for table '{Name}'."); } + YamlConfigSchema? schema = null; + if (!string.IsNullOrEmpty(SchemaRelativePath)) + { + var schemaPath = Path.Combine(rootPath, SchemaRelativePath); + schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken); + } + var values = new List(); var files = Directory .EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly) @@ -234,6 +298,12 @@ public sealed class YamlConfigLoader : IConfigLoader exception); } + if (schema != null) + { + // 先按 schema 拒绝结构问题,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。 + YamlConfigSchemaValidator.Validate(schema, file, yaml); + } + try { var value = deserializer.Deserialize(yaml); diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs new file mode 100644 index 0000000..3998ec9 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -0,0 +1,441 @@ +using System.Globalization; +using System.IO; +using System.Text.Json; + +namespace GFramework.Game.Config; + +/// +/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 +/// 该校验器与当前配置生成器支持的 schema 子集保持一致, +/// 以便在配置进入运行时注册表之前就拒绝缺失字段、未知字段和基础类型错误。 +/// +internal static class YamlConfigSchemaValidator +{ + /// + /// 从磁盘加载并解析一个 JSON Schema 文件。 + /// + /// Schema 文件路径。 + /// 取消令牌。 + /// 解析后的 schema 模型。 + /// 为空时抛出。 + /// 当 schema 文件不存在时抛出。 + /// 当 schema 内容不符合当前运行时支持的子集时抛出。 + internal static async Task LoadAsync( + string schemaPath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(schemaPath)) + { + throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath)); + } + + if (!File.Exists(schemaPath)) + { + throw new FileNotFoundException($"Schema file '{schemaPath}' was not found.", schemaPath); + } + + string schemaText; + try + { + schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken); + } + catch (Exception exception) + { + throw new InvalidOperationException($"Failed to read schema file '{schemaPath}'.", exception); + } + + try + { + using var document = JsonDocument.Parse(schemaText); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var typeElement) || + !string.Equals(typeElement.GetString(), "object", StringComparison.Ordinal)) + { + 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 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)); + } + + return new YamlConfigSchema(schemaPath, properties, requiredProperties); + } + catch (JsonException exception) + { + throw new InvalidOperationException($"Schema file '{schemaPath}' contains invalid JSON.", exception); + } + } + + /// + /// 使用已解析的 schema 校验 YAML 文本。 + /// + /// 已解析的 schema 模型。 + /// YAML 文件路径,仅用于诊断信息。 + /// YAML 文本内容。 + /// 当参数为空时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 + internal static void Validate( + YamlConfigSchema schema, + string yamlPath, + string yamlText) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(yamlPath); + ArgumentNullException.ThrowIfNull(yamlText); + + YamlStream yamlStream = new(); + try + { + using var reader = new StringReader(yamlText); + yamlStream.Load(reader); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' could not be parsed as YAML before schema validation.", + exception); + } + + if (yamlStream.Documents.Count != 1 || + yamlStream.Documents[0].RootNode is not YamlMappingNode rootMapping) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' must contain a single root mapping object."); + } + + var seenProperties = new HashSet(StringComparer.Ordinal); + foreach (var entry in rootMapping.Children) + { + if (entry.Key is not YamlScalarNode keyNode || + string.IsNullOrWhiteSpace(keyNode.Value)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' contains a non-scalar or empty top-level property name."); + } + + var propertyName = keyNode.Value; + if (!seenProperties.Add(propertyName)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' contains duplicate property '{propertyName}'."); + } + + if (!schema.Properties.TryGetValue(propertyName, out var property)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' contains unknown property '{propertyName}' that is not declared in schema '{schema.SchemaPath}'."); + } + + ValidateNode(yamlPath, propertyName, entry.Value, property); + } + + foreach (var requiredProperty in schema.RequiredProperties) + { + if (!seenProperties.Contains(requiredProperty)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'."); + } + } + } + + 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'."); + } + + 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}'.") + }; + + if (propertyType != YamlConfigSchemaPropertyType.Array) + { + return new YamlConfigSchemaProperty(property.Name, propertyType, 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}'.") + }; + + return new YamlConfigSchemaProperty(property.Name, propertyType, itemType); + } + + private static void ValidateNode( + string yamlPath, + string propertyName, + YamlNode node, + YamlConfigSchemaProperty property) + { + if (property.PropertyType == YamlConfigSchemaPropertyType.Array) + { + if (node is not YamlSequenceNode sequenceNode) + { + throw new InvalidOperationException( + $"Property '{propertyName}' in config file '{yamlPath}' must be an array."); + } + + foreach (var item in sequenceNode.Children) + { + ValidateScalarNode(yamlPath, propertyName, item, property.ItemType!.Value, isArrayItem: true); + } + + return; + } + + ValidateScalarNode(yamlPath, propertyName, node, property.PropertyType, isArrayItem: false); + } + + private static void ValidateScalarNode( + string yamlPath, + string propertyName, + YamlNode node, + YamlConfigSchemaPropertyType expectedType, + bool isArrayItem) + { + 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)}'."); + } + + 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)}'."); + } + + var tag = scalarNode.Tag.ToString(); + var isValid = expectedType switch + { + YamlConfigSchemaPropertyType.String => IsStringScalar(tag), + YamlConfigSchemaPropertyType.Integer => long.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out _), + YamlConfigSchemaPropertyType.Number => double.TryParse( + value, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out _), + YamlConfigSchemaPropertyType.Boolean => bool.TryParse(value, out _), + _ => false + }; + + if (isValid) + { + return; + } + + 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}'."); + } + + private static string GetTypeName(YamlConfigSchemaPropertyType type) + { + return type switch + { + YamlConfigSchemaPropertyType.Integer => "integer", + YamlConfigSchemaPropertyType.Number => "number", + YamlConfigSchemaPropertyType.Boolean => "boolean", + YamlConfigSchemaPropertyType.String => "string", + YamlConfigSchemaPropertyType.Array => "array", + _ => type.ToString() + }; + } + + private static bool IsStringScalar(string tag) + { + if (string.IsNullOrWhiteSpace(tag)) + { + return true; + } + + return !string.Equals(tag, "tag:yaml.org,2002:int", StringComparison.Ordinal) && + !string.Equals(tag, "tag:yaml.org,2002:float", StringComparison.Ordinal) && + !string.Equals(tag, "tag:yaml.org,2002:bool", StringComparison.Ordinal) && + !string.Equals(tag, "tag:yaml.org,2002:null", StringComparison.Ordinal); + } +} + +/// +/// 表示已解析并可用于运行时校验的 JSON Schema。 +/// 该模型只保留当前运行时加载器真正需要的最小信息,以避免在游戏运行时引入完整 schema 引擎。 +/// +internal sealed class YamlConfigSchema +{ + /// + /// 初始化一个可用于运行时校验的 schema 模型。 + /// + /// Schema 文件路径。 + /// Schema 属性定义。 + /// 必填属性集合。 + public YamlConfigSchema( + string schemaPath, + IReadOnlyDictionary properties, + IReadOnlyCollection requiredProperties) + { + ArgumentNullException.ThrowIfNull(schemaPath); + ArgumentNullException.ThrowIfNull(properties); + ArgumentNullException.ThrowIfNull(requiredProperties); + + SchemaPath = schemaPath; + Properties = properties; + RequiredProperties = requiredProperties; + } + + /// + /// 获取 schema 文件路径。 + /// + public string SchemaPath { get; } + + /// + /// 获取按属性名索引的 schema 属性定义。 + /// + public IReadOnlyDictionary Properties { get; } + + /// + /// 获取 schema 声明的必填属性集合。 + /// + public IReadOnlyCollection RequiredProperties { get; } +} + +/// +/// 表示单个 schema 属性的最小运行时描述。 +/// +internal sealed class YamlConfigSchemaProperty +{ + /// + /// 初始化一个 schema 属性描述。 + /// + /// 属性名称。 + /// 属性类型。 + /// 数组元素类型;仅当属性类型为数组时有效。 + public YamlConfigSchemaProperty( + string name, + YamlConfigSchemaPropertyType propertyType, + YamlConfigSchemaPropertyType? itemType) + { + ArgumentNullException.ThrowIfNull(name); + + Name = name; + PropertyType = propertyType; + ItemType = itemType; + } + + /// + /// 获取属性名称。 + /// + public string Name { get; } + + /// + /// 获取属性类型。 + /// + public YamlConfigSchemaPropertyType PropertyType { get; } + + /// + /// 获取数组元素类型;非数组属性时返回空。 + /// + public YamlConfigSchemaPropertyType? ItemType { get; } +} + +/// +/// 表示当前运行时 schema 校验器支持的属性类型。 +/// +internal enum YamlConfigSchemaPropertyType +{ + /// + /// 整数类型。 + /// + Integer, + + /// + /// 数值类型。 + /// + Number, + + /// + /// 布尔类型。 + /// + Boolean, + + /// + /// 字符串类型。 + /// + String, + + /// + /// 数组类型。 + /// + Array +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets b/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets index de3165c..b66b376 100644 --- a/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets +++ b/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets @@ -3,14 +3,30 @@ + + + schemas + + + + + + + - \ No newline at end of file + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 51c1d68..5657f12 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -174,6 +174,7 @@ export default defineConfig({ text: 'Game 游戏模块', items: [ { text: '概览', link: '/zh-CN/game/' }, + { text: '内容配置系统', link: '/zh-CN/game/config-system' }, { text: '数据管理', link: '/zh-CN/game/data' }, { text: '场景系统', link: '/zh-CN/game/scene' }, { text: 'UI 系统', link: '/zh-CN/game/ui' }, diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md new file mode 100644 index 0000000..87a0cbe --- /dev/null +++ b/docs/zh-CN/game/config-system.md @@ -0,0 +1,126 @@ +# 游戏内容配置系统 + +> 面向静态游戏内容的 AI-First 配表方案 + +该配置系统用于管理怪物、物品、技能、任务等静态内容数据。 + +它与 `GFramework.Core.Configuration` 不同,后者面向运行时键值配置;它也不同于 `GFramework.Game.Setting`,后者面向玩家设置和持久化。 + +## 当前能力 + +- YAML 作为配置源文件 +- JSON Schema 作为结构描述 +- 一对象一文件的目录组织 +- 运行时只读查询 +- Source Generator 生成配置类型和表包装 +- VS Code 插件提供配置浏览、raw 编辑、schema 打开和轻量校验入口 + +## 推荐目录结构 + +```text +GameProject/ +├─ config/ +│ ├─ monster/ +│ │ ├─ slime.yaml +│ │ └─ goblin.yaml +│ └─ item/ +│ └─ potion.yaml +├─ schemas/ +│ ├─ monster.schema.json +│ └─ item.schema.json +``` + +## Schema 示例 + +```json +{ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" }, + "dropItems": { + "type": "array", + "items": { "type": "string" } + } + } +} +``` + +## YAML 示例 + +```yaml +id: 1 +name: Slime +hp: 10 +dropItems: + - potion + - slime_gel +``` + +## 运行时接入 + +当你希望加载后的配置在运行时以只读表形式暴露时,可以使用 `YamlConfigLoader` 和 `ConfigRegistry`: + +```csharp +using GFramework.Game.Config; + +var registry = new ConfigRegistry(); + +var loader = new YamlConfigLoader("config-root") + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + +await loader.LoadAsync(registry); + +var monsterTable = registry.GetTable("monster"); +var slime = monsterTable.Get(1); +``` + +这个重载会先按 schema 校验,再进行反序列化和注册。 + +## 运行时校验行为 + +绑定 schema 的表在加载时会拒绝以下问题: + +- 缺失必填字段 +- 未在 schema 中声明的未知字段 +- 标量类型不匹配 +- 数组元素类型不匹配 + +这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 + +## 生成器接入约定 + +配置生成器会从 `*.schema.json` 生成配置类型和表包装类。 + +通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`。 + +如果你在仓库内直接使用项目引用而不是打包后的 NuGet,请确认 schema 文件同样被加入 `AdditionalFiles`。 + +## VS Code 工具 + +仓库中的 `tools/vscode-config-extension` 当前提供以下能力: + +- 浏览 `config/` 目录 +- 打开 raw YAML 文件 +- 打开匹配的 schema 文件 +- 对必填字段和基础标量类型做轻量校验 +- 对顶层标量字段提供轻量表单入口 + +当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。 + +## 当前限制 + +以下能力尚未完全完成: + +- 运行时热重载 +- 跨表引用校验 +- 更完整的 JSON Schema 支持 +- 更强的 VS Code 表单编辑器 + +因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 diff --git a/docs/zh-CN/game/index.md b/docs/zh-CN/game/index.md index 2e1803d..2919e27 100644 --- a/docs/zh-CN/game/index.md +++ b/docs/zh-CN/game/index.md @@ -8,6 +8,7 @@ GFramework.Game 是 GFramework 框架的游戏特定功能模块,提供了游 - [概述](#概述) - [核心特性](#核心特性) +- [内容配置系统](#内容配置系统) - [架构模块系统](#架构模块系统) - [资产管理](#资产管理) - [存储系统](#存储系统) @@ -57,6 +58,24 @@ GFramework.Game 为游戏开发提供了专门的功能模块,与 GFramework.C - **性能优化**:序列化缓存和优化策略 - **类型安全**:强类型的序列化和反序列化 +## 内容配置系统 + +`GFramework.Game` 当前包含面向静态游戏内容的 AI-First 配表能力,用于怪物、物品、技能、任务等只读内容数据。 + +这一能力的核心定位是: + +- 使用 `YAML` 作为配置源文件 +- 使用 `JSON Schema` 描述结构和约束 +- 在运行时以只读配置表形式暴露 +- 通过 Source Generator 生成配置类型和表包装 +- 配套 VS Code 工具提供浏览、校验和轻量编辑入口 + +如果你准备在游戏项目中接入这套系统,请先阅读: + +- [游戏内容配置系统](/zh-CN/game/config-system) + +该页面包含目录约定、运行时注册方式、schema 绑定方式、生成器接入约定和当前限制说明。 + ## 架构模块系统 ### AbstractModule 基础使用 @@ -1395,4 +1414,4 @@ graph TD - **.NET**: 6.0+ - **Newtonsoft.Json**: 13.0.3+ - **GFramework.Core**: 与 Core 模块版本保持同步 ---- \ No newline at end of file +--- diff --git a/docs/zh-CN/source-generators/index.md b/docs/zh-CN/source-generators/index.md index 5c400f7..f5c8fee 100644 --- a/docs/zh-CN/source-generators/index.md +++ b/docs/zh-CN/source-generators/index.md @@ -10,6 +10,7 @@ GFramework.SourceGenerators 是 GFramework 框架的源代码生成器包,通 - [核心特性](#核心特性) - [安装配置](#安装配置) - [Log 属性生成器](#log-属性生成器) +- [Config Schema 生成器](#config-schema-生成器) - [ContextAware 属性生成器](#contextaware-属性生成器) - [GenerateEnumExtensions 属性生成器](#generateenumextensions-属性生成器) - [Priority 属性生成器](#priority-属性生成器) @@ -36,6 +37,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译 ### 🎯 主要生成器 - **[Log] 属性**:自动生成 ILogger 字段和日志方法 +- **Config Schema 生成器**:根据 `*.schema.json` 生成配置类型和表包装 - **[ContextAware] 属性**:自动实现 IContextAware 接口 - **[GenerateEnumExtensions] 属性**:自动生成枚举扩展方法 - **[Priority] 属性**:自动实现 IPrioritized 接口,为类添加优先级标记 @@ -68,6 +70,45 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译 ``` +### Config Schema 文件约定 + +当项目引用 `GeWuYou.GFramework.SourceGenerators` 的打包产物时,生成器会默认从 `schemas/**/*.schema.json` 收集配置 schema +文件并作为 `AdditionalFiles` 输入。 + +这意味着消费者项目通常只需要维护如下结构: + +```text +GameProject/ +├─ config/ +│ └─ monster/ +│ └─ slime.yaml +└─ schemas/ + └─ monster.schema.json +``` + +如果你需要完整接入运行时加载、schema 校验和 VS Code 工具链,请继续阅读: + +- [游戏内容配置系统](/zh-CN/game/config-system) + +## Config Schema 生成器 + +Config Schema 生成器会扫描 `*.schema.json` 文件,并生成: + +- 配置数据类型 +- 与 `IConfigTable` 对齐的表包装类型 + +这一生成器适合与 `GFramework.Game.Config.YamlConfigLoader` 配合使用,让 schema、运行时和工具链共享同一份结构约定。 + +当前支持的 schema 子集以内容配置系统文档中的说明为准,重点覆盖: + +- `object` 根节点 +- `required` +- `integer` +- `number` +- `boolean` +- `string` +- `array` + ### 项目文件配置 ```xml From ae9693e0ff03a31fb702f0e69ab61377cb5531d1 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:32:57 +0800 Subject: [PATCH 07/14] =?UTF-8?q?refactor(config):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在YamlConfigLoader中添加YamlDotNet序列化库引用 - 移除YamlConfigSchemaValidator中的未使用引用 - 在全局引用中添加YAML处理相关库引用 - 优化配置模块的依赖管理和命名空间使用 --- GFramework.Game/Config/YamlConfigLoader.cs | 3 ++- GFramework.Game/Config/YamlConfigSchemaValidator.cs | 4 ---- GFramework.Game/GlobalUsings.cs | 6 +++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index d6d4f59..11f2d99 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,5 +1,6 @@ -using System.IO; using GFramework.Game.Abstractions.Config; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 3998ec9..bc16016 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1,7 +1,3 @@ -using System.Globalization; -using System.IO; -using System.Text.Json; - namespace GFramework.Game.Config; /// diff --git a/GFramework.Game/GlobalUsings.cs b/GFramework.Game/GlobalUsings.cs index 74db065..b91413e 100644 --- a/GFramework.Game/GlobalUsings.cs +++ b/GFramework.Game/GlobalUsings.cs @@ -16,4 +16,8 @@ global using System.Collections.Generic; global using System.Collections.Concurrent; global using System.Linq; global using System.Threading; -global using System.Threading.Tasks; \ No newline at end of file +global using System.Threading.Tasks; +global using System.Globalization; +global using System.IO; +global using System.Text.Json; +global using YamlDotNet.RepresentationModel; \ No newline at end of file From 3332aaff7bf384663953583c94d5c093a71b2529 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:39:39 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=8F=8A=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=9C=9F=E7=83=AD=E9=87=8D=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现YamlConfigLoader支持基于文件目录的YAML配置加载 - 添加EnableHotReload方法支持开发期配置文件变更自动重载 - 提供带schema校验的配置表注册功能 - 实现按表粒度的热重载机制及错误处理回调 - 添加配置文件变更监听和防抖处理 - 更新文档说明热重载使用方法和行为特性 - 移除未完成功能列表中的运行时热重载项 --- .../Config/YamlConfigLoaderTests.cs | 159 ++++++++- GFramework.Game/Config/YamlConfigLoader.cs | 331 ++++++++++++++++++ docs/zh-CN/game/config-system.md | 35 +- 3 files changed, 522 insertions(+), 3 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index c4d5e81..236bf34 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -9,8 +9,6 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public class YamlConfigLoaderTests { - private string _rootPath = null!; - /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -33,6 +31,8 @@ public class YamlConfigLoaderTests } } + private string _rootPath = null!; + /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// @@ -331,6 +331,143 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 + /// + [Test] + public async Task EnableHotReload_Should_Update_Registered_Table_When_Config_File_Changes() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + await loader.LoadAsync(registry); + + var reloadTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var hotReload = loader.EnableHotReload( + registry, + onTableReloaded: tableName => reloadTaskSource.TrySetResult(tableName), + debounceDelay: TimeSpan.FromMilliseconds(150)); + + try + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 25 + """); + + var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5)); + + Assert.Multiple(() => + { + Assert.That(tableName, Is.EqualTo("monster")); + Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(25)); + }); + } + finally + { + hotReload.UnRegister(); + } + } + + /// + /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。 + /// + [Test] + public async Task EnableHotReload_Should_Keep_Previous_Table_When_Schema_Change_Makes_Reload_Fail() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + await loader.LoadAsync(registry); + + var reloadFailureTaskSource = + new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions + .RunContinuationsAsynchronously); + var hotReload = loader.EnableHotReload( + registry, + onTableReloadFailed: (tableName, exception) => + reloadFailureTaskSource.TrySetResult((tableName, exception)), + debounceDelay: TimeSpan.FromMilliseconds(150)); + + try + { + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "rarity"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" }, + "rarity": { "type": "string" } + } + } + """); + + var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); + + Assert.Multiple(() => + { + Assert.That(failure.TableName, Is.EqualTo("monster")); + Assert.That(failure.Exception.Message, Does.Contain("rarity")); + Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(10)); + }); + } + finally + { + hotReload.UnRegister(); + } + } + /// /// 创建测试用配置文件。 /// @@ -358,6 +495,24 @@ public class YamlConfigLoaderTests CreateConfigFile(relativePath, content); } + /// + /// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。 + /// + /// 任务结果类型。 + /// 要等待的任务。 + /// 超时时间。 + /// 任务结果。 + private static async Task WaitForTaskWithinAsync(Task task, TimeSpan timeout) + { + var completedTask = await Task.WhenAny(task, Task.Delay(timeout)); + if (!ReferenceEquals(completedTask, task)) + { + Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification."); + } + + return await task; + } + /// /// 用于 YAML 加载测试的最小怪物配置类型。 /// diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 11f2d99..6570035 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,3 +1,4 @@ +using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -71,6 +72,35 @@ public sealed class YamlConfigLoader : IConfigLoader } } + /// + /// 启用开发期热重载。 + /// 该能力会监听已注册配置表对应的配置目录和 schema 文件,并在检测到文件变更后按表粒度重新加载。 + /// 重载失败时会保留注册表中的旧表,避免开发期错误配置直接破坏当前运行时状态。 + /// + /// 要被热重载更新的配置注册表。 + /// 单个配置表重载成功后的可选回调。 + /// 单个配置表重载失败后的可选回调。 + /// 防抖延迟;为空时默认使用 200 毫秒。 + /// 用于停止热重载监听的注销句柄。 + /// 为空时抛出。 + public IUnRegister EnableHotReload( + IConfigRegistry registry, + Action? onTableReloaded = null, + Action? onTableReloadFailed = null, + TimeSpan? debounceDelay = null) + { + ArgumentNullException.ThrowIfNull(registry); + + return new HotReloadSession( + _rootPath, + _deserializer, + registry, + _registrations, + onTableReloaded, + onTableReloadFailed, + debounceDelay ?? TimeSpan.FromMilliseconds(200)); + } + /// /// 注册一个 YAML 配置表定义。 /// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。 @@ -193,6 +223,21 @@ public sealed class YamlConfigLoader : IConfigLoader /// private interface IYamlTableRegistration { + /// + /// 获取配置表名称。 + /// + string Name { get; } + + /// + /// 获取相对配置根目录的子目录。 + /// + string RelativePath { get; } + + /// + /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。 + /// + string? SchemaRelativePath { get; } + /// /// 从指定根目录加载配置表。 /// @@ -337,4 +382,290 @@ public sealed class YamlConfigLoader : IConfigLoader } } } + + /// + /// 封装开发期热重载所需的文件监听与按表重载逻辑。 + /// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。 + /// + private sealed class HotReloadSession : IUnRegister, IDisposable + { + private readonly TimeSpan _debounceDelay; + private readonly IDeserializer _deserializer; + private readonly object _gate = new(); + private readonly Action? _onTableReloaded; + private readonly Action? _onTableReloadFailed; + private readonly Dictionary _registrations = new(StringComparer.Ordinal); + private readonly IConfigRegistry _registry; + private readonly Dictionary _reloadLocks = new(StringComparer.Ordinal); + private readonly Dictionary _reloadTokens = new(StringComparer.Ordinal); + private readonly string _rootPath; + private readonly List _watchers = new(); + private bool _disposed; + + /// + /// 初始化一个热重载会话并立即开始监听文件变更。 + /// + /// 配置根目录。 + /// YAML 反序列化器。 + /// 要更新的配置注册表。 + /// 已注册的配置表定义。 + /// 单表重载成功回调。 + /// 单表重载失败回调。 + /// 监听事件防抖延迟。 + public HotReloadSession( + string rootPath, + IDeserializer deserializer, + IConfigRegistry registry, + IEnumerable registrations, + Action? onTableReloaded, + Action? onTableReloadFailed, + TimeSpan debounceDelay) + { + ArgumentNullException.ThrowIfNull(rootPath); + ArgumentNullException.ThrowIfNull(deserializer); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(registrations); + + _rootPath = rootPath; + _deserializer = deserializer; + _registry = registry; + _onTableReloaded = onTableReloaded; + _onTableReloadFailed = onTableReloadFailed; + _debounceDelay = debounceDelay; + + foreach (var registration in registrations) + { + _registrations.Add(registration.Name, registration); + _reloadLocks.Add(registration.Name, new SemaphoreSlim(1, 1)); + CreateWatchersForRegistration(registration); + } + } + + /// + /// 释放热重载会话持有的文件监听器与等待资源。 + /// + public void Dispose() + { + List watchersToDispose; + List reloadTokensToDispose; + List reloadLocksToDispose; + + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + watchersToDispose = _watchers.ToList(); + _watchers.Clear(); + reloadTokensToDispose = _reloadTokens.Values.ToList(); + _reloadTokens.Clear(); + reloadLocksToDispose = _reloadLocks.Values.ToList(); + _reloadLocks.Clear(); + } + + foreach (var reloadToken in reloadTokensToDispose) + { + reloadToken.Cancel(); + reloadToken.Dispose(); + } + + foreach (var watcher in watchersToDispose) + { + watcher.Dispose(); + } + + foreach (var reloadLock in reloadLocksToDispose) + { + reloadLock.Dispose(); + } + } + + /// + /// 停止热重载监听。 + /// + public void UnRegister() + { + Dispose(); + } + + private void CreateWatchersForRegistration(IYamlTableRegistration registration) + { + var configDirectoryPath = Path.Combine(_rootPath, registration.RelativePath); + AddWatcher(configDirectoryPath, "*.yaml", registration.Name); + AddWatcher(configDirectoryPath, "*.yml", registration.Name); + + if (string.IsNullOrEmpty(registration.SchemaRelativePath)) + { + return; + } + + var schemaFullPath = Path.Combine(_rootPath, registration.SchemaRelativePath); + var schemaDirectoryPath = Path.GetDirectoryName(schemaFullPath); + if (string.IsNullOrWhiteSpace(schemaDirectoryPath)) + { + schemaDirectoryPath = _rootPath; + } + + AddWatcher(schemaDirectoryPath, Path.GetFileName(schemaFullPath), registration.Name); + } + + private void AddWatcher(string directoryPath, string filter, string tableName) + { + if (!Directory.Exists(directoryPath)) + { + return; + } + + var watcher = new FileSystemWatcher(directoryPath, filter) + { + IncludeSubdirectories = false, + NotifyFilter = NotifyFilters.FileName | + NotifyFilters.LastWrite | + NotifyFilters.Size | + NotifyFilters.CreationTime | + NotifyFilters.DirectoryName + }; + + watcher.Changed += (_, _) => ScheduleReload(tableName); + watcher.Created += (_, _) => ScheduleReload(tableName); + watcher.Deleted += (_, _) => ScheduleReload(tableName); + watcher.Renamed += (_, _) => ScheduleReload(tableName); + watcher.Error += (_, eventArgs) => + { + var exception = eventArgs.GetException() ?? new InvalidOperationException( + $"Hot reload watcher for table '{tableName}' encountered an unknown error."); + InvokeReloadFailed(tableName, exception); + }; + + watcher.EnableRaisingEvents = true; + + lock (_gate) + { + if (_disposed) + { + watcher.Dispose(); + return; + } + + _watchers.Add(watcher); + } + } + + private void ScheduleReload(string tableName) + { + CancellationTokenSource reloadTokenSource; + + lock (_gate) + { + if (_disposed) + { + return; + } + + if (_reloadTokens.TryGetValue(tableName, out var previousTokenSource)) + { + previousTokenSource.Cancel(); + previousTokenSource.Dispose(); + } + + reloadTokenSource = new CancellationTokenSource(); + _reloadTokens[tableName] = reloadTokenSource; + } + + _ = Task.Run(async () => + { + try + { + await Task.Delay(_debounceDelay, reloadTokenSource.Token); + await ReloadTableAsync(tableName, reloadTokenSource.Token); + } + catch (OperationCanceledException) when (reloadTokenSource.IsCancellationRequested) + { + // 新事件会替换旧任务;取消属于正常防抖行为。 + } + finally + { + lock (_gate) + { + if (_reloadTokens.TryGetValue(tableName, out var currentTokenSource) && + ReferenceEquals(currentTokenSource, reloadTokenSource)) + { + _reloadTokens.Remove(tableName); + } + } + + reloadTokenSource.Dispose(); + } + }); + } + + private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken) + { + if (!_registrations.TryGetValue(tableName, out var registration)) + { + return; + } + + var reloadLock = _reloadLocks[tableName]; + await reloadLock.WaitAsync(cancellationToken); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var (name, table) = await registration.LoadAsync(_rootPath, _deserializer, cancellationToken); + RegistrationDispatcher.Register(_registry, name, table); + InvokeReloaded(name); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 防抖替换或会话关闭导致的取消不应视为错误。 + } + catch (Exception exception) + { + InvokeReloadFailed(tableName, exception); + } + finally + { + reloadLock.Release(); + } + } + + private void InvokeReloaded(string tableName) + { + if (_onTableReloaded == null) + { + return; + } + + try + { + _onTableReloaded(tableName); + } + catch + { + // 诊断回调不应反向破坏热重载流程。 + } + } + + private void InvokeReloadFailed(string tableName, Exception exception) + { + if (_onTableReloadFailed == null) + { + return; + } + + try + { + _onTableReloadFailed(tableName, exception); + } + catch + { + // 诊断回调不应反向破坏热重载流程。 + } + } + } } \ 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 87a0cbe..591febc 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -94,6 +94,40 @@ var slime = monsterTable.Get(1); 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 +## 开发期热重载 + +如果你希望在开发期修改配置文件后自动刷新运行时表,可以在初次加载完成后启用热重载: + +```csharp +using GFramework.Game.Config; + +var registry = new ConfigRegistry(); +var loader = new YamlConfigLoader("config-root") + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + +await loader.LoadAsync(registry); + +var hotReload = loader.EnableHotReload( + registry, + onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"), + onTableReloadFailed: (tableName, exception) => + Console.WriteLine($"Reload failed: {tableName}, {exception.Message}")); +``` + +当前热重载行为如下: + +- 监听已注册表对应的配置目录 +- 监听该表绑定的 schema 文件 +- 检测到变更后按表粒度重载 +- 重载成功后替换该表在 `IConfigRegistry` 中的注册 +- 重载失败时保留旧表,并通过失败回调提供诊断 + +这项能力默认定位为开发期工具,不承诺生产环境热更新平台语义。 + ## 生成器接入约定 配置生成器会从 `*.schema.json` 生成配置类型和表包装类。 @@ -118,7 +152,6 @@ var slime = monsterTable.Get(1); 以下能力尚未完全完成: -- 运行时热重载 - 跨表引用校验 - 更完整的 JSON Schema 支持 - 更强的 VS Code 表单编辑器 From e8d0ea2daf34c85b802cfcd0fe077b3a9d7b9927 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:44:58 +0800 Subject: [PATCH 09/14] =?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=92=8CVS=20Code=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入基于YAML和JSON Schema的静态内容配置系统 - 实现运行时只读查询和Source Generator支持 - 提供VS Code扩展用于配置浏览、验证和轻量编辑 - 支持开发期热重载和跨表引用校验功能 - 包含完整的文档说明和工具链集成 --- docs/zh-CN/game/config-system.md | 2 +- tools/vscode-config-extension/README.md | 20 +- tools/vscode-config-extension/package.json | 3 + .../src/configValidation.js | 340 ++++++++++++++++++ .../vscode-config-extension/src/extension.js | 215 ++--------- .../test/configValidation.test.js | 111 ++++++ 6 files changed, 500 insertions(+), 191 deletions(-) create mode 100644 tools/vscode-config-extension/src/configValidation.js create mode 100644 tools/vscode-config-extension/test/configValidation.test.js diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 591febc..f840f0c 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -143,7 +143,7 @@ var hotReload = loader.EnableHotReload( - 浏览 `config/` 目录 - 打开 raw YAML 文件 - 打开匹配的 schema 文件 -- 对必填字段和基础标量类型做轻量校验 +- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验 - 对顶层标量字段提供轻量表单入口 当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。 diff --git a/tools/vscode-config-extension/README.md b/tools/vscode-config-extension/README.md index c9e3a58..6007c32 100644 --- a/tools/vscode-config-extension/README.md +++ b/tools/vscode-config-extension/README.md @@ -7,9 +7,27 @@ 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 and simple scalar types +- 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 +## Validation Coverage + +The extension currently validates the repository's minimal config-schema subset: + +- required top-level properties +- unknown top-level properties +- scalar compatibility for `integer`, `number`, `boolean`, and `string` +- top-level scalar arrays with scalar item type checks + +Nested objects and complex arrays should still be reviewed in raw YAML. + +## Local Testing + +```bash +cd tools/vscode-config-extension +node --test ./test/*.test.js +``` + ## Current Constraints - Multi-root workspaces use the first workspace folder diff --git a/tools/vscode-config-extension/package.json b/tools/vscode-config-extension/package.json index 92c5c71..33b7832 100644 --- a/tools/vscode-config-extension/package.json +++ b/tools/vscode-config-extension/package.json @@ -20,6 +20,9 @@ "onCommand:gframeworkConfig.validateAll" ], "main": "./src/extension.js", + "scripts": { + "test": "node --test ./test/*.test.js" + }, "contributes": { "views": { "explorer": [ diff --git a/tools/vscode-config-extension/src/configValidation.js b/tools/vscode-config-extension/src/configValidation.js new file mode 100644 index 0000000..ef71331 --- /dev/null +++ b/tools/vscode-config-extension/src/configValidation.js @@ -0,0 +1,340 @@ +/** + * 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. + * + * @param {string} content Raw schema JSON text. + * @returns {{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; + } + + if (value.type === "array" && + value.items && + typeof value.items === "object" && + typeof value.items.type === "string") { + properties[key] = { + type: "array", + itemType: value.items.type + }; + continue; + } + + properties[key] = { + type: value.type + }; + } + + return { + required, + properties + }; +} + +/** + * 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. + * + * @param {string} text YAML text. + * @returns {{entries: Map}>, keys: Set}} Parsed YAML. + */ +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; + } + + return { + entries, + keys + }; +} + +/** + * 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. + * @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; + } + } + + 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.` + }); + } + } + + return diagnostics; +} + +/** + * Determine whether a scalar value matches a minimal schema type. + * + * @param {string} expectedType Schema type. + * @param {string} scalarValue YAML scalar value. + * @returns {boolean} True when compatible. + */ +function isScalarCompatible(expectedType, scalarValue) { + const value = unquoteScalar(scalarValue); + switch (expectedType) { + case "integer": + return /^-?\d+$/u.test(value); + case "number": + return /^-?\d+(?:\.\d+)?$/u.test(value); + case "boolean": + return /^(true|false)$/iu.test(value); + case "string": + return true; + default: + return true; + } +} + +/** + * Apply scalar field updates back into the original YAML text. + * + * @param {string} originalYaml Original YAML content. + * @param {Record} updates Updated scalar values. + * @returns {string} Updated YAML content. + */ +function applyScalarUpdates(originalYaml, updates) { + const lines = originalYaml.split(/\r?\n/u); + const touched = new Set(); + + const updatedLines = lines.map((line) => { + if (/^\s/u.test(line)) { + return line; + } + + const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); + if (!match) { + return line; + } + + const key = match[1]; + if (!Object.prototype.hasOwnProperty.call(updates, key)) { + return line; + } + + touched.add(key); + return `${key}: ${formatYamlScalar(updates[key])}`; + }); + + for (const [key, value] of Object.entries(updates)) { + if (touched.has(key)) { + continue; + } + + updatedLines.push(`${key}: ${formatYamlScalar(value)}`); + } + + return updatedLines.join("\n"); +} + +/** + * Format a scalar value for YAML output. + * + * @param {string} value Scalar value. + * @returns {string} YAML-ready scalar. + */ +function formatYamlScalar(value) { + if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) { + return value; + } + + if (value.length === 0 || /[:#\[\]\{\},]|^\s|\s$/u.test(value)) { + return JSON.stringify(value); + } + + return value; +} + +/** + * Remove a simple YAML string quote wrapper. + * + * @param {string} value Scalar value. + * @returns {string} Unquoted value. + */ +function unquoteScalar(value) { + if ((value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + + return value; +} + +/** + * Parse a sequence of child lines as a top-level scalar array. + * + * @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. + */ +function parseTopLevelArray(childLines) { + const items = []; + + for (const line of childLines) { + if (line.trim().length === 0 || line.trim().startsWith("#")) { + continue; + } + + const trimmed = line.trimStart(); + if (!trimmed.startsWith("-")) { + return null; + } + + 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) + }); + } + + return items; +} + +module.exports = { + applyScalarUpdates, + formatYamlScalar, + isScalarCompatible, + parseSchemaContent, + parseTopLevelYaml, + unquoteScalar, + validateParsedConfig +}; diff --git a/tools/vscode-config-extension/src/extension.js b/tools/vscode-config-extension/src/extension.js index 633cf6a..c73775c 100644 --- a/tools/vscode-config-extension/src/extension.js +++ b/tools/vscode-config-extension/src/extension.js @@ -1,6 +1,13 @@ const fs = require("fs"); const path = require("path"); const vscode = require("vscode"); +const { + applyScalarUpdates, + parseSchemaContent, + parseTopLevelYaml, + unquoteScalar, + validateParsedConfig +} = require("./configValidation"); /** * Activate the GFramework config extension. @@ -342,27 +349,13 @@ async function validateConfigFile(configUri, diagnostics) { return; } - for (const requiredProperty of schemaInfo.required) { - if (!parsedYaml.keys.has(requiredProperty)) { - fileDiagnostics.push(new vscode.Diagnostic( - new vscode.Range(0, 0, 0, 1), - `Required property '${requiredProperty}' is missing.`, - vscode.DiagnosticSeverity.Error)); - } - } - - for (const [propertyName, expectedType] of Object.entries(schemaInfo.propertyTypes)) { - if (!parsedYaml.scalars.has(propertyName)) { - continue; - } - - const scalarValue = parsedYaml.scalars.get(propertyName); - if (!isScalarCompatible(expectedType, scalarValue)) { - fileDiagnostics.push(new vscode.Diagnostic( - new vscode.Range(0, 0, 0, 1), - `Property '${propertyName}' is expected to be '${expectedType}', but the current scalar value is incompatible.`, - vscode.DiagnosticSeverity.Warning)); - } + for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml)) { + fileDiagnostics.push(new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 1), + diagnostic.message, + diagnostic.severity === "error" + ? vscode.DiagnosticSeverity.Error + : vscode.DiagnosticSeverity.Warning)); } diagnostics.set(configUri, fileDiagnostics); @@ -373,7 +366,7 @@ async function validateConfigFile(configUri, diagnostics) { * * @param {vscode.Uri} configUri Config file URI. * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. - * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], propertyTypes: Record}>} Schema info. + * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], properties: Record}>} Schema info. */ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); @@ -383,144 +376,44 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { exists: false, schemaPath, required: [], - propertyTypes: {} + properties: {} }; } const content = await fs.promises.readFile(schemaUri.fsPath, "utf8"); try { - const parsed = JSON.parse(content); - const required = Array.isArray(parsed.required) - ? parsed.required.filter((value) => typeof value === "string") - : []; - const propertyTypes = {}; - const properties = parsed.properties || {}; - - for (const [key, value] of Object.entries(properties)) { - if (!value || typeof value !== "object") { - continue; - } - - if (typeof value.type === "string") { - propertyTypes[key] = value.type; - } - } + const parsed = parseSchemaContent(content); return { exists: true, schemaPath, - required, - propertyTypes + required: parsed.required, + properties: parsed.properties }; } catch (error) { return { exists: false, schemaPath, required: [], - propertyTypes: {} + properties: {} }; } } -/** - * Parse top-level YAML keys and scalar values. - * This intentionally supports only the MVP subset needed for lightweight form - * preview and validation. - * - * @param {string} text YAML text. - * @returns {{keys: Set, scalars: Map}} Parsed shape. - */ -function parseTopLevelYaml(text) { - const keys = new Set(); - const scalars = new Map(); - const lines = text.split(/\r?\n/u); - - for (const line of lines) { - 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) { - continue; - } - - if (rawValue.startsWith("|") || rawValue.startsWith(">")) { - continue; - } - - scalars.set(key, rawValue.trim()); - } - - return {keys, scalars}; -} - -/** - * Apply scalar field updates back into the original YAML text. - * - * @param {string} originalYaml Original YAML content. - * @param {Record} updates Updated scalar values. - * @returns {string} Updated YAML content. - */ -function applyScalarUpdates(originalYaml, updates) { - const lines = originalYaml.split(/\r?\n/u); - const touched = new Set(); - - const updatedLines = lines.map((line) => { - if (/^\s/u.test(line)) { - return line; - } - - const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); - if (!match) { - return line; - } - - const key = match[1]; - if (!Object.prototype.hasOwnProperty.call(updates, key)) { - return line; - } - - touched.add(key); - return `${key}: ${formatYamlScalar(updates[key])}`; - }); - - for (const [key, value] of Object.entries(updates)) { - if (touched.has(key)) { - continue; - } - - updatedLines.push(`${key}: ${formatYamlScalar(value)}`); - } - - return updatedLines.join("\n"); -} - /** * Render the form-preview webview HTML. * * @param {string} fileName File name. - * @param {{exists: boolean, schemaPath: string, required: string[], propertyTypes: Record}} schemaInfo Schema info. - * @param {{keys: Set, scalars: Map}} parsedYaml Parsed YAML data. + * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record}} schemaInfo Schema info. + * @param {{entries: Map}>, keys: Set}} parsedYaml Parsed YAML data. * @returns {string} HTML string. */ function renderFormHtml(fileName, schemaInfo, parsedYaml) { - const fields = Array.from(parsedYaml.scalars.entries()) - .map(([key, value]) => { + const fields = Array.from(parsedYaml.entries.entries()) + .filter(([, entry]) => entry.kind === "scalar") + .map(([key, entry]) => { const escapedKey = escapeHtml(key); - const escapedValue = escapeHtml(unquoteScalar(value)); + const escapedValue = escapeHtml(unquoteScalar(entry.value || "")); const required = schemaInfo.required.includes(key) ? "required" : ""; return `