import { FlowEdge, FlowEnd, FlowNode, FlowSchema, FlowStep, FlowNodeOrEnd, GuardNode, TryNode, IfNode } from './flow.js'; import { isCall, isConditionGroup, isFlowSlot, methodOf } from './flow.js'; import type { FlowMethodRef, FlowNodeMethodRef, GuardCondition } from './flow.js'; import { Page } from './page.js'; import { PageEdge, PageFlow } from './page-flow.js'; // Mermaid driver: converts a FlowSchema into a Mermaid flowchart (TD). // Node ids are hierarchical (n0, n0_0, n0_0_0, ...) so nested sub-flows stay // globally unique. A node with a sub-flow renders as a subgraph block whose // internals are rendered recursively. A TryNode renders as a subgraph for its // body plus one per catch handler (and finally): body exception ends route to // handlers as dashed `catch X` edges, fall-through paths (body return end and // handler return ends, through finally when present) continue via the // TryNode's outgoing edges, and handler exception ends rethrow via the // TryNode's typed throws edges. Guard nodes render as diamonds, FlowEnd nodes // as stadium shapes. Normal edges render as -->, conditional edges as // -->|"WHEN"|, typed throws and catch routes as dashed edges. function escapeLabel(s: string): string { return s.replace(/"/g, '\\"').replace(/\n/g, '
').replace(/\|/g, '|'); } export function renderFlowMermaid(schema: FlowSchema): string { const lines: string[] = ['flowchart TD']; const ids = new Map(); renderFlow(schema, 'n', lines, ids); lines.push(''); lines.push(' classDef start fill:#e6f4ea,stroke:#333,stroke-width:1px;'); lines.push(` class ${ids.get(schema.start)} start;`); return lines.join('\n'); } function renderFlow(schema: FlowSchema, prefix: string, lines: string[], ids: Map): void { schema.nodes.forEach((n, i) => ids.set(n, `${prefix}${i}`)); const outgoing = new Map(); for (const n of schema.nodes) outgoing.set(n, []); for (const e of schema.edges) outgoing.get(e.start)!.push(e); for (const n of schema.nodes) { const id = ids.get(n)!; if (isTryNode(n)) { lines.push(` subgraph ${id}["${escapeLabel(n.name)}"]`); renderFlow(n.body, `${id}_b`, lines, ids); lines.push(' end'); const handlers: FlowSchema[] = []; const handlerIdx = new Map(); for (const c of n.catches) { if (handlerIdx.has(c.handler)) continue; // one handler may serve many catches handlerIdx.set(c.handler, handlers.length); handlers.push(c.handler); } handlers.forEach((h, i) => { lines.push(` subgraph ${id}_h${i}["${escapeLabel(h.name)}"]`); renderFlow(h, `${id}_h${i}_`, lines, ids); lines.push(' end'); }); if (n.finally) { lines.push(` subgraph ${id}_f["${escapeLabel(n.finally.name)}"]`); renderFlow(n.finally, `${id}_f_`, lines, ids); lines.push(' end'); } } else if (isFlowNode(n) && n.flow) { lines.push(` subgraph ${id}["${escapeLabel(renderNodeLabel(n, outgoing.get(n)!))}"]`); renderFlow(n.flow, `${id}_`, lines, ids); lines.push(' end'); } else { const shape = renderShape(n, schema, outgoing.get(n)!); lines.push(` ${id}${shape.open}${escapeLabel(renderNodeLabel(n, outgoing.get(n)!))}${shape.close}`); } } for (const e of schema.edges) { if (isTryNode(e.start)) continue; // rendered by renderTryRoutes if (isTryNode(e.end)) { // entering a TryNode means entering its body lines.push(renderEdgeLine(e, ids.get(e.start)!, ids.get(e.end.body.start)!)); continue; } lines.push(renderEdgeLine(e, ids.get(e.start)!, ids.get(e.end)!)); } // guard checks: implicit branch edges of the guard itself for (const n of schema.nodes) { if (!isGuard(n)) continue; const id = ids.get(n)!; for (const c of n.checks) { if (c.return) { lines.push(` ${id} -->|"${escapeLabel(c.when)}"| ${ids.get(schema.returnEnd)}`); } else if (c.exception) { const t = findExceptionEnd(schema, c.exception.name); lines.push( ` ${id} -.->|"${escapeLabel(c.when)} · throw ${escapeLabel(c.exception.name)}"| ${ids.get(t)}`, ); } } } // ifNode cases: implicit branch edges of the decision node const branchTarget = (t: FlowNodeOrEnd): FlowNodeOrEnd => (isTryNode(t) ? t.body.start : t); for (const n of schema.nodes) { if (!isIfNode(n)) continue; const id = ids.get(n)!; for (const c of n.cases) { lines.push(` ${id} -->|"${escapeLabel(c.when)}"| ${ids.get(branchTarget(c.to))}`); } lines.push(` ${id} -->|"else"| ${ids.get(branchTarget(n.else))}`); } // TryNode internal routes for (const n of schema.nodes) { if (isTryNode(n)) renderTryRoutes(n, schema, ids, lines); } } /** Routes of a TryNode: catch edges, fall-through continuation (through * finally when present), and handler rethrows (also through finally). */ function renderTryRoutes(n: TryNode, schema: FlowSchema, ids: Map, lines: string[]): void { const next = schema.edges.filter((e) => e.start === n && e.throws === undefined && e.exception !== true); const rethrows = schema.edges.filter((e) => e.start === n && e.throws !== undefined); for (const c of n.catches) { const end = findExceptionEnd(n.body, c.exception.name); lines.push(` ${ids.get(end)} -.->|"catch ${escapeLabel(c.exception.name)}"| ${ids.get(c.handler.start)}`); } const continueFrom = (sourceId: string): void => { if (n.finally) { lines.push(` ${sourceId} --> ${ids.get(n.finally.start)}`); return; } for (const e of next) { lines.push(renderEdgeLine(e, sourceId, ids.get(e.end)!)); } }; continueFrom(ids.get(n.body.returnEnd)!); const doneHandlers = new Set(); for (const c of n.catches) { if (doneHandlers.has(c.handler)) continue; // one handler may serve many catches doneHandlers.add(c.handler); // A handler whose every path throws has no fall-through — its return end // is never targeted and stays out of the handler's node list. const handlerReturn = ids.get(c.handler.returnEnd); if (handlerReturn !== undefined) continueFrom(handlerReturn); } if (n.finally) { for (const e of next) { lines.push(renderEdgeLine(e, ids.get(n.finally.returnEnd)!, ids.get(e.end)!)); } } const rethrown = new Map>(); for (const e of rethrows) { for (const c of n.catches) { let names = rethrown.get(c.handler); if (!names) { names = new Set(); rethrown.set(c.handler, names); } if (names.has(e.throws!.name)) continue; // one handler may serve many catches names.add(e.throws!.name); const end = findExceptionEnd(c.handler, e.throws!.name); if (!end) continue; if (n.finally) { lines.push(` ${ids.get(end)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(n.finally.start)}`); lines.push(` ${ids.get(n.finally.returnEnd)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(e.end)}`); } else { lines.push(` ${ids.get(end)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(e.end)}`); } } } } /** The exception end of a flow carrying the given exception name. */ function findExceptionEnd(flow: FlowSchema, exceptionName: string): FlowEnd { const end = flow.nodes.find( (n): n is FlowEnd => isEnd(n) && n.type === 'exception' && n.exception?.name === exceptionName, ); if (!end) { throw new Error(`flow ${flow.name}: no exception end for ${exceptionName}`); } return end; } /** Node label: name on the first line, method references (including utils * predicates of guard checks) on the second, and the data line (slots * consumed `r:` / produced `w:`) last. */ function renderNodeLabel(n: FlowNodeOrEnd, outgoing: FlowEdge[]): string { if (isEnd(n) || isTryNode(n)) return n.name; const lines: string[] = [n.name]; if (isFlowNode(n) && n.publish) { const payload = n.publish.payload ? ` (${n.publish.payload.name})` : ''; lines.push(`publish ${n.publish.event.name}${payload}`); } const refs: FlowNodeMethodRef[] = []; if (!isIfNode(n)) { refs.push(...(n.methods ?? [])); if (isGuard(n)) { for (const c of n.checks) { if (c.check !== undefined && isCall(c.check)) refs.push(c.check); } } } if (refs.length > 0) { const rendered = refs.map((m) => renderMethodRef(methodOf(m))).join(' | '); // Script-compiled nodes carry the full method name themselves. if (rendered !== n.name) lines.push(rendered); } const data = renderDataLine(n, outgoing); if (data !== '') lines.push(data); return lines.join('\n'); } /** Data line: `r:` lists slots the node consumes (reads, call args, decision * condition slots, and branch-edge conditions decided here), `w:` lists * slots it produces (writes and call results). */ function renderDataLine(n: FlowNode | GuardNode | IfNode, outgoing: FlowEdge[]): string { const reads = new Set(); const writes = new Set(); const addCondition = (c: GuardCondition | undefined): void => { if (c === undefined) return; if (isConditionGroup(c)) { for (const s of c.conds) addCondition(s); return; } if (!isCall(c)) { reads.add(isFlowSlot(c.field) ? c.field.name : c.field.slot.name); return; } for (const t of c.args ?? []) reads.add(t.name); }; if (isGuard(n)) { for (const c of n.checks) { for (const t of c.reads ?? []) reads.add(t.name); addCondition(c.check); } } else if (isIfNode(n)) { for (const c of n.cases) addCondition(c.check); } else { for (const t of n.reads ?? []) reads.add(t.name); for (const t of n.writes ?? []) writes.add(t.name); if (isFlowNode(n) && n.publish?.payload) reads.add(n.publish.payload.name); } for (const e of outgoing) addCondition(e.check); if (!isIfNode(n)) { for (const ref of n.methods ?? []) { if (!isCall(ref)) continue; for (const t of ref.args ?? []) reads.add(t.name); if (ref.result) writes.add(ref.result.name); } } const parts: string[] = []; if (reads.size > 0) parts.push(`r:${[...reads].join(',')}`); if (writes.size > 0) parts.push(`w:${[...writes].join(',')}`); return parts.join(' '); } /** Display form: owner.name for descriptors, schema.name for container methods. */ function renderMethodRef(m: FlowMethodRef): string { if ('owner' in m) return `${m.owner}.${m.name}`; return `${m.schema.name}.${m.name}`; } /** Shape derived from topology: guards are diamonds, ends rounded, other * branching nodes diamonds, else rect. Exception edges (typed or not) do not * count as branches. The start keeps its rounded shape unless it branches. */ function renderShape(n: FlowNodeOrEnd, schema: FlowSchema, outgoing: FlowEdge[]): { open: string; close: string } { if (isGuard(n)) return { open: '{"', close: '"}' }; if (isIfNode(n)) return { open: '{"', close: '"}' }; if (isEnd(n) || outgoing.length === 0) return { open: '(["', close: '"])' }; const normal = outgoing.filter((e) => e.exception !== true && e.throws === undefined).length; if (normal >= 2) return { open: '{"', close: '"}' }; if (n === schema.start) return { open: '(["', close: '"])' }; return { open: '["', close: '"]' }; } function renderEdgeLine(e: FlowEdge, startId: string, endId: string): string { const isException = e.exception === true || e.throws !== undefined; const arrow = isException ? '-.->' : '-->'; const throwLabel = e.throws ? `throw ${escapeLabel(e.throws.name)}` : ''; const labelParts = [e.when ? escapeLabel(e.when) : '', throwLabel].filter((s) => s !== ''); const label = labelParts.length > 0 ? `|"${labelParts.join(' · ')}"|` : ''; return ` ${startId} ${arrow}${label} ${endId}`; } function isEnd(n: FlowNodeOrEnd): n is FlowEnd { return 'type' in n && (n.type === 'return' || n.type === 'exception'); } function isFlowNode(n: FlowNodeOrEnd): n is FlowNode { return !('type' in n); } function isGuard(n: FlowNodeOrEnd): n is GuardNode { return 'type' in n && n.type === 'guard'; } function isTryNode(n: FlowNodeOrEnd): n is TryNode { return 'type' in n && n.type === 'try'; } function isIfNode(n: FlowNodeOrEnd): n is IfNode { return 'type' in n && n.type === 'if'; } // Page-driven flow renderer: groups pages by their app into swimlane // subgraphs, then renders edges across the whole flow. export function renderPageFlowMermaid(schema: PageFlow): string { const lines: string[] = ['flowchart TD']; const ids = new Map(); const byApp = new Map(); for (const p of schema.pages) { const list = byApp.get(p.app.name); if (!list) { byApp.set(p.app.name, [p]); } else { list.push(p); } } let appIdx = 0; for (const [appName, pages] of byApp) { lines.push(` subgraph app${appIdx}["${escapeLabel(appName)}"]`); pages.forEach((p, i) => ids.set(p, `app${appIdx}_p${i}`)); for (const p of pages) { const display = p.label ?? p.name; lines.push(` ${ids.get(p)}["${escapeLabel(display)}"]`); } appIdx++; lines.push(' end'); } for (const e of schema.edges) { const label = e.when ? `|"${escapeLabel(e.when.name)}"|` : ''; lines.push(` ${ids.get(e.start)} -->${label} ${ids.get(e.end)}`); } lines.push(''); lines.push(' classDef start fill:#e6f4ea,stroke:#333,stroke-width:1px;'); lines.push(` class ${ids.get(schema.start)} start;`); return lines.join('\n'); }