import fs from 'node:fs'; import path from 'node:path'; import Ajv2020, { ErrorObject, ValidateFunction } from 'ajv/dist/2020'; import { LineCounter, Node, isMap, isScalar, isSeq, parseDocument } from 'yaml'; /** A schema problem placed on the YAML node it is about; offsets into the text. */ export interface SchemaProblem { from: number; to: number; message: string; /** 'yaml' for a syntax error (only the first is reported), 'schema' otherwise. */ kind: 'yaml' | 'schema'; } /** Schema file names in schemas/ that a file kind is checked against. */ export const DIAGRAM_SCHEMA = 'archify.schema.json'; export const REGISTRY_SCHEMA = 'archify-registry.schema.json'; /** Reads every *.schema.json of a folder: file name → schema. */ export function readSchemas(folder: string): Record { return Object.fromEntries( fs .readdirSync(folder) .filter((name) => name.endsWith('.schema.json')) .map((name) => [name, JSON.parse(fs.readFileSync(path.join(folder, name), 'utf8')) as object]), ); } /** * Checks YAML text against our JSON Schemas (draft 2020-12, with ajv), so the * Source view and the Problems panel see the same problems whether or not a * YAML extension is installed. Schemas refer to each other by file name. */ export class SchemaChecker { private readonly ajv = new Ajv2020({ allErrors: true, strict: false }); private readonly compiled = new Map(); constructor(schemas: Record) { for (const [name, schema] of Object.entries(schemas)) this.ajv.addSchema(schema, name); } private validator(name: string): ValidateFunction | undefined { if (!this.compiled.has(name)) this.compiled.set(name, this.ajv.getSchema(name)); return this.compiled.get(name); } check(text: string, schema: string): SchemaProblem[] { const doc = parseDocument(text, { lineCounter: new LineCounter(), prettyErrors: false, uniqueKeys: false }); if (doc.errors.length) { // Later syntax errors are usually echoes of the first. const error = doc.errors[0]; return [{ from: error.pos[0], to: Math.max(error.pos[1], error.pos[0] + 1), message: `YAML: ${error.message.split('\n')[0]}`, kind: 'yaml' }]; } const validate = this.validator(schema); if (!validate || validate(doc.toJS())) return []; return collapse(validate.errors ?? []).map((error) => { const [from, to] = rangeFor(doc.contents, error, text.length); return { from, to, message: describe(error), kind: 'schema' as const }; }); } } /** oneOf/anyOf/if produce a cascade; keep the errors a person can act on. */ function collapse(errors: ErrorObject[]): ErrorObject[] { const seen = new Set(); return errors .filter((e) => !['if', 'oneOf', 'anyOf', 'allOf'].includes(e.keyword)) .filter((e) => { const key = `${e.instancePath}|${e.keyword}|${JSON.stringify(e.params)}`; if (seen.has(key)) return false; seen.add(key); return true; }); } /** "/connections/4/label" → "connections[4].label". */ export function readablePointer(pointer: string): string { if (!pointer) return 'the file'; return pointer .split('/') .slice(1) .map((part) => (/^\d+$/.test(part) ? `[${part}]` : `.${part.replace(/~1/g, '/').replace(/~0/g, '~')}`)) .join('') .replace(/^\./, ''); } function describe(e: ErrorObject): string { const where = readablePointer(e.instancePath); const params = e.params as Record; switch (e.keyword) { case 'additionalProperties': return `Unknown key "${params.additionalProperty}" in ${where}.`; case 'required': return `"${params.missingProperty}" is required in ${where}.`; case 'enum': return `${where} must be one of: ${(params.allowedValues as unknown[]).join(', ')}.`; case 'const': return `${where} must be ${JSON.stringify(params.allowedValue)}.`; case 'type': { const type = String(params.type); return `${where} must be ${type === 'array' ? 'a list' : type === 'object' ? 'a mapping' : `a ${type}`}.`; } case 'minItems': case 'maxItems': { const limit = Number(params.limit); return `${where} needs ${e.keyword === 'minItems' ? 'at least' : 'at most'} ${limit} item${limit === 1 ? '' : 's'}.`; } case 'propertyNames': return `"${params.propertyName}" in ${where} is not a valid id: start with a letter; letters, digits, - and _ after.`; default: return `${where} ${e.message ?? 'is not valid'}.`; } } function keyText(key: unknown): string { return isScalar(key) ? String(key.value) : String(key); } /** The node an error is about: the offending key for unknown keys, else the value. */ function rangeFor(root: Node | null, error: ErrorObject, length: number): [number, number] { const parts = error.instancePath.split('/').slice(1).map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~')); let node: unknown = root; let keyNode: Node | null = null; for (const part of parts) { if (isMap(node)) { const pair = node.items.find((p) => keyText(p.key) === part); if (!pair) break; keyNode = pair.key as Node; node = pair.value; } else if (isSeq(node)) { keyNode = null; node = node.items[Number(part)]; } else break; } const params = error.params as Record; const extra = params.additionalProperty ?? params.propertyName; if (extra !== undefined && isMap(node)) { const pair = node.items.find((p) => keyText(p.key) === String(extra)); const range = (pair?.key as Node | undefined)?.range; if (range) return [range[0], range[1]]; } if (error.keyword === 'required' && keyNode?.range) return [keyNode.range[0], keyNode.range[1]]; const target = (node as Node | undefined)?.range ? (node as Node) : keyNode; if (!target?.range) return [0, Math.min(1, length)]; // A block mapping or list spans many lines; mark its first character only. if ((isMap(target) || isSeq(target)) && !target.flow) return [target.range[0], target.range[0] + 1]; return [target.range[0], Math.max(target.range[1], target.range[0] + 1)]; }