import path from 'node:path'; import * as vscode from 'vscode'; import { log } from '../util/log'; import { DecisionAttachment, DiagramSummary, MAIN_FILE, ModelMeta, REGISTRY_EXT, Registry, hasDiagramBody, isMainFile, isRegistryFile, mergeRegistries, parseRegistry, readModelMeta, sortedGroups, summarizeDiagram, } from './format'; import { parse as parseYaml } from 'yaml'; import { DECISION_EXT, DecisionSet, isDecisionFile, mergeDecisions, parseDecision, validateDecisions } from './decisions'; /** * Finds models in the workspace and keeps what they contain. A model is the * folder tree under a `main.archify`; this service answers "which model is * this file in?", loads the model's registry, lists its diagrams, and tells * listeners when any of that changes. */ export interface ModelIndex { root: vscode.Uri; main: vscode.Uri; meta: ModelMeta; /** True when main.archify also carries a diagram. */ mainHasDiagram: boolean; registry: Registry; diagrams: DiagramSummary[]; groups: { group: string; diagrams: DiagramSummary[] }[]; /** Element id → diagrams (relative paths) that refer to it. */ usage: Map; /** The model's decisions, with link problems already checked. */ decisions: DecisionSet; /** Decision id → diagrams that point at it, with where. */ decisionUsage: Map; /** Nested main.archify files found below the root; not supported. */ nested: string[]; } const SKIP = '{**/node_modules/**,**/.git/**}'; async function exists(uri: vscode.Uri): Promise { try { await vscode.workspace.fs.stat(uri); return true; } catch { return false; } } function text(bytes: Uint8Array): string { return new TextDecoder('utf-8').decode(bytes); } /** Open editor text when the file is open, else the file on disk. */ async function readText(uri: vscode.Uri): Promise { const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString()); if (open) return open.getText(); return text(await vscode.workspace.fs.readFile(uri)); } export class ModelService implements vscode.Disposable { private readonly roots = new Map(); private readonly indexes = new Map>(); private readonly changeEmitter = new vscode.EventEmitter<{ root: vscode.Uri; file: vscode.Uri }>(); /** Fires with the model root and the file under it that changed. */ readonly onDidChangeModel = this.changeEmitter.event; private readonly disposables: vscode.Disposable[]; constructor() { const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT},${DECISION_EXT}}`); const changed = (uri: vscode.Uri) => void this.onFileChanged(uri); this.disposables = [ watcher, watcher.onDidCreate(changed), watcher.onDidDelete(changed), watcher.onDidChange(changed), vscode.workspace.onDidChangeTextDocument((event) => { // Registry edits in the editor should re-render open diagrams before a save. if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName) || isDecisionFile(event.document.fileName)) changed(event.document.uri); }), this.changeEmitter, ]; } private async onFileChanged(uri: vscode.Uri): Promise { const name = path.basename(uri.path); if (name === MAIN_FILE) this.roots.clear(); // a model may have appeared or disappeared const root = await this.rootOf(uri); if (!root) return; this.indexes.delete(root.toString()); this.changeEmitter.fire({ root, file: uri }); } /** The model root (folder with main.archify) above a file, or undefined. */ async rootOf(uri: vscode.Uri): Promise { if (uri.scheme !== 'file') return undefined; const dirKey = path.dirname(uri.fsPath); const cached = this.roots.get(dirKey); if (cached !== undefined) return cached ?? undefined; let dir = dirKey; const visited: string[] = []; let found: vscode.Uri | null = null; for (;;) { visited.push(dir); if (await exists(vscode.Uri.file(path.join(dir, MAIN_FILE)))) { found = vscode.Uri.file(dir); break; } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } for (const d of visited) this.roots.set(d, found); return found ?? undefined; } /** The model index of the model a file belongs to, or undefined outside a model. */ async indexFor(uri: vscode.Uri): Promise { const root = await this.rootOf(uri); return root ? this.index(root) : undefined; } /** The registry of the model a file belongs to, or undefined outside a model. */ async registryFor(uri: vscode.Uri): Promise<{ root: vscode.Uri; registry: Registry } | undefined> { const root = await this.rootOf(uri); if (!root) return undefined; const index = await this.index(root); return { root, registry: index.registry }; } /** Everything known about a model, cached until a file under it changes. */ index(root: vscode.Uri): Promise { const key = root.toString(); let pending = this.indexes.get(key); if (!pending) { pending = this.build(root).catch((error) => { this.indexes.delete(key); throw error; }); this.indexes.set(key, pending); } return pending; } private async build(root: vscode.Uri): Promise { const main = vscode.Uri.joinPath(root, MAIN_FILE); let meta: ModelMeta = {}; let mainHasDiagram = false; try { const doc = parseYaml(await readText(main), { prettyErrors: false, uniqueKeys: false }); meta = readModelMeta(doc) ?? {}; mainHasDiagram = hasDiagramBody(doc); } catch (error) { log().warn(`main.archify of ${root.fsPath} does not parse: ${error instanceof Error ? error.message : String(error)}`); } const registryFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${REGISTRY_EXT}`), SKIP); const parts = await Promise.all( registryFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => { const rel = path.relative(root.fsPath, file.fsPath); return { file: rel, ...parseRegistry(await readText(file), rel) }; }), ); const registry = mergeRegistries(parts); const diagramFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.archify'), SKIP); const nested: string[] = []; const diagrams: DiagramSummary[] = []; for (const file of diagramFiles) { const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/'); if (rel === MAIN_FILE) continue; if (path.basename(file.fsPath) === MAIN_FILE) { nested.push(rel); continue; } diagrams.push(summarizeDiagram(await readText(file), rel)); } const usage = new Map(); for (const d of diagrams) for (const id of d.used) usage.set(id, [...(usage.get(id) ?? []), d.relativePath]); const decisionFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${DECISION_EXT}`), SKIP); const decisionParts = await Promise.all( decisionFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => { const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/'); return { file: rel, ...parseDecision(await readText(file), rel) }; }), ); const decisions = mergeDecisions(decisionParts); decisions.problems.push(...validateDecisions(decisions, registry, new Set(diagrams.map((d) => d.relativePath)))); const decisionUsage = new Map(); for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]); return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, decisions, decisionUsage, nested }; } dispose(): void { for (const d of this.disposables.splice(0)) d.dispose(); } }