Files
archify-vscode-ext/src/model/service.ts
T
root-at-skic ebf320a023 Architecture decisions: files, diagram references, views and panels
- *.archify-decision files: id (unique, words), title, status, date,
  elements, diagrams, supersedes, and prose; parsed, merged and link-checked
  in the model index; diagnostics on the file
- diagrams point at decisions with decisions: [...] on the diagram, nodes
  and edges; stripped before Archify, unknown ids reported, completion,
  hover and go-to-definition for the ids and for elements/diagrams/
  supersedes in decision files
- Decisions panel on the main.archify page; Decisions rail panel under a
  diagram (hover lights up the node or connection); decisions on element
  cards and in the element dialog; decision view for the file, with a
  lost-model note outside a model
- Archify | New Decision… command and template; editor schemas; the three
  New Model examples carry decisions; docs, changelog, 0.8.0
2026-10-19 23:13:05 +03:00

209 lines
8.1 KiB
TypeScript

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<string, string[]>;
/** The model's decisions, with link problems already checked. */
decisions: DecisionSet;
/** Decision id → diagrams that point at it, with where. */
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
/** Nested main.archify files found below the root; not supported. */
nested: string[];
}
const SKIP = '{**/node_modules/**,**/.git/**}';
async function exists(uri: vscode.Uri): Promise<boolean> {
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<string> {
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<string, vscode.Uri | null>();
private readonly indexes = new Map<string, Promise<ModelIndex>>();
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<void> {
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<vscode.Uri | undefined> {
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<ModelIndex | undefined> {
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<ModelIndex> {
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<ModelIndex> {
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<string, string[]>();
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<string, { path: string; attachedTo: DecisionAttachment }[]>();
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();
}
}