- *.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
209 lines
8.1 KiB
TypeScript
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();
|
|
}
|
|
}
|