import { GraphEdge, GraphNode, ModelGraph } from './graph'; /** * The graph page's query box. A few words, no language to learn: terms are * ANDed, a leading `-` excludes, `key:value` terms filter by a field or walk * the graph. Pure, shared by the webview bundle and the unit tests. * * database label, id, sublabel, brand or type contain it * type:backend elements of that type * diagram:0200-* drawn by a diagram whose path matches (glob) * decision: what the decision is about or attached to * uses:api api and its neighbours * near:api,2 api and everything within two steps * from:web to:db the shortest path between two elements * unused registry elements no diagram draws * shared / -shared registry elements / inline elements */ export interface QueryTerm { key: string; value: string; negate: boolean; } export interface QueryResult { /** Ids of the elements that match. Every element when the query is empty. */ nodes: Set; /** Ids of the connections shown: both ends match, or the path found. */ edges: Set; /** The path found by `from: to:`, in order. */ path?: string[]; /** Why the result is empty or partial, when it is. */ message?: string; terms: QueryTerm[]; } const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']); const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']); export function parseQuery(text: string): QueryTerm[] { const tokens = text.trim().split(/\s+/).filter(Boolean); const terms: QueryTerm[] = []; for (let i = 0; i < tokens.length; i += 1) { let token = tokens[i]; const negate = token.startsWith('-') && token.length > 1; if (negate) token = token.slice(1); const colon = token.indexOf(':'); let key = colon > 0 ? token.slice(0, colon).toLowerCase() : token.toLowerCase(); let value = colon > 0 ? token.slice(colon + 1) : ''; if (colon > 0 && !WALK_KEYS.has(key) && !FIELD_KEYS.has(key)) { key = 'text'; value = token; } else if (colon < 0 && key !== 'shared' && key !== 'unused') { key = 'text'; value = token; } // `near:api 2` is also accepted: a bare number after near is the hop count. if (key === 'near' && !value.includes(',') && /^\d+$/.test(tokens[i + 1] ?? '')) { value = `${value},${tokens[i + 1]}`; i += 1; } if (!value && key === 'text') continue; terms.push({ key, value, negate }); } return terms; } const lower = (s: string | undefined) => (s ?? '').toLowerCase(); const isElement = (n: GraphNode) => n.kind === 'element'; const isConnection = (e: GraphEdge) => e.kind === 'connection'; /** An element by id, then by id or label ignoring case. Decision and registry nodes are not walked from. */ export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined { const elements = graph.nodes.filter(isElement); const exact = elements.find((n) => n.id === ref); if (exact) return exact; const l = ref.toLowerCase(); return elements.find((n) => n.id.toLowerCase() === l) ?? elements.find((n) => n.label.toLowerCase() === l); } function globToRegExp(pattern: string): RegExp { const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.'); return new RegExp(`^${escaped}$`, 'i'); } function diagramMatches(path: string, pattern: string): boolean { const bare = path.replace(/\.archify$/, ''); const wanted = pattern.replace(/\.archify$/, ''); if (wanted.includes('*') || wanted.includes('?')) return globToRegExp(wanted).test(bare) || globToRegExp(wanted).test(path); const l = wanted.toLowerCase(); return bare.toLowerCase() === l || bare.toLowerCase().startsWith(`${l}/`) || bare.toLowerCase().startsWith(l) || bare.toLowerCase().includes(`/${l}`); } /** Element-to-element adjacency over the diagrams' connections; decision and registry links do not count as steps. */ function neighbours(graph: ModelGraph): Map> { const map = new Map>(); for (const n of graph.nodes) map.set(n.id, new Set()); for (const e of graph.edges.filter(isConnection)) { map.get(e.from)?.add(e.to); map.get(e.to)?.add(e.from); } return map; } function within(graph: ModelGraph, start: string, hops: number): Set { const adjacency = neighbours(graph); let frontier = new Set([start]); const seen = new Set([start]); for (let i = 0; i < hops; i += 1) { const next = new Set(); for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); } frontier = next; if (!frontier.size) break; } return seen; } /** Shortest path by breadth-first search; directed first, then ignoring direction. */ export function shortestPath(graph: ModelGraph, from: string, to: string): { nodes: string[]; edges: string[] } | undefined { const attempt = (directed: boolean) => { const out = new Map(); for (const n of graph.nodes) out.set(n.id, []); for (const e of graph.edges.filter(isConnection)) { out.get(e.from)?.push({ to: e.to, edge: e }); if (!directed) out.get(e.to)?.push({ to: e.from, edge: e }); } const previous = new Map(); const queue = [from]; const seen = new Set([from]); while (queue.length) { const id = queue.shift()!; if (id === to) break; for (const step of out.get(id) ?? []) { if (seen.has(step.to)) continue; seen.add(step.to); previous.set(step.to, { from: id, edge: step.edge }); queue.push(step.to); } } if (!seen.has(to)) return undefined; const nodes = [to]; const edges: string[] = []; let at = to; while (at !== from) { const step = previous.get(at)!; edges.unshift(step.edge.id); nodes.unshift(step.from); at = step.from; } return { nodes, edges }; }; if (from === to) return { nodes: [from], edges: [] }; return attempt(true) ?? attempt(false); } export function runQuery(graph: ModelGraph, text: string): QueryResult { const terms = parseQuery(text); const everything = new Set(graph.nodes.map((n) => n.id)); let result = new Set(everything); const notes: string[] = []; let from: string | undefined; let to: string | undefined; let pathEdges: Set | undefined; let path: string[] | undefined; const matching = (term: QueryTerm): Set | undefined => { const v = term.value; switch (term.key) { case 'text': { const l = v.toLowerCase(); return new Set(graph.nodes.filter((n) => [n.id, n.label, n.sublabel, n.brand, n.type].some((f) => lower(f).includes(l))).map((n) => n.id)); } case 'type': return new Set(graph.nodes.filter((n) => lower(n.type) === v.toLowerCase()).map((n) => n.id)); case 'diagram': return new Set(graph.nodes.filter((n) => n.diagrams.some((d) => diagramMatches(d, v))).map((n) => n.id)); case 'decision': { const ids = new Set(graph.nodes.filter((n) => n.decisions.includes(v)).map((n) => n.id)); for (const e of graph.edges) if (e.decisions.includes(v)) { ids.add(e.from); ids.add(e.to); } if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`); else ids.add(`decision:${v}`); return ids; } case 'shared': return new Set(graph.nodes.filter((n) => isElement(n) && n.shared).map((n) => n.id)); case 'unused': return new Set(graph.nodes.filter((n) => isElement(n) && n.shared && n.diagrams.length === 0).map((n) => n.id)); case 'uses': case 'near': { const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1']; const node = findNode(graph, ref); if (!node) { notes.push(`No element "${ref}".`); return new Set(); } const hops = Math.max(1, Math.min(Number(hopsText) || 1, 50)); return within(graph, node.id, hops); } case 'from': from = v; return undefined; case 'to': to = v; return undefined; } return undefined; }; const negated: Set[] = []; for (const term of terms) { const ids = matching(term); if (!ids) continue; if (term.negate) { negated.push(ids); for (const id of ids) result.delete(id); } else result = new Set([...result].filter((id) => ids.has(id))); } // Decisions and registries follow their elements: a matching element brings the nodes attached to it. if (terms.length) { for (const e of graph.edges) { if (isConnection(e)) continue; if (result.has(e.to) && graph.nodes.some((n) => n.id === e.from && !isElement(n))) result.add(e.from); } for (const ids of negated) for (const id of ids) result.delete(id); } if (from || to) { if (!from || !to) notes.push('A path needs both from: and to:.'); else { const a = findNode(graph, from); const b = findNode(graph, to); if (!a || !b) notes.push(`No element "${!a ? from : to}".`); else { const found = shortestPath(graph, a.id, b.id); if (!found) notes.push(`No path from ${a.id} to ${b.id}.`); else { path = found.nodes; pathEdges = new Set(found.edges); result = new Set(found.nodes.filter((id) => result.has(id))); } } if (!path) result = new Set(); } } const edges = new Set(); for (const e of graph.edges) { if (!result.has(e.from) || !result.has(e.to)) continue; if (pathEdges && !pathEdges.has(e.id)) continue; edges.add(e.id); } return { nodes: result, edges, path, message: notes[0], terms }; }