using GFramework.Game.SourceGenerators.Diagnostics; namespace GFramework.Game.SourceGenerators.Config; /// /// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 /// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集, /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。 /// 当前共享子集也会把 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired /// 与稳定字符串 format 子集写入生成代码文档, /// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// [Generator] public sealed class SchemaConfigGenerator : IIncrementalGenerator { private const string ConfigPathMetadataKey = "x-gframework-config-path"; private const string LookupIndexMetadataKey = "x-gframework-index"; private const string GeneratedNamespace = "GFramework.Game.Config.Generated"; private const string LookupIndexTopLevelScalarOnlyMessage = "Only top-level required non-key scalar properties can declare a generated lookup index."; private const string LookupIndexRequiresRequiredScalarMessage = "Generated lookup indexes currently require a required scalar property so dictionary keys remain non-null."; private const string LookupIndexPrimaryKeyMessage = "The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index."; private const string LookupIndexReferencePropertyMessage = "Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics."; private const string SupportedStringFormatNames = "'date', 'date-time', 'duration', 'email', 'time', 'uri', and 'uuid'"; /// 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)); productionContext.AddSource( $"{result.Schema.EntityName}ConfigBindings.g.cs", SourceText.From(GenerateBindingsClass(result.Schema), Encoding.UTF8)); }); var collectedSchemas = schemaFiles.Collect(); context.RegisterSourceOutput(collectedSchemas, static (productionContext, results) => { var schemas = results .Where(static result => result.Schema is not null) .Select(static result => result.Schema!) .OrderBy(static schema => schema.TableRegistrationName, StringComparer.Ordinal) .ToArray(); if (schemas.Length == 0) { return; } productionContext.AddSource( "GeneratedConfigCatalog.g.cs", SourceText.From(GenerateCatalogClass(schemas), 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 (!TryValidateStringFormatMetadataRecursively( file.Path, "", root, out var rootFormatDiagnostic)) { return SchemaParseResult.FromDiagnostic(rootFormatDiagnostic!); } if (!TryValidateDependentRequiredMetadataRecursively( file.Path, "", root, out var dependentRequiredDiagnostic)) { return SchemaParseResult.FromDiagnostic(dependentRequiredDiagnostic!); } var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var rootObject = ParseObjectSpec( file.Path, root, "", $"{entityName}Config", isRoot: true); if (rootObject.Diagnostic is not null) { return SchemaParseResult.FromDiagnostic(rootObject.Diagnostic); } var schemaObject = rootObject.Object!; var idProperty = schemaObject.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 (idProperty.TypeSpec.SchemaType != "integer" && idProperty.TypeSpec.SchemaType != "string") { return SchemaParseResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedKeyType, CreateFileLocation(file.Path), Path.GetFileName(file.Path), idProperty.TypeSpec.SchemaType)); } var schemaBaseName = GetSchemaBaseName(file.Path); var configRelativePath = ResolveConfigRelativePath(file.Path, root, schemaBaseName); if (configRelativePath.Diagnostic is not null) { return SchemaParseResult.FromDiagnostic(configRelativePath.Diagnostic); } var schema = new SchemaFileSpec( Path.GetFileName(file.Path), entityName, schemaObject.ClassName, $"{entityName}Table", GeneratedNamespace, idProperty.TypeSpec.ClrType.TrimEnd('?'), idProperty.PropertyName, schemaBaseName, configRelativePath.Path!, GetSchemaRelativePath(file.Path), TryGetMetadataString(root, "title"), TryGetMetadataString(root, "description"), schemaObject); 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 文件路径。 /// 对象 schema 节点。 /// 当前对象的逻辑字段路径。 /// 要生成的 CLR 类型名。 /// 是否为根对象。 /// 对象模型或诊断。 private static ParsedObjectResult ParseObjectSpec( string filePath, JsonElement element, string displayPath, string className, bool isRoot = false) { if (!element.TryGetProperty("properties", out var propertiesElement) || propertiesElement.ValueKind != JsonValueKind.Object) { return ParsedObjectResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.RootObjectSchemaRequired, CreateFileLocation(filePath), Path.GetFileName(filePath))); } var requiredProperties = new HashSet(StringComparer.Ordinal); if (element.TryGetProperty("required", out var requiredElement) && requiredElement.ValueKind == JsonValueKind.Array) { foreach (var item in requiredElement.EnumerateArray()) { if (item.ValueKind == JsonValueKind.String) { var value = item.GetString(); if (!string.IsNullOrWhiteSpace(value)) { requiredProperties.Add(value!); } } } } var properties = new List(); foreach (var property in propertiesElement.EnumerateObject()) { var parsedProperty = ParseProperty( filePath, property, requiredProperties.Contains(property.Name), CombinePath(displayPath, property.Name), isDirectChildOfRoot: isRoot); if (parsedProperty.Diagnostic is not null) { return ParsedObjectResult.FromDiagnostic(parsedProperty.Diagnostic); } properties.Add(parsedProperty.Property!); } return ParsedObjectResult.FromObject(new SchemaObjectSpec( displayPath, className, TryGetMetadataString(element, "title"), TryGetMetadataString(element, "description"), TryBuildConstraintDocumentation(element, "object"), properties)); } /// /// 解析单个 schema 属性定义。 /// /// Schema 文件路径。 /// 属性 JSON 节点。 /// 属性是否必填。 /// 逻辑字段路径。 /// 属性是否为根对象下的直接子属性。 /// 解析后的属性信息或诊断。 private static ParsedPropertyResult ParseProperty( string filePath, JsonProperty property, bool isRequired, string displayPath, bool isDirectChildOfRoot) { if (!property.Value.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) { return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "")); } var schemaType = typeElement.GetString() ?? string.Empty; var title = TryGetMetadataString(property.Value, "title"); var description = TryGetMetadataString(property.Value, "description"); var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table"); if (!TryValidateStringFormatMetadata( filePath, displayPath, property.Value, schemaType, out var formatDiagnostic)) { return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!); } var indexedLookupMetadata = TryGetMetadataBoolean(property.Value, LookupIndexMetadataKey); if (indexedLookupMetadata.Diagnostic is not null) { return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, LookupIndexMetadataKey, indexedLookupMetadata.Diagnostic!)); } var isIndexedLookup = indexedLookupMetadata.Value ?? false; if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic)) { return ParsedPropertyResult.FromDiagnostic(diagnostic!); } if (isIndexedLookup && !TryValidateIndexedLookupEligibility( filePath, property.Name, displayPath, isDirectChildOfRoot, isRequired, refTableName, out diagnostic)) { return ParsedPropertyResult.FromDiagnostic(diagnostic!); } switch (schemaType) { case "integer": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, displayPath, propertyName, isRequired, title, description, isIndexedLookup, new SchemaTypeSpec( SchemaNodeKind.Scalar, "integer", isRequired ? "int" : "int?", TryBuildScalarInitializer(property.Value, "integer"), TryBuildEnumDocumentation(property.Value, "integer"), TryBuildConstraintDocumentation(property.Value, "integer"), refTableName, null, null))); case "number": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, displayPath, propertyName, isRequired, title, description, isIndexedLookup, new SchemaTypeSpec( SchemaNodeKind.Scalar, "number", isRequired ? "double" : "double?", TryBuildScalarInitializer(property.Value, "number"), TryBuildEnumDocumentation(property.Value, "number"), TryBuildConstraintDocumentation(property.Value, "number"), refTableName, null, null))); case "boolean": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, displayPath, propertyName, isRequired, title, description, isIndexedLookup, new SchemaTypeSpec( SchemaNodeKind.Scalar, "boolean", isRequired ? "bool" : "bool?", TryBuildScalarInitializer(property.Value, "boolean"), TryBuildEnumDocumentation(property.Value, "boolean"), TryBuildConstraintDocumentation(property.Value, "boolean"), refTableName, null, null))); case "string": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, displayPath, propertyName, isRequired, title, description, isIndexedLookup, new SchemaTypeSpec( SchemaNodeKind.Scalar, "string", isRequired ? "string" : "string?", TryBuildScalarInitializer(property.Value, "string") ?? (isRequired ? " = string.Empty;" : null), TryBuildEnumDocumentation(property.Value, "string"), TryBuildConstraintDocumentation(property.Value, "string"), refTableName, null, null))); case "object": if (isIndexedLookup) { return ParsedPropertyResult.FromDiagnostic( CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage)); } if (!string.IsNullOrWhiteSpace(refTableName)) { return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "object-ref")); } var objectResult = ParseObjectSpec( filePath, property.Value, displayPath, $"{propertyName}Config"); if (objectResult.Diagnostic is not null) { return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic); } var objectSpec = objectResult.Object!; return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, displayPath, propertyName, isRequired, title, description, false, new SchemaTypeSpec( SchemaNodeKind.Object, "object", isRequired ? objectSpec.ClassName : $"{objectSpec.ClassName}?", isRequired ? " = new();" : null, TryBuildEnumDocumentation(property.Value, "object"), null, null, objectSpec, null))); case "array": return ParseArrayProperty(filePath, property, isRequired, displayPath, propertyName, title, description, refTableName, isIndexedLookup); default: return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, schemaType)); } } /// /// 验证字段是否满足生成只读精确匹配索引的前提。 /// /// Schema 文件路径。 /// Schema 原始字段名。 /// 逻辑字段路径。 /// 字段是否直接从属于 schema 根对象。 /// 字段是否必填。 /// 可选的引用表名。 /// 不满足条件时输出的诊断。 /// 当前字段是否允许声明只读索引。 private static bool TryValidateIndexedLookupEligibility( string filePath, string schemaName, string displayPath, bool isDirectChildOfRoot, bool isRequired, string? refTableName, out Diagnostic? diagnostic) { diagnostic = null; if (!isDirectChildOfRoot) { diagnostic = CreateInvalidLookupIndexDiagnostic( filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage); return false; } if (!isRequired) { diagnostic = CreateInvalidLookupIndexDiagnostic( filePath, displayPath, LookupIndexRequiresRequiredScalarMessage); return false; } if (string.Equals(schemaName, "id", StringComparison.OrdinalIgnoreCase)) { diagnostic = CreateInvalidLookupIndexDiagnostic( filePath, displayPath, LookupIndexPrimaryKeyMessage); return false; } if (!string.IsNullOrWhiteSpace(refTableName)) { diagnostic = CreateInvalidLookupIndexDiagnostic( filePath, displayPath, LookupIndexReferencePropertyMessage); return false; } return true; } /// /// 验证字符串 format 元数据是否属于当前共享支持子集。 /// 生成器不尝试解释开放格式名,而是直接在编译阶段拒绝三端无法稳定对齐的 schema。 /// /// Schema 文件路径。 /// 逻辑字段路径。 /// 属性 schema 节点。 /// 当前 schema type。 /// 失败时返回的诊断。 /// 当前节点的 format 元数据是否有效。 private static bool TryValidateStringFormatMetadata( string filePath, string displayPath, JsonElement element, string schemaType, out Diagnostic? diagnostic) { diagnostic = null; if (!element.TryGetProperty("format", out var formatElement)) { return true; } if (!string.Equals(schemaType, "string", StringComparison.Ordinal)) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidStringFormatMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "Only 'string' properties can declare 'format'."); return false; } if (formatElement.ValueKind != JsonValueKind.String) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidStringFormatMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "The 'format' value must be a string."); return false; } var formatName = formatElement.GetString() ?? string.Empty; if (IsSupportedStringFormat(formatName)) { return true; } diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidStringFormatMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, $"Unsupported string format '{formatName}'. Supported formats are {SupportedStringFormatNames}."); return false; } /// /// 递归验证 schema 树中的字符串 format 元数据。 /// 该遍历专门补足根节点、contains 子 schema 等不会完全进入常规属性解析路径的片段, /// 避免生成器对同一份 schema 比运行时和工具链更宽松。 /// /// Schema 文件路径。 /// 逻辑字段路径。 /// 当前 schema 节点。 /// 失败时返回的诊断。 /// 当前节点树的 format 元数据是否有效。 private static bool TryValidateStringFormatMetadataRecursively( string filePath, string displayPath, JsonElement element, out Diagnostic? diagnostic) { return TryTraverseSchemaRecursively( filePath, displayPath, element, static (currentFilePath, currentDisplayPath, currentElement, schemaType) => { if (string.IsNullOrWhiteSpace(schemaType)) { return (true, (Diagnostic?)null); } return TryValidateStringFormatMetadata( currentFilePath, currentDisplayPath, currentElement, schemaType, out var currentDiagnostic) ? (true, (Diagnostic?)null) : (false, currentDiagnostic); }, out diagnostic); } /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 /// 该遍历覆盖对象属性、not 子 schema、数组 itemscontains, /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。 /// /// Schema 文件路径。 /// 逻辑字段路径。 /// 当前 schema 节点。 /// 当前节点的关键字校验回调。 /// 失败时返回的诊断。 /// 当前节点树是否通过指定关键字的校验。 private static bool TryTraverseSchemaRecursively( string filePath, string displayPath, JsonElement element, Func nodeValidator, out Diagnostic? diagnostic) { diagnostic = null; if (element.ValueKind != JsonValueKind.Object) { return true; } var schemaType = string.Empty; if (element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String) { schemaType = typeElement.GetString() ?? string.Empty; } var nodeValidationResult = nodeValidator(filePath, displayPath, element, schemaType); if (!nodeValidationResult.IsValid) { diagnostic = nodeValidationResult.Diagnostic; return false; } if (string.Equals(schemaType, "object", StringComparison.Ordinal) && element.TryGetProperty("properties", out var propertiesElement) && propertiesElement.ValueKind == JsonValueKind.Object) { foreach (var property in propertiesElement.EnumerateObject()) { if (!TryTraverseSchemaRecursively( filePath, CombinePath(displayPath, property.Name), property.Value, nodeValidator, out diagnostic)) { return false; } } } if (element.TryGetProperty("not", out var notElement) && notElement.ValueKind == JsonValueKind.Object && !TryTraverseSchemaRecursively( filePath, $"{displayPath}[not]", notElement, nodeValidator, out diagnostic)) { return false; } if (!string.Equals(schemaType, "array", StringComparison.Ordinal)) { return true; } if (element.TryGetProperty("items", out var itemsElement) && itemsElement.ValueKind == JsonValueKind.Object && !TryTraverseSchemaRecursively( filePath, $"{displayPath}[]", itemsElement, nodeValidator, out diagnostic)) { return false; } if (element.TryGetProperty("contains", out var containsElement) && containsElement.ValueKind == JsonValueKind.Object && !TryTraverseSchemaRecursively( filePath, $"{displayPath}[contains]", containsElement, nodeValidator, out diagnostic)) { return false; } return true; } /// /// 递归验证 schema 树中的对象级 dependentRequired 元数据。 /// 该遍历会覆盖根节点、not 子 schema、数组元素与 contains 子 schema, /// 避免生成器在对象字段依赖规则上比运行时和工具侧更宽松。 /// /// Schema 文件路径。 /// 逻辑字段路径。 /// 当前 schema 节点。 /// 失败时返回的诊断。 /// 当前节点树的 dependentRequired 元数据是否有效。 private static bool TryValidateDependentRequiredMetadataRecursively( string filePath, string displayPath, JsonElement element, out Diagnostic? diagnostic) { return TryTraverseSchemaRecursively( filePath, displayPath, element, static (currentFilePath, currentDisplayPath, currentElement, schemaType) => { if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) { return (true, (Diagnostic?)null); } return TryValidateDependentRequiredMetadata( currentFilePath, currentDisplayPath, currentElement, out var currentDiagnostic) ? (true, (Diagnostic?)null) : (false, currentDiagnostic); }, out diagnostic); } /// /// 验证单个对象 schema 节点上的 dependentRequired 元数据。 /// 生成器只接受“当前对象已声明字段之间”的依赖关系,避免强类型文档描述出运行时无法解析的无效键名。 /// /// Schema 文件路径。 /// 逻辑字段路径。 /// 当前对象 schema 节点。 /// 失败时返回的诊断。 /// 当前对象上的 dependentRequired 元数据是否有效。 private static bool TryValidateDependentRequiredMetadata( string filePath, string displayPath, JsonElement element, out Diagnostic? diagnostic) { diagnostic = null; if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement)) { return true; } if (dependentRequiredElement.ValueKind != JsonValueKind.Object) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "The 'dependentRequired' value must be an object."); return false; } if (!element.TryGetProperty("properties", out var propertiesElement) || propertiesElement.ValueKind != JsonValueKind.Object) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "Object schemas using 'dependentRequired' must also declare an object-valued 'properties' map."); return false; } var declaredProperties = new HashSet( propertiesElement .EnumerateObject() .Select(static property => property.Name), StringComparer.Ordinal); foreach (var dependency in dependentRequiredElement.EnumerateObject()) { if (!declaredProperties.Contains(dependency.Name)) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, $"Trigger property '{dependency.Name}' is not declared in the same object schema."); return false; } if (dependency.Value.ValueKind != JsonValueKind.Array) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, $"Property '{dependency.Name}' must declare 'dependentRequired' as an array of sibling property names."); return false; } foreach (var dependencyTarget in dependency.Value.EnumerateArray()) { if (dependencyTarget.ValueKind != JsonValueKind.String) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, $"Property '{dependency.Name}' must declare 'dependentRequired' entries as strings."); return false; } var dependencyTargetName = dependencyTarget.GetString(); if (string.IsNullOrWhiteSpace(dependencyTargetName)) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, $"Property '{dependency.Name}' cannot declare blank 'dependentRequired' entries."); return false; } var normalizedDependencyTargetName = dependencyTargetName!; if (!declaredProperties.Contains(normalizedDependencyTargetName)) { diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, $"Dependent target '{normalizedDependencyTargetName}' is not declared in the same object schema."); return false; } } } return true; } /// /// 判断给定 format 名称是否属于当前共享支持子集。 /// /// schema 中声明的 format 名称。 /// 是否支持该格式。 private static bool IsSupportedStringFormat(string formatName) { return formatName switch { "date" => true, "date-time" => true, "duration" => true, "email" => true, "time" => true, "uri" => true, "uuid" => true, _ => false }; } /// /// 解析数组属性,支持标量数组与对象数组。 /// /// Schema 文件路径。 /// 属性 JSON 节点。 /// 属性是否必填。 /// 逻辑字段路径。 /// CLR 属性名。 /// 标题元数据。 /// 说明元数据。 /// 目标引用表名称。 /// 是否为索引查找。 /// 解析后的属性信息或诊断。 private static ParsedPropertyResult ParseArrayProperty( string filePath, JsonProperty property, bool isRequired, string displayPath, string propertyName, string? title, string? description, string? refTableName, bool isIndexedLookup) { if (isIndexedLookup) { return ParsedPropertyResult.FromDiagnostic( CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage)); } if (!property.Value.TryGetProperty("items", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Object || !itemsElement.TryGetProperty("type", out var itemTypeElement) || itemTypeElement.ValueKind != JsonValueKind.String) { return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "array")); } var itemType = itemTypeElement.GetString() ?? string.Empty; if (!TryValidateStringFormatMetadata(filePath, $"{displayPath}[]", itemsElement, itemType, out var formatDiagnostic)) { return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!); } switch (itemType) { case "integer": case "number": case "boolean": case "string": var itemClrType = itemType switch { "integer" => "int", "number" => "double", "boolean" => "bool", _ => "string" }; return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, displayPath, propertyName, isRequired, title, description, false, new SchemaTypeSpec( SchemaNodeKind.Array, "array", $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? $" = global::System.Array.Empty<{itemClrType}>();", TryBuildEnumDocumentation(property.Value, "array") ?? TryBuildEnumDocumentation(itemsElement, itemType), TryBuildConstraintDocumentation(property.Value, "array"), refTableName, null, new SchemaTypeSpec( SchemaNodeKind.Scalar, itemType, itemClrType, null, TryBuildEnumDocumentation(itemsElement, itemType), TryBuildConstraintDocumentation(itemsElement, itemType), refTableName, null, null)))); case "object": if (!string.IsNullOrWhiteSpace(refTableName)) { return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, "array-ref")); } var objectResult = ParseObjectSpec( filePath, itemsElement, $"{displayPath}[]", $"{propertyName}ItemConfig"); if (objectResult.Diagnostic is not null) { return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic); } var objectSpec = objectResult.Object!; return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( property.Name, displayPath, propertyName, isRequired, title, description, false, new SchemaTypeSpec( SchemaNodeKind.Array, "array", $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", $" = global::System.Array.Empty<{objectSpec.ClassName}>();", TryBuildEnumDocumentation(property.Value, "array") ?? TryBuildEnumDocumentation(itemsElement, "object"), TryBuildConstraintDocumentation(property.Value, "array"), null, null, new SchemaTypeSpec( SchemaNodeKind.Object, "object", objectSpec.ClassName, null, TryBuildEnumDocumentation(itemsElement, "object"), null, null, objectSpec, null)))); default: return ParsedPropertyResult.FromDiagnostic( Diagnostic.Create( ConfigSchemaDiagnostics.UnsupportedPropertyType, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, $"array<{itemType}>")); } } /// /// 生成配置类型源码。 /// /// 已解析的 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(); AppendObjectType(builder, schema.RootObject, schema.FileName, schema.Title, schema.Description, isRoot: true, indentationLevel: 0); return builder.ToString().TrimEnd(); } /// /// 生成表包装源码。 /// /// 已解析的 schema 模型。 /// 配置表包装源码。 private static string GenerateTableClass(SchemaFileSpec schema) { var builder = new StringBuilder(); var queryableProperties = CollectQueryableProperties(schema).ToArray(); var indexedQueryableProperties = queryableProperties .Where(static property => property.IsIndexedLookup) .ToArray(); 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;"); foreach (var property in indexedQueryableProperties) { builder.AppendLine( $" private readonly global::System.Lazy>> _{ToCamelCase(property.PropertyName)}Index;"); } 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));"); foreach (var property in indexedQueryableProperties) { builder.AppendLine( $" _{ToCamelCase(property.PropertyName)}Index = new global::System.Lazy>>("); builder.AppendLine($" Build{property.PropertyName}Index,"); builder.AppendLine(" global::System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);"); } 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(" }"); if (indexedQueryableProperties.Length > 0) { foreach (var property in indexedQueryableProperties) { builder.AppendLine(); AppendIndexedLookupBuilderMethod(builder, schema, property); } builder.AppendLine(); AppendSharedLookupIndexBuilderMethod(builder, schema); } foreach (var property in queryableProperties) { builder.AppendLine(); AppendFindByPropertyMethod(builder, schema, property); builder.AppendLine(); AppendTryFindFirstByPropertyMethod(builder, schema, property); } builder.AppendLine("}"); return builder.ToString().TrimEnd(); } /// /// 生成运行时注册与访问辅助源码。 /// 该辅助类型把 schema 命名约定、配置目录和 schema 相对路径固化为生成代码, /// 让消费端无需重复手写字符串常量和主键提取逻辑。 /// /// 已解析的 schema 模型。 /// 辅助类型源码。 private static string GenerateBindingsClass(SchemaFileSpec schema) { var registerMethodName = $"Register{schema.EntityName}Table"; var getMethodName = $"Get{schema.EntityName}Table"; var tryGetMethodName = $"TryGet{schema.EntityName}Table"; var bindingsClassName = $"{schema.EntityName}ConfigBindings"; var referenceSpecs = CollectReferenceSpecs(schema.RootObject).ToArray(); 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 registration and lookup helpers for schema file '{schema.FileName}'."); builder.AppendLine( "/// The helper centralizes table naming, config directory, schema path, and strongly-typed registry access so consumer projects do not need to duplicate the same conventions."); builder.AppendLine("/// "); builder.AppendLine($"public static class {bindingsClassName}"); builder.AppendLine("{"); builder.AppendLine(" /// "); builder.AppendLine( " /// Describes one schema property that declares x-gframework-ref-table metadata."); builder.AppendLine(" /// "); builder.AppendLine(" public readonly struct ReferenceMetadata"); builder.AppendLine(" {"); builder.AppendLine(" /// "); builder.AppendLine(" /// Initializes one generated cross-table reference descriptor."); builder.AppendLine(" /// "); builder.AppendLine(" /// Schema property path."); builder.AppendLine(" /// Referenced runtime table name."); builder.AppendLine( " /// Schema scalar type used by the reference value."); builder.AppendLine( " /// Whether the property stores multiple reference keys."); builder.AppendLine(" public ReferenceMetadata("); builder.AppendLine(" string displayPath,"); builder.AppendLine(" string referencedTableName,"); builder.AppendLine(" string valueSchemaType,"); builder.AppendLine(" bool isCollection)"); builder.AppendLine(" {"); builder.AppendLine( " DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));"); builder.AppendLine( " ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));"); builder.AppendLine( " ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));"); builder.AppendLine(" IsCollection = isCollection;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets the schema property path such as dropItems or phases[].monsterId."); builder.AppendLine(" /// "); builder.AppendLine(" public string DisplayPath { get; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the runtime registration name of the referenced config table."); builder.AppendLine(" /// "); builder.AppendLine(" public string ReferencedTableName { get; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the schema scalar type used by the referenced key value."); builder.AppendLine(" /// "); builder.AppendLine(" public string ValueSchemaType { get; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets a value indicating whether the property stores multiple reference keys."); builder.AppendLine(" /// "); builder.AppendLine(" public bool IsCollection { get; }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point."); builder.AppendLine(" /// "); builder.AppendLine(" public static class Metadata"); builder.AppendLine(" {"); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name."); builder.AppendLine(" /// "); builder.AppendLine( $" public const string ConfigDomain = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the runtime registration name of the generated config table."); builder.AppendLine(" /// "); builder.AppendLine( $" public const string TableName = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets the config directory path expected by the generated registration helper."); builder.AppendLine(" /// "); builder.AppendLine( $" public const string ConfigRelativePath = {SymbolDisplay.FormatLiteral(schema.ConfigRelativePath, true)};"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper."); builder.AppendLine(" /// "); builder.AppendLine( $" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name."); builder.AppendLine(" /// "); builder.AppendLine(" public const string ConfigDomain = Metadata.ConfigDomain;"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the runtime registration name of the generated config table."); builder.AppendLine(" /// "); builder.AppendLine(" public const string TableName = Metadata.TableName;"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the config directory path expected by the generated registration helper."); builder.AppendLine(" /// "); builder.AppendLine(" public const string ConfigRelativePath = Metadata.ConfigRelativePath;"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper."); builder.AppendLine(" /// "); builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;"); builder.AppendLine(); AppendYamlSerializationHelpers(builder, schema); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Exposes generated metadata for schema properties that declare x-gframework-ref-table."); builder.AppendLine(" /// "); builder.AppendLine(" public static class References"); builder.AppendLine(" {"); foreach (var referenceSpec in referenceSpecs) { builder.AppendLine(" /// "); builder.AppendLine( $" /// Gets generated reference metadata for schema property path '{EscapeXmlDocumentation(referenceSpec.DisplayPath)}'."); builder.AppendLine(" /// "); builder.AppendLine( $" public static readonly ReferenceMetadata {referenceSpec.MemberName} = new("); builder.AppendLine( $" {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)},"); builder.AppendLine( $" {SymbolDisplay.FormatLiteral(referenceSpec.ReferencedTableName, true)},"); builder.AppendLine( $" {SymbolDisplay.FormatLiteral(referenceSpec.ValueSchemaType, true)},"); builder.AppendLine( $" {(referenceSpec.IsCollection ? "true" : "false")});"); builder.AppendLine(); } builder.AppendLine(" /// "); builder.AppendLine( " /// Gets all generated cross-table reference descriptors for the current schema."); builder.AppendLine(" /// "); if (referenceSpecs.Length == 0) { builder.AppendLine( " public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.Empty();"); } else { builder.AppendLine( " public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]"); builder.AppendLine(" {"); foreach (var referenceSpec in referenceSpecs) { builder.AppendLine($" {referenceSpec.MemberName},"); } builder.AppendLine(" });"); } builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Tries to resolve generated reference metadata by schema property path."); builder.AppendLine(" /// "); builder.AppendLine(" /// Schema property path."); builder.AppendLine( " /// Resolved generated reference metadata when the path is known; otherwise the default value."); builder.AppendLine( " /// True when the schema property path has generated cross-table metadata; otherwise false."); builder.AppendLine( " public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)"); builder.AppendLine(" {"); builder.AppendLine(" if (displayPath is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(displayPath));"); builder.AppendLine(" }"); builder.AppendLine(); if (referenceSpecs.Length == 0) { builder.AppendLine(" metadata = default;"); builder.AppendLine(" return false;"); } else { foreach (var referenceSpec in referenceSpecs) { builder.AppendLine( $" if (string.Equals(displayPath, {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)}, global::System.StringComparison.Ordinal))"); builder.AppendLine(" {"); builder.AppendLine($" metadata = {referenceSpec.MemberName};"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); } builder.AppendLine(); builder.AppendLine(" metadata = default;"); builder.AppendLine(" return false;"); } builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Registers the generated config table using the schema-derived runtime conventions."); builder.AppendLine(" /// "); builder.AppendLine(" /// The target YAML config loader."); builder.AppendLine( " /// Optional key comparer for the generated table registration."); builder.AppendLine(" /// The same loader instance so registration can keep chaining."); builder.AppendLine( $" public static global::GFramework.Game.Config.YamlConfigLoader {registerMethodName}("); builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader,"); builder.AppendLine( $" global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>? comparer = null)"); builder.AppendLine(" {"); builder.AppendLine(" if (loader is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( $" return loader.RegisterTable<{schema.KeyClrType}, {schema.ClassName}>("); builder.AppendLine(" Metadata.TableName,"); builder.AppendLine(" Metadata.ConfigRelativePath,"); builder.AppendLine(" Metadata.SchemaRelativePath,"); builder.AppendLine($" static config => config.{schema.KeyPropertyName},"); builder.AppendLine(" comparer);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the generated config table wrapper from the registry."); builder.AppendLine(" /// "); builder.AppendLine(" /// The source config registry."); builder.AppendLine(" /// The generated strongly-typed table wrapper."); builder.AppendLine( " /// When is null."); builder.AppendLine( $" public static {schema.TableName} {getMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry)"); builder.AppendLine(" {"); builder.AppendLine(" if (registry is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( $" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Tries to get the generated config table wrapper from the registry."); builder.AppendLine(" /// "); builder.AppendLine(" /// The source config registry."); builder.AppendLine( " /// The generated strongly-typed table wrapper when lookup succeeds; otherwise null."); builder.AppendLine( " /// True when the generated table is registered and type-compatible; otherwise false."); builder.AppendLine( " /// When is null."); builder.AppendLine( $" public static bool {tryGetMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry, out {schema.TableName}? table)"); builder.AppendLine(" {"); builder.AppendLine(" if (registry is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( $" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName, out var innerTable) && innerTable is not null)"); builder.AppendLine(" {"); builder.AppendLine($" table = new {schema.TableName}(innerTable);"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" table = null;"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); builder.AppendLine("}"); return builder.ToString().TrimEnd(); } /// /// 生成项目级聚合辅助源码。 /// 该辅助把当前消费者项目内所有有效 schema 汇总为一个统一入口, /// 以便运行时快速完成批量注册并在需要时枚举已生成的配置域元数据。 /// /// 当前编译中成功解析的 schema 集合。 /// 聚合辅助源码。 private static string GenerateCatalogClass(IReadOnlyList schemas) { var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine(); builder.AppendLine($"namespace {GeneratedNamespace};"); builder.AppendLine(); builder.AppendLine("/// "); builder.AppendLine( "/// Provides a project-level catalog for every config table generated from the current consumer project's schemas."); builder.AppendLine( "/// Use this entry point when you want the C# runtime bootstrap path to register all generated tables without repeating one call per schema."); builder.AppendLine("/// "); builder.AppendLine("public static class GeneratedConfigCatalog"); builder.AppendLine("{"); builder.AppendLine(" /// "); builder.AppendLine( " /// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime."); builder.AppendLine(" /// "); builder.AppendLine(" public readonly struct TableMetadata"); builder.AppendLine(" {"); builder.AppendLine(" /// "); builder.AppendLine(" /// Initializes one generated table metadata entry."); builder.AppendLine(" /// "); builder.AppendLine( " /// Logical config domain derived from the schema base name."); builder.AppendLine(" /// Runtime registration name."); builder.AppendLine(" /// Relative YAML directory path."); builder.AppendLine(" /// Relative schema file path."); builder.AppendLine(" public TableMetadata("); builder.AppendLine(" string configDomain,"); builder.AppendLine(" string tableName,"); builder.AppendLine(" string configRelativePath,"); builder.AppendLine(" string schemaRelativePath)"); builder.AppendLine(" {"); builder.AppendLine( " ConfigDomain = configDomain ?? throw new global::System.ArgumentNullException(nameof(configDomain));"); builder.AppendLine( " TableName = tableName ?? throw new global::System.ArgumentNullException(nameof(tableName));"); builder.AppendLine( " ConfigRelativePath = configRelativePath ?? throw new global::System.ArgumentNullException(nameof(configRelativePath));"); builder.AppendLine( " SchemaRelativePath = schemaRelativePath ?? throw new global::System.ArgumentNullException(nameof(schemaRelativePath));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the logical config domain derived from the schema base name."); builder.AppendLine(" /// "); builder.AppendLine(" public string ConfigDomain { get; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets the runtime registration name used by ."); builder.AppendLine(" /// "); builder.AppendLine(" public string TableName { get; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets the relative directory that stores YAML files for the generated config table."); builder.AppendLine(" /// "); builder.AppendLine(" public string ConfigRelativePath { get; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Gets the relative schema file path collected by the source generator."); builder.AppendLine(" /// "); builder.AppendLine(" public string SchemaRelativePath { get; }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets metadata for every generated config table in the current consumer project."); builder.AppendLine(" /// "); builder.AppendLine( " public static global::System.Collections.Generic.IReadOnlyList Tables { get; } = global::System.Array.AsReadOnly(new TableMetadata[]"); builder.AppendLine(" {"); foreach (var schema in schemas) { builder.AppendLine(" new("); builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.ConfigDomain,"); builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.TableName,"); builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.ConfigRelativePath,"); builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.SchemaRelativePath),"); } builder.AppendLine(" });"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Resolves one generated relative config path against the caller-supplied config root directory."); builder.AppendLine(" /// "); builder.AppendLine( " /// Absolute or workspace-local config root directory."); builder.AppendLine(" /// Generated relative config or schema path."); builder.AppendLine(" /// The combined absolute path."); builder.AppendLine( " /// When is null, empty, or whitespace."); builder.AppendLine( " /// When is null."); builder.AppendLine( " internal static string ResolveAbsolutePath(string configRootPath, string relativePath)"); builder.AppendLine(" {"); builder.AppendLine(" if (string.IsNullOrWhiteSpace(configRootPath))"); builder.AppendLine(" {"); builder.AppendLine( " throw new global::System.ArgumentException(\"Config root path cannot be null or whitespace.\", nameof(configRootPath));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" if (relativePath is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(relativePath));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( " var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)"); builder.AppendLine( " .Replace('\\\\', global::System.IO.Path.DirectorySeparatorChar);"); builder.AppendLine( " return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name."); builder.AppendLine(" /// "); builder.AppendLine(" /// Runtime registration name."); builder.AppendLine( " /// Resolved generated table metadata when the registration name exists; otherwise the default value."); builder.AppendLine( " /// when the registration name belongs to a generated config table; otherwise ."); builder.AppendLine(" public static bool TryGetByTableName(string tableName, out TableMetadata metadata)"); builder.AppendLine(" {"); builder.AppendLine(" if (tableName is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(tableName));"); builder.AppendLine(" }"); builder.AppendLine(); for (var index = 0; index < schemas.Count; index++) { var schema = schemas[index]; builder.AppendLine( $" if (string.Equals(tableName, {schema.EntityName}ConfigBindings.Metadata.TableName, global::System.StringComparison.Ordinal))"); builder.AppendLine(" {"); builder.AppendLine( $" metadata = Tables[{index.ToString(CultureInfo.InvariantCulture)}];"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); builder.AppendLine(); } builder.AppendLine(" metadata = default;"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Resolves the generated table metadata entries that belong to the specified logical config domain."); builder.AppendLine(" /// "); builder.AppendLine( " /// Logical config domain derived from the schema base name."); builder.AppendLine( " /// A deterministic metadata snapshot for the requested config domain, or an empty list when no generated table belongs to that domain."); builder.AppendLine( " /// When is null."); builder.AppendLine( " public static global::System.Collections.Generic.IReadOnlyList GetTablesInConfigDomain(string configDomain)"); builder.AppendLine(" {"); builder.AppendLine(" if (configDomain is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(configDomain));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" var matchedTables = new global::System.Collections.Generic.List();"); builder.AppendLine(" foreach (var metadata in Tables)"); builder.AppendLine(" {"); builder.AppendLine( " if (string.Equals(metadata.ConfigDomain, configDomain, global::System.StringComparison.Ordinal))"); builder.AppendLine(" {"); builder.AppendLine(" matchedTables.Add(metadata);"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( " return matchedTables.Count == 0 ? global::System.Array.Empty() : matchedTables.ToArray();"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Resolves the generated table metadata entries that aggregate registration would currently include under the supplied filters."); builder.AppendLine(" /// "); builder.AppendLine( " /// Optional aggregate registration filters and comparer overrides. When null, every generated table remains eligible."); builder.AppendLine( " /// A deterministic metadata snapshot in the same order used by ."); builder.AppendLine( " public static global::System.Collections.Generic.IReadOnlyList GetTablesForRegistration(GeneratedConfigRegistrationOptions? options = null)"); builder.AppendLine(" {"); builder.AppendLine(" var matchedTables = new global::System.Collections.Generic.List();"); builder.AppendLine(" foreach (var metadata in Tables)"); builder.AppendLine(" {"); builder.AppendLine(" if (MatchesRegistrationOptions(metadata, options))"); builder.AppendLine(" {"); builder.AppendLine(" matchedTables.Add(metadata);"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( " return matchedTables.Count == 0 ? global::System.Array.Empty() : matchedTables.ToArray();"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Evaluates whether one generated table metadata entry remains eligible under the supplied aggregate registration filters."); builder.AppendLine(" /// "); builder.AppendLine(" /// Generated table metadata under evaluation."); builder.AppendLine( " /// Optional aggregate registration filters and comparer overrides. When null, the metadata entry is always eligible."); builder.AppendLine( " /// when the generated table would be included by aggregate registration; otherwise ."); builder.AppendLine(" public static bool MatchesRegistrationOptions("); builder.AppendLine(" TableMetadata metadata,"); builder.AppendLine(" GeneratedConfigRegistrationOptions? options)"); builder.AppendLine(" {"); builder.AppendLine(" if (options is null)"); builder.AppendLine(" {"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( " // Apply cheap generated allow-lists before invoking the optional caller predicate so startup diagnostics stay aligned with real registration."); builder.AppendLine( " if (!MatchesOptionalAllowList(options.IncludedConfigDomains, metadata.ConfigDomain))"); builder.AppendLine(" {"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" if (!MatchesOptionalAllowList(options.IncludedTableNames, metadata.TableName))"); builder.AppendLine(" {"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" return options.TableFilter?.Invoke(metadata) ?? true;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Treats a null or empty allow-list as an unrestricted match, and otherwise performs ordinal string comparison against the generated metadata value."); builder.AppendLine(" /// "); builder.AppendLine(" /// Optional caller-supplied allow-list."); builder.AppendLine(" /// Generated metadata value being evaluated."); builder.AppendLine( " /// when the value should remain eligible for registration; otherwise ."); builder.AppendLine(" private static bool MatchesOptionalAllowList("); builder.AppendLine(" global::System.Collections.Generic.IReadOnlyCollection? allowedValues,"); builder.AppendLine(" string candidate)"); builder.AppendLine(" {"); builder.AppendLine(" if (allowedValues is null || allowedValues.Count == 0)"); builder.AppendLine(" {"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" foreach (var allowedValue in allowedValues)"); builder.AppendLine(" {"); builder.AppendLine(" if (allowedValue is not null &&"); builder.AppendLine( " string.Equals(allowedValue, candidate, global::System.StringComparison.Ordinal))"); builder.AppendLine(" {"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" return false;"); builder.AppendLine(" }"); builder.AppendLine("}"); builder.AppendLine(); builder.AppendLine("/// "); builder.AppendLine( "/// Captures optional per-table registration overrides for the generated aggregate registration entry point."); builder.AppendLine("/// "); builder.AppendLine("public sealed class GeneratedConfigRegistrationOptions"); builder.AppendLine("{"); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets or sets the optional allow-list of generated config domains that aggregate registration should include. When null or empty, every generated domain remains eligible."); builder.AppendLine(" /// "); builder.AppendLine( " public global::System.Collections.Generic.IReadOnlyCollection? IncludedConfigDomains { get; init; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets or sets the optional allow-list of runtime table names that aggregate registration should include. When null or empty, every generated table remains eligible."); builder.AppendLine(" /// "); builder.AppendLine( " public global::System.Collections.Generic.IReadOnlyCollection? IncludedTableNames { get; init; }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Gets or sets the optional predicate that can reject individual generated table metadata entries after allow-list filtering has passed."); builder.AppendLine(" /// "); builder.AppendLine( " public global::System.Predicate? TableFilter { get; init; }"); if (schemas.Count > 0) { builder.AppendLine(); } for (var index = 0; index < schemas.Count; index++) { var schema = schemas[index]; builder.AppendLine(" /// "); builder.AppendLine( $" /// Gets or sets the optional key comparer forwarded to {schema.EntityName}ConfigBindings.Register{schema.EntityName}Table(global::GFramework.Game.Config.YamlConfigLoader, global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>?) when aggregate registration runs."); builder.AppendLine(" /// "); builder.AppendLine( $" public global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>? {schema.EntityName}Comparer {{ get; init; }}"); if (index < schemas.Count - 1) { builder.AppendLine(); } } builder.AppendLine("}"); builder.AppendLine(); builder.AppendLine("/// "); builder.AppendLine( "/// Provides a single extension method that registers every generated config table discovered in the current consumer project."); builder.AppendLine("/// "); builder.AppendLine("public static class GeneratedConfigRegistrationExtensions"); builder.AppendLine("{"); builder.AppendLine(" /// "); builder.AppendLine( " /// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow."); builder.AppendLine(" /// "); builder.AppendLine(" /// Target YAML config loader."); builder.AppendLine( " /// The same loader instance after all generated table registrations have been applied."); builder.AppendLine( " /// When is null."); builder.AppendLine( " public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables("); builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader)"); builder.AppendLine(" {"); builder.AppendLine(" if (loader is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" return RegisterAllGeneratedConfigTables(loader, options: null);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers."); builder.AppendLine(" /// "); builder.AppendLine(" /// Target YAML config loader."); builder.AppendLine( " /// Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior."); builder.AppendLine( " /// The same loader instance after all generated table registrations have been applied."); builder.AppendLine( " /// When is null."); builder.AppendLine( " public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables("); builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader,"); builder.AppendLine(" GeneratedConfigRegistrationOptions? options)"); builder.AppendLine(" {"); builder.AppendLine(" if (loader is null)"); builder.AppendLine(" {"); builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" var effectiveOptions = options ?? new GeneratedConfigRegistrationOptions();"); builder.AppendLine(); for (var index = 0; index < schemas.Count; index++) { var schema = schemas[index]; builder.AppendLine( $" if (GeneratedConfigCatalog.MatchesRegistrationOptions(GeneratedConfigCatalog.Tables[{index.ToString(CultureInfo.InvariantCulture)}], effectiveOptions))"); builder.AppendLine(" {"); builder.AppendLine( $" loader.Register{schema.EntityName}Table(effectiveOptions.{schema.EntityName}Comparer);"); builder.AppendLine(" }"); builder.AppendLine(); } builder.AppendLine(" return loader;"); builder.AppendLine(" }"); builder.AppendLine("}"); return builder.ToString().TrimEnd(); } /// /// 为生成的绑定类输出 YAML 序列化与 schema 路径辅助。 /// /// 输出缓冲区。 /// 生成器级 schema 模型。 private static void AppendYamlSerializationHelpers( StringBuilder builder, SchemaFileSpec schema) { builder.AppendLine(" /// "); builder.AppendLine( " /// Serializes one generated config instance to YAML text using the shared runtime naming convention."); builder.AppendLine(" /// "); builder.AppendLine(" /// The generated config instance to serialize."); builder.AppendLine( " /// YAML text that preserves the shared camelCase field naming convention."); builder.AppendLine( " /// Thrown when is ."); builder.AppendLine($" public static string SerializeToYaml({schema.ClassName} config)"); builder.AppendLine(" {"); builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Resolves the absolute config directory path by combining the caller-supplied config root with the generated relative directory."); builder.AppendLine(" /// "); builder.AppendLine( " /// Absolute or workspace-local config root directory."); builder.AppendLine(" /// The absolute config directory path for the generated table."); builder.AppendLine( " /// Thrown when is null, empty, or whitespace."); builder.AppendLine(" public static string GetConfigDirectoryPath(string configRootPath)"); builder.AppendLine(" {"); builder.AppendLine( " return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Resolves the absolute schema file path by combining the caller-supplied config root with the generated relative schema path."); builder.AppendLine(" /// "); builder.AppendLine( " /// Absolute or workspace-local config root directory."); builder.AppendLine(" /// The absolute schema file path for the generated table."); builder.AppendLine( " /// Thrown when is null, empty, or whitespace."); builder.AppendLine(" public static string GetSchemaPath(string configRootPath)"); builder.AppendLine(" {"); builder.AppendLine( " return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Validates YAML text against the generated schema file located under the supplied config root directory."); builder.AppendLine(" /// "); builder.AppendLine( " /// Absolute or workspace-local config root directory."); builder.AppendLine( " /// Logical or absolute YAML path used for diagnostics."); builder.AppendLine(" /// YAML text to validate."); builder.AppendLine( " /// Thrown when is null, empty, or whitespace."); builder.AppendLine( " /// Thrown when or is ."); builder.AppendLine( " /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation."); builder.AppendLine( " public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)"); builder.AppendLine(" {"); builder.AppendLine(" global::GFramework.Game.Config.YamlConfigTextValidator.Validate("); builder.AppendLine(" Metadata.TableName,"); builder.AppendLine(" GetSchemaPath(configRootPath),"); builder.AppendLine(" yamlPath,"); builder.AppendLine(" yamlText);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( " /// Asynchronously validates YAML text against the generated schema file located under the supplied config root directory."); builder.AppendLine(" /// "); builder.AppendLine( " /// Absolute or workspace-local config root directory."); builder.AppendLine( " /// Logical or absolute YAML path used for diagnostics."); builder.AppendLine(" /// YAML text to validate."); builder.AppendLine(" /// Cancellation token."); builder.AppendLine(" /// A task that represents the asynchronous validation operation."); builder.AppendLine( " /// Thrown when is null, empty, or whitespace."); builder.AppendLine( " /// Thrown when or is ."); builder.AppendLine( " /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation."); builder.AppendLine( " public static global::System.Threading.Tasks.Task ValidateYamlAsync("); builder.AppendLine(" string configRootPath,"); builder.AppendLine(" string yamlPath,"); builder.AppendLine(" string yamlText,"); builder.AppendLine(" global::System.Threading.CancellationToken cancellationToken = default)"); builder.AppendLine(" {"); builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextValidator.ValidateAsync("); builder.AppendLine(" Metadata.TableName,"); builder.AppendLine(" GetSchemaPath(configRootPath),"); builder.AppendLine(" yamlPath,"); builder.AppendLine(" yamlText,"); builder.AppendLine(" cancellationToken);"); builder.AppendLine(" }"); } /// /// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。 /// /// 根对象模型。 /// 生成期引用元数据集合。 private static IEnumerable CollectReferenceSpecs(SchemaObjectSpec rootObject) { var nextSuffixByBaseMemberName = new Dictionary(StringComparer.Ordinal); var allocatedMemberNames = new HashSet(StringComparer.Ordinal); foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties)) { var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath); var memberName = baseMemberName; if (!allocatedMemberNames.Add(memberName)) { // Track globally allocated member names because a suffixed duplicate from one path can collide // with the unsuffixed base name produced by a later, different path. var duplicateCount = nextSuffixByBaseMemberName.TryGetValue(baseMemberName, out var nextSuffix) ? nextSuffix + 1 : 1; memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}"; while (!allocatedMemberNames.Add(memberName)) { duplicateCount++; memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}"; } nextSuffixByBaseMemberName[baseMemberName] = duplicateCount; } else { nextSuffixByBaseMemberName[baseMemberName] = 0; } yield return new GeneratedReferenceSpec( memberName, referenceSeed.DisplayPath, referenceSeed.ReferencedTableName, referenceSeed.ValueSchemaType, referenceSeed.IsCollection); } } /// /// 收集适合生成轻量查询辅助的根级标量字段。 /// 当前实现故意限定在顶层非主键标量字段,避免把嵌套结构、数组或引用语义提前固化为运行时契约。 /// /// 生成器级 schema 模型。 /// 可生成查询辅助的属性集合。 private static IEnumerable CollectQueryableProperties(SchemaFileSpec schema) { foreach (var property in schema.RootObject.Properties) { if (property.TypeSpec.Kind != SchemaNodeKind.Scalar) { continue; } if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName)) { continue; } if (string.Equals(property.PropertyName, schema.KeyPropertyName, StringComparison.Ordinal)) { continue; } yield return property; } } /// /// 为单个索引字段生成延迟构建器。 /// /// 输出缓冲区。 /// 生成器级 schema 模型。 /// 声明了索引元数据的字段。 private static void AppendIndexedLookupBuilderMethod( StringBuilder builder, SchemaFileSpec schema, SchemaPropertySpec property) { builder.AppendLine(" /// "); builder.AppendLine( $" /// Builds the exact-match lookup index declared for property '{EscapeXmlDocumentation(property.DisplayPath)}'."); builder.AppendLine(" /// "); builder.AppendLine( $" /// A read-only lookup index keyed by {EscapeXmlDocumentation(property.PropertyName)}."); builder.AppendLine( $" private global::System.Collections.Generic.IReadOnlyDictionary<{property.TypeSpec.ClrType}, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>> Build{property.PropertyName}Index()"); builder.AppendLine(" {"); builder.AppendLine( $" return BuildLookupIndex(static config => config.{property.PropertyName});"); builder.AppendLine(" }"); } /// /// 为当前生成表输出共享索引构建逻辑。 /// /// 输出缓冲区。 /// 生成器级 schema 模型。 private static void AppendSharedLookupIndexBuilderMethod( StringBuilder builder, SchemaFileSpec schema) { builder.AppendLine(" /// "); builder.AppendLine( " /// Materializes a read-only exact-match lookup index from the current table snapshot."); builder.AppendLine(" /// "); builder.AppendLine(" /// Indexed property type."); builder.AppendLine( " /// Selects the indexed property from one config entry."); builder.AppendLine( " /// A read-only dictionary whose values preserve snapshot iteration order."); builder.AppendLine(" /// "); builder.AppendLine( " /// The generated index skips runtime null keys even though is constrained to notnull. Malformed YAML payloads can still deserialize missing indexed values to , and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance."); builder.AppendLine(" /// "); builder.AppendLine( $" private global::System.Collections.Generic.IReadOnlyDictionary> BuildLookupIndex("); builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)"); builder.AppendLine(" where TProperty : notnull"); builder.AppendLine(" {"); builder.AppendLine( " var buckets = new global::System.Collections.Generic.Dictionary>();"); builder.AppendLine(); builder.AppendLine( " // Capture the current table snapshot once so indexed lookups stay deterministic for this wrapper instance."); builder.AppendLine(" foreach (var candidate in All())"); builder.AppendLine(" {"); builder.AppendLine(" var key = keySelector(candidate);"); builder.AppendLine(" if (key is null)"); builder.AppendLine(" {"); builder.AppendLine( " // Skip malformed runtime data so the lazy lookup cache remains usable for valid keys."); builder.AppendLine( " // Throwing here would permanently poison the cached index for this wrapper instance."); builder.AppendLine(" continue;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))"); builder.AppendLine(" {"); builder.AppendLine( $" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();"); builder.AppendLine(" buckets.Add(key, matches);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" matches.Add(candidate);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( $" var materialized = new global::System.Collections.Generic.Dictionary>(buckets.Count, buckets.Comparer);"); builder.AppendLine(" foreach (var pair in buckets)"); builder.AppendLine(" {"); builder.AppendLine( $" materialized.Add(pair.Key, pair.Value.AsReadOnly());"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( $" return new global::System.Collections.ObjectModel.ReadOnlyDictionary>(materialized);"); builder.AppendLine(" }"); } /// /// 生成按字段匹配全部结果的轻量查询辅助。 /// /// 输出缓冲区。 /// 生成器级 schema 模型。 /// 要生成查询辅助的字段模型。 private static void AppendFindByPropertyMethod( StringBuilder builder, SchemaFileSpec schema, SchemaPropertySpec property) { builder.AppendLine(" /// "); builder.AppendLine( $" /// Finds all config entries whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value."); builder.AppendLine(" /// "); builder.AppendLine(" /// The property value to match."); builder.AppendLine(" /// A read-only snapshot containing every matching config entry."); builder.AppendLine(" /// "); if (property.IsIndexedLookup) { builder.AppendLine( " /// This property declares x-gframework-index, so the generated helper resolves matches through a lazily materialized read-only lookup index built from the current table snapshot."); } else { builder.AppendLine( " /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure."); } builder.AppendLine(" /// "); builder.AppendLine( $" public global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}> FindBy{property.PropertyName}({property.TypeSpec.ClrType} value)"); builder.AppendLine(" {"); if (property.IsIndexedLookup) { if (RequiresIndexedLookupNullGuard(property.TypeSpec)) { builder.AppendLine(" if (value is null)"); builder.AppendLine(" {"); builder.AppendLine($" return global::System.Array.Empty<{schema.ClassName}>();"); builder.AppendLine(" }"); builder.AppendLine(); } builder.AppendLine( $" if (_{ToCamelCase(property.PropertyName)}Index.Value.TryGetValue(value, out var matches))"); builder.AppendLine(" {"); builder.AppendLine(" return matches;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine($" return global::System.Array.Empty<{schema.ClassName}>();"); } else { builder.AppendLine( $" var matches = new global::System.Collections.Generic.List<{schema.ClassName}>();"); builder.AppendLine(); builder.AppendLine( " // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data."); builder.AppendLine(" foreach (var candidate in All())"); builder.AppendLine(" {"); builder.AppendLine( $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))"); builder.AppendLine(" {"); builder.AppendLine(" matches.Add(candidate);"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( $" return matches.Count == 0 ? global::System.Array.Empty<{schema.ClassName}>() : matches.AsReadOnly();"); } builder.AppendLine(" }"); } /// /// 生成按字段匹配首个结果的轻量查询辅助。 /// /// 输出缓冲区。 /// 生成器级 schema 模型。 /// 要生成查询辅助的字段模型。 private static void AppendTryFindFirstByPropertyMethod( StringBuilder builder, SchemaFileSpec schema, SchemaPropertySpec property) { builder.AppendLine(" /// "); builder.AppendLine( $" /// Tries to find the first config entry whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value."); builder.AppendLine(" /// "); builder.AppendLine(" /// The property value to match."); builder.AppendLine( " /// The first matching config entry when lookup succeeds; otherwise ."); builder.AppendLine( " /// when a matching config entry is found; otherwise ."); builder.AppendLine(" /// "); if (property.IsIndexedLookup) { builder.AppendLine( " /// This property declares x-gframework-index, so the generated helper returns the first element from the lazily materialized exact-match bucket."); } else { builder.AppendLine( " /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order."); } builder.AppendLine(" /// "); builder.AppendLine( $" public bool TryFindFirstBy{property.PropertyName}({property.TypeSpec.ClrType} value, out {schema.ClassName}? result)"); builder.AppendLine(" {"); if (property.IsIndexedLookup) { if (RequiresIndexedLookupNullGuard(property.TypeSpec)) { builder.AppendLine(" if (value is null)"); builder.AppendLine(" {"); builder.AppendLine(" result = null;"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); builder.AppendLine(); } builder.AppendLine( $" if (_{ToCamelCase(property.PropertyName)}Index.Value.TryGetValue(value, out var matches) && matches.Count > 0)"); builder.AppendLine(" {"); builder.AppendLine(" result = matches[0];"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" result = null;"); builder.AppendLine(" return false;"); } else { builder.AppendLine( " // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches."); builder.AppendLine(" foreach (var candidate in All())"); builder.AppendLine(" {"); builder.AppendLine( $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))"); builder.AppendLine(" {"); builder.AppendLine(" result = candidate;"); builder.AppendLine(" return true;"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" result = null;"); builder.AppendLine(" return false;"); } builder.AppendLine(" }"); } /// /// 递归枚举对象树中所有带 ref-table 元数据的字段。 /// /// 对象属性集合。 /// 原始引用字段信息。 private static IEnumerable EnumerateReferenceSeeds( IEnumerable properties) { foreach (var property in properties) { if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName)) { yield return new GeneratedReferenceSeed( property.DisplayPath, property.TypeSpec.RefTableName!, property.TypeSpec.Kind == SchemaNodeKind.Array ? property.TypeSpec.ItemTypeSpec?.SchemaType ?? property.TypeSpec.SchemaType : property.TypeSpec.SchemaType, property.TypeSpec.Kind == SchemaNodeKind.Array); } if (property.TypeSpec.NestedObject is not null) { foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.NestedObject.Properties)) { yield return nestedReference; } } if (property.TypeSpec.ItemTypeSpec?.NestedObject is not null) { foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.ItemTypeSpec.NestedObject .Properties)) { yield return nestedReference; } } } } /// /// 递归生成配置对象类型。 /// /// 输出缓冲区。 /// 要生成的对象类型。 /// Schema 文件名。 /// 对象标题元数据。 /// 对象说明元数据。 /// 是否为根配置类型。 /// 缩进层级。 private static void AppendObjectType( StringBuilder builder, SchemaObjectSpec objectSpec, string fileName, string? title, string? description, bool isRoot, int indentationLevel) { var indent = new string(' ', indentationLevel * 4); builder.AppendLine($"{indent}/// "); if (isRoot) { builder.AppendLine( $"{indent}/// Auto-generated config type for schema file '{fileName}'."); builder.AppendLine( $"{indent}/// {EscapeXmlDocumentation(description ?? title ?? "This type is generated from JSON schema so runtime loading and editor tooling can share the same contract.")}"); } else { builder.AppendLine( $"{indent}/// Auto-generated nested config type for schema property path '{EscapeXmlDocumentation(objectSpec.DisplayPath)}'."); builder.AppendLine( $"{indent}/// {EscapeXmlDocumentation(description ?? title ?? "This nested type is generated so object-valued schema fields remain strongly typed in consumer code.")}"); } builder.AppendLine($"{indent}/// "); if (!string.IsNullOrWhiteSpace(objectSpec.ConstraintDocumentation)) { builder.AppendLine($"{indent}/// "); builder.AppendLine( $"{indent}/// Constraints: {EscapeXmlDocumentation(objectSpec.ConstraintDocumentation!)}."); builder.AppendLine($"{indent}/// "); } builder.AppendLine($"{indent}public sealed partial class {objectSpec.ClassName}"); builder.AppendLine($"{indent}{{"); for (var index = 0; index < objectSpec.Properties.Count; index++) { var property = objectSpec.Properties[index]; AppendPropertyDocumentation(builder, property, indentationLevel + 1); var propertyIndent = new string(' ', (indentationLevel + 1) * 4); builder.Append( $"{propertyIndent}public {property.TypeSpec.ClrType} {property.PropertyName} {{ get; set; }}"); if (!string.IsNullOrEmpty(property.TypeSpec.Initializer)) { builder.Append(property.TypeSpec.Initializer); } builder.AppendLine(); builder.AppendLine(); } var nestedTypes = CollectNestedTypes(objectSpec.Properties).ToArray(); for (var index = 0; index < nestedTypes.Length; index++) { var nestedType = nestedTypes[index]; AppendObjectType( builder, nestedType, fileName, nestedType.Title, nestedType.Description, isRoot: false, indentationLevel: indentationLevel + 1); if (index < nestedTypes.Length - 1) { builder.AppendLine(); } } builder.AppendLine($"{indent}}}"); } /// /// 枚举一个对象直接拥有的嵌套类型。 /// /// 对象属性集合。 /// 嵌套对象类型序列。 private static IEnumerable CollectNestedTypes(IEnumerable properties) { foreach (var property in properties) { if (property.TypeSpec.Kind == SchemaNodeKind.Object && property.TypeSpec.NestedObject is not null) { yield return property.TypeSpec.NestedObject; continue; } if (property.TypeSpec.Kind == SchemaNodeKind.Array && property.TypeSpec.ItemTypeSpec?.Kind == SchemaNodeKind.Object && property.TypeSpec.ItemTypeSpec.NestedObject is not null) { yield return property.TypeSpec.ItemTypeSpec.NestedObject; } } } /// /// 为生成属性输出 XML 文档。 /// /// 输出缓冲区。 /// 属性模型。 /// 缩进层级。 private static void AppendPropertyDocumentation( StringBuilder builder, SchemaPropertySpec property, int indentationLevel) { var indent = new string(' ', indentationLevel * 4); builder.AppendLine($"{indent}/// "); builder.AppendLine( $"{indent}/// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property path '{property.DisplayPath}'.")}"); builder.AppendLine($"{indent}/// "); builder.AppendLine($"{indent}/// "); builder.AppendLine( $"{indent}/// Schema property path: '{EscapeXmlDocumentation(property.DisplayPath)}'."); if (!string.IsNullOrWhiteSpace(property.Title)) { builder.AppendLine( $"{indent}/// Display title: '{EscapeXmlDocumentation(property.Title!)}'."); } if (!string.IsNullOrWhiteSpace(property.TypeSpec.EnumDocumentation)) { builder.AppendLine( $"{indent}/// Allowed values: {EscapeXmlDocumentation(property.TypeSpec.EnumDocumentation!)}."); } if (!string.IsNullOrWhiteSpace(property.TypeSpec.ConstraintDocumentation)) { builder.AppendLine( $"{indent}/// Constraints: {EscapeXmlDocumentation(property.TypeSpec.ConstraintDocumentation!)}."); } if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName)) { builder.AppendLine( $"{indent}/// References config table: '{EscapeXmlDocumentation(property.TypeSpec.RefTableName!)}'."); } var itemConstraintDocumentation = property.TypeSpec.ItemTypeSpec?.ConstraintDocumentation; if (property.TypeSpec.Kind == SchemaNodeKind.Array && !string.IsNullOrWhiteSpace(itemConstraintDocumentation)) { builder.AppendLine( $"{indent}/// Item constraints: {EscapeXmlDocumentation(itemConstraintDocumentation!)}."); } if (!string.IsNullOrWhiteSpace(property.TypeSpec.Initializer)) { builder.AppendLine( $"{indent}/// Generated default initializer: {EscapeXmlDocumentation(property.TypeSpec.Initializer!.Trim())}"); } builder.AppendLine($"{indent}/// "); } /// /// 将 schema 字段名转换并验证为生成代码可直接使用的属性标识符。 /// 生成器会在这里拒绝无法映射为合法 C# 标识符的外部输入,避免生成源码后才在编译阶段失败。 /// /// Schema 文件路径。 /// 逻辑字段路径。 /// Schema 原始字段名。 /// 生成后的属性名。 /// 字段名非法时生成的诊断。 /// 是否成功生成合法属性标识符。 private static bool TryBuildPropertyIdentifier( string filePath, string displayPath, string schemaName, out string propertyName, out Diagnostic? diagnostic) { propertyName = ToPascalCase(schemaName); if (SyntaxFacts.IsValidIdentifier(propertyName)) { diagnostic = null; return true; } diagnostic = Diagnostic.Create( ConfigSchemaDiagnostics.InvalidGeneratedIdentifier, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, schemaName, propertyName); return false; } /// /// 从 schema 文件路径提取实体基础名。 /// /// 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 相对路径。 /// 生成器优先保留 `schemas/` 目录以下的相对路径,以便消费端默认约定和 MSBuild AdditionalFiles 约定保持一致。 /// /// Schema 文件路径。 /// 用于运行时注册的 schema 相对路径。 private static string GetSchemaRelativePath(string path) { var normalizedPath = path.Replace('\\', '/'); const string rootMarker = "schemas/"; const string nestedMarker = "/schemas/"; if (normalizedPath.StartsWith(rootMarker, StringComparison.OrdinalIgnoreCase)) { return normalizedPath; } var nestedMarkerIndex = normalizedPath.LastIndexOf(nestedMarker, StringComparison.OrdinalIgnoreCase); if (nestedMarkerIndex >= 0) { return normalizedPath.Substring(nestedMarkerIndex + 1); } return $"schemas/{Path.GetFileName(path)}"; } /// /// 将 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); } /// /// 将 PascalCase 标识符转换为 camelCase 字段名。 /// /// PascalCase 标识符。 /// 适合作为私有字段名的 camelCase 标识符。 private static string ToCamelCase(string value) { if (string.IsNullOrEmpty(value)) { return value; } if (value.Length == 1) { return char.ToLowerInvariant(value[0]).ToString(); } return char.ToLowerInvariant(value[0]) + value.Substring(1); } /// /// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。 /// /// Schema 字段路径。 /// 稳定的成员名。 private static string BuildReferenceMemberName(string displayPath) { var segments = displayPath.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); var builder = new StringBuilder(); foreach (var segment in segments) { var normalizedSegment = segment .Replace("[]", "Items") .Replace("[", " ") .Replace("]", " "); builder.Append(ToPascalCase(normalizedSegment)); } return builder.Length == 0 ? "Reference" : builder.ToString(); } /// /// 为 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 节点。 /// 元数据字段名。 /// 非空字符串值;不存在时返回空。 private static string? TryGetMetadataString(JsonElement element, string propertyName) { if (!element.TryGetProperty(propertyName, out var metadataElement) || metadataElement.ValueKind != JsonValueKind.String) { return null; } var value = metadataElement.GetString(); return string.IsNullOrWhiteSpace(value) ? null : value; } /// /// 创建统一格式的无效查询索引元数据诊断。 /// /// Schema 文件路径。 /// 逻辑字段路径。 /// 具体失败原因。 /// 稳定的查询索引诊断。 private static Diagnostic CreateInvalidLookupIndexDiagnostic( string filePath, string displayPath, string reason) { return Diagnostic.Create( ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), displayPath, LookupIndexMetadataKey, reason); } /// /// 读取布尔元数据。 /// /// Schema 节点。 /// 元数据字段名。 /// 布尔元数据值;不存在时返回空。 private static (bool? Value, string? Diagnostic) TryGetMetadataBoolean(JsonElement element, string propertyName) { if (!element.TryGetProperty(propertyName, out var metadataElement)) { return (null, null); } if (metadataElement.ValueKind != JsonValueKind.True && metadataElement.ValueKind != JsonValueKind.False) { return (null, $"Expected a JSON boolean but found '{metadataElement.ValueKind}'."); } return (metadataElement.GetBoolean(), null); } /// /// 判断某个已支持的索引标量映射是否需要在查询辅助中生成空值守卫。 /// 这里必须显式枚举所有已支持的 schema 标量类型,避免未来新增引用类型标量时静默漏掉空检查。 /// /// 生成字段的标量类型模型。 /// 需要在生成的索引查询辅助中保护 参数时返回 true;否则返回 false /// 当前受支持的标量映射未被完整分类时抛出。 private static bool RequiresIndexedLookupNullGuard(SchemaTypeSpec typeSpec) { return typeSpec.SchemaType switch { "integer" => false, "number" => false, "boolean" => false, "string" => true, _ => throw new InvalidOperationException( $"Indexed lookup null-guard classification does not cover schema scalar type '{typeSpec.SchemaType}' mapped to '{typeSpec.ClrType}'.") }; } /// /// 解析 schema 顶层配置目录元数据。 /// /// Schema 文件路径。 /// Schema 顶层节点。 /// 默认配置目录。 /// 最终使用的配置目录或诊断。 private static (string? Path, Diagnostic? Diagnostic) ResolveConfigRelativePath( string filePath, JsonElement element, string defaultRelativePath) { if (!element.TryGetProperty(ConfigPathMetadataKey, out var configPathElement)) { return (defaultRelativePath, null); } if (configPathElement.ValueKind != JsonValueKind.String) { return ( null, Diagnostic.Create( ConfigSchemaDiagnostics.InvalidConfigRelativePathMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), ConfigPathMetadataKey, $"Expected a JSON string but found '{configPathElement.ValueKind}'.")); } var configuredPath = configPathElement.GetString(); if (string.IsNullOrWhiteSpace(configuredPath)) { return ( null, Diagnostic.Create( ConfigSchemaDiagnostics.InvalidConfigRelativePathMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), ConfigPathMetadataKey, "Path cannot be null, empty, or whitespace.")); } var normalizedPath = NormalizeConfigRelativePath(configuredPath!); if (normalizedPath is null) { return ( null, Diagnostic.Create( ConfigSchemaDiagnostics.InvalidConfigRelativePathMetadata, CreateFileLocation(filePath), Path.GetFileName(filePath), ConfigPathMetadataKey, "Path must be relative and cannot contain '..' segments.")); } return (normalizedPath, null); } /// /// 标准化配置目录元数据,统一斜杠并拒绝逃逸配置根目录的写法。 /// /// Schema 中声明的相对目录。 /// 标准化后的相对目录;无效时返回空。 private static string? NormalizeConfigRelativePath(string configuredPath) { var normalizedPath = configuredPath.Replace('\\', '/').Trim(); if (string.IsNullOrWhiteSpace(normalizedPath) || normalizedPath.StartsWith("/", StringComparison.Ordinal) || Path.IsPathRooted(normalizedPath)) { return null; } var normalizedSegments = new List(); foreach (var segment in normalizedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)) { if (string.Equals(segment, ".", StringComparison.Ordinal)) { continue; } if (string.Equals(segment, "..", StringComparison.Ordinal)) { return null; } normalizedSegments.Add(segment); } return normalizedSegments.Count == 0 ? null : string.Join("/", normalizedSegments); } /// /// 为标量字段构建可直接生成到属性上的默认值初始化器。 /// /// Schema 节点。 /// 标量类型。 /// 初始化器源码;不兼容时返回空。 private static string? TryBuildScalarInitializer(JsonElement element, string schemaType) { if (!element.TryGetProperty("default", out var defaultElement)) { return null; } return schemaType switch { "integer" when defaultElement.ValueKind == JsonValueKind.Number && defaultElement.TryGetInt64(out var intValue) => $" = {intValue.ToString(CultureInfo.InvariantCulture)};", "number" when defaultElement.ValueKind == JsonValueKind.Number => $" = {defaultElement.GetDouble().ToString(CultureInfo.InvariantCulture)};", "boolean" when defaultElement.ValueKind == JsonValueKind.True => " = true;", "boolean" when defaultElement.ValueKind == JsonValueKind.False => " = false;", "string" when defaultElement.ValueKind == JsonValueKind.String => $" = {SymbolDisplay.FormatLiteral(defaultElement.GetString() ?? string.Empty, true)};", _ => null }; } /// /// 为标量数组构建默认值初始化器。 /// /// Schema 节点。 /// 元素类型。 /// 元素 CLR 类型。 /// 初始化器源码;不兼容时返回空。 private static string? TryBuildArrayInitializer(JsonElement element, string itemType, string itemClrType) { if (!element.TryGetProperty("default", out var defaultElement) || defaultElement.ValueKind != JsonValueKind.Array) { return null; } var items = new List(); foreach (var item in defaultElement.EnumerateArray()) { var literal = itemType switch { "integer" when item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var intValue) => intValue.ToString(CultureInfo.InvariantCulture), "number" when item.ValueKind == JsonValueKind.Number => item.GetDouble().ToString(CultureInfo.InvariantCulture), "boolean" when item.ValueKind == JsonValueKind.True => "true", "boolean" when item.ValueKind == JsonValueKind.False => "false", "string" when item.ValueKind == JsonValueKind.String => SymbolDisplay.FormatLiteral(item.GetString() ?? string.Empty, true), _ => string.Empty }; if (string.IsNullOrEmpty(literal)) { return null; } items.Add(literal); } return $" = new {itemClrType}[] {{ {string.Join(", ", items)} }};"; } /// /// 将 enum 值整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 当前 schema 类型。 /// 格式化后的枚举说明。 private static string? TryBuildEnumDocumentation(JsonElement element, string schemaType) { if (!element.TryGetProperty("enum", out var enumElement) || enumElement.ValueKind != JsonValueKind.Array) { return null; } var values = new List(); foreach (var item in enumElement.EnumerateArray()) { var displayValue = schemaType switch { "integer" when item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var intValue) => intValue.ToString(CultureInfo.InvariantCulture), "number" when item.ValueKind == JsonValueKind.Number => item.GetDouble().ToString(CultureInfo.InvariantCulture), "boolean" when item.ValueKind == JsonValueKind.True => "true", "boolean" when item.ValueKind == JsonValueKind.False => "false", "string" when item.ValueKind == JsonValueKind.String => item.GetString(), "array" when item.ValueKind == JsonValueKind.Array => item.GetRawText(), "object" when item.ValueKind == JsonValueKind.Object => item.GetRawText(), _ => null }; if (!string.IsNullOrWhiteSpace(displayValue)) { values.Add(displayValue!); } } return values.Count > 0 ? string.Join(", ", values) : null; } /// /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains 与对象属性数量约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 /// 格式化后的约束说明。 private static string? TryBuildConstraintDocumentation(JsonElement element, string schemaType) { var parts = new List(); var constDocumentation = TryBuildConstDocumentation(element, schemaType); if (constDocumentation is not null) { parts.Add($"const = {constDocumentation}"); } if ((schemaType == "integer" || schemaType == "number") && TryGetFiniteNumber(element, "minimum", out var minimum)) { parts.Add($"minimum = {minimum.ToString(CultureInfo.InvariantCulture)}"); } if ((schemaType == "integer" || schemaType == "number") && TryGetFiniteNumber(element, "exclusiveMinimum", out var exclusiveMinimum)) { parts.Add($"exclusiveMinimum = {exclusiveMinimum.ToString(CultureInfo.InvariantCulture)}"); } if ((schemaType == "integer" || schemaType == "number") && TryGetFiniteNumber(element, "maximum", out var maximum)) { parts.Add($"maximum = {maximum.ToString(CultureInfo.InvariantCulture)}"); } if ((schemaType == "integer" || schemaType == "number") && TryGetFiniteNumber(element, "exclusiveMaximum", out var exclusiveMaximum)) { parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}"); } if ((schemaType == "integer" || schemaType == "number") && TryGetFiniteNumber(element, "multipleOf", out var multipleOf) && multipleOf > 0d) { parts.Add($"multipleOf = {multipleOf.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "string" && TryGetNonNegativeInt32(element, "minLength", out var minLength)) { parts.Add($"minLength = {minLength.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "string" && TryGetNonNegativeInt32(element, "maxLength", out var maxLength)) { parts.Add($"maxLength = {maxLength.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "string" && element.TryGetProperty("pattern", out var patternElement) && patternElement.ValueKind == JsonValueKind.String) { parts.Add($"pattern = '{patternElement.GetString() ?? string.Empty}'"); } if (schemaType == "string" && element.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String) { var formatName = formatElement.GetString() ?? string.Empty; if (IsSupportedStringFormat(formatName)) { parts.Add($"format = '{formatName}'"); } } if (schemaType == "array" && TryGetNonNegativeInt32(element, "minItems", out var minItems)) { parts.Add($"minItems = {minItems.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "array" && TryGetNonNegativeInt32(element, "maxItems", out var maxItems)) { parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "array" && element.TryGetProperty("uniqueItems", out var uniqueItemsElement) && uniqueItemsElement.ValueKind == JsonValueKind.True) { parts.Add("uniqueItems = true"); } if (schemaType == "array") { var containsDocumentation = TryBuildContainsDocumentation(element); if (containsDocumentation is not null) { parts.Add($"contains = {containsDocumentation}"); } } var notDocumentation = TryBuildNotDocumentation(element); if (notDocumentation is not null) { parts.Add($"not = {notDocumentation}"); } if (schemaType == "array" && TryGetNonNegativeInt32(element, "minContains", out var minContains)) { parts.Add($"minContains = {minContains.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "array" && TryGetNonNegativeInt32(element, "maxContains", out var maxContains)) { parts.Add($"maxContains = {maxContains.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "object" && TryGetNonNegativeInt32(element, "minProperties", out var minProperties)) { parts.Add($"minProperties = {minProperties.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "object" && TryGetNonNegativeInt32(element, "maxProperties", out var maxProperties)) { parts.Add($"maxProperties = {maxProperties.ToString(CultureInfo.InvariantCulture)}"); } if (schemaType == "object") { var dependentRequiredDocumentation = TryBuildDependentRequiredDocumentation(element); if (dependentRequiredDocumentation is not null) { parts.Add($"dependentRequired = {dependentRequiredDocumentation}"); } } return parts.Count > 0 ? string.Join(", ", parts) : null; } /// /// 将对象 dependentRequired 依赖关系整理成 XML 文档可读字符串。 /// /// 对象 schema 节点。 /// 格式化后的 dependentRequired 说明。 private static string? TryBuildDependentRequiredDocumentation(JsonElement element) { if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement) || dependentRequiredElement.ValueKind != JsonValueKind.Object) { return null; } var parts = new List(); foreach (var dependency in dependentRequiredElement.EnumerateObject()) { if (dependency.Value.ValueKind != JsonValueKind.Array) { continue; } var targets = dependency.Value .EnumerateArray() .Where(static item => item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString())) .Select(static item => item.GetString()!) .Distinct(StringComparer.Ordinal) .ToArray(); if (targets.Length == 0) { continue; } parts.Add($"{dependency.Name} => [{string.Join(", ", targets)}]"); } return parts.Count > 0 ? $"{{ {string.Join("; ", parts)} }}" : null; } /// /// 将数组 contains 子 schema 整理成 XML 文档可读字符串。 /// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。 /// /// 数组 schema 节点。 /// 格式化后的 contains 说明。 private static string? TryBuildContainsDocumentation(JsonElement element) { if (!element.TryGetProperty("contains", out var containsElement) || containsElement.ValueKind != JsonValueKind.Object) { return null; } return TryBuildInlineSchemaSummary(containsElement); } /// /// 将 not 子 schema 整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 格式化后的 not 说明。 private static string? TryBuildNotDocumentation(JsonElement element) { if (!element.TryGetProperty("not", out var notElement) || notElement.ValueKind != JsonValueKind.Object) { return null; } return TryBuildInlineSchemaSummary(notElement); } /// /// 为内联子 schema 生成紧凑摘要。 /// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains / not 与主属性文档逐渐漂移。 /// /// 内联子 schema。 /// 格式化后的摘要字符串。 private static string? TryBuildInlineSchemaSummary(JsonElement schemaElement) { if (!schemaElement.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) { return null; } var schemaType = typeElement.GetString(); if (string.IsNullOrWhiteSpace(schemaType)) { return null; } var details = new List(); var enumDocumentation = TryBuildEnumDocumentation(schemaElement, schemaType!); if (enumDocumentation is not null) { details.Add($"enum = {enumDocumentation}"); } var constraintDocumentation = TryBuildConstraintDocumentation(schemaElement, schemaType!); if (constraintDocumentation is not null) { details.Add(constraintDocumentation); } var refTable = TryGetMetadataString(schemaElement, "x-gframework-ref-table"); if (!string.IsNullOrWhiteSpace(refTable)) { details.Add($"ref-table = {refTable}"); } return details.Count == 0 ? schemaType : $"{schemaType} ({string.Join(", ", details)})"; } /// /// 将 const 值整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 当前 schema 类型。 /// 格式化后的常量说明。 private static string? TryBuildConstDocumentation(JsonElement element, string schemaType) { if (!element.TryGetProperty("const", out var constElement)) { return null; } return schemaType switch { "integer" when constElement.ValueKind == JsonValueKind.Number && constElement.TryGetInt64(out var intValue) => intValue.ToString(CultureInfo.InvariantCulture), "number" when constElement.ValueKind == JsonValueKind.Number => constElement.GetDouble().ToString(CultureInfo.InvariantCulture), "boolean" when constElement.ValueKind == JsonValueKind.True => "true", "boolean" when constElement.ValueKind == JsonValueKind.False => "false", // Preserve the exact JSON literal so empty strings and other string-shaped constants // remain unambiguous in generated XML documentation. "string" when constElement.ValueKind == JsonValueKind.String => constElement.GetRawText(), "array" when constElement.ValueKind == JsonValueKind.Array => constElement.GetRawText(), "object" when constElement.ValueKind == JsonValueKind.Object => constElement.GetRawText(), _ => null }; } /// /// 读取有限数值元数据。 /// /// Schema 节点。 /// 元数据名称。 /// 读取到的数值。 /// 是否读取成功。 private static bool TryGetFiniteNumber( JsonElement element, string propertyName, out double value) { value = default; return element.TryGetProperty(propertyName, out var metadataElement) && metadataElement.ValueKind == JsonValueKind.Number && metadataElement.TryGetDouble(out value) && !double.IsNaN(value) && !double.IsInfinity(value); } /// /// 读取非负整数元数据。 /// /// Schema 节点。 /// 元数据名称。 /// 读取到的整数值。 /// 是否读取成功。 private static bool TryGetNonNegativeInt32( JsonElement element, string propertyName, out int value) { value = default; return element.TryGetProperty(propertyName, out var metadataElement) && metadataElement.ValueKind == JsonValueKind.Number && metadataElement.TryGetInt32(out value) && value >= 0; } /// /// 组合逻辑字段路径。 /// /// 父路径。 /// 当前属性名。 /// 组合后的路径。 private static string CombinePath(string parentPath, string propertyName) { return parentPath == "" ? propertyName : $"{parentPath}.{propertyName}"; } /// /// 转义 XML 文档文本。 /// /// 原始字符串。 /// 已转义的字符串。 private static string EscapeXmlDocumentation(string value) { return value .Replace("&", "&") .Replace("<", "<") .Replace(">", ">"); } /// /// 解析结果包装。 /// /// 解析出的 schema。 /// 生成过程中收集的诊断。 private sealed record SchemaParseResult( SchemaFileSpec? Schema, IReadOnlyList Diagnostics) { public static SchemaParseResult FromSchema(SchemaFileSpec schema) { return new SchemaParseResult(schema, Array.Empty()); } public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic) { return new SchemaParseResult(null, new[] { diagnostic }); } } /// /// 对象解析结果包装。 /// /// 解析出的对象类型。 /// 错误诊断。 private sealed record ParsedObjectResult( SchemaObjectSpec? Object, Diagnostic? Diagnostic) { public static ParsedObjectResult FromObject(SchemaObjectSpec schemaObject) { return new ParsedObjectResult(schemaObject, null); } public static ParsedObjectResult FromDiagnostic(Diagnostic diagnostic) { return new ParsedObjectResult(null, diagnostic); } } /// /// 生成器级 schema 模型。 /// /// Schema 文件名。 /// 实体名基础标识。 /// 根配置类型名。 /// 配置表包装类型名。 /// 目标命名空间。 /// 主键 CLR 类型。 /// 生成配置类型中的主键属性名。 /// 运行时注册名。 /// 配置目录相对路径。 /// Schema 文件相对路径。 /// 根标题元数据。 /// 根描述元数据。 /// 根对象模型。 private sealed record SchemaFileSpec( string FileName, string EntityName, string ClassName, string TableName, string Namespace, string KeyClrType, string KeyPropertyName, string TableRegistrationName, string ConfigRelativePath, string SchemaRelativePath, string? Title, string? Description, SchemaObjectSpec RootObject); /// /// 生成器内部的对象类型模型。 /// /// 对象字段路径。 /// 要生成的 CLR 类型名。 /// 对象标题元数据。 /// 对象描述元数据。 /// 对象约束说明。 /// 对象属性集合。 private sealed record SchemaObjectSpec( string DisplayPath, string ClassName, string? Title, string? Description, string? ConstraintDocumentation, IReadOnlyList Properties); /// /// 单个配置属性模型。 /// /// Schema 原始字段名。 /// 逻辑字段路径。 /// CLR 属性名。 /// 是否必填。 /// 字段标题元数据。 /// 字段描述元数据。 /// 是否声明生成只读精确匹配索引。 /// 字段类型模型。 private sealed record SchemaPropertySpec( string SchemaName, string DisplayPath, string PropertyName, bool IsRequired, string? Title, string? Description, bool IsIndexedLookup, SchemaTypeSpec TypeSpec); /// /// 类型模型,覆盖标量、对象和数组。 /// /// 节点种类。 /// Schema 类型名。 /// CLR 类型名。 /// 属性初始化器。 /// 枚举文档说明。 /// 范围或长度约束说明。 /// 目标引用表名称。 /// 对象节点对应的嵌套类型。 /// 数组元素类型模型。 private sealed record SchemaTypeSpec( SchemaNodeKind Kind, string SchemaType, string ClrType, string? Initializer, string? EnumDocumentation, string? ConstraintDocumentation, string? RefTableName, SchemaObjectSpec? NestedObject, SchemaTypeSpec? ItemTypeSpec); /// /// 生成代码前的跨表引用字段种子信息。 /// /// Schema 字段路径。 /// 目标表名称。 /// 引用值的标量 schema 类型。 /// 是否为数组引用。 private sealed record GeneratedReferenceSeed( string DisplayPath, string ReferencedTableName, string ValueSchemaType, bool IsCollection); /// /// 已分配稳定成员名的生成期跨表引用信息。 /// /// 生成到绑定类中的成员名。 /// Schema 字段路径。 /// 目标表名称。 /// 引用值的标量 schema 类型。 /// 是否为数组引用。 private sealed record GeneratedReferenceSpec( string MemberName, string DisplayPath, string ReferencedTableName, string ValueSchemaType, bool IsCollection); /// /// 属性解析结果包装。 /// /// 解析出的属性模型。 /// 错误诊断。 private sealed record ParsedPropertyResult( SchemaPropertySpec? Property, Diagnostic? Diagnostic) { public static ParsedPropertyResult FromProperty(SchemaPropertySpec property) { return new ParsedPropertyResult(property, null); } public static ParsedPropertyResult FromDiagnostic(Diagnostic diagnostic) { return new ParsedPropertyResult(null, diagnostic); } } /// /// 类型节点种类。 /// private enum SchemaNodeKind { Scalar, Object, Array } }