import { parse, ParseError } from 'jsonc-parser'; import * as vscode from 'vscode'; import { isArchifyFile, readDiagramSource } from './detect'; import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer'; import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer'; import { evidenceMode, log, runtimeFor } from './runtime'; const JSON_LANGUAGES = new Set(['json', 'jsonc']); function severity(diagnostic: ArchifyDiagnostic, downgradeEvidence: boolean): vscode.DiagnosticSeverity { if (downgradeEvidence && isEvidenceError(`${diagnostic.code ?? ''} ${diagnostic.message}`)) { return vscode.DiagnosticSeverity.Warning; } switch (diagnostic.severity) { case 'warning': return vscode.DiagnosticSeverity.Warning; case 'info': return vscode.DiagnosticSeverity.Information; default: return vscode.DiagnosticSeverity.Error; } } /** Runs `archify validate` on diagram sources and reports into Problems. */ export class DiagnosticsController implements vscode.Disposable { private readonly collection = vscode.languages.createDiagnosticCollection('archify'); private readonly timers = new Map(); private readonly sequence = new Map(); private readonly disposables: vscode.Disposable[]; constructor(private readonly context: vscode.ExtensionContext) { this.disposables = [ this.collection, vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)), vscode.workspace.onDidChangeTextDocument((event) => { if (event.contentChanges.length) this.schedule(event.document, 700); }), vscode.workspace.onDidSaveTextDocument((doc) => this.schedule(doc, 0)), vscode.workspace.onDidCloseTextDocument((doc) => this.clear(doc.uri)), vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration('archify')) this.revalidateAll(); }), ]; this.revalidateAll(); } private enabled(uri: vscode.Uri): boolean { return vscode.workspace.getConfiguration('archify', uri).get('validation.enabled', true); } revalidateAll(): void { for (const doc of vscode.workspace.textDocuments) this.schedule(doc, 0); } private clear(uri: vscode.Uri): void { const key = uri.toString(); clearTimeout(this.timers.get(key)); this.timers.delete(key); this.sequence.set(key, (this.sequence.get(key) ?? 0) + 1); this.collection.delete(uri); } private schedule(doc: vscode.TextDocument, delay: number): void { if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return; const key = doc.uri.toString(); clearTimeout(this.timers.get(key)); this.timers.set(key, setTimeout(() => void this.validate(doc), delay)); } private async validate(doc: vscode.TextDocument): Promise { const key = doc.uri.toString(); this.timers.delete(key); const seq = (this.sequence.get(key) ?? 0) + 1; this.sequence.set(key, seq); const text = doc.getText(); if (!this.enabled(doc.uri) || doc.isClosed) { this.collection.delete(doc.uri); return; } if (isArchifyFile(doc.fileName)) { // Nothing else reports YAML syntax errors in .archify files, and a file // that does not parse has no diagram to validate yet. const problems = yamlSyntaxProblems(text); if (problems.length) { this.collection.set( doc.uri, problems.map((problem) => { const diagnostic = new vscode.Diagnostic( new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)), `YAML: ${problem.message}`, vscode.DiagnosticSeverity.Error, ); diagnostic.source = 'archify'; return diagnostic; }), ); return; } } const source = readDiagramSource(text, doc.fileName); if (!source) { this.collection.delete(doc.uri); return; } if (source.format === 'json') { // Leave JSON syntax errors to the JSON language service. const syntax: ParseError[] = []; parse(text, syntax, { allowTrailingComma: false }); if (syntax.length) { this.collection.delete(doc.uri); return; } } try { const runtime = await runtimeFor(this.context, doc.uri); const result = await validate(runtime, source.type, source.json); if (seq !== this.sequence.get(key) || doc.isClosed) return; if (result.crash) { const diagnostic = new vscode.Diagnostic( new vscode.Range(0, 0, 0, 0), `Archify validation did not complete: ${result.crash.split('\n')[0]}`, vscode.DiagnosticSeverity.Error, ); diagnostic.source = 'archify'; this.collection.set(doc.uri, [diagnostic]); return; } const downgradeEvidence = evidenceMode(doc.uri) === 'fallback'; this.collection.set( doc.uri, result.diagnostics.map((item) => { const pointer = diagnosticPointer(item); const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer); const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end)); const fix = item.supportedFixes?.[0]; const diagnostic = new vscode.Diagnostic( range, fix ? `${item.message}\nFix: ${fix}` : item.message, severity(item, downgradeEvidence), ); diagnostic.source = 'archify'; if (item.code) diagnostic.code = item.code; return diagnostic; }), ); } catch (error) { log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`); } } dispose(): void { for (const timer of this.timers.values()) clearTimeout(timer); for (const disposable of this.disposables.splice(0)) disposable.dispose(); } }