import { toPath } from 'lodash'; import invariant from 'ts-invariant'; import { isNode as yamlIsNode, LineCounter, parseDocument, Node } from 'yaml'; import { AnySchema, ValidationError } from 'yup'; import { ValidateOptions } from 'yup/lib/types'; export interface YAMLDocumentInput { filepath: string; content: string; } export interface YAMLDocumentValidatorIssue { filepath: string; error: ValidationError; message: string; line: number; col: number; responsibleNode: Node; } export type YAMLDocumentValidatorReporter = ( issue: YAMLDocumentValidatorIssue, ) => void; function isNode(obj: unknown): obj is Node { return yamlIsNode(obj); } export class YAMLDocumentValidator { private document: ReturnType; private lineCounter: LineCounter; constructor( public readonly source: YAMLDocumentInput, public readonly schema: AnySchema, ) { this.lineCounter = new LineCounter(); this.document = parseDocument(this.source.content, { lineCounter: this.lineCounter, }); } async run( validateOptions?: ValidateOptions, ): Promise { const issues: YAMLDocumentValidatorIssue[] = []; const jsonDoc: unknown = this.document.toJSON(); try { await this.schema.validate(jsonDoc, validateOptions); } catch (err) { if (!(err instanceof ValidationError)) { throw err; } const validationErrors = err.inner.length > 0 ? err.inner : [err]; for (const validationError of validationErrors) { const issue = this.createIssue(validationError); issues.push(issue); } } return issues; } createIssue(err: ValidationError): YAMLDocumentValidatorIssue { const pathIterable = toPath(err.path); const responsibleNode = this.document.getIn(pathIterable, true) ?? this.document.getIn(pathIterable.slice(0, -1), true); invariant( typeof responsibleNode !== 'undefined', `couldn't find node for ValidationError path: ${String(err.path)}`, ); invariant(isNode(responsibleNode), 'responsibleNode must be a Node'); invariant(responsibleNode.range != null, 'node must have a range'); const { line, col } = this.lineCounter.linePos(responsibleNode.range[0]); return { filepath: this.source.filepath, message: err.message, error: err, responsibleNode, line, col, }; } }