import { parse, ParseError } from 'jsonc-parser'; import * as vscode from 'vscode'; import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli'; import { evidenceMode, runtimeFor } from '../archify/runtime'; import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format'; import { isDecisionFile, parseDecision } from '../model/decisions'; import { prepareDiagram } from '../model/prepare'; import { ModelService } from '../model/service'; import { isArchifyFile } from '../sources/detect'; import { log } from '../util/log'; import path from 'node:path'; import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer'; import { DIAGRAM_SCHEMA, REGISTRY_SCHEMA, SchemaChecker, SchemaProblem, readSchemas } from './schemaCheck'; 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[]; private checker: SchemaChecker | undefined; constructor( private readonly context: vscode.ExtensionContext, private readonly models: ModelService, ) { this.disposables = [ this.collection, models.onDidChangeModel(() => this.revalidateAll()), 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(); } /** Our schemas, compiled on first use. */ private schemas(): SchemaChecker { this.checker ??= new SchemaChecker(readSchemas(vscode.Uri.joinPath(this.context.extensionUri, 'schemas').fsPath)); return this.checker; } private schemaDiagnostic(doc: vscode.TextDocument, problem: SchemaProblem): vscode.Diagnostic { const diagnostic = new vscode.Diagnostic( new vscode.Range(doc.positionAt(problem.from), doc.positionAt(problem.to)), problem.message, vscode.DiagnosticSeverity.Error, ); diagnostic.source = 'archify'; return diagnostic; } 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) && !isRegistryFile(doc.fileName) && !isDecisionFile(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 (isRegistryFile(doc.fileName)) { await this.validateRegistry(doc, seq); return; } if (isDecisionFile(doc.fileName)) { await this.validateDecision(doc, seq); 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 prepared = await prepareDiagram(this.models, doc.uri, text); if (seq !== this.sequence.get(key) || doc.isClosed) return; if (!prepared) { this.collection.delete(doc.uri); return; } const source = prepared.source; const modelDiagnostics = prepared.problems .filter((problem) => !problem.file) .map((problem) => this.modelDiagnostic(doc, text, source.format, problem)); 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; } } if (isArchifyFile(doc.fileName)) { // The schema check sees the source as written and answers at once; a // file that fails it would fail archify validate the same way, so that // runs only on files the schema accepts. const schemaProblems = this.schemas().check(text, DIAGRAM_SCHEMA); if (schemaProblems.length) { this.collection.set(doc.uri, [...modelDiagnostics, ...schemaProblems.map((problem) => this.schemaDiagnostic(doc, problem))]); 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, [ ...modelDiagnostics, ...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)}`); } } private modelDiagnostic(doc: vscode.TextDocument, text: string, format: 'yaml' | 'json', problem: ModelProblem): vscode.Diagnostic { const offsets = format === 'yaml' ? rangeForPointerYaml(text, problem.pointer) : rangeForPointer(text, problem.pointer); const range = problem.line !== undefined && !problem.pointer ? doc.lineAt(Math.min(problem.line, doc.lineCount - 1)).range : new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end)); const diagnostic = new vscode.Diagnostic(range, problem.message, problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error); diagnostic.source = 'archify'; return diagnostic; } /** A registry file: its own YAML and element problems, plus duplicates across the model. */ private async validateDecision(doc: vscode.TextDocument, seq: number): Promise { const text = doc.getText(); const root = await this.models.rootOf(doc.uri); const rel = root ? path.relative(root.fsPath, doc.uri.fsPath).split(path.sep).join('/') : path.basename(doc.uri.fsPath); const problems: ModelProblem[] = [...parseDecision(text, rel).problems]; if (root) { try { const index = await this.models.index(root); for (const problem of index.decisions.problems) { if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem); } } catch { // The index reports its own failures when the model page opens. } } else { problems.push({ message: `This decision has lost its model: no main.archify above ${path.basename(doc.uri.fsPath)}, so its elements and diagrams cannot be resolved.`, severity: 'warning', line: 0 }); } if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return; this.collection.set(doc.uri, problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem))); } private async validateRegistry(doc: vscode.TextDocument, seq: number): Promise { const text = doc.getText(); const root = await this.models.rootOf(doc.uri); const rel = root ? path.relative(root.fsPath, doc.uri.fsPath) : path.basename(doc.uri.fsPath); const problems: ModelProblem[] = [...parseRegistry(text, rel).problems]; // The schema adds what the parser does not check (types, unknown keys, // ids); a file the parser already rejects as a whole gets no second opinion. const shapeless = problems.some((p) => /not valid YAML|must be a mapping|needs an "elements" mapping/.test(p.message)); const schemaProblems = shapeless ? [] : this.schemas().check(text, REGISTRY_SCHEMA); if (root) { try { const index = await this.models.index(root); for (const problem of index.registry.problems) { if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem); } } catch { // The index reports its own failures when the model page opens. } } else { problems.push({ message: `This registry is not inside a model: no main.archify above ${path.basename(doc.uri.fsPath)}.`, severity: 'warning', line: 0 }); } if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return; this.collection.set(doc.uri, [ ...problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem)), ...schemaProblems.map((problem) => this.schemaDiagnostic(doc, problem)), ]); } dispose(): void { for (const timer of this.timers.values()) clearTimeout(timer); for (const disposable of this.disposables.splice(0)) disposable.dispose(); } }