const { joinArrayIndexPath, joinArrayTemplatePath, joinPropertyPath, splitObjectPath } = require("./configPath"); const {ValidationMessageKeys} = require("./localizationKeys"); const IntegerScalarPattern = /^[+-]?\d+$/u; const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u; const BooleanScalarPattern = /^(true|false)$/iu; /** * Compare two strings using the same UTF-16 code-unit ordering as C#'s * string.CompareOrdinal so tooling stays aligned with the runtime. * * @param {string} left Left operand. * @param {string} right Right operand. * @returns {number} Negative when left < right, positive when left > right, zero when equal. */ function compareStringsOrdinal(left, right) { if (left === right) { return 0; } return left < right ? -1 : 1; } /** * Parse the repository's minimal config-schema subset into a recursive tree. * The parser intentionally mirrors the same high-level contract used by the * runtime validator and source generator so tooling diagnostics stay aligned. * * @param {string} content Raw schema JSON text. * @throws {Error} Thrown when the schema declares one unsupported or invalid pattern string. * @returns {{ * type: "object", * required: string[], * properties: Record, * minProperties?: number, * maxProperties?: number * }} Parsed schema info. */ function parseSchemaContent(content) { const parsed = JSON.parse(content); return parseSchemaNode(parsed, ""); } /** * Collect top-level schema fields that the current batch editor can update * safely. Batch editing intentionally remains conservative even though the form * preview can now navigate nested object structures. * * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. * @returns {Array<{ * key: string, * path: string, * type: string, * itemType?: string, * title?: string, * description?: string, * defaultValue?: string, * enumValues?: string[], * itemEnumValues?: string[], * refTable?: string, * inputKind: "scalar" | "array", * required: boolean * }>} Editable field descriptors. */ function getEditableSchemaFields(schemaInfo) { const editableFields = []; const requiredSet = new Set(Array.isArray(schemaInfo.required) ? schemaInfo.required : []); for (const [key, property] of Object.entries(schemaInfo.properties || {})) { if (isEditableScalarType(property.type)) { editableFields.push({ key, path: key, type: property.type, title: property.title, description: property.description, defaultValue: property.defaultValue, enumValues: property.enumValues, refTable: property.refTable, inputKind: "scalar", required: requiredSet.has(key) }); continue; } if (property.type === "array" && property.items && isEditableScalarType(property.items.type)) { editableFields.push({ key, path: key, type: property.type, itemType: property.items.type, title: property.title, description: property.description, defaultValue: property.defaultValue, itemEnumValues: property.items.enumValues, refTable: property.refTable, inputKind: "array", required: requiredSet.has(key) }); } } return editableFields.sort((left, right) => compareStringsOrdinal(left.key, right.key)); } /** * Parse YAML into a recursive object/array/scalar tree. * The parser covers the config system's intended subset: root mappings, * indentation-based nested objects, scalar arrays, and arrays of objects. * * @param {string} text YAML text. * @returns {YamlNode} Parsed YAML tree. */ function parseTopLevelYaml(text) { const tokens = tokenizeYaml(text); if (tokens.length === 0) { return createObjectNode(); } const state = {index: 0}; return parseBlock(tokens, state, tokens[0].indent); } /** * Extract comment text from a YAML document and map it to logical field paths. * The extractor focuses on comment lines that appear immediately above one key * or array item so the form preview can surface author intent near the field. * * @param {string} text YAML text. * @returns {Record} Comment lookup keyed by logical path. */ function extractYamlComments(text) { const lines = String(text).split(/\r?\n/u); const comments = {}; const stack = [{indent: -1, type: "object", path: "", nextIndex: 0}]; let pendingComments = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const trimmed = line.trim(); if (trimmed.length === 0) { pendingComments = []; continue; } const indent = countLeadingSpaces(line); if (trimmed.startsWith("#")) { pendingComments.push(trimmed.replace(/^#\s?/u, "")); continue; } while (stack.length > 1 && indent < stack[stack.length - 1].indent) { stack.pop(); } const currentContext = stack[stack.length - 1]; if (trimmed.startsWith("-")) { if (currentContext.type !== "array") { pendingComments = []; continue; } const itemIndex = currentContext.nextIndex || 0; currentContext.nextIndex = itemIndex + 1; const itemPath = joinArrayIndexPath(currentContext.path, itemIndex); assignPendingComments(comments, itemPath, pendingComments); pendingComments = []; const rest = trimmed.slice(1).trim(); if (rest.length === 0) { const nextLine = findNextMeaningfulLine(lines, index + 1); if (nextLine && nextLine.indent > indent) { stack.push(createContextForChild(itemPath, nextLine)); } continue; } const inlineObjectMapping = parseYamlMappingText(rest); if (!inlineObjectMapping) { continue; } const itemObjectContext = {indent: indent + 2, type: "object", path: itemPath, nextIndex: 0}; stack.push(itemObjectContext); const key = inlineObjectMapping.key; const parsedValue = splitYamlValueAndInlineComment(inlineObjectMapping.rawValue.trim()); if (parsedValue.comment) { comments[joinPropertyPath(itemPath, key)] = parsedValue.comment; } const nextLine = findNextMeaningfulLine(lines, index + 1); if (parsedValue.value.length === 0 && nextLine && nextLine.indent > indent) { stack.push(createContextForChild(joinPropertyPath(itemPath, key), nextLine)); } continue; } const mapping = parseYamlMappingText(trimmed); if (!mapping) { pendingComments = []; continue; } const key = mapping.key; const valueInfo = splitYamlValueAndInlineComment(mapping.rawValue.trim()); const currentPath = joinPropertyPath(currentContext.path, key); assignPendingComments(comments, currentPath, pendingComments); pendingComments = []; if (valueInfo.comment) { comments[currentPath] = comments[currentPath] ? `${comments[currentPath]}\n${valueInfo.comment}` : valueInfo.comment; } const nextLine = findNextMeaningfulLine(lines, index + 1); if (valueInfo.value.length === 0 && nextLine && nextLine.indent > indent) { stack.push(createContextForChild(currentPath, nextLine)); } } return comments; } /** * Create one example YAML config from a parsed schema tree. * The sample includes schema descriptions as YAML comments so empty files can * be bootstrapped into a readable starting point from the form preview. * * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. * @returns {string} Example YAML text. */ function createSampleConfigYaml(schemaInfo) { const sampleRoot = createSampleNodeFromSchema(schemaInfo); const schemaComments = {}; collectSchemaComments(schemaInfo, "", schemaComments); return renderYaml(sampleRoot, 0, "", schemaComments).join("\n"); } /** * Produce extension-facing validation diagnostics from schema and parsed YAML. * * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. * @param {YamlNode} parsedYaml Parsed YAML tree. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. * @returns {Array<{severity: "error" | "warning", message: string}>} Validation diagnostics. */ function validateParsedConfig(schemaInfo, parsedYaml, localizer) { const diagnostics = []; validateNode(schemaInfo, parsedYaml, "", diagnostics, localizer); return diagnostics; } /** * Determine whether the current schema type can be edited through the batch * editor. The richer form preview handles nested objects separately. * * @param {string} schemaType Schema type. * @returns {boolean} True when the type is batch-editable. */ function isEditableScalarType(schemaType) { return schemaType === "string" || schemaType === "integer" || schemaType === "number" || schemaType === "boolean"; } /** * Determine whether a scalar value matches a minimal schema type. * * @param {string} expectedType Schema type. * @param {string} scalarValue YAML scalar value. * @returns {boolean} True when compatible. */ function isScalarCompatible(expectedType, scalarValue) { const value = unquoteScalar(String(scalarValue)); switch (expectedType) { case "integer": return IntegerScalarPattern.test(value); case "number": return NumberScalarPattern.test(value); case "boolean": return BooleanScalarPattern.test(value); case "string": return true; default: return true; } } /** * Apply form updates back into YAML. The implementation rewrites the YAML tree * from the parsed structure so nested object edits can be saved safely. * * @param {string} originalYaml Original YAML content. * @param {{scalars?: Record, arrays?: Record, objectArrays?: Record>>, comments?: Record}} updates Updated form values. * @returns {string} Updated YAML content. */ function applyFormUpdates(originalYaml, updates) { const root = normalizeRootNode(parseTopLevelYaml(originalYaml)); const preservedComments = extractYamlComments(originalYaml); const scalarUpdates = updates.scalars || {}; const arrayUpdates = updates.arrays || {}; const objectArrayUpdates = updates.objectArrays || {}; const commentUpdates = updates.comments || {}; for (const [path, value] of Object.entries(scalarUpdates)) { setNodeAtPath(root, splitObjectPath(path), createScalarNode(String(value))); } for (const [path, values] of Object.entries(arrayUpdates)) { setNodeAtPath(root, splitObjectPath(path), createArrayNode( (values || []).map((item) => createScalarNode(String(item))))); } for (const [path, items] of Object.entries(objectArrayUpdates)) { setNodeAtPath(root, splitObjectPath(path), createArrayNode( (items || []).map((item) => createNodeFromFormValue(item)))); } for (const [path, comment] of Object.entries(commentUpdates)) { const normalizedComment = String(comment || "").trim(); if (normalizedComment.length === 0) { delete preservedComments[path]; continue; } preservedComments[path] = normalizedComment; } return renderYaml(root, 0, "", preservedComments).join("\n"); } /** * Apply only scalar updates back into YAML. * * @param {string} originalYaml Original YAML content. * @param {Record} updates Updated scalar values. * @returns {string} Updated YAML content. */ function applyScalarUpdates(originalYaml, updates) { return applyFormUpdates(originalYaml, {scalars: updates}); } /** * Parse the batch editor's comma-separated array input. * * @param {string} value Raw input value. * @returns {string[]} Parsed array items. */ function parseBatchArrayValue(value) { return String(value) .split(",") .map((item) => item.trim()) .filter((item) => item.length > 0); } /** * Normalize a schema enum array into string values that can be shown in UI * hints and compared against parsed YAML scalar content. * * @param {unknown} value Raw schema enum value. * @returns {string[] | undefined} Normalized enum values. */ function normalizeSchemaEnumValues(value) { if (!Array.isArray(value)) { return undefined; } const normalized = value .filter((item) => ["string", "number", "boolean"].includes(typeof item)) .map((item) => String(item)); return normalized.length > 0 ? normalized : undefined; } /** * Normalize one finite schema number for tooling metadata and comparisons. * * @param {unknown} value Raw schema value. * @returns {number | undefined} Normalized finite number. */ function normalizeSchemaNumber(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } /** * Normalize one strictly positive finite schema number. * * @param {unknown} value Raw schema value. * @returns {number | undefined} Normalized positive number. */ function normalizeSchemaPositiveNumber(value) { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; } /** * Normalize one non-negative integer schema value for length constraints. * * @param {unknown} value Raw schema value. * @returns {number | undefined} Normalized non-negative integer. */ function normalizeSchemaNonNegativeInteger(value) { return Number.isInteger(value) && value >= 0 ? value : undefined; } /** * Normalize one boolean schema flag. * * @param {unknown} value Raw schema value. * @returns {boolean | undefined} Normalized boolean. */ function normalizeSchemaBoolean(value) { return typeof value === "boolean" ? value : undefined; } /** * Normalize one schema pattern string when the regular expression can be * compiled by the local tooling runtime. * * @param {unknown} value Raw schema value. * @param {string} displayPath Logical property path used in diagnostics. * @throws {Error} Thrown when the pattern string cannot be compiled. * @returns {{source: string, regex: RegExp} | undefined} Normalized pattern metadata. */ function normalizeSchemaPattern(value, displayPath) { if (typeof value !== "string") { return undefined; } try { return { source: value, regex: new RegExp(value, "u") }; } catch (error) { throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); } } /** * Convert a schema default value into a compact string that can be shown in UI * metadata hints. * * @param {unknown} value Raw schema default value. * @returns {string | undefined} Display string for the default value. */ function formatSchemaDefaultValue(value) { if (value === null || value === undefined) { return undefined; } if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return String(value); } if (Array.isArray(value)) { const normalized = value .filter((item) => ["string", "number", "boolean"].includes(typeof item)) .map((item) => String(item)); return normalized.length > 0 ? normalized.join(", ") : undefined; } if (typeof value === "object") { return JSON.stringify(value); } return undefined; } /** * Convert a schema const value into the raw scalar text used by sample YAML * generation and scalar editors. * * @param {SchemaNode} schemaNode Parsed schema node. * @param {unknown} value Raw schema const value. * @returns {string | undefined} Raw scalar text, or a JSON literal fallback. */ function formatSchemaConstEditableValue(schemaNode, value) { if (value === undefined) { return undefined; } if (schemaNode.type === "string" && typeof value === "string") { return value; } if ((schemaNode.type === "integer" || schemaNode.type === "number") && typeof value === "number" && Number.isFinite(value)) { return String(value); } if (schemaNode.type === "boolean" && typeof value === "boolean") { return String(value); } return formatSchemaConstDisplayValue(value); } /** * Convert a schema const value into an exact JSON-style literal for diagnostics * and metadata hints. * * @param {unknown} value Raw schema const value. * @returns {string | undefined} Display string for the const value. */ function formatSchemaConstDisplayValue(value) { if (value === undefined) { return undefined; } if (typeof value === "string") { return JSON.stringify(value); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (value === null || Array.isArray(value) || typeof value === "object") { return JSON.stringify(value); } return undefined; } /** * Attach parsed const metadata to one schema node. * * @param {SchemaNode} schemaNode Parsed schema node. * @param {unknown} rawConst Raw schema const value. * @param {string} displayPath Logical property path. * @returns {SchemaNode} Schema node with optional const metadata. */ function applyConstMetadata(schemaNode, rawConst, displayPath) { if (rawConst === undefined) { return schemaNode; } return { ...schemaNode, constValue: formatSchemaConstEditableValue(schemaNode, rawConst), constDisplayValue: formatSchemaConstDisplayValue(rawConst), constComparableValue: buildSchemaConstComparableValue(schemaNode, rawConst, displayPath) }; } /** * Test one scalar value against one compiled schema pattern. * * @param {string} scalarValue Scalar value from YAML. * @param {RegExp | undefined} patternRegex Compiled schema pattern. * @returns {boolean} True when the value matches or no pattern is declared. */ function matchesSchemaPattern(scalarValue, patternRegex) { if (!(patternRegex instanceof RegExp)) { return true; } return patternRegex.test(scalarValue); } /** * Build one schema-normalized comparable key for a const value declared in * JSON Schema so tooling comparisons align with runtime comparisons. * * @param {SchemaNode} schemaNode Parsed schema node. * @param {unknown} rawConst Raw schema const value. * @param {string} displayPath Logical property path. * @returns {string} Comparable key. */ function buildSchemaConstComparableValue(schemaNode, rawConst, displayPath) { if (schemaNode.type === "object") { return buildSchemaConstObjectComparableValue(schemaNode, rawConst, displayPath); } if (schemaNode.type === "array") { return buildSchemaConstArrayComparableValue(schemaNode, rawConst, displayPath); } return buildSchemaConstScalarComparableValue(schemaNode, rawConst, displayPath); } /** * Build one comparable key for an object-shaped const value. * * @param {Extract} schemaNode Parsed object schema node. * @param {unknown} rawConst Raw schema const value. * @param {string} displayPath Logical property path. * @returns {string} Comparable key. */ function buildSchemaConstObjectComparableValue(schemaNode, rawConst, displayPath) { if (!rawConst || typeof rawConst !== "object" || Array.isArray(rawConst)) { throw new Error(`Schema property '${displayPath}' declares 'const', but the value is not compatible with schema type 'object'.`); } const objectEntries = []; for (const [key, value] of Object.entries(rawConst)) { if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, key)) { const childPath = joinPropertyPath(displayPath, key); throw new Error(`Schema property '${displayPath}' declares 'const', but nested property '${childPath}' is not declared in the object schema.`); } const childComparableValue = buildSchemaConstComparableValue( schemaNode.properties[key], value, joinPropertyPath(displayPath, key)); objectEntries.push([key, childComparableValue]); } objectEntries.sort((left, right) => compareStringsOrdinal(left[0], right[0])); return objectEntries.map(([key, value]) => `${key.length}:${key}=${value.length}:${value}`).join("|"); } /** * Build one comparable key for an array-shaped const value. * * @param {Extract} schemaNode Parsed array schema node. * @param {unknown} rawConst Raw schema const value. * @param {string} displayPath Logical property path. * @returns {string} Comparable key. */ function buildSchemaConstArrayComparableValue(schemaNode, rawConst, displayPath) { if (!Array.isArray(rawConst)) { throw new Error(`Schema property '${displayPath}' declares 'const', but the value is not compatible with schema type 'array'.`); } return `[${rawConst.map((item, index) => { const comparableValue = buildSchemaConstComparableValue( schemaNode.items, item, joinArrayIndexPath(displayPath, index)); return `${comparableValue.length}:${comparableValue}`; }).join(",")}]`; } /** * Build one comparable key for a scalar const value. * * @param {Extract} schemaNode Parsed scalar schema node. * @param {unknown} rawConst Raw schema const value. * @param {string} displayPath Logical property path. * @returns {string} Comparable key. */ function buildSchemaConstScalarComparableValue(schemaNode, rawConst, displayPath) { const normalizedValue = normalizeSchemaConstScalarValue(schemaNode.type, rawConst, displayPath); return `${schemaNode.type}:${normalizedValue.length}:${normalizedValue}`; } /** * Normalize one scalar const value into the same comparison format used by * parsed YAML scalar nodes. * * @param {"string" | "integer" | "number" | "boolean"} schemaType Scalar schema type. * @param {unknown} rawConst Raw schema const value. * @param {string} displayPath Logical property path. * @returns {string} Normalized scalar value. */ function normalizeSchemaConstScalarValue(schemaType, rawConst, displayPath) { switch (schemaType) { case "integer": if (typeof rawConst === "number" && Number.isInteger(rawConst)) { return String(rawConst); } break; case "number": if (typeof rawConst === "number" && Number.isFinite(rawConst)) { return String(rawConst); } break; case "boolean": if (typeof rawConst === "boolean") { return String(rawConst); } break; case "string": if (typeof rawConst === "string") { return rawConst; } break; default: break; } throw new Error(`Schema property '${displayPath}' declares 'const', but the value is not compatible with schema type '${schemaType}'.`); } /** * Test whether one numeric scalar satisfies a multipleOf constraint. * * @param {string} scalarValue YAML scalar value. * @param {number | undefined} multipleOf Schema multipleOf value. * @returns {boolean} True when compatible or the constraint is absent. */ function matchesSchemaMultipleOf(scalarValue, multipleOf) { if (typeof multipleOf !== "number") { return true; } const exactDecimalResult = tryMatchesExactDecimalMultiple(scalarValue, String(multipleOf)); if (exactDecimalResult !== null) { return exactDecimalResult; } const numericValue = Number(scalarValue); const quotient = numericValue / multipleOf; const nearestInteger = Math.round(quotient); const tolerance = 1e-9 * Math.max(1, Math.abs(quotient)); return Math.abs(quotient - nearestInteger) <= tolerance; } /** * Try to evaluate one multipleOf constraint using exact decimal arithmetic. * This keeps common YAML / JSON decimal literals aligned with the runtime and * avoids large-number false positives that a pure floating-point quotient check can miss. * * @param {string} valueText YAML scalar text. * @param {string} divisorText Schema multipleOf text. * @returns {boolean | null} Exact result, or null when the inputs cannot be normalized exactly. */ function tryMatchesExactDecimalMultiple(valueText, divisorText) { const valueParts = tryParseExactDecimal(valueText); const divisorParts = tryParseExactDecimal(divisorText); if (!valueParts || !divisorParts || divisorParts.significand === 0n) { return null; } const commonScale = Math.max(valueParts.scale, divisorParts.scale); const scaledValue = scaleDecimalSignificand(valueParts.significand, valueParts.scale, commonScale); const scaledDivisor = scaleDecimalSignificand(divisorParts.significand, divisorParts.scale, commonScale); return scaledValue % scaledDivisor === 0n; } /** * Normalize a finite decimal literal into an integer significand plus decimal scale. * The normalized form lets multipleOf checks run as integer modulo instead of floating-point math. * * @param {string} text Numeric text to normalize. * @returns {{significand: bigint, scale: number} | null} Normalized parts, or null for unsupported input. */ function tryParseExactDecimal(text) { const match = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/u.exec(String(text).trim()); if (!match) { return null; } const exponent = match[5] ? Number.parseInt(match[5], 10) : 0; if (!Number.isSafeInteger(exponent)) { return null; } const integerDigits = match[2] ?? ""; const fractionDigits = match[3] !== undefined ? match[3] : (match[4] ?? ""); let digits = `${integerDigits}${fractionDigits}`.replace(/^0+/u, ""); if (digits.length === 0) { return {significand: 0n, scale: 0}; } let scale = fractionDigits.length - exponent; if (scale < 0) { digits += "0".repeat(-scale); scale = 0; } while (scale > 0 && digits.endsWith("0")) { digits = digits.slice(0, -1); scale -= 1; } let significand = BigInt(digits); if (match[1] === "-") { significand = -significand; } return {significand, scale}; } /** * Scale one normalized decimal significand to a larger decimal precision. * * @param {bigint} significand Integer significand. * @param {number} currentScale Current decimal scale. * @param {number} targetScale Target decimal scale. * @returns {bigint} Scaled significand. */ function scaleDecimalSignificand(significand, currentScale, targetScale) { if (currentScale === targetScale) { return significand; } return significand * (10n ** BigInt(targetScale - currentScale)); } /** * Format a scalar value for YAML output. * * @param {string} value Scalar value. * @returns {string} YAML-ready scalar. */ function formatYamlScalar(value) { if (NumberScalarPattern.test(value) || BooleanScalarPattern.test(value)) { return value; } if (value.length === 0 || /[:#\[\]\{\},]|^\s|\s$/u.test(value)) { return JSON.stringify(value); } return value; } /** * Remove a simple YAML string quote wrapper. * * @param {string} value Scalar value. * @returns {string} Unquoted value. */ function unquoteScalar(value) { if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { return value.slice(1, -1); } return value; } /** * Parse one schema node recursively. * * @param {unknown} rawNode Raw schema node. * @param {string} displayPath Logical property path. * @returns {SchemaNode} Parsed schema node. */ function parseSchemaNode(rawNode, displayPath) { const value = rawNode && typeof rawNode === "object" ? rawNode : {}; const type = typeof value.type === "string" ? value.type : "object"; const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); const metadata = { title: typeof value.title === "string" ? value.title : undefined, description: typeof value.description === "string" ? value.description : undefined, defaultValue: formatSchemaDefaultValue(value.default), minimum: normalizeSchemaNumber(value.minimum), exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum), maximum: normalizeSchemaNumber(value.maximum), exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum), multipleOf: normalizeSchemaPositiveNumber(value.multipleOf), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), pattern: patternMetadata ? patternMetadata.source : undefined, patternRegex: patternMetadata ? patternMetadata.regex : undefined, minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), minContains: normalizeSchemaNonNegativeInteger(value.minContains), maxContains: normalizeSchemaNonNegativeInteger(value.maxContains), minProperties: normalizeSchemaNonNegativeInteger(value.minProperties), maxProperties: normalizeSchemaNonNegativeInteger(value.maxProperties), uniqueItems: normalizeSchemaBoolean(value.uniqueItems), refTable: typeof value["x-gframework-ref-table"] === "string" ? value["x-gframework-ref-table"] : undefined }; if (type === "object") { const required = Array.isArray(value.required) ? value.required.filter((item) => typeof item === "string") : []; const properties = {}; for (const [key, propertyNode] of Object.entries(value.properties || {})) { properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key)); } return applyConstMetadata({ type: "object", displayPath, required, properties, minProperties: metadata.minProperties, maxProperties: metadata.maxProperties, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue }, value.const, displayPath); } if (type === "array") { const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath)); const containsNode = value.contains && typeof value.contains === "object" ? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath)) : undefined; if (!containsNode && (typeof metadata.minContains === "number" || typeof metadata.maxContains === "number")) { throw new Error(`Schema property '${displayPath}' declares 'minContains' or 'maxContains' without 'contains'.`); } if (containsNode && containsNode.type === "array") { throw new Error(`Schema property '${displayPath}' uses unsupported nested array 'contains' schemas.`); } const effectiveMinContains = containsNode ? (typeof metadata.minContains === "number" ? metadata.minContains : 1) : undefined; if (containsNode && typeof metadata.maxContains === "number" && effectiveMinContains > metadata.maxContains) { throw new Error(`Schema property '${displayPath}' declares 'minContains' greater than 'maxContains'.`); } return applyConstMetadata({ type: "array", displayPath, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, minItems: metadata.minItems, maxItems: metadata.maxItems, minContains: containsNode ? metadata.minContains : undefined, maxContains: containsNode ? metadata.maxContains : undefined, uniqueItems: metadata.uniqueItems === true, refTable: metadata.refTable, contains: containsNode, items: itemNode }, value.const, displayPath); } return applyConstMetadata({ type, displayPath, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, minimum: type === "integer" || type === "number" ? metadata.minimum : undefined, exclusiveMinimum: type === "integer" || type === "number" ? metadata.exclusiveMinimum : undefined, maximum: type === "integer" || type === "number" ? metadata.maximum : undefined, exclusiveMaximum: type === "integer" || type === "number" ? metadata.exclusiveMaximum : undefined, multipleOf: type === "integer" || type === "number" ? metadata.multipleOf : undefined, minLength: type === "string" ? metadata.minLength : undefined, maxLength: type === "string" ? metadata.maxLength : undefined, pattern: type === "string" ? metadata.pattern : undefined, patternRegex: type === "string" ? metadata.patternRegex : undefined, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }, value.const, displayPath); } /** * Validate one schema node against one YAML node. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. * @param {string} displayPath Current logical path. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. */ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (schemaNode.type === "object") { validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer); return; } if (schemaNode.type === "array") { if (!yamlNode || yamlNode.kind !== "array") { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.expectedArray, localizer, { displayPath }) }); return; } if (typeof schemaNode.minItems === "number" && yamlNode.items.length < schemaNode.minItems) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.minItemsViolation, localizer, { displayPath, value: String(schemaNode.minItems) }) }); } if (typeof schemaNode.maxItems === "number" && yamlNode.items.length > schemaNode.maxItems) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.maxItemsViolation, localizer, { displayPath, value: String(schemaNode.maxItems) }) }); } const comparableItems = []; let hasInvalidArrayItems = false; for (let index = 0; index < yamlNode.items.length; index += 1) { const diagnosticsBeforeValidation = diagnostics.length; validateNode( schemaNode.items, yamlNode.items[index], joinArrayIndexPath(displayPath, index), diagnostics, localizer); // Keep uniqueItems focused on values that are otherwise valid so a // shape/type error does not also surface as a misleading duplicate. if (diagnostics.length === diagnosticsBeforeValidation) { comparableItems.push({index, node: yamlNode.items[index]}); } else { hasInvalidArrayItems = true; } } if (schemaNode.uniqueItems === true) { const seenItems = new Map(); for (const {index, node} of comparableItems) { const comparableValue = buildComparableNodeValue(schemaNode.items, node); if (seenItems.has(comparableValue)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.uniqueItemsViolation, localizer, { displayPath: joinArrayIndexPath(displayPath, index), duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue)) }) }); continue; } seenItems.set(comparableValue, index); } } if (!hasInvalidArrayItems && schemaNode.contains) { let matchingContainsCount = 0; for (const {node} of comparableItems) { if (matchesSchemaNode(schemaNode.contains, node)) { matchingContainsCount += 1; } } const requiredMinContains = typeof schemaNode.minContains === "number" ? schemaNode.minContains : 1; if (matchingContainsCount < requiredMinContains) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.minContainsViolation, localizer, { displayPath, value: String(requiredMinContains) }) }); } if (typeof schemaNode.maxContains === "number" && matchingContainsCount > schemaNode.maxContains) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.maxContainsViolation, localizer, { displayPath, value: String(schemaNode.maxContains) }) }); } } validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); return; } if (!yamlNode || yamlNode.kind !== "scalar") { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.expectedScalarShape, localizer, { displayPath, schemaType: schemaNode.type, yamlKind: yamlNode ? yamlNode.kind : "missing" }) }); return; } if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.expectedScalarValue, localizer, { displayPath, schemaType: schemaNode.type }) }); return; } if (Array.isArray(schemaNode.enumValues) && schemaNode.enumValues.length > 0 && !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.enumMismatch, localizer, { displayPath, values: schemaNode.enumValues.join(", ") }) }); } const scalarValue = unquoteScalar(yamlNode.value); const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number"; const supportsLengthConstraints = schemaNode.type === "string"; const supportsPatternConstraints = schemaNode.type === "string"; if (supportsNumericConstraints && typeof schemaNode.minimum === "number" && Number(scalarValue) < schemaNode.minimum) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.minimumViolation, localizer, { displayPath, value: String(schemaNode.minimum) }) }); } if (supportsNumericConstraints && typeof schemaNode.exclusiveMinimum === "number" && Number(scalarValue) <= schemaNode.exclusiveMinimum) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.exclusiveMinimumViolation, localizer, { displayPath, value: String(schemaNode.exclusiveMinimum) }) }); } if (supportsNumericConstraints && typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.maximumViolation, localizer, { displayPath, value: String(schemaNode.maximum) }) }); } if (supportsNumericConstraints && typeof schemaNode.exclusiveMaximum === "number" && Number(scalarValue) >= schemaNode.exclusiveMaximum) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.exclusiveMaximumViolation, localizer, { displayPath, value: String(schemaNode.exclusiveMaximum) }) }); } if (supportsNumericConstraints && !matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.multipleOfViolation, localizer, { displayPath, value: String(schemaNode.multipleOf) }) }); } if (supportsLengthConstraints && typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.minLengthViolation, localizer, { displayPath, value: String(schemaNode.minLength) }) }); } if (supportsLengthConstraints && typeof schemaNode.maxLength === "number" && scalarValue.length > schemaNode.maxLength) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.maxLengthViolation, localizer, { displayPath, value: String(schemaNode.maxLength) }) }); } if (supportsPatternConstraints && !matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, { displayPath, value: schemaNode.pattern }) }); } validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); } /** * Validate an object node recursively. * * @param {Extract} schemaNode Object schema node. * @param {YamlNode} yamlNode YAML node. * @param {string} displayPath Current logical path. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. */ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (!yamlNode || yamlNode.kind !== "object") { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, { displayPath }) }); return; } const propertyCount = yamlNode.map instanceof Map ? yamlNode.map.size : Array.isArray(yamlNode.entries) ? new Set(yamlNode.entries.map((entry) => entry.key)).size : 0; for (const requiredProperty of schemaNode.required) { if (!yamlNode.map.has(requiredProperty)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.missingRequired, localizer, { displayPath: joinPropertyPath(displayPath, requiredProperty) }) }); } } for (const entry of yamlNode.entries) { if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.unknownProperty, localizer, { displayPath: joinPropertyPath(displayPath, entry.key) }) }); continue; } validateNode( schemaNode.properties[entry.key], entry.node, joinPropertyPath(displayPath, entry.key), diagnostics, localizer); } if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.minPropertiesViolation, localizer, { displayPath, value: String(schemaNode.minProperties) }) }); } if (typeof schemaNode.maxProperties === "number" && propertyCount > schemaNode.maxProperties) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.maxPropertiesViolation, localizer, { displayPath, value: String(schemaNode.maxProperties) }) }); } validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); } /** * Test whether one YAML node satisfies one schema node without emitting user-facing diagnostics. * This is used by array `contains` so the tooling can reuse the same recursive validator * while treating regular validation failures as a simple "does not match" result. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. * @returns {boolean} True when the YAML node matches the schema node. */ function matchesSchemaNode(schemaNode, yamlNode) { const diagnostics = []; validateNode(schemaNode, yamlNode, schemaNode.displayPath, diagnostics, undefined); return diagnostics.length === 0; } /** * Validate one parsed YAML node against one normalized const comparable value. * The helper reuses the same comparable-key logic as uniqueItems so array order * and scalar normalization stay aligned with runtime behavior. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. * @param {string} displayPath Current logical path. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. */ function validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (typeof schemaNode.constComparableValue !== "string") { return; } if (buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue) { return; } diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.constMismatch, localizer, { displayPath, value: schemaNode.constDisplayValue ?? schemaNode.constValue }) }); } /** * Build one schema-aware comparable key for uniqueItems checks. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode | undefined} yamlNode YAML node. * @returns {string} Comparable key. */ function buildComparableNodeValue(schemaNode, yamlNode) { if (!yamlNode) { return "missing"; } if (schemaNode.type === "object") { if (yamlNode.kind !== "object") { return yamlNode.kind; } return Object.keys(schemaNode.properties) .filter((key) => yamlNode.map.has(key)) .sort(compareStringsOrdinal) .map((key) => { const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key)); return `${key.length}:${key}=${valueKey.length}:${valueKey}`; }) .join("|"); } if (schemaNode.type === "array") { if (yamlNode.kind !== "array") { return yamlNode.kind; } return `[${yamlNode.items.map((item) => { const valueKey = buildComparableNodeValue(schemaNode.items, item); return `${valueKey.length}:${valueKey}`; }).join(",")}]`; } if (yamlNode.kind !== "scalar") { return yamlNode.kind; } const scalarValue = unquoteScalar(yamlNode.value); const normalizedScalar = schemaNode.type === "integer" || schemaNode.type === "number" ? String(Number(scalarValue)) : schemaNode.type === "boolean" ? String(/^true$/iu.test(scalarValue)) : scalarValue; return `${schemaNode.type}:${normalizedScalar.length}:${normalizedScalar}`; } /** * Format one validation message in either English or Simplified Chinese. * * @param {string} key Message key. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. * @param {Record} params Message parameters. * @returns {string} Localized validation message. */ function localizeValidationMessage(key, localizer, params) { if (key === ValidationMessageKeys.expectedObject) { return formatExpectedObjectMessage(params.displayPath, Boolean(localizer && localizer.isChinese)); } if (key === ValidationMessageKeys.minPropertiesViolation) { if (localizer && typeof localizer.t === "function" && params.displayPath) { return localizer.t(key, params); } return formatObjectPropertyCountMessage( params.displayPath, params.value, "min", Boolean(localizer && localizer.isChinese)); } if (key === ValidationMessageKeys.maxPropertiesViolation) { if (localizer && typeof localizer.t === "function" && params.displayPath) { return localizer.t(key, params); } return formatObjectPropertyCountMessage( params.displayPath, params.value, "max", Boolean(localizer && localizer.isChinese)); } if (localizer && typeof localizer.t === "function") { return localizer.t(key, params); } if (localizer && localizer.isChinese) { switch (key) { case ValidationMessageKeys.constMismatch: return `属性“${params.displayPath}”必须匹配固定值 ${params.value}。`; case ValidationMessageKeys.expectedArray: return `属性“${params.displayPath}”应为数组。`; case ValidationMessageKeys.expectedScalarShape: return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`; case ValidationMessageKeys.expectedScalarValue: return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; case ValidationMessageKeys.enumMismatch: return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; case ValidationMessageKeys.exclusiveMaximumViolation: return `属性“${params.displayPath}”必须小于 ${params.value}。`; case ValidationMessageKeys.exclusiveMinimumViolation: return `属性“${params.displayPath}”必须大于 ${params.value}。`; case ValidationMessageKeys.maximumViolation: return `属性“${params.displayPath}”必须小于或等于 ${params.value}。`; case ValidationMessageKeys.maxContainsViolation: return `属性“${params.displayPath}”最多只能包含 ${params.value} 个匹配 contains 条件的元素。`; case ValidationMessageKeys.maxItemsViolation: return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`; case ValidationMessageKeys.maxLengthViolation: return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; case ValidationMessageKeys.minimumViolation: return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; case ValidationMessageKeys.multipleOfViolation: return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`; case ValidationMessageKeys.minContainsViolation: return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`; case ValidationMessageKeys.minItemsViolation: return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; case ValidationMessageKeys.minLengthViolation: return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; case ValidationMessageKeys.patternViolation: return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`; case ValidationMessageKeys.uniqueItemsViolation: return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`; case ValidationMessageKeys.missingRequired: return `缺少必填属性“${params.displayPath}”。`; case ValidationMessageKeys.unknownProperty: return `属性“${params.displayPath}”未在匹配的 schema 中声明。`; default: return key; } } switch (key) { case ValidationMessageKeys.constMismatch: return `Property '${params.displayPath}' must match constant value ${params.value}.`; case ValidationMessageKeys.expectedArray: return `Property '${params.displayPath}' is expected to be an array.`; case ValidationMessageKeys.expectedScalarShape: return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`; case ValidationMessageKeys.expectedScalarValue: return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; case ValidationMessageKeys.enumMismatch: return `Property '${params.displayPath}' must be one of: ${params.values}.`; case ValidationMessageKeys.exclusiveMaximumViolation: return `Property '${params.displayPath}' must be less than ${params.value}.`; case ValidationMessageKeys.exclusiveMinimumViolation: return `Property '${params.displayPath}' must be greater than ${params.value}.`; case ValidationMessageKeys.maximumViolation: return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`; case ValidationMessageKeys.maxContainsViolation: return `Property '${params.displayPath}' must contain at most ${params.value} items matching the 'contains' schema.`; case ValidationMessageKeys.maxItemsViolation: return `Property '${params.displayPath}' must contain at most ${params.value} items.`; case ValidationMessageKeys.maxLengthViolation: return `Property '${params.displayPath}' must be at most ${params.value} characters long.`; case ValidationMessageKeys.minimumViolation: return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; case ValidationMessageKeys.multipleOfViolation: return `Property '${params.displayPath}' must be a multiple of ${params.value}.`; case ValidationMessageKeys.minContainsViolation: return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`; case ValidationMessageKeys.minItemsViolation: return `Property '${params.displayPath}' must contain at least ${params.value} items.`; case ValidationMessageKeys.minLengthViolation: return `Property '${params.displayPath}' must be at least ${params.value} characters long.`; case ValidationMessageKeys.patternViolation: return `Property '${params.displayPath}' must match pattern '${params.value}'.`; case ValidationMessageKeys.uniqueItemsViolation: return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`; case ValidationMessageKeys.missingRequired: return `Required property '${params.displayPath}' is missing.`; case ValidationMessageKeys.unknownProperty: return `Property '${params.displayPath}' is not declared in the matching schema.`; default: return key; } } /** * Format one object-shape expectation diagnostic. * * @param {string} displayPath Logical object path, or empty for the root object. * @param {boolean} isChinese Whether Chinese text should be produced. * @returns {string} Formatted message. */ function formatExpectedObjectMessage(displayPath, isChinese) { const isRoot = !displayPath; if (isChinese) { return isRoot ? "根对象应为对象。" : `属性“${displayPath}”应为对象。`; } return isRoot ? "Root object is expected to be an object." : `Property '${displayPath}' is expected to be an object.`; } /** * Format one object-property-count validation message. * * @param {string} displayPath Logical object path, or empty for the root object. * @param {string} value Constraint value. * @param {"min" | "max"} mode Whether the message describes a minimum or maximum. * @param {boolean} isChinese Whether Chinese text should be produced. * @returns {string} Formatted message. */ function formatObjectPropertyCountMessage(displayPath, value, mode, isChinese) { const isRoot = !displayPath; if (isChinese) { if (mode === "min") { return isRoot ? `根对象至少需要包含 ${value} 个属性。` : `对象属性“${displayPath}”至少需要包含 ${value} 个子属性。`; } return isRoot ? `根对象最多只能包含 ${value} 个属性。` : `对象属性“${displayPath}”最多只能包含 ${value} 个子属性。`; } if (mode === "min") { return isRoot ? `Root object must contain at least ${value} properties.` : `Property '${displayPath}' must contain at least ${value} properties.`; } return isRoot ? `Root object must contain at most ${value} properties.` : `Property '${displayPath}' must contain at most ${value} properties.`; } /** * Tokenize YAML lines into indentation-aware units. * * @param {string} text YAML text. * @returns {Array<{indent: number, text: string}>} Tokens. */ function tokenizeYaml(text) { const tokens = []; const lines = String(text).split(/\r?\n/u); for (const line of lines) { if (!line || line.trim().length === 0 || line.trimStart().startsWith("#")) { continue; } const indentMatch = /^(\s*)/u.exec(line); const indent = indentMatch ? indentMatch[1].length : 0; const trimmed = line.slice(indent); tokens.push({indent, text: trimmed}); } return tokens; } /** * Parse the next YAML block from the token stream. * * @param {Array<{indent: number, text: string}>} tokens Token array. * @param {{index: number}} state Mutable parser state. * @param {number} indent Expected indentation. * @returns {YamlNode} Parsed node. */ function parseBlock(tokens, state, indent) { if (state.index >= tokens.length) { return createObjectNode(); } const token = tokens[state.index]; if (token.text.startsWith("-")) { return parseSequence(tokens, state, indent); } return parseMapping(tokens, state, indent); } /** * Parse a mapping block. * * @param {Array<{indent: number, text: string}>} tokens Token array. * @param {{index: number}} state Mutable parser state. * @param {number} indent Expected indentation. * @returns {YamlNode} Parsed object node. */ function parseMapping(tokens, state, indent) { const entries = []; const map = new Map(); while (state.index < tokens.length) { const token = tokens[state.index]; if (token.indent < indent || token.text.startsWith("-")) { break; } if (token.indent > indent) { state.index += 1; continue; } const mapping = parseYamlMappingText(token.text); if (!mapping) { state.index += 1; continue; } const key = mapping.key; const rawValue = mapping.rawValue.trim(); state.index += 1; let node; if (rawValue.length > 0 && !rawValue.startsWith("|") && !rawValue.startsWith(">")) { node = createScalarNode(rawValue); } else if (state.index < tokens.length && tokens[state.index].indent > indent) { node = parseBlock(tokens, state, tokens[state.index].indent); } else { node = createScalarNode(""); } entries.push({key, node}); map.set(key, node); } return {kind: "object", entries, map}; } /** * Parse a sequence block. * * @param {Array<{indent: number, text: string}>} tokens Token array. * @param {{index: number}} state Mutable parser state. * @param {number} indent Expected indentation. * @returns {YamlNode} Parsed array node. */ function parseSequence(tokens, state, indent) { const items = []; while (state.index < tokens.length) { const token = tokens[state.index]; if (token.indent !== indent || !token.text.startsWith("-")) { break; } const rest = token.text.slice(1).trim(); state.index += 1; if (rest.length === 0) { if (state.index < tokens.length && tokens[state.index].indent > indent) { items.push(parseBlock(tokens, state, tokens[state.index].indent)); } else { items.push(createScalarNode("")); } continue; } if (parseYamlMappingText(rest)) { items.push(parseInlineObjectItem(tokens, state, indent, rest)); continue; } items.push(createScalarNode(rest)); } return createArrayNode(items); } /** * Parse an array item written as an inline mapping head followed by nested * child lines, for example `- wave: 1`. * * @param {Array<{indent: number, text: string}>} tokens Token array. * @param {{index: number}} state Mutable parser state. * @param {number} parentIndent Array indentation. * @param {string} firstEntry Inline first entry text. * @returns {YamlNode} Parsed object node. */ function parseInlineObjectItem(tokens, state, parentIndent, firstEntry) { const syntheticTokens = [{indent: parentIndent + 2, text: firstEntry}]; while (state.index < tokens.length && tokens[state.index].indent > parentIndent) { syntheticTokens.push(tokens[state.index]); state.index += 1; } return parseBlock(syntheticTokens, {index: 0}, parentIndent + 2); } /** * Ensure the root node is an object, creating one if the YAML was empty or not * object-shaped enough for structured edits. * * @param {YamlNode} node Parsed node. * @returns {YamlObjectNode} Root object node. */ function normalizeRootNode(node) { return node && node.kind === "object" ? node : createObjectNode(); } /** * Replace or create a node at a dot-separated object path. * * @param {YamlObjectNode} root Root object node. * @param {string[]} segments Path segments. * @param {YamlNode} valueNode Value node. */ function setNodeAtPath(root, segments, valueNode) { let current = root; for (let index = 0; index < segments.length; index += 1) { const segment = segments[index]; if (!segment) { continue; } if (index === segments.length - 1) { setObjectEntry(current, segment, valueNode); return; } let nextNode = current.map.get(segment); if (!nextNode || nextNode.kind !== "object") { nextNode = createObjectNode(); setObjectEntry(current, segment, nextNode); } current = nextNode; } } /** * Insert or replace one mapping entry while preserving insertion order. * * @param {YamlObjectNode} objectNode Target object node. * @param {string} key Mapping key. * @param {YamlNode} valueNode Value node. */ function setObjectEntry(objectNode, key, valueNode) { const existingIndex = objectNode.entries.findIndex((entry) => entry.key === key); if (existingIndex >= 0) { objectNode.entries[existingIndex] = {key, node: valueNode}; } else { objectNode.entries.push({key, node: valueNode}); } objectNode.map.set(key, valueNode); } /** * Render a YAML node back to text lines. * * @param {YamlNode} node YAML node. * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ function renderYaml(node, indent = 0, currentPath = "", commentMap = {}) { if (node.kind === "object") { return renderObjectNode(node, indent, currentPath, commentMap); } if (node.kind === "array") { return renderArrayNode(node, indent, currentPath, commentMap); } return [`${" ".repeat(indent)}${formatYamlScalar(node.value)}`]; } /** * Render an object node. * * @param {YamlObjectNode} node Object node. * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ function renderObjectNode(node, indent, currentPath, commentMap) { const lines = []; for (const entry of node.entries) { const entryPath = joinPropertyPath(currentPath, entry.key); if (commentMap[entryPath]) { lines.push(...renderYamlComments(commentMap[entryPath], indent)); } if (entry.node.kind === "scalar") { lines.push(`${" ".repeat(indent)}${entry.key}: ${formatYamlScalar(entry.node.value)}`); continue; } if (entry.node.kind === "array" && entry.node.items.length === 0) { lines.push(`${" ".repeat(indent)}${entry.key}: []`); continue; } lines.push(`${" ".repeat(indent)}${entry.key}:`); lines.push(...renderYaml(entry.node, indent + 2, entryPath, commentMap)); } return lines; } /** * Render an array node. * * @param {YamlArrayNode} node Array node. * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ function renderArrayNode(node, indent, currentPath, commentMap) { const lines = []; for (let index = 0; index < node.items.length; index += 1) { const item = node.items[index]; const itemPath = joinArrayIndexPath(currentPath, index); if (commentMap[itemPath]) { lines.push(...renderYamlComments(commentMap[itemPath], indent)); } if (item.kind === "scalar") { lines.push(`${" ".repeat(indent)}- ${formatYamlScalar(item.value)}`); continue; } lines.push(`${" ".repeat(indent)}-`); lines.push(...renderYaml(item, indent + 2, itemPath, commentMap)); } return lines; } /** * Create a scalar node. * * @param {string} value Scalar value. * @returns {YamlScalarNode} Scalar node. */ function createScalarNode(value) { return {kind: "scalar", value}; } /** * Create an array node. * * @param {YamlNode[]} items Array items. * @returns {YamlArrayNode} Array node. */ function createArrayNode(items) { return {kind: "array", items}; } /** * Convert one structured form value back into a YAML node tree. * Object-array editors submit plain JavaScript objects so the writer can * rebuild the full array deterministically instead of patching item paths * one by one. * * @param {unknown} value Structured form value. * @returns {YamlNode} YAML node. */ function createNodeFromFormValue(value) { if (Array.isArray(value)) { return createArrayNode(value.map((item) => createNodeFromFormValue(item))); } if (value && typeof value === "object") { const objectNode = createObjectNode(); for (const [key, childValue] of Object.entries(value)) { setObjectEntry(objectNode, key, createNodeFromFormValue(childValue)); } return objectNode; } return createScalarNode(String(value ?? "")); } /** * Create an object node. * * @returns {YamlObjectNode} Object node. */ function createObjectNode() { return {kind: "object", entries: [], map: new Map()}; } /** * Build one example node recursively from schema metadata. * * @param {SchemaNode} schemaNode Schema node. * @returns {YamlNode} Example YAML node. */ function createSampleNodeFromSchema(schemaNode) { if (!schemaNode || schemaNode.type === "object") { const objectNode = createObjectNode(); for (const [key, propertySchema] of Object.entries(schemaNode && schemaNode.properties ? schemaNode.properties : {})) { const childNode = createSampleNodeFromSchema(propertySchema); setObjectEntry(objectNode, key, childNode); } return objectNode; } if (schemaNode.type === "array") { if (schemaNode.items.type === "object") { return createArrayNode([createSampleNodeFromSchema(schemaNode.items)]); } return createArrayNode([createScalarNode(getSampleScalarValue(schemaNode.items))]); } return createScalarNode(getSampleScalarValue(schemaNode)); } /** * Collect schema descriptions into a YAML comment lookup so sample configs can * start with human-readable guidance right above generated fields. * * @param {SchemaNode} schemaNode Schema node. * @param {string} currentPath Current logical path. * @param {Record} commentMap Comment lookup. */ function collectSchemaComments(schemaNode, currentPath, commentMap) { if (!schemaNode || schemaNode.type !== "object") { return; } for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { const propertyPath = joinPropertyPath(currentPath, key); if (propertySchema.description) { commentMap[propertyPath] = propertySchema.description; } if (propertySchema.type === "object") { collectSchemaComments(propertySchema, propertyPath, commentMap); continue; } if (propertySchema.type === "array" && propertySchema.items.type === "object") { collectSchemaComments(propertySchema.items, joinArrayIndexPath(propertyPath, 0), commentMap); } } } /** * Resolve one sample scalar value from schema metadata. * * @param {Extract} schemaNode Scalar schema node. * @returns {string} Sample scalar value. */ function getSampleScalarValue(schemaNode) { if (schemaNode.constValue !== undefined) { return schemaNode.constValue; } if (schemaNode.defaultValue !== undefined) { return schemaNode.defaultValue; } if (Array.isArray(schemaNode.enumValues) && schemaNode.enumValues.length > 0) { return schemaNode.enumValues[0]; } switch (schemaNode.type) { case "integer": return "0"; case "number": return "0"; case "boolean": return "false"; case "string": default: return schemaNode.refTable ? "example_id" : "example"; } } /** * Render one comment block to YAML lines. * * @param {string} commentText Comment text. * @param {number} indent Current indentation. * @returns {string[]} YAML comment lines. */ function renderYamlComments(commentText, indent) { return String(commentText) .split(/\r?\n/u) .filter((line) => line.length > 0) .map((line) => `${" ".repeat(indent)}# ${line}`); } /** * Assign pending comment lines to one logical path. * * @param {Record} commentMap Comment lookup. * @param {string} path Logical path. * @param {string[]} pendingComments Pending comment lines. */ function assignPendingComments(commentMap, path, pendingComments) { if (!path || !Array.isArray(pendingComments) || pendingComments.length === 0) { return; } commentMap[path] = pendingComments.join("\n"); } /** * Count leading spaces in one source line. * * @param {string} line Source line. * @returns {number} Leading-space count. */ function countLeadingSpaces(line) { const indentMatch = /^(\s*)/u.exec(line); return indentMatch ? indentMatch[1].length : 0; } /** * Find the next non-empty, non-comment source line. * * @param {string[]} lines Source lines. * @param {number} startIndex Starting index. * @returns {{indent: number, trimmed: string} | undefined} Next significant line. */ function findNextMeaningfulLine(lines, startIndex) { for (let index = startIndex; index < lines.length; index += 1) { const line = lines[index]; const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith("#")) { continue; } return { indent: countLeadingSpaces(line), trimmed }; } return undefined; } /** * Create one container context from the next meaningful line. * * @param {string} path Logical parent path. * @param {{indent: number, trimmed: string}} nextLine Next meaningful line. * @returns {{indent: number, type: "object" | "array", path: string, nextIndex: number}} Context model. */ function createContextForChild(path, nextLine) { return { indent: nextLine.indent, type: nextLine.trimmed.startsWith("-") ? "array" : "object", path, nextIndex: 0 }; } /** * Split a YAML value from one inline trailing comment. * * @param {string} rawValue Raw value segment after `key:`. * @returns {{value: string, comment?: string}} Parsed value and optional comment. */ function splitYamlValueAndInlineComment(rawValue) { let inSingleQuote = false; let inDoubleQuote = false; for (let index = 0; index < rawValue.length; index += 1) { const character = rawValue[index]; if (character === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; continue; } if (character === "\"" && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; continue; } if (character === "#" && !inSingleQuote && !inDoubleQuote && (index === 0 || /\s/u.test(rawValue[index - 1]))) { return { value: rawValue.slice(0, index).trimEnd(), comment: rawValue.slice(index + 1).trim() }; } } return {value: rawValue}; } /** * Parse one YAML mapping entry such as `key: value` or `"complex key": value`. * * @param {string} text Raw YAML line text without leading indentation. * @returns {{key: string, rawValue: string} | undefined} Parsed mapping entry. */ function parseYamlMappingText(text) { const separatorIndex = findYamlKeyValueSeparator(text); if (separatorIndex < 0) { return undefined; } const rawKey = text.slice(0, separatorIndex).trim(); if (rawKey.length === 0) { return undefined; } return { key: normalizeYamlKey(rawKey), rawValue: text.slice(separatorIndex + 1) }; } /** * Find the first `:` that acts as a YAML key/value separator. * * @param {string} text Raw YAML line text without leading indentation. * @returns {number} Separator index, or -1 when not found. */ function findYamlKeyValueSeparator(text) { let inSingleQuote = false; let inDoubleQuote = false; for (let index = 0; index < text.length; index += 1) { const character = text[index]; if (character === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; continue; } if (character === "\"" && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; continue; } if (character === ":" && !inSingleQuote && !inDoubleQuote) { return index; } } return -1; } /** * Normalize a YAML key token into the logical key name used in the form model. * * @param {string} rawKey Raw YAML key token. * @returns {string} Normalized key name. */ function normalizeYamlKey(rawKey) { return unquoteScalar(rawKey.trim()); } module.exports = { applyFormUpdates, applyScalarUpdates, createSampleConfigYaml, extractYamlComments, getEditableSchemaFields, isEditableScalarType, isScalarCompatible, parseBatchArrayValue, parseSchemaContent, parseTopLevelYaml, unquoteScalar, validateParsedConfig }; /** * @typedef {{ * type: "object", * displayPath: string, * required: string[], * properties: Record, * minProperties?: number, * maxProperties?: number, * title?: string, * description?: string, * defaultValue?: string, * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string * } | { * type: "array", * displayPath: string, * title?: string, * description?: string, * defaultValue?: string, * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, * minItems?: number, * maxItems?: number, * minContains?: number, * maxContains?: number, * uniqueItems?: boolean, * refTable?: string, * contains?: SchemaNode, * items: SchemaNode * } | { * type: "string" | "integer" | "number" | "boolean", * displayPath: string, * title?: string, * description?: string, * defaultValue?: string, * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, * minimum?: number, * exclusiveMinimum?: number, * maximum?: number, * exclusiveMaximum?: number, * multipleOf?: number, * minLength?: number, * maxLength?: number, * pattern?: string, * patternRegex?: RegExp, * enumValues?: string[], * refTable?: string * }} SchemaNode */ /** * @typedef {{kind: "scalar", value: string}} YamlScalarNode * @typedef {{kind: "array", items: YamlNode[]}} YamlArrayNode * @typedef {{kind: "object", entries: Array<{key: string, node: YamlNode}>, map: Map}} YamlObjectNode * @typedef {YamlScalarNode | YamlArrayNode | YamlObjectNode} YamlNode */