Skip to content

Commit 15c525d

Browse files
authored
fix(merman): support & node groups in flowchart statements (#48425)
1 parent 4d12e01 commit 15c525d

3 files changed

Lines changed: 161 additions & 26 deletions

File tree

packages/merman/src/flowchart/flowchart.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,100 @@ flowchart TD
12801280
])
12811281
})
12821282

1283+
test("expands & node groups into fan-in and fan-out edges", () => {
1284+
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
1285+
N[Native] & M[Mapped] & O --> LM["LanguageModel"]
1286+
LM -->|prepare| REQ & LOG`)
1287+
1288+
expect(diagram.nodes).toEqual([
1289+
{ id: "N", label: "Native", shape: "box" },
1290+
{ id: "M", label: "Mapped", shape: "box" },
1291+
{ id: "O", label: "O", shape: "box" },
1292+
{ id: "LM", label: "LanguageModel", shape: "box" },
1293+
{ id: "REQ", label: "REQ", shape: "box" },
1294+
{ id: "LOG", label: "LOG", shape: "box" },
1295+
])
1296+
expect(diagram.edges).toEqual([
1297+
{ from: "N", to: "LM", label: "" },
1298+
{ from: "M", to: "LM", label: "" },
1299+
{ from: "O", to: "LM", label: "" },
1300+
{ from: "LM", to: "REQ", label: "prepare" },
1301+
{ from: "LM", to: "LOG", label: "prepare" },
1302+
])
1303+
})
1304+
1305+
test("expands & groups on both sides of an edge and through a chain", () => {
1306+
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
1307+
A & B --> C & D --> E`)
1308+
1309+
expect(diagram.edges).toEqual([
1310+
{ from: "A", to: "C", label: "" },
1311+
{ from: "A", to: "D", label: "" },
1312+
{ from: "B", to: "C", label: "" },
1313+
{ from: "B", to: "D", label: "" },
1314+
{ from: "C", to: "E", label: "" },
1315+
{ from: "D", to: "E", label: "" },
1316+
])
1317+
})
1318+
1319+
test("declares every node of a bare & group inside the current subgraph", () => {
1320+
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
1321+
subgraph Runtime
1322+
A[Alpha] & B[Beta]:::focus
1323+
end
1324+
A --> B`)
1325+
1326+
expect(diagram.nodes).toEqual([
1327+
{ id: "A", label: "Alpha", shape: "box" },
1328+
{ id: "B", label: "Beta", shape: "box" },
1329+
])
1330+
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["A", "B"])
1331+
})
1332+
1333+
test("keeps & inside quoted or bracketed labels as label text", () => {
1334+
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
1335+
A["Fetch & parse"] & B[R&D] --> C[Done & dusted]`)
1336+
1337+
expect(diagram.nodes).toEqual([
1338+
{ id: "A", label: "Fetch & parse", shape: "box" },
1339+
{ id: "B", label: "R&D", shape: "box" },
1340+
{ id: "C", label: "Done & dusted", shape: "box" },
1341+
])
1342+
expect(diagram.edges).toEqual([
1343+
{ from: "A", to: "C", label: "" },
1344+
{ from: "B", to: "C", label: "" },
1345+
])
1346+
})
1347+
1348+
test("keeps & inside edge labels as label text", () => {
1349+
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
1350+
X[a & b] -->|x & y| Y`)
1351+
1352+
expect(diagram.nodes.find((node) => node.id === "X")?.label).toBe("a & b")
1353+
expect(diagram.edges).toEqual([{ from: "X", to: "Y", label: "x & y" }])
1354+
})
1355+
1356+
test("rejects empty & group members", () => {
1357+
for (const statement of ["A & --> B", "& A --> B", "A --> B &", "A &"]) {
1358+
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(
1359+
`Unsupported syntax in flowchart diagram at line 2: "${statement}"`,
1360+
)
1361+
}
1362+
})
1363+
1364+
test("renders a fan-in expressed with & the same as separate edge statements", () => {
1365+
const grouped = renderFlowchartDiagram(`flowchart LR
1366+
N & M & O --> LM[LanguageModel] --> REQ[LLMRequest]`)
1367+
const separate = renderFlowchartDiagram(`flowchart LR
1368+
N --> LM[LanguageModel]
1369+
M --> LM
1370+
O --> LM
1371+
LM --> REQ[LLMRequest]`)
1372+
1373+
expect(grouped).toBe(separate)
1374+
expect(grouped).toContain("LanguageModel")
1375+
})
1376+
12831377
test("parses chained undirected solid edges", () => {
12841378
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
12851379
A --- B --- C`)

packages/merman/src/flowchart/parser.ts

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,41 @@ function stripNodeToken(token: string): string {
127127
.trim()
128128
}
129129

130+
/** Split an `&`-joined node group, leaving `&` inside labels (brackets or quotes) untouched. */
131+
function splitNodeGroup(token: string): string[] {
132+
const groups: string[] = []
133+
const stack: string[] = []
134+
let quote: '"' | "'" | undefined
135+
let start = 0
136+
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
137+
138+
for (let index = 0; index < token.length; index++) {
139+
const character = token[index]!
140+
if (quote) {
141+
if (character === quote && token[index - 1] !== "\\") quote = undefined
142+
continue
143+
}
144+
if (character === '"' || character === "'") {
145+
quote = character
146+
continue
147+
}
148+
if (character in closes) {
149+
stack.push(character)
150+
continue
151+
}
152+
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
153+
stack.pop()
154+
continue
155+
}
156+
if (stack.length === 0 && character === "&") {
157+
groups.push(token.slice(start, index))
158+
start = index + 1
159+
}
160+
}
161+
groups.push(token.slice(start))
162+
return groups
163+
}
164+
130165
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
131166
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
132167
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
@@ -305,51 +340,57 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
305340

306341
const edgeOperators = parseEdgeOperators(line)
307342
if (edgeOperators.length > 0) {
308-
const nodeTokens = [
343+
// Each chain position may be an `&` group (`A & B --> C`), so endpoints are lists of node tokens.
344+
const nodeGroups = [
309345
line.slice(0, edgeOperators[0]!.index),
310346
...edgeOperators.map((operator, index) =>
311347
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
312348
),
313-
]
349+
].map((group) => splitNodeGroup(group).map(stripNodeToken))
314350

315-
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
316-
const unsupportedEndpoint = nodeTokens.find((token, index) => {
317-
const stripped = stripNodeToken(token)
351+
if (nodeGroups.every((group) => group.every((token) => token.length > 0))) {
352+
const unsupportedEndpoint = nodeGroups.find((group, index) => {
318353
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
319-
return (
320-
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
321-
!isSupportedNodeToken(stripped)
354+
return group.some(
355+
(stripped) =>
356+
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
357+
!isSupportedNodeToken(stripped),
322358
)
323359
})
324360
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
325-
const chainNodeIds = nodeTokens.map((token, index) => {
326-
const stripped = stripNodeToken(token)
361+
const chainNodeIds = nodeGroups.map((group, index) => {
327362
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
328-
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
329-
return ensureNode(nodes, stripped).id
363+
return group.map((stripped) => {
364+
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
365+
return ensureNode(nodes, stripped).id
366+
})
330367
})
331-
for (const nodeId of chainNodeIds) {
368+
for (const nodeId of chainNodeIds.flat()) {
332369
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
333370
}
334371
for (let index = 0; index < edgeOperators.length; index++) {
335372
const operator = edgeOperators[index]!
336-
const edge = createEdge(
337-
chainNodeIds[index]!,
338-
chainNodeIds[index + 1]!,
339-
operator.label,
340-
operator.style,
341-
operator.arrowhead,
342-
operator.sourceArrowhead,
343-
)
344-
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
373+
for (const from of chainNodeIds[index]!) {
374+
for (const to of chainNodeIds[index + 1]!) {
375+
const edge = createEdge(
376+
from,
377+
to,
378+
operator.label,
379+
operator.style,
380+
operator.arrowhead,
381+
operator.sourceArrowhead,
382+
)
383+
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
384+
}
385+
}
345386
}
346387
continue
347388
}
348389
}
349390

350-
if (isSupportedNodeToken(line)) {
351-
const node = ensureNode(nodes, line)
352-
addNodeToSubgraph(currentSubgraph, node.id)
391+
const nodeGroup = splitNodeGroup(line)
392+
if (nodeGroup.every(isSupportedNodeToken)) {
393+
for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id)
353394
continue
354395
}
355396

packages/merman/src/test/diagnostics.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe("parser diagnostics", () => {
2929
})
3030

3131
test("does not partially parse unsupported flowchart syntax", () => {
32-
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
32+
for (const statement of ["A & --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
3333
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
3434
}
3535
})

0 commit comments

Comments
 (0)