- Completion for brand values in .archify (YAML) and Archify JSON sources, from the bundled brand-marks/catalog.json (107 marks); aliases and product names match, so "kafka" offers apache-kafka - brand/unknown diagnostics carry their JSON pointer only at the start of the message; use it when subject.path is missing, so the error lands on the brand line instead of the top of the file - Unit tests for catalog loading, value detection and pointer fallback; integration checks for completion and diagnostic placement Closes #5
160 lines
5.9 KiB
TypeScript
160 lines
5.9 KiB
TypeScript
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<string, NodeJS.Timeout>();
|
|
private readonly sequence = new Map<string, number>();
|
|
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<boolean>('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<void> {
|
|
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();
|
|
}
|
|
}
|