Support quoted labels and ampersands in Mermaid flowcharts (#47678)

## Why

Flowcharts with labels such as `A["Review & confirm"]` fell back to source display instead of rendering a diagram.

## What changed

Accept double-quoted node and edge labels, strip the surrounding quotes, and allow literal ampersands in flowchart labels. Keep other diagram families' label restrictions and continue rejecting HTML, entity escapes, and malformed quotes.

## Testing

Add parser and renderer regression tests for quoted labels, literal ampersands, statement separators, and unsupported text, plus TUI coverage for rendered labels and entity source fallback.

GitOrigin-RevId: 9e61a04665f0266d369fa3595411c9a72f10cb9d
This commit is contained in:
Eric Traut
2026-09-23 22:09:41 +00:00
committed by copyberry
parent b2aa42e6f1
commit 9477081ccd
5 changed files with 126 additions and 4 deletions
+4
View File
@@ -22,6 +22,10 @@ example `class Order {` followed by member lines and a final `}`). Class member
is retained in one compartment, including visibility, signatures, and return types.
ER attribute types and names use the identifier grammar above.
Flowchart node and edge labels may be enclosed in double quotes. Literal ampersands
are supported in these flowchart labels; other diagram families retain their stricter
label subset. HTML and entity escapes remain unsupported.
These are explicit subsets, not complete Mermaid compatibility. Compound states,
flowchart subgraphs, other shapes, sequence activation and parallel fragments,
styling, front matter, directives, HTML, escapes, combining/zero-width characters,
+21 -3
View File
@@ -30,7 +30,7 @@ pub(super) fn parse(header: &str, body: &[&str]) -> Result<Graph, RenderError> {
rest = rest.trim_start();
let label = if let Some(after) = rest.strip_prefix('|') {
let (label, remaining) = after.split_once('|').ok_or(RenderError::Unsupported)?;
check_label(label)?;
let label = flowchart_label(label)?;
rest = remaining;
label.to_owned()
} else {
@@ -70,7 +70,7 @@ fn node(rest: &mut &str, graph: &mut Graph) -> Result<usize, RenderError> {
let (label, remaining) = rest[open.len()..]
.split_once(close)
.ok_or(RenderError::Unsupported)?;
check_label(label)?;
let label = flowchart_label(label)?;
*rest = remaining;
let node = &mut graph.nodes[index];
if node.declared && (node.label != label || node.shape != shape) {
@@ -83,7 +83,26 @@ fn node(rest: &mut &str, graph: &mut Graph) -> Result<usize, RenderError> {
Ok(index)
}
fn flowchart_label(label: &str) -> Result<&str, RenderError> {
let label = if let Some(quoted) = label.strip_prefix('"') {
quoted.strip_suffix('"').ok_or(RenderError::Unsupported)?
} else {
label
};
check_label_text(label)?;
Ok(label)
}
pub(super) fn check_label(label: &str) -> Result<(), RenderError> {
// Only delimited flowchart labels support literal ampersands. Other families must keep
// rejecting them so statement splitting cannot turn an entity into truncated label text.
if label.contains('&') {
return Err(RenderError::Unsupported);
}
check_label_text(label)
}
fn check_label_text(label: &str) -> Result<(), RenderError> {
if label.trim().is_empty()
|| label.chars().any(|ch| {
ch.is_control()
@@ -95,7 +114,6 @@ pub(super) fn check_label(label: &str) -> Result<(), RenderError> {
| '|'
| '<'
| '>'
| '&'
| '"'
| '\\'
| '┌'
+66 -1
View File
@@ -35,6 +35,72 @@ fn stadiums_with_other_shapes_in_every_direction() {
assert_snapshot!(cases.join("\n\n"));
}
#[test]
fn quoted_flowchart_labels_match_unquoted_labels() {
let quoted = r#"A["Review & confirm"] -->|"Yes & continue"| B{"Ready?"}
B --> C(["Checkout"])
A[Review & confirm]"#;
let unquoted = quoted.replace('"', "");
assert_eq!(
super::parse::parse("flowchart TD", &quoted.lines().collect::<Vec<_>>()).unwrap(),
super::parse::parse("flowchart TD", &unquoted.lines().collect::<Vec<_>>()).unwrap(),
);
}
#[test]
fn entity_labels_keep_source_fallback() {
for entity in ["&amp;", "&#38;", "&#x26;"] {
for source in [
format!("sequenceDiagram\nA->>B: {entity}"),
format!("flowchart TD; A[\"{entity}\"]"),
format!("flowchart TD; A -->|\"{entity}\"| B"),
] {
assert_eq!(
render(&source, /*max_width*/ 100),
Err(RenderError::Unsupported),
"{source:?}",
);
}
}
}
#[test]
fn flowchart_ampersands_preserve_statement_separators() {
let source = "flowchart TD; A[R&D] -->|R&D| B[Review & confirm]; B --> C";
assert_eq!(
render(&format!("%% &amp;\n{source}"), /*max_width*/ 100),
render(&source.replace(';', "\n"), /*max_width*/ 100),
);
assert!(
render(source, /*max_width*/ 100)
.unwrap()
.contains("Review & confirm")
);
}
#[test]
fn quoted_flowchart_labels_reject_malformed_and_unsafe_text() {
for label in [
"\"unfinished",
"unfinished\"",
"\"embedded\"quote\"",
"\"\"",
"\"<b>HTML</b>\"",
"\"\u{1b}\"",
] {
for source in [
format!("flowchart TD; A[{label}]"),
format!("flowchart TD; A -->|{label}| B"),
] {
assert_eq!(
render(&source, /*max_width*/ 100),
Err(RenderError::Unsupported),
"{source:?}",
);
}
}
}
#[test]
fn branches_merges_and_retry_loop() {
let source = "flowchart TD\nA[Checkout] --> B{In stock?}\nB -->|yes| C[Reserve]\nB -->|no| D[Waitlist]\nC --> E{Paid?}\nE -->|yes| F[Ship]\nE -->|no| G[Retry payment]\nG --> E\nD --> H[Notify buyer]\nF --> H";
@@ -72,7 +138,6 @@ fn rejects_partial_or_unsupported_input() {
"flowchart TD; A([same]); A[same]",
"flowchart TD; A{same}; A([same])",
"flowchart TD; A -->|unclosed B",
"flowchart TD; A[\"quoted\"]",
"flowchart TD; A[foo;bar]",
"flowchart TD; A[لا]",
"flowchart TD; A -->|yes┐| B",
@@ -41,6 +41,27 @@ fn mermaid_nested_fences_and_unicode() {
assert_snapshot!(markdown_text(source, /*width*/ 60));
}
#[test]
fn mermaid_quoted_labels_and_ampersands() {
let source = r#"```mermaid
flowchart LR
A["Your saved order"] --> B["Review & confirm"] --> C["DoorDash checkout"]
```"#;
let output = markdown_text(source, /*width*/ 100);
assert!(output.starts_with('┌'));
assert_snapshot!(output);
}
#[test]
fn mermaid_entities_keep_source() {
let source = "```mermaid\nsequenceDiagram\nA->>B: &amp;\n```";
let output = markdown_text(source, /*width*/ 100);
assert!(output.ends_with(&markdown_text(
&source.replacen("mermaid", "unknown", /*count*/ 1),
/*width*/ 100,
)));
}
#[test]
fn mermaid_stadium_flowchart() {
let source = "```mermaid
@@ -0,0 +1,14 @@
---
source: tui/src/markdown_render/mermaid_tests.rs
expression: output
---
┌──────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ Your saved order │ │ Review & confirm │ │ DoorDash checkout │
└┬─────────────────┘ └┬─┬───────────────┘ └┬──────────────────┘
│ ▲ │ ▲
│ │ │ │
│ │ │ │
│ │ │ │
└─────────────────────┘ │ │
│ │
└───────────────────┘