diff --git a/src/mermaidFlowchartDiff.ts b/src/mermaidFlowchartDiff.ts index fef5f2d..0ac4a66 100644 --- a/src/mermaidFlowchartDiff.ts +++ b/src/mermaidFlowchartDiff.ts @@ -1,4 +1,102 @@ -// stub, replaced in Tasks 2-3 +/** + * mermaidFlowchartDiff — F7.1 (#22). Pure flowchart parser + diff/emit. Parses a + * `graph`/`flowchart` source into nodes (by id) and edges (by declaration order), + * diffs baseline vs current, and re-emits the CURRENT source augmented with + * `classDef`/`class`/`linkStyle` directives coloring added/changed elements and + * ghosting removed ones (INV-29/31). Deterministic; no vscode, no DOM. + */ +import { CW_COLORS } from "./mermaidDiff"; + +export interface FlowNode { + id: string; + label?: string; + open?: string; + close?: string; + /** Verbatim declaration token, e.g. `A[Start]`, for ghost re-injection. */ + decl?: string; +} +export interface FlowEdge { + from: string; + to: string; + label?: string; + index: number; + /** Verbatim edge statement for ghost re-injection. */ + raw: string; +} +export interface FlowGraph { + header: string; + nodes: Map; + edges: FlowEdge[]; +} + +const NODE_TOKEN = /^([A-Za-z0-9_]+)(\[\[[^\]]*\]\]|\[[^\]]*\]|\(\([^)]*\)\)|\([^)]*\)|\{[^}]*\}|>[^\]]*\])?/; +// link operators: -->, ---, -.->, -.-, ==>, ===, --x, --o, ==x, ==o, optionally |label| +const LINK = /^(-->|---|-\.->|-\.-|==>|===|--[xo]|==[xo])(\|([^|]*)\|)?/; + +function shapeOf(bracket: string): { label: string; open: string; close: string } { + if (bracket.startsWith("[[")) return { label: bracket.slice(2, -2), open: "[[", close: "]]" }; + if (bracket.startsWith("((")) return { label: bracket.slice(2, -2), open: "((", close: "))" }; + const open = bracket[0]; + const close = bracket[bracket.length - 1]; + return { label: bracket.slice(1, -1), open, close }; +} + +function recordNode(nodes: Map, token: string): string { + const m = token.match(NODE_TOKEN)!; + const id = m[1]; + const existing = nodes.get(id) ?? { id }; + if (m[2]) { + const s = shapeOf(m[2]); + existing.label = s.label; + existing.open = s.open; + existing.close = s.close; + existing.decl = `${id}${m[2]}`; + } + nodes.set(id, existing); + return id; +} + +export function parseFlowchart(source: string): FlowGraph { + const lines = source.split(/\r?\n/); + let header = ""; + const nodes = new Map(); + const edges: FlowEdge[] = []; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("%%")) continue; + if (!header && /^(flowchart|graph)\b/.test(line)) { + header = line; + continue; + } + let rest = line; + const firstM = rest.match(NODE_TOKEN); + if (!firstM || firstM[0] === "") continue; + let leftId = recordNode(nodes, firstM[0]); + rest = rest.slice(firstM[0].length).trimStart(); + // Walk a (possibly chained) edge statement: A[..] --> B -->|x| C + while (rest.length > 0) { + const linkM = rest.match(LINK); + if (!linkM) break; + rest = rest.slice(linkM[0].length).trimStart(); + const rhsM = rest.match(NODE_TOKEN); + if (!rhsM || rhsM[0] === "") break; + const rightId = recordNode(nodes, rhsM[0]); + edges.push({ + from: leftId, + to: rightId, + label: linkM[3] || undefined, + index: edges.length, + raw: `${leftId} ${linkM[1]}${linkM[2] ?? ""} ${rightId}`, + }); + rest = rest.slice(rhsM[0].length).trimStart(); + leftId = rightId; + } + } + return { header: header || "flowchart TD", nodes, edges }; +} + +// Replaced in Task 3 with the real diff/emit. export function diffFlowchart(_beforeSrc: string, currentSrc: string): string { + void CW_COLORS; return currentSrc; } diff --git a/test/mermaidFlowchartDiff.test.ts b/test/mermaidFlowchartDiff.test.ts new file mode 100644 index 0000000..1faf5b1 --- /dev/null +++ b/test/mermaidFlowchartDiff.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { parseFlowchart } from "../src/mermaidFlowchartDiff"; + +describe("parseFlowchart", () => { + it("captures the header and node declarations with labels + shapes", () => { + const g = parseFlowchart("flowchart LR\n A[Start] --> B(Middle)\n B --> C{End}"); + expect(g.header).toBe("flowchart LR"); + expect(g.nodes.get("A")).toMatchObject({ id: "A", label: "Start", open: "[", close: "]" }); + expect(g.nodes.get("B")).toMatchObject({ id: "B", label: "Middle", open: "(", close: ")" }); + expect(g.nodes.get("C")).toMatchObject({ id: "C", label: "End", open: "{", close: "}" }); + }); + + it("captures edges in declaration order with from/to and index", () => { + const g = parseFlowchart("graph TD\n A-->B\n B-->C"); + expect(g.edges.map((e) => [e.from, e.to, e.index])).toEqual([ + ["A", "B", 0], + ["B", "C", 1], + ]); + }); + + it("records a node referenced only in an edge (no explicit declaration)", () => { + const g = parseFlowchart("flowchart LR\n A-->B"); + expect(g.nodes.has("A")).toBe(true); + expect(g.nodes.has("B")).toBe(true); + expect(g.nodes.get("A")?.label).toBeUndefined(); + }); + + it("handles a labelled edge `A -->|yes| B`", () => { + const g = parseFlowchart("flowchart LR\n A -->|yes| B"); + expect(g.edges[0]).toMatchObject({ from: "A", to: "B", label: "yes" }); + }); + + it("ignores comments and blank lines", () => { + const g = parseFlowchart("flowchart LR\n%% a comment\n\n A-->B"); + expect(g.edges).toHaveLength(1); + }); +});