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');
}