Expand native Mermaid flowchart syntax support (#48895)

## What changed

- Default `flowchart` and `graph` to top-down layout when no direction is specified.
- Support solid and dashed edges with directed, undirected, or bidirectional endpoints: `-->`, `---`, `<-->`, `-.->`, `-.-`, and `<-.->`.
- Accept pipe labels on these edges and spaced directed infix labels such as `A -- send --> B` and `A -. retry .-> B`.
- Expand `&` groups into all source-to-target edges, including in chains. For example, `A & B --> C & D` creates four edges. Enforce the 24-edge limit after expansion and cap each group at 24 node references.

## Testing

Add coverage for equivalent syntax, edge styles and endpoints, malformed input, and expansion limits. Extend rendering snapshots across all four directions and the TUI native-renderer test with the new syntax.

GitOrigin-RevId: 6e51653ebf6b8b7e7e1edec0c2e09a5d9c8dec74
This commit is contained in:
Eric Traut
2026-09-28 06:30:53 +00:00
committed by copyberry
parent 1cc7e23612
commit 44fe510ce3
6 changed files with 265 additions and 82 deletions
+3 -3
View File
@@ -8,7 +8,7 @@ production dependency is needed.
| Family | Supported syntax |
| --- | --- |
| `flowchart`, `graph` | TD/TB, BT, LR, RL; rectangle `[label]`, decision `{label}`, and stadium `([label])` nodes; directed and labeled `-->` edges; chains, branches, merges, loops |
| `flowchart`, `graph` | TD/TB (default), BT, LR, RL; rectangle `[label]`, decision `{label}`, and stadium `([label])` nodes; solid `-->`/`---`/`<-->` and dashed `-.->`/`-.-`/`<-.->` edges; pipe labels and spaced directed infix labels (`-- text -->`, `-. text .->`); `&` fan-out/fan-in, chains, branches, merges, loops |
| `sequenceDiagram` | Implicit participants, `participant`/`actor`, aliases, `->`, `->>`, `-->`, `-->>`, `-x`, `--x`, self-messages, `Note over A[,B]`, nested `loop`/`alt`/`opt`/`critical`/`break`, one labeled `else` per `alt` |
| `stateDiagram-v2`, `stateDiagram` | Flat states, `state "label" as ID`, descriptions, directed transitions with optional labels, initial/final `[*]`, direction declarations |
| `classDiagram` | `class ID`, multiline member bodies, `ID : member`, solid/dashed links, association, inheritance, composition, aggregation, dependency, realization, quoted endpoint cardinalities, relationship labels, direction declarations |
@@ -59,8 +59,8 @@ named participants. States use separate `● initial` and `◎ final` nodes.
## Limits and evaluation
All inputs are limited to 16 KiB. Graphs allow 16 nodes, 24 edges, and 16 members per
node. Sequences allow 8 participants, 64 events (including fragment boundaries),
All inputs are limited to 16 KiB. Graphs allow 16 nodes, 24 edges (after expanding
`&` groups), and 16 members per node. Each `&` group allows at most 24 node references. Sequences allow 8 participants, 64 events (including fragment boundaries),
and 4 fragment levels. Source labels and identifiers are limited to 40 display
cells/ASCII bytes respectively. Rendered canvases are capped at 65,536 cells,
independent of the caller's maximum width. The library performs no I/O.
+28 -1
View File
@@ -156,7 +156,7 @@ fn unicode_labels_and_later_declarations() {
}
#[test]
fn class_relationship_endpoints() {
fn graph_relationship_endpoints() {
for (operator, source_tip, target_tip, dashed) in [
("<|--", '◁', '─', false),
("*--", '◆', '─', false),
@@ -192,6 +192,33 @@ fn class_relationship_endpoints() {
assert!(ports[1].contains("(many)"));
assert_eq!(output.contains('┆'), dashed);
}
for (operator, source_tip, target_tip, dashed) in [
("---", '─', '─', false),
("-.->", '─', '◄', true),
("-.-", '─', '─', true),
("<-->", '◄', '◄', false),
("<-.->", '◄', '◄', true),
] {
let output = render(
&format!("flowchart; A{operator}|uses|B"),
/*max_width*/ 100,
)
.unwrap();
let ports = output
.lines()
.filter(|line| line.contains('├'))
.collect::<Vec<_>>();
assert!(
ports[0].contains(&format!("├{source_tip}")),
"{operator}: {output}"
);
assert!(
ports[1].contains(&format!("├{target_tip}")),
"{operator}: {output}"
);
assert!(ports[0].contains("uses"));
assert_eq!(output.contains('┆'), dashed);
}
for source in [
"classDiagram\nclass A; A --> B : {ok}",
"classDiagram\nclass A; class B {\n+id\n}",
+78 -17
View File
@@ -1,4 +1,5 @@
//! Strict parser for a small flowchart grammar; every non-comment byte must be consumed.
//! Node groups expand into explicit edges within the graph edge cap.
use super::Direction;
use super::Edge;
@@ -13,35 +14,83 @@ use unicode_width::UnicodeWidthStr;
pub(super) fn parse(header: &str, body: &[&str]) -> Result<Graph, RenderError> {
let tokens = header.split_whitespace().collect::<Vec<_>>();
let ["flowchart" | "graph", direction] = tokens.as_slice() else {
return Err(RenderError::Unsupported);
let direction = match tokens.as_slice() {
["flowchart" | "graph"] => Direction::Down,
["flowchart" | "graph", direction] => Direction::parse(direction)?,
_ => return Err(RenderError::Unsupported),
};
let mut graph = Graph {
direction: Direction::parse(direction)?,
direction,
..Graph::default()
};
for statement in body {
let mut rest = *statement;
let mut from = node(&mut rest, &mut graph)?;
let mut from = nodes(&mut rest, &mut graph)?;
while !rest.trim_start().is_empty() {
rest = rest
.trim_start()
.strip_prefix("-->")
.ok_or(RenderError::Unsupported)?;
rest = rest.trim_start();
let label = if let Some(after) = rest.strip_prefix('|') {
let (label, remaining) = delimited_label(after, "|")?;
let label = flowchart_label(label)?;
rest = remaining;
label.to_owned()
let mut label = String::new();
let (source_tip, target_tip, dashed) = if let Some((after, source, target, dashed)) = [
("<-.->", '◄', '◄', true),
("<-->", '◄', '◄', false),
("-.->", '─', '◄', true),
("-.-", '─', '─', true),
("-->", '─', '◄', false),
("---", '─', '─', false),
]
.into_iter()
.find_map(|(token, source, target, dashed)| {
rest.strip_prefix(token)
.map(|after| (after, source, target, dashed))
}) {
// Circle/cross tips are unsupported; without a space they are not node IDs.
if target == '─' && after.starts_with(['o', 'x']) {
return Err(RenderError::Unsupported);
}
rest = after.trim_start();
if let Some(after) = rest.strip_prefix('|') {
let (text, remaining) = delimited_label(after, "|")?;
label = flowchart_label(text)?.to_owned();
rest = remaining;
}
(source, target, dashed)
} else {
String::new()
let (after, stem, dashed) = if let Some(after) = rest.strip_prefix("--") {
(after, "--", false)
} else if let Some(after) = rest.strip_prefix("-.") {
(after, ".-", true)
} else {
return Err(RenderError::Unsupported);
};
// Spaced labels keep endpoint markers out of the text. Stop at the first
// closing stem instead of swallowing an unsupported edge and its target.
let (text, remaining) = after.split_once(stem).ok_or(RenderError::Unsupported)?;
if !text.starts_with(char::is_whitespace) || !text.ends_with(char::is_whitespace) {
return Err(RenderError::Unsupported);
}
rest = remaining
.strip_prefix('>')
.ok_or(RenderError::Unsupported)?;
label = flowchart_label(text.trim())?.to_owned();
('─', '◄', dashed)
};
let to = node(&mut rest, &mut graph)?;
if graph.edges.len() == MAX_EDGES {
let to = nodes(&mut rest, &mut graph)?;
// Check the Cartesian expansion before allocating edges, including repeated IDs.
if from.len() * to.len() > MAX_EDGES - graph.edges.len() {
return Err(RenderError::Limit);
}
graph.edges.push(Edge::directed(from, to, label));
for &from in &from {
for &to in &to {
graph.edges.push(Edge {
from,
to,
label: label.clone(),
target_label: String::new(),
source_tip,
target_tip,
dashed,
});
}
}
from = to;
}
}
@@ -51,6 +100,18 @@ pub(super) fn parse(header: &str, body: &[&str]) -> Result<Graph, RenderError> {
Ok(graph)
}
fn nodes(rest: &mut &str, graph: &mut Graph) -> Result<Vec<usize>, RenderError> {
let mut nodes = vec![node(rest, graph)?];
while let Some(after) = rest.trim_start().strip_prefix('&') {
if nodes.len() == MAX_EDGES {
return Err(RenderError::Limit);
}
*rest = after;
nodes.push(node(rest, graph)?);
}
Ok(nodes)
}
fn node(rest: &mut &str, graph: &mut Graph) -> Result<usize, RenderError> {
let id = identifier(rest)?;
// Reserved constructs must not be interpreted as ordinary node declarations.
@@ -1,73 +1,106 @@
---
source: mermaid/src/tests.rs
assertion_line: 35
expression: "cases.join(\"\\n\\n\")"
---
TD
╭─────────╮
│ 请求 │
│ ├─────┐
│ ├◄────╪───┐
╰─────────╯ │ │
│ │
│ │
┌─────────┐ │ │
│ Work │ │ │
│ ├◄────┘ │
│ ├───────┐ │
└─────────┘ │ │
│ │
│ │
┌─────────┐ │ │
│ ◇ Done? │ │ │
│ ├◄──────┘ │
│ ├─────────┘
│ ├──go───┐
│ ├──go───╪─┐
│ ├◄──────╪─╪───┐
│ ├───────╪─╪───╪─┐
│ ├◄┄┄┄┄┄┄╪┄╪┄┄┄╪┄╪┄┄┄┐
╰─────────╯ │ │ │ │ ┆
│ │ │ │ ┆
│ │ │ │ ┆
┌─────────┐ │ │ │ │ ┆
│ Work │ │ │ │ │ ┆
│ ├◄──────┘ │ │ │ ┆
│ ├─┄no┄┄┄┄┄╪┄┐ │ │ ┆
│ ├─────────╪─╪─╪─┘ ┆
│ ├─┄┄┄┄┄┄┄┄╪┄╪┄╪┄┄┄┐ ┆
│ ├◄┄┄┄┄┄┄┄┄╪┄╪┄╪┄┄┄╪┄┘
└─────────┘ │ ┆ │ ┆
│ ┆ │ ┆
│ ┆ │ ┆
┌─────────┐ │ ┆ │ ┆
│ ◇ Done? │ │ ┆ │ ┆
│ ├◄────────┘ ┆ │ ┆
│ ├◄┄┄┄┄┄┄┄┄┄┄┘ │ ┆
│ ├◄────────────┘ ┆
│ ├─┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
└─────────┘
BT
┌─────────┐
│ ◇ Done? │
│ ├◄──────┐
│ ├───────╪─┐
└─────────┘ │ │
│ │
│ │
┌─────────┐ │ │
│ Work │ │ │
│ ├◄────┐ │ │
│ ├─────╪─┘ │
└─────────┘ │ │
│ │
│ │
╭─────────╮ │ │
│ 请求 │ │ │
│ ├─────┘ │
│ ├◄────────┘
│ ├◄────────┐
│ ├◄┄┄┄┄┄┄┄┄╪┄┐
│ ├◄────────╪─╪─┐
│ ├─┄┄┄┄┄┄┄┄╪┄╪┄╪┄┄┄┐
└─────────┘ │ ┆ │ ┆
│ ┆ │ ┆
│ ┆ │ ┆
┌─────────┐ │ ┆ │ ┆
│ Work │ │ ┆ │ ┆
│ ├◄──────┐ │ ┆ │ ┆
│ ├─┄no┄┄┄╪┄╪┄┘ │ ┆
│ ├───────╪─╪───╪─┐ ┆
│ ├─┄┄┄┄┄┄╪┄╪┄┄┄╪┄╪┄┘
│ ├◄┄┄┄┄┄┄╪┄╪┄┄┄╪┄╪┄┄┄┐
└─────────┘ │ │ │ │ ┆
│ │ │ │ ┆
│ │ │ │ ┆
╭─────────╮ │ │ │ │ ┆
│ 请求 │ │ │ │ │ ┆
│ ├──go───┘ │ │ │ ┆
│ ├──go─────┘ │ │ ┆
│ ├◄────────────┘ │ ┆
│ ├───────────────┘ ┆
│ ├◄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
╰─────────╯
LR
╭──────╮ ┌──────┐ ┌─────────┐
│ 请求 │ │ Work │ │ ◇ Done? │
╰┬─┬───╯ └┬─┬───┘ └┬─┬──────┘
│ ▲ ▲ │ ▲ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
└─╪───────┘ │ │ │
│ │ │ │
│ └───────┘ │
│ │
└───────────────────┘
╭────────────────────╮ ┌────────────────────┐ ┌────────────────┐
│ 请求 │ │ Work │ │ ◇ Done? │
╰┬───┬───┬───┬───┬───╯ └┬───┬───┬───┬───┬───┘ └┬───┬───┬───┬───┘
│ │ ▲ │ ▲ ▲ │ │ │ ▲ ▲ ▲ ▲ │
│go │go │ │ ┆ │ ┆no │ ┆ ┆ │ ┆ │ ┆
│ │ │ │ ┆ │ ┆ │ ┆ ┆ │ ┆ │ ┆
│ │ │ │ ┆ │ ┆ │ ┆ ┆ │ ┆ │ ┆
└───╪───╪───╪───╪───────┘ ┆ │ ┆ ┆ │ ┆ │ ┆
│ │ │ ┆ ┆ │ ┆ ┆ │ ┆ │ ┆
└───╪───╪───╪───────────╪───╪───╪───╪───────┘ ┆ │ ┆
│ │ ┆ ┆ │ ┆ ┆ ┆ │ ┆
│ │ ┆ └┄┄┄╪┄┄┄╪┄┄┄╪┄┄┄┄┄┄┄┄┄┄┄┘ │ ┆
│ │ ┆ │ ┆ ┆ │ ┆
└───╪───╪───────────────╪───╪───╪───────────────┘ ┆
│ ┆ │ ┆ ┆ ┆
└───╪───────────────┘ ┆ ┆ ┆
┆ ┆ ┆ ┆
┆ └┄┄┄╪┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
┆ ┆
└┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
RL
┌─────────┐ ┌──────┐ ╭──────╮
│ ◇ Done? │ │ Work │ │ 请求 │
└┬─┬──────┘ └┬─┬───┘ ╰┬─┬───╯
▲ │ ▲ │ │ ▲
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ └─╪───────┘ │
│ │ │ │
└─╪────────────┘ │
│ │
└──────────────────────┘
┌────────────────┐ ┌────────────────────┐ ╭────────────────────╮
│ ◇ Done? │ │ Work │ │ 请求 │
└┬───┬───┬───┬───┘ └┬───┬───┬───┬───┬───┘ ╰┬───┬───┬───┬───┬───╯
▲ ▲ ▲ │ ▲ │ │ │ ▲ │ │ ▲ │ ▲
│ ┆ │ ┆ │ ┆no │ ┆ ┆ │go │go │ │ ┆
│ ┆ │ ┆ │ ┆ │ ┆ ┆ │ │ │ │ ┆
│ ┆ │ ┆ │ ┆ │ ┆ ┆ │ │ │ │ ┆
│ ┆ │ ┆ └───╪───╪───╪───╪───────┘ │ │ │ ┆
│ ┆ │ ┆ ┆ │ ┆ ┆ │ │ │ ┆
└───╪───╪───╪───────────╪───╪───╪───╪───────────┘ │ │ ┆
┆ │ ┆ ┆ │ ┆ ┆ │ │ ┆
└┄┄┄╪┄┄┄╪┄┄┄┄┄┄┄┄┄┄┄┘ │ ┆ ┆ │ │ ┆
│ ┆ │ ┆ ┆ │ │ ┆
└───╪───────────────╪───╪───╪───────────────┘ │ ┆
┆ │ ┆ ┆ │ ┆
┆ └───╪───╪───────────────────┘ ┆
┆ ┆ ┆ ┆
└┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ ┆ ┆
┆ ┆
└┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
+66 -5
View File
@@ -24,8 +24,9 @@ fn stadium_declarations_and_references() {
fn stadiums_with_other_shapes_in_every_direction() {
let mut cases = Vec::new();
for direction in ["TD", "BT", "LR", "RL"] {
let source =
format!("flowchart {direction}; A([请求]) --> B[Work] --> C{{Done?}}; C --> A");
let source = format!(
"flowchart {direction}; A([请求]) -- go --> B[Work] & C{{Done?}}; B -. no .-> C; C <--> A; B --- A; C -.- B; A <-.-> B"
);
let output = render(&source, /*max_width*/ 100).unwrap();
let width = output.lines().map(UnicodeWidthStr::width).max().unwrap();
assert_eq!(render(&source, width), Ok(output.clone()));
@@ -36,7 +37,7 @@ fn stadiums_with_other_shapes_in_every_direction() {
}
#[test]
fn quoted_flowchart_labels_match_unquoted_labels() {
fn equivalent_flowchart_forms_preserve_graph() {
let quoted = r#"A["Review & confirm;"] -->|"Yes & continue"| B{"Ready?"}
B --> C(["Checkout"])
A[Review & confirm;]"#;
@@ -45,6 +46,48 @@ A[Review & confirm;]"#;
super::parse::parse("flowchart TD", &quoted.lines().collect::<Vec<_>>()).unwrap(),
super::parse::parse("flowchart TD", &unquoted.lines().collect::<Vec<_>>()).unwrap(),
);
for (infix, pipe) in [
("-- Yes -->", "-->|Yes|"),
("-- \"Yes & continue\" -->", "-->|\"Yes & continue\"|"),
("-. retry .->", "-.->|retry|"),
] {
assert_eq!(
super::parse::parse("flowchart", &[&format!("A {infix} B --> C")]).unwrap(),
super::parse::parse("graph TB", &[&format!("A {pipe} B --> C")]).unwrap(),
);
}
assert_eq!(
super::parse::parse("graph", &["A[Input & config] & B -- send --> C & D -.-> E"]).unwrap(),
super::parse::parse(
"flowchart TD",
&[
"A[Input & config]",
"B",
"C",
"D",
"E",
"A -->|send| C",
"A -->|send| D",
"B -->|send| C",
"B -->|send| D",
"C -.-> E",
"D -.-> E",
]
)
.unwrap(),
);
assert_eq!(
super::parse::parse("flowchart", &["A--- oB", "A-.- xB"])
.unwrap()
.nodes,
super::parse::parse("flowchart", &["A", "oB", "xB"])
.unwrap()
.nodes,
);
assert_eq!(
super::parse::parse("flowchart", &["A --> B & B"]).unwrap(),
super::parse::parse("flowchart TD", &["A --> B", "A --> B"]).unwrap(),
);
assert_eq!(
render(&format!("graph TD; {quoted}"), /*max_width*/ 180).unwrap(),
render(&format!("graph TD; {unquoted}"), /*max_width*/ 180).unwrap(),
@@ -144,6 +187,7 @@ fn quoted_flowchart_labels_reject_malformed_and_unsafe_text() {
for source in [
format!("flowchart TD; A[{label}]"),
format!("flowchart TD; A -->|{label}| B"),
format!("flowchart TD; A -- {label} --> B"),
] {
assert_eq!(
render(&source, /*max_width*/ 100),
@@ -176,8 +220,20 @@ fn rejects_partial_or_unsupported_input() {
r#"flowchart TD; P --> Q; A -->|"`Caption`"| B"#,
"flowchart TD; subgraph X; A; end",
"flowchart TD; A --> B; garbage syntax",
"flowchart TD; A -.-> B",
"flowchart TD; A & B --> C",
"flowchart TD; A --> B &",
"flowchart TD; A && B",
"flowchart TD; A -- Yes B",
"flowchart TD; A -. retry --> B",
"flowchart TD; A ==> B",
"flowchart TD; A -- hello --- B --> C",
"flowchart TD; A -. hello .- B -.-> C",
"flowchart TD; A -- hello ----> B",
"flowchart TD; A -. hello ..-> B",
"flowchart TD; A -- hello o--> B",
"flowchart TD; A --oB --> C",
"flowchart TD; A -. hello -.-> B",
"flowchart TD; A---oB",
"flowchart TD; A-.-xB",
"flowchart TD; A[one]; A[two]",
"flowchart TD; A[<b>HTML</b>]",
"flowchart TD; A[&#27;]",
@@ -215,9 +271,14 @@ fn rejects_partial_or_unsupported_input() {
#[test]
fn source_graph_and_width_limits() {
let grouped = "A & B & C & D --> E & F & G & H & I & J";
assert!(render(&format!("graph; {grouped}"), /*max_width*/ 200).is_ok());
for source in [
" ".repeat(16 * 1024 + 1),
format!("graph TD; A[{}]", "x".repeat(41)),
format!("graph; A -- {} --> B", "x".repeat(41)),
format!("graph; A --> E; {grouped}"),
format!("graph; {} --> B", ["A"; 25].join(" & ")),
format!("graph TD; A[\"{}\"]", "[]".repeat(21)),
format!("graph TD; A([{}])", "x".repeat(41)),
format!(
@@ -21,6 +21,7 @@ fn mermaid_fences_use_native_renderer_for_every_family() {
for source in [
"%% heading\nflowchart TD; A --> B",
"graph LR; A --> B",
"flowchart; A -- send --> B & C; B <-.-> C",
"sequenceDiagram; A->>B: request; B-->>A: response",
"stateDiagram-v2; [*] --> Active; Active --> [*]",
"stateDiagram; [*] --> Active; Active --> [*]",