feat(f7.1): flowchart parser (#22)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, FlowNode>;
|
||||
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<string, FlowNode>, 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<string, FlowNode>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user