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 { 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 { 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 { 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; } }