- *.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
103 lines
6.0 KiB
TypeScript
103 lines
6.0 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import { Decision, DECISION_STATUSES, effectiveStatus, isDecisionFile } from '../model/decisions';
|
|
import { ModelIndex, ModelService } from '../model/service';
|
|
|
|
/** Diagrams (decisions: […]) and decision files (elements: […], diagrams: […], supersedes: […]). */
|
|
export const DECISION_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }, { pattern: '**/*.archify-decision' }];
|
|
|
|
type ListKey = 'decisions' | 'elements' | 'diagrams' | 'supersedes';
|
|
|
|
/** Which list the cursor is in on a `key: [a, b, …` line, and what has been typed of the current item. */
|
|
export function listValueAt(linePrefix: string, fileName: string): { key: ListKey; typed: string } | undefined {
|
|
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
|
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)$`).exec(linePrefix);
|
|
if (match) {
|
|
const items = match[2].split(',');
|
|
return { key: match[1] as ListKey, typed: items[items.length - 1].replace(/^[\s"']+/, '') };
|
|
}
|
|
// Block list item under the key: " - 00" (the key is on a line above; the caller checks).
|
|
return undefined;
|
|
}
|
|
|
|
/** The list item under a position on a flow-list line, with its range. */
|
|
export function listItemAt(line: string, character: number, fileName: string): { key: ListKey; value: string; start: number; end: number } | undefined {
|
|
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
|
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)\\]?`).exec(line);
|
|
if (!match) return undefined;
|
|
const listStart = match.index + match[0].indexOf('[') + 1;
|
|
const re = /[^,\s"'\]]+/g;
|
|
const inner = match[2];
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(inner))) {
|
|
const start = listStart + m.index;
|
|
const end = start + m[0].length;
|
|
if (character >= start && character <= end) return { key: match[1] as ListKey, value: m[0], start, end };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function describeDecision(d: Decision, index: ModelIndex): vscode.MarkdownString {
|
|
const md = new vscode.MarkdownString();
|
|
const { status, supersededBy } = effectiveStatus(d, index.decisions.decisions);
|
|
md.appendMarkdown(`**${d.id}**${d.title ? ` · ${d.title}` : ''} \`${status}\`${supersededBy ? ` by ${supersededBy}` : ''}${d.date ? ` · ${d.date}` : ''}\n\n`);
|
|
if (d.decision) md.appendMarkdown(`${d.decision.trim()}\n\n`);
|
|
md.appendMarkdown(`*${d.file}*`);
|
|
return md;
|
|
}
|
|
|
|
/** Completion, hover and go-to-definition for decision ids in diagrams and for links inside decision files. */
|
|
export class DecisionLanguage implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.DefinitionProvider {
|
|
constructor(private readonly models: ModelService) {}
|
|
|
|
async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.CompletionItem[] | undefined> {
|
|
const at = listValueAt(document.lineAt(position.line).text.slice(0, position.character), document.fileName);
|
|
if (!at) return undefined;
|
|
const index = await this.models.indexFor(document.uri);
|
|
if (!index) return undefined;
|
|
const range = new vscode.Range(position.translate(0, -at.typed.length), position);
|
|
const item = (label: string, description: string | undefined, detail: string, doc?: vscode.MarkdownString) => {
|
|
const it = new vscode.CompletionItem({ label, description }, vscode.CompletionItemKind.Reference);
|
|
it.detail = detail;
|
|
it.documentation = doc;
|
|
it.filterText = `${label} ${description ?? ''}`;
|
|
it.range = range;
|
|
return it;
|
|
};
|
|
if (at.key === 'decisions' || at.key === 'supersedes') {
|
|
return [...index.decisions.decisions.values()].map((d) => item(d.id, d.title, effectiveStatus(d, index.decisions.decisions).status, describeDecision(d, index)));
|
|
}
|
|
if (at.key === 'elements') {
|
|
return [...index.registry.elements.values()].map((e) => item(e.id, e.label, [e.type, e.sublabel].filter(Boolean).join(' · ')));
|
|
}
|
|
return index.diagrams.map((d) => item(d.relativePath, d.title, d.kind ?? 'diagram'));
|
|
}
|
|
|
|
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
|
|
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
|
if (!hit || (hit.key !== 'decisions' && hit.key !== 'supersedes')) return undefined;
|
|
const index = await this.models.indexFor(document.uri);
|
|
const range = new vscode.Range(position.line, hit.start, position.line, hit.end);
|
|
const d = index?.decisions.decisions.get(hit.value);
|
|
if (!index) return new vscode.Hover(`\`${hit.value}\` cannot be resolved: this file is not inside a model.`, range);
|
|
if (!d) return new vscode.Hover(`Unknown decision \`${hit.value}\`: no .archify-decision file of this model defines it.`, range);
|
|
return new vscode.Hover(describeDecision(d, index), range);
|
|
}
|
|
|
|
async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Location | undefined> {
|
|
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
|
if (!hit) return undefined;
|
|
const index = await this.models.indexFor(document.uri);
|
|
if (!index) return undefined;
|
|
if (hit.key === 'decisions' || hit.key === 'supersedes') {
|
|
const d = index.decisions.decisions.get(hit.value);
|
|
return d ? new vscode.Location(vscode.Uri.joinPath(index.root, ...d.file.split('/')), new vscode.Position(d.lines.id, 2)) : undefined;
|
|
}
|
|
if (hit.key === 'elements') {
|
|
const e = index.registry.elements.get(hit.value);
|
|
return e ? new vscode.Location(vscode.Uri.joinPath(index.root, ...e.file.split(/[\\/]/)), new vscode.Position(e.line, 2)) : undefined;
|
|
}
|
|
const diagram = index.diagrams.find((d) => d.relativePath === hit.value);
|
|
return diagram ? new vscode.Location(vscode.Uri.joinPath(index.root, ...diagram.relativePath.split('/')), new vscode.Position(0, 0)) : undefined;
|
|
}
|
|
}
|