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; const EmailFormatPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; const UuidFormatPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; const DateFormatPattern = /^(?\d{4})-(?\d{2})-(?\d{2})$/u; const DateTimeFormatPattern = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$/u; const DurationFormatPattern = /^P(?:(?\d+)D)?(?:T(?:(?\d+)H)?(?:(?\d+)M)?(?:(?\d+(?:\.\d+)?)S)?)?$/u; const TimeFormatPattern = /^(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$/u; const SupportedStringFormats = new Set(["date", "date-time", "duration", "email", "time", "uri", "uuid"]); /** * 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 pattern or format 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 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}`); } } /** * Normalize one schema string-format declaration into the shared supported subset. * The tooling intentionally rejects unknown format names so editor diagnostics do * not drift away from the runtime and source generator. * * @param {unknown} value Raw schema value. * @param {string} schemaType Current schema type. * @param {string} displayPath Logical property path used in diagnostics. * @throws {Error} Thrown when the format value is invalid or unsupported for strings. * @returns {string | undefined} Normalized format name. */ function normalizeSchemaStringFormat(value, schemaType, displayPath) { if (value === undefined) { return undefined; } if (schemaType !== "string") { throw new Error(`Schema property '${displayPath}' can only declare 'format' on type 'string'.`); } if (typeof value !== "string") { throw new Error(`Schema property '${displayPath}' must declare 'format' as a string.`); } if (SupportedStringFormats.has(value)) { return value; } throw new Error( `Schema property '${displayPath}' declares unsupported string format '${value}'. ` + "Supported formats are 'date', 'date-time', 'duration', 'email', 'time', 'uri', and 'uuid'."); } /** * 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) }; } /** * Attach parsed enum metadata to one schema node. * * @param {SchemaNode} schemaNode Parsed schema node. * @param {unknown} rawEnum Raw schema enum value. * @param {string} displayPath Logical property path. * @returns {SchemaNode} Schema node with optional enum metadata. */ function applyEnumMetadata(schemaNode, rawEnum, displayPath) { if (!Array.isArray(rawEnum)) { return schemaNode; } if (rawEnum.length === 0) { throw new Error(`Schema property '${displayPath}' must declare 'enum' with at least one value.`); } const enumComparableValues = []; const enumDisplayValues = []; const enumValues = []; for (const item of rawEnum) { enumComparableValues.push(buildSchemaConstComparableValue(schemaNode, item, displayPath)); const displayValue = formatSchemaConstDisplayValue(item); if (displayValue !== undefined) { enumDisplayValues.push(displayValue); } if (schemaNode.type !== "object" && schemaNode.type !== "array") { const editableValue = formatSchemaConstEditableValue(schemaNode, item); if (editableValue !== undefined) { enumValues.push(editableValue); } } } return { ...schemaNode, enumSampleValue: rawEnum[0], enumValues: enumValues.length > 0 ? enumValues : undefined, enumDisplayValues: enumDisplayValues.length > 0 ? enumDisplayValues : undefined, enumComparableValues: enumComparableValues.length > 0 ? enumComparableValues : undefined }; } /** * 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); } /** * Test one scalar value against one shared string-format constraint. * * @param {string} scalarValue Scalar value from YAML. * @param {string | undefined} formatName Normalized schema format name. * @returns {boolean} True when compatible or no format is declared. */ function matchesSchemaStringFormat(scalarValue, formatName) { if (typeof formatName !== "string") { return true; } switch (formatName) { case "date": return matchesSchemaDateFormat(scalarValue); case "date-time": return matchesSchemaDateTimeFormat(scalarValue); case "duration": return matchesSchemaDurationFormat(scalarValue); case "email": return EmailFormatPattern.test(scalarValue); case "time": return matchesSchemaTimeFormat(scalarValue); case "uri": return matchesSchemaUriFormat(scalarValue); case "uuid": return UuidFormatPattern.test(scalarValue); default: return false; } } /** * Validate one RFC 3339 full-date string. * * @param {string} scalarValue Scalar value from YAML. * @returns {boolean} True when the value is a valid calendar date. */ function matchesSchemaDateFormat(scalarValue) { const match = DateFormatPattern.exec(scalarValue); if (!match || !match.groups) { return false; } const year = Number.parseInt(match.groups.year, 10); const month = Number.parseInt(match.groups.month, 10); const day = Number.parseInt(match.groups.day, 10); return isValidCalendarDate(year, month, day); } /** * Validate one RFC 3339 date-time string with explicit timezone offset. * * @param {string} scalarValue Scalar value from YAML. * @returns {boolean} True when the value is structurally and calendrically valid. */ function matchesSchemaDateTimeFormat(scalarValue) { const match = DateTimeFormatPattern.exec(scalarValue); if (!match || !match.groups) { return false; } const year = Number.parseInt(match.groups.year, 10); const month = Number.parseInt(match.groups.month, 10); const day = Number.parseInt(match.groups.day, 10); if (!isValidCalendarDate(year, month, day)) { return false; } const hour = Number.parseInt(match.groups.hour, 10); const minute = Number.parseInt(match.groups.minute, 10); const second = Number.parseInt(match.groups.second, 10); if (hour > 23 || minute > 59 || second > 59) { return false; } const offset = match.groups.offset; if (offset === "Z") { return true; } const offsetHour = Number.parseInt(offset.slice(1, 3), 10); const offsetMinute = Number.parseInt(offset.slice(4, 6), 10); return offsetHour <= 23 && offsetMinute <= 59; } /** * Validate one shared day-time duration string. * * @param {string} scalarValue Scalar value from YAML. * @returns {boolean} True when the value stays within the shared day-time subset. */ function matchesSchemaDurationFormat(scalarValue) { const match = DurationFormatPattern.exec(scalarValue); if (!match || !match.groups) { return false; } const hasDayComponent = match.groups.days !== undefined; const hasHourComponent = match.groups.hours !== undefined; const hasMinuteComponent = match.groups.minutes !== undefined; const hasSecondComponent = match.groups.seconds !== undefined; const hasAnyComponent = hasDayComponent || hasHourComponent || hasMinuteComponent || hasSecondComponent; if (!hasAnyComponent) { return false; } const hasTimeSection = scalarValue.includes("T"); if (hasTimeSection && !hasHourComponent && !hasMinuteComponent && !hasSecondComponent) { return false; } return true; } /** * Validate one RFC 3339 full-time string with explicit timezone offset. * * @param {string} scalarValue Scalar value from YAML. * @returns {boolean} True when the value is structurally valid. */ function matchesSchemaTimeFormat(scalarValue) { const match = TimeFormatPattern.exec(scalarValue); if (!match || !match.groups) { return false; } const hour = Number.parseInt(match.groups.hour, 10); const minute = Number.parseInt(match.groups.minute, 10); const second = Number.parseInt(match.groups.second, 10); if (hour > 23 || minute > 59 || second > 59) { return false; } const offset = match.groups.offset; if (offset === "Z") { return true; } const offsetHour = Number.parseInt(offset.slice(1, 3), 10); const offsetMinute = Number.parseInt(offset.slice(4, 6), 10); return offsetHour <= 23 && offsetMinute <= 59; } /** * Validate one absolute URI string using the platform URL parser. * * @param {string} scalarValue Scalar value from YAML. * @returns {boolean} True when the value parses as an absolute URI. */ function matchesSchemaUriFormat(scalarValue) { try { const parsed = new URL(scalarValue); return typeof parsed.protocol === "string" && parsed.protocol.length > 1; } catch { return false; } } /** * Check whether one year-month-day triple forms a valid calendar date. * * @param {number} year Year component. * @param {number} month Month component. * @param {number} day Day component. * @returns {boolean} True when the date exists in the Gregorian calendar. */ function isValidCalendarDate(year, month, day) { if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { return false; } if (year < 1 || year > 9999 || month < 1 || month > 12 || day < 1) { return false; } const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); const monthDays = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; const lastDay = monthDays[month - 1]; return day <= lastDay; } /** * 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 stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath); const negatedSchemaNode = parseNegatedSchemaNode(value.not, 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, format: stringFormat, 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 (value.allOf !== undefined && type !== "object") { throw new Error(`Only object schemas can declare 'allOf' at '${displayPath}'.`); } if ((value.if !== undefined || value.then !== undefined || value.else !== undefined) && type !== "object") { throw new Error(`Only object schemas can declare 'if', 'then', or 'else' at '${displayPath}'.`); } 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)); } const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties); const dependentSchemas = parseDependentSchemasMetadata(value.dependentSchemas, displayPath, properties); const allOf = parseAllOfSchemaNodes(value.allOf, displayPath, properties); const conditionalSchemas = parseConditionalSchemaMetadata(value.if, value.then, value.else, displayPath, properties); return applyEnumMetadata(applyConstMetadata({ type: "object", displayPath, required, properties, minProperties: metadata.minProperties, maxProperties: metadata.maxProperties, dependentRequired, dependentSchemas, allOf, ifSchema: conditionalSchemas ? conditionalSchemas.ifSchema : undefined, thenSchema: conditionalSchemas ? conditionalSchemas.thenSchema : undefined, elseSchema: conditionalSchemas ? conditionalSchemas.elseSchema : undefined, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, not: negatedSchemaNode }, value.const, displayPath), value.enum, 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 applyEnumMetadata(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, not: negatedSchemaNode }, value.const, displayPath), value.enum, displayPath); } return applyEnumMetadata(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, format: type === "string" ? metadata.format : undefined, refTable: metadata.refTable, not: negatedSchemaNode }, value.const, displayPath), value.enum, displayPath); } /** * Parse one optional `not` sub-schema and keep path formatting aligned with * the runtime/generator diagnostics. * * @param {unknown} rawNot Raw `not` node. * @param {string} displayPath Parent schema path. * @returns {SchemaNode | undefined} Parsed negated schema node. */ function parseNegatedSchemaNode(rawNot, displayPath) { if (rawNot === undefined) { return undefined; } if (!rawNot || typeof rawNot !== "object" || Array.isArray(rawNot)) { throw new Error(`Schema property '${displayPath}' must declare 'not' as an object-valued schema.`); } return parseSchemaNode(rawNot, `${displayPath}[not]`); } /** * Parse one object-level `dependentRequired` map and keep it aligned with the * runtime's "declared siblings only" contract. * * @param {unknown} rawDependentRequired Raw dependentRequired node. * @param {string} displayPath Parent schema path. * @param {Record} properties Declared object properties. * @returns {Record | undefined} Normalized dependency map. */ function parseDependentRequiredMetadata(rawDependentRequired, displayPath, properties) { if (rawDependentRequired === undefined) { return undefined; } if (!rawDependentRequired || typeof rawDependentRequired !== "object" || Array.isArray(rawDependentRequired)) { throw new Error(`Schema property '${displayPath}' must declare 'dependentRequired' as an object.`); } const normalized = {}; for (const [triggerProperty, rawDependencies] of Object.entries(rawDependentRequired)) { if (!Object.prototype.hasOwnProperty.call(properties, triggerProperty)) { throw new Error( `Schema property '${displayPath}' declares 'dependentRequired' for undeclared property '${triggerProperty}'.`); } if (!Array.isArray(rawDependencies)) { throw new Error( `Schema property '${displayPath}' must declare 'dependentRequired' for '${triggerProperty}' as an array of sibling property names.`); } const dependencies = []; const seenDependencies = new Set(); for (const dependency of rawDependencies) { if (typeof dependency !== "string") { throw new Error( `Schema property '${displayPath}' must declare 'dependentRequired' entries for '${triggerProperty}' as strings.`); } if (dependency.trim().length === 0) { throw new Error( `Schema property '${displayPath}' cannot declare blank 'dependentRequired' entries for '${triggerProperty}'.`); } if (!Object.prototype.hasOwnProperty.call(properties, dependency)) { throw new Error( `Schema property '${displayPath}' declares 'dependentRequired' target '${dependency}' that is not declared in the same object schema.`); } if (!seenDependencies.has(dependency)) { seenDependencies.add(dependency); dependencies.push(dependency); } } if (dependencies.length > 0) { normalized[triggerProperty] = dependencies; } } return Object.keys(normalized).length > 0 ? normalized : undefined; } /** * Parse one object-level `dependentSchemas` map and keep it aligned with the * runtime's "declared siblings trigger object-typed inline schemas" contract. * * @param {unknown} rawDependentSchemas Raw dependentSchemas node. * @param {string} displayPath Parent schema path. * @param {Record} properties Declared object properties. * @returns {Record | undefined} Normalized dependency schema map. */ function parseDependentSchemasMetadata(rawDependentSchemas, displayPath, properties) { if (rawDependentSchemas === undefined) { return undefined; } if (!rawDependentSchemas || typeof rawDependentSchemas !== "object" || Array.isArray(rawDependentSchemas)) { throw new Error(`Schema property '${displayPath}' must declare 'dependentSchemas' as an object.`); } const normalized = {}; for (const [triggerProperty, rawDependencySchema] of Object.entries(rawDependentSchemas)) { if (!Object.prototype.hasOwnProperty.call(properties, triggerProperty)) { throw new Error( `Schema property '${displayPath}' declares 'dependentSchemas' for undeclared property '${triggerProperty}'.`); } if (!rawDependencySchema || typeof rawDependencySchema !== "object" || Array.isArray(rawDependencySchema)) { throw new Error( `Schema property '${displayPath}' must declare 'dependentSchemas' for '${triggerProperty}' as an object-valued schema.`); } const dependencySchema = parseSchemaNode( rawDependencySchema, `${displayPath}[dependentSchemas:${triggerProperty}]`); if (dependencySchema.type !== "object") { throw new Error( `Schema property '${displayPath}' must declare an object-typed 'dependentSchemas' schema for '${triggerProperty}'.`); } normalized[triggerProperty] = dependencySchema; } return Object.keys(normalized).length > 0 ? normalized : undefined; } /** * Parse one object-level `allOf` list and keep it aligned with the runtime's * focused-constraint-block contract. * * @param {unknown} rawAllOf Raw `allOf` node. * @param {string} displayPath Parent schema path. * @param {Record} properties Declared object properties. * @returns {SchemaNode[] | undefined} Normalized allOf schema list. */ function parseAllOfSchemaNodes(rawAllOf, displayPath, properties) { if (rawAllOf === undefined) { return undefined; } if (!Array.isArray(rawAllOf)) { throw new Error(`Schema property '${displayPath}' must declare 'allOf' as an array.`); } const normalized = []; for (let index = 0; index < rawAllOf.length; index += 1) { const rawAllOfSchema = rawAllOf[index]; if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) { throw new Error( `Schema property '${displayPath}' must declare 'allOf' entry #${index + 1} as an object-valued schema.`); } if (rawAllOfSchema.type !== "object") { throw new Error( `Schema property '${displayPath}' must declare object-typed schemas in 'allOf' entry #${index + 1}.`); } validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties); const allOfSchema = parseSchemaNode(rawAllOfSchema, `${displayPath}[allOf[${index}]]`); normalized.push(allOfSchema); } return normalized.length > 0 ? normalized : undefined; } /** * Parse one object-level `if/then/else` group and keep it aligned with the * runtime's object-focused conditional constraint contract. * * @param {unknown} rawIf Raw `if` node. * @param {unknown} rawThen Raw `then` node. * @param {unknown} rawElse Raw `else` node. * @param {string} displayPath Parent schema path. * @param {Record} properties Declared parent properties. * @returns {{ifSchema: SchemaNode, thenSchema?: SchemaNode, elseSchema?: SchemaNode} | undefined} Normalized conditional schema group. */ function parseConditionalSchemaMetadata(rawIf, rawThen, rawElse, displayPath, properties) { const hasIf = rawIf !== undefined; const hasThen = rawThen !== undefined; const hasElse = rawElse !== undefined; if (!hasIf && !hasThen && !hasElse) { return undefined; } if (!hasIf) { throw new Error(`Schema property '${displayPath}' must declare 'if' when using 'then' or 'else'.`); } if (!hasThen && !hasElse) { throw new Error(`Schema property '${displayPath}' must declare at least one of 'then' or 'else' when using 'if'.`); } const ifSchema = parseConditionalObjectSchema(rawIf, displayPath, "if", properties); const conditionalSchemas = {ifSchema}; if (hasThen) { conditionalSchemas.thenSchema = parseConditionalObjectSchema(rawThen, displayPath, "then", properties); } if (hasElse) { conditionalSchemas.elseSchema = parseConditionalObjectSchema(rawElse, displayPath, "else", properties); } return conditionalSchemas; } /** * Parse one object-focused conditional branch schema. * * @param {unknown} rawSchema Raw branch schema. * @param {string} displayPath Parent schema path. * @param {"if" | "then" | "else"} keywordName Branch keyword. * @param {Record} properties Declared parent properties. * @returns {SchemaNode} Parsed object-typed branch schema. */ function parseConditionalObjectSchema(rawSchema, displayPath, keywordName, properties) { if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) { throw new Error(`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema.`); } if (rawSchema.type !== "object") { throw new Error(`Schema property '${displayPath}' must declare an object-typed '${keywordName}' schema.`); } validateConditionalSchemaTargets(rawSchema, displayPath, keywordName, properties); const conditionalSchema = parseSchemaNode(rawSchema, `${displayPath}[${keywordName}]`); if (conditionalSchema.type !== "object") { throw new Error(`Schema property '${displayPath}' must declare an object-typed '${keywordName}' schema.`); } return conditionalSchema; } /** * Ensure one object-focused conditional branch only constrains properties that * the parent object schema already declared. * * @param {unknown} rawSchema Raw branch schema. * @param {string} displayPath Parent schema path. * @param {"if" | "then" | "else"} keywordName Branch keyword. * @param {Record} properties Declared parent properties. */ function validateConditionalSchemaTargets(rawSchema, displayPath, keywordName, properties) { validateDeclaredTargetReferences(rawSchema, displayPath, `'${keywordName}'`, properties); } /** * Ensure one object-focused `allOf` entry only constrains properties that the * parent object schema already declared. * * @param {unknown} rawAllOfSchema Raw allOf entry. * @param {string} displayPath Parent schema path. * @param {number} index Zero-based allOf entry index. * @param {Record} properties Declared parent properties. */ function validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties) { validateDeclaredTargetReferences(rawAllOfSchema, displayPath, `'allOf' entry #${index + 1}`, properties); } /** * Ensure one focused object schema only references properties that the parent * object schema already declared. * * @param {unknown} rawSchema Raw object-focused schema. * @param {string} displayPath Parent schema path. * @param {string} contextLabel Human-readable constraint origin label. * @param {Record} properties Declared parent properties. */ function validateDeclaredTargetReferences(rawSchema, displayPath, contextLabel, properties) { if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) { return; } if (rawSchema.properties !== undefined) { if (!rawSchema.properties || typeof rawSchema.properties !== "object" || Array.isArray(rawSchema.properties)) { throw new Error( `Schema property '${displayPath}' must declare 'properties' in ${contextLabel} as an object-valued map.`); } for (const propertyName of Object.keys(rawSchema.properties)) { if (Object.prototype.hasOwnProperty.call(properties, propertyName)) { continue; } throw new Error( `Schema property '${displayPath}' declares property '${propertyName}' in ${contextLabel}, ` + "but that property is not declared in the parent object schema."); } } if (rawSchema.required === undefined) { return; } if (!Array.isArray(rawSchema.required)) { throw new Error( `Schema property '${displayPath}' must declare 'required' in ${contextLabel} as an array of property names.`); } for (const requiredProperty of rawSchema.required) { if (typeof requiredProperty !== "string") { throw new Error( `Schema property '${displayPath}' must declare 'required' entries in ${contextLabel} as property-name strings.`); } if (requiredProperty.trim().length === 0) { throw new Error( `Schema property '${displayPath}' cannot declare blank property names in 'required' for ${contextLabel}.`); } if (Object.prototype.hasOwnProperty.call(properties, requiredProperty)) { continue; } throw new Error( `Schema property '${displayPath}' requires property '${requiredProperty}' in ${contextLabel}, ` + "but that property is not declared in the parent object schema."); } } /** * 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") { const diagnosticsBeforeNode = diagnostics.length; validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode); return; } if (schemaNode.type === "array") { const diagnosticsBeforeNode = diagnostics.length; 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 = []; const containsCandidateItems = []; let hasStructurallyInvalidArrayItems = 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); if (isStructurallyCompatibleWithSchemaNode(schemaNode.items, yamlNode.items[index])) { containsCandidateItems.push({index, node: yamlNode.items[index]}); } else { hasStructurallyInvalidArrayItems = true; } // Keep uniqueItems focused on values that are otherwise valid so a // shape/type or constraint error does not also surface as a misleading duplicate. if (diagnostics.length === diagnosticsBeforeValidation) { comparableItems.push({index, node: yamlNode.items[index]}); } } 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 (!hasStructurallyInvalidArrayItems && schemaNode.contains) { let matchingContainsCount = 0; for (const {node} of containsCandidateItems) { if (matchesSchemaNode(schemaNode.contains, node, true)) { 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) }) }); } } validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateNotSchemaMatch(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; } validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); 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 }) }); } if (supportsPatternConstraints && !matchesSchemaStringFormat(scalarValue, schemaNode.format)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.formatViolation, localizer, { displayPath, value: schemaNode.format }) }); } validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateNotSchemaMatch(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. * @param {number} diagnosticsBeforeNode Diagnostic count recorded before validating this object node. */ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode) { 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) }) }); } } const reportedMessages = new Set( diagnostics .slice(diagnosticsBeforeNode) .map((diagnostic) => diagnostic.message)); 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 (schemaNode.dependentRequired && typeof schemaNode.dependentRequired === "object") { for (const [triggerProperty, dependencies] of Object.entries(schemaNode.dependentRequired)) { if (!yamlNode.map.has(triggerProperty)) { continue; } for (const dependency of dependencies) { if (yamlNode.map.has(dependency)) { continue; } const localizedMessage = localizeValidationMessage( ValidationMessageKeys.dependentRequiredViolation, localizer, { displayPath: joinPropertyPath(displayPath, dependency), triggerProperty: joinPropertyPath(displayPath, triggerProperty) }); if (reportedMessages.has(localizedMessage)) { continue; } diagnostics.push({ severity: "error", message: localizedMessage }); reportedMessages.add(localizedMessage); } } } if (schemaNode.dependentSchemas && typeof schemaNode.dependentSchemas === "object") { for (const [triggerProperty, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) { if (matchesSchemaNode(dependentSchema, yamlNode, true)) { continue; } const localizedMessage = localizeValidationMessage( ValidationMessageKeys.dependentSchemasViolation, localizer, { displayPath: displayPath || "", triggerProperty: joinPropertyPath(displayPath, triggerProperty) }); if (reportedMessages.has(localizedMessage)) { continue; } diagnostics.push({ severity: "error", message: localizedMessage }); reportedMessages.add(localizedMessage); } } if (Array.isArray(schemaNode.allOf)) { for (let index = 0; index < schemaNode.allOf.length; index += 1) { if (matchesSchemaNode(schemaNode.allOf[index], yamlNode, true)) { continue; } const localizedMessage = localizeValidationMessage( ValidationMessageKeys.allOfViolation, localizer, { displayPath: displayPath || "", index: String(index + 1) }); if (reportedMessages.has(localizedMessage)) { continue; } diagnostics.push({ severity: "error", message: localizedMessage }); reportedMessages.add(localizedMessage); } } const ifMatched = schemaNode.ifSchema ? matchesSchemaNode(schemaNode.ifSchema, yamlNode, true) : false; if (ifMatched && schemaNode.thenSchema && !matchesSchemaNode(schemaNode.thenSchema, yamlNode, true)) { const localizedMessage = localizeValidationMessage( ValidationMessageKeys.thenViolation, localizer, { displayPath: displayPath || "" }); if (!reportedMessages.has(localizedMessage)) { diagnostics.push({ severity: "error", message: localizedMessage }); reportedMessages.add(localizedMessage); } } if (!ifMatched && schemaNode.ifSchema && schemaNode.elseSchema && !matchesSchemaNode(schemaNode.elseSchema, yamlNode, true)) { const localizedMessage = localizeValidationMessage( ValidationMessageKeys.elseViolation, localizer, { displayPath: displayPath || "" }); if (!reportedMessages.has(localizedMessage)) { diagnostics.push({ severity: "error", message: localizedMessage }); reportedMessages.add(localizedMessage); } } 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) }) }); } validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); } /** * Enumerate object-level `dependentSchemas` entries whose trigger property is * present on the current YAML object. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. * @returns {Array<[string, SchemaNode]>} Triggered dependent schema entries. */ function getTriggeredDependentSchemas(schemaNode, yamlNode) { if (!schemaNode.dependentSchemas || typeof schemaNode.dependentSchemas !== "object" || !yamlNode || yamlNode.kind !== "object") { return []; } const triggeredSchemas = []; for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) { if (yamlNode.map.has(triggerProperty)) { triggeredSchemas.push([triggerProperty, dependentSchema]); } } return triggeredSchemas; } /** * Test whether one YAML node satisfies one schema node without emitting user-facing diagnostics. * This is used by array `contains`, where object sub-schemas must behave like * partial matchers: declared properties, required members, and constraints must * match, but additional object members outside the sub-schema must not block a hit. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. * @param {boolean} allowUnknownObjectProperties Whether object matching should * tolerate extra undeclared properties. * @returns {boolean} True when the YAML node matches the schema node. */ function matchesSchemaNode(schemaNode, yamlNode, allowUnknownObjectProperties = false) { return matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties); } /** * Match one YAML node against one schema node using JSON-Schema-style subset semantics. * The helper mirrors validation rules closely, but it intentionally skips unknown-property * rejection for objects so `contains` can test whether one item satisfies a sub-schema. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. * @param {boolean} allowUnknownObjectProperties Whether object matching should * tolerate extra undeclared properties. * @returns {boolean} True when the YAML node satisfies the schema node. */ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties) { if (schemaNode.type === "object") { if (!yamlNode || yamlNode.kind !== "object") { return false; } 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)) { return false; } } if (!allowUnknownObjectProperties) { for (const entry of yamlNode.entries) { if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { return false; } } } for (const [key, childSchema] of Object.entries(schemaNode.properties)) { if (yamlNode.map.has(key) && !matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key), allowUnknownObjectProperties)) { return false; } } if (schemaNode.dependentRequired && typeof schemaNode.dependentRequired === "object") { for (const [triggerProperty, dependencies] of Object.entries(schemaNode.dependentRequired)) { if (!yamlNode.map.has(triggerProperty)) { continue; } for (const dependency of dependencies) { if (!yamlNode.map.has(dependency)) { return false; } } } } for (const [, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) { if (!matchesSchemaNodeInternal(dependentSchema, yamlNode, true)) { return false; } } if (Array.isArray(schemaNode.allOf)) { for (const allOfSchema of schemaNode.allOf) { if (!matchesSchemaNodeInternal(allOfSchema, yamlNode, true)) { return false; } } } const ifMatched = schemaNode.ifSchema ? matchesSchemaNodeInternal(schemaNode.ifSchema, yamlNode, true) : false; if (ifMatched && schemaNode.thenSchema && !matchesSchemaNodeInternal(schemaNode.thenSchema, yamlNode, true)) { return false; } if (!ifMatched && schemaNode.ifSchema && schemaNode.elseSchema && !matchesSchemaNodeInternal(schemaNode.elseSchema, yamlNode, true)) { return false; } if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { return false; } if (typeof schemaNode.maxProperties === "number" && propertyCount > schemaNode.maxProperties) { return false; } if (Array.isArray(schemaNode.enumComparableValues) && schemaNode.enumComparableValues.length > 0 && !schemaNode.enumComparableValues.includes(buildComparableNodeValue(schemaNode, yamlNode))) { return false; } if (typeof schemaNode.constComparableValue === "string" && buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { return false; } return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false); } if (schemaNode.type === "array") { if (!yamlNode || yamlNode.kind !== "array") { return false; } if (typeof schemaNode.minItems === "number" && yamlNode.items.length < schemaNode.minItems) { return false; } if (typeof schemaNode.maxItems === "number" && yamlNode.items.length > schemaNode.maxItems) { return false; } for (const item of yamlNode.items) { if (!matchesSchemaNodeInternal(schemaNode.items, item, allowUnknownObjectProperties)) { return false; } } if (schemaNode.uniqueItems === true) { const seenItems = new Set(); for (const item of yamlNode.items) { const comparableValue = buildComparableNodeValue(schemaNode.items, item); if (seenItems.has(comparableValue)) { return false; } seenItems.add(comparableValue); } } if (schemaNode.contains) { let matchingContainsCount = 0; for (const item of yamlNode.items) { if (matchesSchemaNodeInternal(schemaNode.contains, item, true)) { matchingContainsCount += 1; } } const requiredMinContains = typeof schemaNode.minContains === "number" ? schemaNode.minContains : 1; if (matchingContainsCount < requiredMinContains) { return false; } if (typeof schemaNode.maxContains === "number" && matchingContainsCount > schemaNode.maxContains) { return false; } } if (Array.isArray(schemaNode.enumComparableValues) && schemaNode.enumComparableValues.length > 0 && !schemaNode.enumComparableValues.includes(buildComparableNodeValue(schemaNode, yamlNode))) { return false; } if (typeof schemaNode.constComparableValue === "string" && buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { return false; } return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false); } if (!yamlNode || yamlNode.kind !== "scalar") { return false; } if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { return false; } if (Array.isArray(schemaNode.enumComparableValues) && schemaNode.enumComparableValues.length > 0 && !schemaNode.enumComparableValues.includes(buildComparableNodeValue(schemaNode, yamlNode))) { return false; } 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) { return false; } if (supportsNumericConstraints && typeof schemaNode.exclusiveMinimum === "number" && Number(scalarValue) <= schemaNode.exclusiveMinimum) { return false; } if (supportsNumericConstraints && typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) { return false; } if (supportsNumericConstraints && typeof schemaNode.exclusiveMaximum === "number" && Number(scalarValue) >= schemaNode.exclusiveMaximum) { return false; } if (supportsNumericConstraints && !matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) { return false; } if (supportsLengthConstraints && typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { return false; } if (supportsLengthConstraints && typeof schemaNode.maxLength === "number" && scalarValue.length > schemaNode.maxLength) { return false; } if (supportsPatternConstraints && !matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) { return false; } if (supportsPatternConstraints && !matchesSchemaStringFormat(scalarValue, schemaNode.format)) { return false; } if (typeof schemaNode.constComparableValue === "string" && buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { return false; } return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false); } /** * Emit one validation error when the current YAML node matches a forbidden `not` * sub-schema. Unlike `contains`, this path keeps object matching strict so * undeclared members still block the negated branch from matching. * * @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 validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (!schemaNode.not || !matchesSchemaNode(schemaNode.not, yamlNode, false)) { return; } diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.notViolation, localizer, { displayPath }) }); } /** * Test whether one YAML node is structurally compatible with one schema node. * This keeps array-level `contains` validation from producing noisy follow-on * diagnostics when an item already has a shape or scalar-type mismatch, while * still allowing value-level constraint failures to participate in contains counting. * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. * @returns {boolean} True when the YAML node has the expected recursive shape. */ function isStructurallyCompatibleWithSchemaNode(schemaNode, yamlNode) { if (schemaNode.type === "object") { if (!yamlNode || yamlNode.kind !== "object") { return false; } for (const requiredProperty of schemaNode.required) { if (!yamlNode.map.has(requiredProperty)) { return false; } } for (const entry of yamlNode.entries) { if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { return false; } if (!isStructurallyCompatibleWithSchemaNode(schemaNode.properties[entry.key], entry.node)) { return false; } } return true; } if (schemaNode.type === "array") { if (!yamlNode || yamlNode.kind !== "array") { return false; } for (const item of yamlNode.items) { if (!isStructurallyCompatibleWithSchemaNode(schemaNode.items, item)) { return false; } } return true; } return Boolean(yamlNode) && yamlNode.kind === "scalar" && isScalarCompatible(schemaNode.type, yamlNode.value); } /** * Validate one parsed YAML node against one normalized enum comparable set. * * @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. * @param {number} [diagnosticsBeforeNode] Diagnostic count recorded before validating this node. */ function validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode) { if (!Array.isArray(schemaNode.enumComparableValues) || schemaNode.enumComparableValues.length === 0) { return; } if (typeof diagnosticsBeforeNode === "number" && diagnostics.length !== diagnosticsBeforeNode) { return; } const comparableValue = buildComparableNodeValue(schemaNode, yamlNode); if (schemaNode.enumComparableValues.includes(comparableValue)) { return; } const displayValues = Array.isArray(schemaNode.enumDisplayValues) && schemaNode.enumDisplayValues.length > 0 ? schemaNode.enumDisplayValues : Array.isArray(schemaNode.enumValues) ? schemaNode.enumValues : []; diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.enumMismatch, localizer, { displayPath, values: displayValues.join(", ") }) }); } /** * 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.allOfViolation: return `对象“${params.displayPath}”必须满足全部 \`allOf\` schema,第 ${params.index} 项未匹配。`; case ValidationMessageKeys.constMismatch: return `属性“${params.displayPath}”必须匹配固定值 ${params.value}。`; case ValidationMessageKeys.dependentRequiredViolation: return `属性“${params.triggerProperty}”存在时,必须同时声明属性“${params.displayPath}”。`; case ValidationMessageKeys.dependentSchemasViolation: return `对象“${params.displayPath}”在属性“${params.triggerProperty}”存在时,必须满足对应的 dependent schema。`; case ValidationMessageKeys.elseViolation: return `对象“${params.displayPath}”在内联 \`if\` 条件未命中时,必须满足对应的 \`else\` schema。`; 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.formatViolation: return `属性“${params.displayPath}”必须满足字符串格式“${params.value}”。`; 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.notViolation: return `属性“${params.displayPath}”不能匹配被 \`not\` 禁止的 schema。`; case ValidationMessageKeys.thenViolation: return `对象“${params.displayPath}”在内联 \`if\` 条件命中时,必须满足对应的 \`then\` schema。`; 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.allOfViolation: return `Object '${params.displayPath}' must satisfy all 'allOf' schemas; entry #${params.index} did not match.`; case ValidationMessageKeys.constMismatch: return `Property '${params.displayPath}' must match constant value ${params.value}.`; case ValidationMessageKeys.dependentRequiredViolation: return `Property '${params.displayPath}' is required when sibling property '${params.triggerProperty}' is present.`; case ValidationMessageKeys.dependentSchemasViolation: return `Object '${params.displayPath}' must satisfy the dependent schema triggered by sibling property '${params.triggerProperty}'.`; case ValidationMessageKeys.elseViolation: return `Object '${params.displayPath}' must satisfy the 'else' schema because the inline 'if' condition did not match.`; 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.formatViolation: return `Property '${params.displayPath}' must satisfy string format '${params.value}'.`; 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.notViolation: return `Property '${params.displayPath}' must not match the forbidden 'not' schema.`; case ValidationMessageKeys.thenViolation: return `Object '${params.displayPath}' must satisfy the 'then' schema because the inline 'if' condition matched.`; 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") { if (schemaNode && schemaNode.enumSampleValue !== undefined) { return createNodeFromFormValue(schemaNode.enumSampleValue); } 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.enumSampleValue !== undefined) { return createNodeFromFormValue(schemaNode.enumSampleValue); } 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, * dependentRequired?: Record, * dependentSchemas?: Record, * allOf?: SchemaNode[], * ifSchema?: SchemaNode, * thenSchema?: SchemaNode, * elseSchema?: SchemaNode, * title?: string, * description?: string, * defaultValue?: string, * enumSampleValue?: unknown, * enumDisplayValues?: string[], * enumComparableValues?: string[], * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, * not?: SchemaNode * } | { * type: "array", * displayPath: string, * title?: string, * description?: string, * defaultValue?: string, * enumSampleValue?: unknown, * enumDisplayValues?: string[], * enumComparableValues?: string[], * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, * minItems?: number, * maxItems?: number, * minContains?: number, * maxContains?: number, * uniqueItems?: boolean, * refTable?: string, * contains?: SchemaNode, * not?: SchemaNode, * items: SchemaNode * } | { * type: "string" | "integer" | "number" | "boolean", * displayPath: string, * title?: string, * description?: string, * defaultValue?: string, * enumSampleValue?: unknown, * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, * enumDisplayValues?: string[], * enumComparableValues?: string[], * minimum?: number, * exclusiveMinimum?: number, * maximum?: number, * exclusiveMaximum?: number, * multipleOf?: number, * minLength?: number, * maxLength?: number, * pattern?: string, * patternRegex?: RegExp, * format?: string, * enumValues?: string[], * refTable?: string, * not?: SchemaNode * }} 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 */