Preserve punctuation and semicolons in Mermaid labels (#48814)

## Why

Splitting every statement at semicolons and rejecting label punctuation prevents rendering labels such as `A["Go []; &"]`, sequence messages containing `data[0] = {x: 1}`, and class members containing semicolons.

## What changed

- Split statements using diagram-specific rules, preserving semicolons inside flowchart labels, quoted tokens, and class member lines. Sequence quotes remain literal text.
- Respect quoted flowchart label delimiters and allow printable label punctuation, including literal ampersands and comparison operators.
- Reject unsupported entity escapes before statement splitting can truncate them, while retaining rejection of HTML markup and malformed labels.
- Document the supported syntax and require newlines between class members.

## Testing

Add parser assertions and update rendering snapshots for punctuation, quoted delimiters, and semicolons. Extend rejection coverage for entity escapes across diagram families, malformed quotes, and invalid class bodies.

GitOrigin-RevId: da94007a66c7c58444e6f0d6a1b409c8c7251941
This commit is contained in:
Eric Traut
2026-09-27 23:41:04 +00:00
committed by copyberry
parent 3f4668da20
commit 659b35f131
11 changed files with 239 additions and 78 deletions
+13 -9
View File
@@ -15,16 +15,20 @@ production dependency is needed.
| `erDiagram` | Entities, multiline attribute bodies, `type name [PK, FK, UK] ["comment"]`, all four endpoint cardinalities, identifying/non-identifying relationships, relationship labels, direction declarations |
Identifiers are ASCII letters followed by letters, digits, or underscores. Text
supports ordinary Unicode and CJK. Full-line `%%` comments and semicolon-separated
statements are supported; semicolons inside labels are not. Member/attribute bodies
need a separate statement for each opening brace, member, and closing brace (for
example `class Order {` followed by member lines and a final `}`). Class member text
is retained in one compartment, including visibility, signatures, and return types.
ER attribute types and names use the identifier grammar above.
supports ordinary Unicode and CJK. Full-line `%%` comments and
semicolon-separated statements are supported outside flowchart labels, quoted
tokens, and class bodies. Sequence quotes are literal text; use newlines between
class members, whose semicolons remain text. Member/attribute bodies need a
separate statement for each opening brace, member, and closing brace (for
example `class Order {` followed by member lines and a final `}`). Class member
text 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.
Flowchart node and edge labels may be enclosed in double quotes. Literal
ampersands are supported in labels, as is printable punctuation after parsing.
Quotes protect flowchart label delimiters such as brackets, pipes, and
semicolons. HTML and entity escapes remain unsupported and return an error
before statement splitting.
These are explicit subsets, not complete Mermaid compatibility. Compound states,
flowchart subgraphs, other shapes, sequence activation and parallel fragments,
+45 -7
View File
@@ -10,9 +10,9 @@ const SEQUENCE: &str = "sequenceDiagram
participant A as API
participant S as 库存
participant P as Payments
U->>A: Place order
U->>A: data[0] = {x: 1}
A->>S: Reserve items
S-->>A: Reservation
S-->>A: say \"hello\" | C:\\tmp
opt Items reserved
loop Up to 3 attempts
A->>P: Charge card
@@ -32,7 +32,7 @@ const STATE: &str = "stateDiagram-v2
state \"Payment pending\" as Charging
[*] --> Draft
Draft --> Validating: submit
Validating --> Charging: valid
Validating --> Charging: [ready]
Validating --> Rejected: invalid
Charging --> Packing: paid
Charging --> Rejected: declined
@@ -46,8 +46,8 @@ const STATE: &str = "stateDiagram-v2
const CLASS: &str = "classDiagram
class order {
+String id
+Status status
+int[] ids
+String a;b
+submit()
+cancel()
}
@@ -192,6 +192,34 @@ fn class_relationship_endpoints() {
assert!(ports[1].contains("(many)"));
assert_eq!(output.contains('┆'), dashed);
}
for source in [
"classDiagram\nclass A; A --> B : {ok}",
"classDiagram\nclass A; class B {\n+id\n}",
] {
assert_eq!(
render(source, /*max_width*/ 100).unwrap(),
render(&source.replace(';', "\n"), /*max_width*/ 100).unwrap(),
);
}
let source = "classDiagram\nclass A {\nstring a;b\nObject[] elementData\n}\nA : +get()";
let statements = super::syntax::statements(source).unwrap();
assert_eq!(
super::relations::parse(statements[0], &statements[1..]).unwrap(),
super::Graph {
nodes: vec![super::Node {
id: "A".into(),
label: "A".into(),
shape: super::Shape::Rectangle,
declared: false,
members: vec![
"string a;b".into(),
"Object[] elementData".into(),
"+get()".into()
],
}],
..super::Graph::default()
}
);
for (line, target, target_tip, target_label, dashed) in [
("A --orange", "orange", '─', "", false),
("A ..o_range", "o_range", '─', "", true),
@@ -269,6 +297,9 @@ fn rejects_incomplete_and_unsupported_families() {
"classDiagram; A --> B; click A",
"classDiagram; class A {; +<html>; }",
"erDiagram; A ||--o{ B",
"erDiagram; A ||--|| B: \"owns\"",
"erDiagram; A {; string value \"x\"junk\"; }",
"classDiagram\nclass A {\nstring a;b }\n}",
"erDiagram; A ||--?? B : owns",
"erDiagram; A {; int x KEY; }",
"erDiagram; A {; int x PK \"open comment; }",
@@ -289,7 +320,7 @@ fn rejects_incomplete_and_unsupported_families() {
#[test]
fn metadata_names_and_style_text_remain_valid_in_content() {
for source in [
"classDiagram; class accTitle {; +id; }; class accDescr {; +id; }",
"classDiagram; class accTitle {\n +id\n }\n class accDescr {\n +id\n }",
"classDiagram; class A; A: ::: literal; A --> B: ::: literal",
"erDiagram; accTitle {; int id; }",
"erDiagram; A ||--|| B: ::: literal",
@@ -314,7 +345,7 @@ fn family_limits() {
"loop retry;".repeat(5),
"end;".repeat(5)
),
format!("classDiagram; class A {{; {} }}", "+field;".repeat(17)),
format!("classDiagram; class A {{\n {} }}", "+field\n".repeat(17)),
format!("erDiagram; A {{; {} }}", "int id;".repeat(17)),
format!("stateDiagram-v2; {}", "A --> B;".repeat(25)),
format!("stateDiagram-v2\n{}", "S: Description\n".repeat(18)),
@@ -346,6 +377,8 @@ fn truncated_sources_and_terminal_widths() {
"sequenceDiagram; A->>B: hi",
"sequenceDiagram; A->>A: self",
"sequenceDiagram; Note over A: memo",
"stateDiagram-v2; state \"a;b\" as A; A-->B: [ready]",
"erDiagram; A {; string value \"a;b\"; }",
] {
let output = render(source, /*max_width*/ 100).unwrap();
let width = output.lines().map(UnicodeWidthStr::width).max().unwrap();
@@ -388,6 +421,11 @@ fn sequence_arrows_preserve_sender_recipient_and_style() {
assert_eq!(rows[row].contains(&'┄'), dashed);
}
}
let source = "sequenceDiagram; A->>B: say \"hello; B->>A: world\"";
assert_eq!(
render(source, /*max_width*/ 180).unwrap(),
render(&source.replace(';', "\n"), /*max_width*/ 180).unwrap(),
);
}
#[test]
+2 -13
View File
@@ -11,6 +11,7 @@ mod parse;
mod relations;
mod sequence;
mod state;
mod syntax;
pub use output::Role;
pub use output::Span;
@@ -63,19 +64,7 @@ pub fn render_spans(source: &str, max_width: usize) -> Result<Vec<Vec<Span>>, Re
if source.len() > MAX_SOURCE {
return Err(RenderError::Limit);
}
let statements = source
.lines()
.filter(|line| !line.trim_start().starts_with("%%"))
.flat_map(|line| line.split(';'))
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if source
.lines()
.any(|line| line.trim_start().starts_with("%%{"))
{
return Err(RenderError::Unsupported);
}
let statements = syntax::statements(source)?;
let (header, body) = statements.split_first().ok_or(RenderError::Unsupported)?;
match *header {
"sequenceDiagram" => sequence::render(body, max_width),
+18 -34
View File
@@ -7,6 +7,7 @@ use super::MAX_EDGES;
use super::MAX_LABEL;
use super::RenderError;
use super::Shape;
use super::syntax::delimited_label;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
@@ -29,7 +30,7 @@ pub(super) fn parse(header: &str, body: &[&str]) -> Result<Graph, RenderError> {
.ok_or(RenderError::Unsupported)?;
rest = rest.trim_start();
let label = if let Some(after) = rest.strip_prefix('|') {
let (label, remaining) = after.split_once('|').ok_or(RenderError::Unsupported)?;
let (label, remaining) = delimited_label(after, "|")?;
let label = flowchart_label(label)?;
rest = remaining;
label.to_owned()
@@ -74,9 +75,7 @@ fn node(rest: &mut &str, graph: &mut Graph) -> Result<usize, RenderError> {
};
let index = graph.node(id)?;
if let Some((open, close, shape)) = declaration {
let (label, remaining) = rest[open.len()..]
.split_once(close)
.ok_or(RenderError::Unsupported)?;
let (label, remaining) = delimited_label(&rest[open.len()..], close)?;
let label = flowchart_label(label)?;
*rest = remaining;
let node = &mut graph.nodes[index];
@@ -98,44 +97,29 @@ 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 {
if label.contains(['[', ']', '{', '}', '|']) {
return Err(RenderError::Unsupported);
}
label
};
check_label_text(label)?;
if label.contains('"') {
return Err(RenderError::Unsupported);
}
check_label(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()
// Markup needs deliberate decoding/layout; printable comparison operators are plain text.
let markup = label.match_indices('<').any(|(index, _)| {
let after = &label[index + 1..];
after.starts_with(|ch: char| ch.is_ascii_alphabetic() || matches!(ch, '/' | '!' | '?'))
});
if markup
|| label.trim().is_empty()
|| label.chars().any(|ch| {
ch.is_control()
|| matches!(
ch,
'[' | ']'
| '{'
| '}'
| '|'
| '<'
| '>'
| '"'
| '\\'
| '┌'
| '┐'
| '└'
| '┘'
| '├'
| '┤'
| '╪'
| '◄'
)
|| matches!(ch, '┌' | '┐' | '└' | '┘' | '├' | '┤' | '╪' | '◄')
|| UnicodeWidthChar::width(ch).is_none_or(|width| width == 0)
})
{
+10
View File
@@ -24,6 +24,9 @@ pub(super) fn parse(header: &str, body: &[&str]) -> Result<Graph, RenderError> {
let member = if er {
attribute(line)?
} else {
if line.contains(['{', '}']) {
return Err(RenderError::Unsupported);
}
check_label(line)?;
line.to_owned()
};
@@ -149,6 +152,10 @@ pub(super) fn parse(header: &str, body: &[&str]) -> Result<Graph, RenderError> {
.filter(|text| !text.starts_with("::"))
{
let label = label.trim();
// ER quotes delimit a token; keep fallback until that grammar is normalized.
if er && label.contains('"') {
return Err(RenderError::Unsupported);
}
check_label(label)?;
label.to_owned()
} else if !rest.is_empty() || er {
@@ -205,6 +212,9 @@ fn attribute(line: &str) -> Result<String, RenderError> {
let name = identifier(&mut rest)?;
let (keys, comment) = if let Some((keys, comment)) = rest.trim().split_once('"') {
let comment = comment.strip_suffix('"').ok_or(RenderError::Unsupported)?;
if comment.contains('"') {
return Err(RenderError::Unsupported);
}
check_label(comment)?;
(keys.trim(), Some(comment))
} else {
@@ -5,8 +5,8 @@ expression: output
┌──────────────────┐
│ order │
│ ───── │
│ +String id │
│ +Status status │
│ +int[] ids │
│ +String a;b │
│ +submit() │
│ +cancel() │
│ ├◆─(1) contains────┐
@@ -1,18 +1,17 @@
---
source: mermaid/src/families_tests.rs
assertion_line: 107
expression: output
---
┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Buyer (actor) │ │ API │ │ 库存 │ │ Payments │
└───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘
│ Place order │ │ │
│ data[0] = {x: 1} │ │ │
├─────────────────────────▶ │ │
│ │ │ │
│ │ Reserve items │ │
│ ├─────────────────────────▶ │
│ │ │ │
│ │ Reservation │ │
│ │ say "hello" | C:\tmp │ │
│ ◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │
│ │ │ │
┌─opt Items reserved───────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
@@ -31,7 +31,7 @@ expression: output
┌─────────────────────┐ │ │ │ │ │
│ Validating │ │ │ │ │ │
│ ├◄──────────────┘ │ │ │ │
│ ├──valid──────────┘ │ │ │
│ ├──[ready]────────┘ │ │ │
│ ├──invalid──────────┐ │ │ │
└─────────────────────┘ │ │ │ │
│ │ │ │
@@ -1,18 +1,17 @@
---
source: mermaid/src/tests.rs
assertion_line: 10
expression: "render(source, 100).unwrap()"
---
┌───────────────┐
│ Checkout │
│ Go []; & │
│ ├────────┐
└───────────────┘ │
│
│
┌───────────────┐ │
│ ◇ In stock? │ │
│ ◇ x < 3? │ │
│ ├◄───────┘
│ ├──yes─────┐
│ ├──y|n─────┐
│ ├──no──────╪─┐
└───────────────┘ │ │
│ │
+101
View File
@@ -0,0 +1,101 @@
//! Small lexical boundaries shared by the family parsers; no decoding or multiline labels.
//! Source limits are enforced before scanning, and undecoded entities always fail intact.
use super::RenderError;
/// Split statements using family-specific quote and member rules. Sequence quotes are literal;
/// class bodies consume member lines whole. Flowchart label delimiters and other quoted tokens
/// protect semicolons, except in colon-delimited class/state text, where quotes are literal too.
pub(super) fn statements(source: &str) -> Result<Vec<&str>, RenderError> {
let mut result = Vec::new();
let mut class_body = false;
for line in source.lines() {
let mut rest = line.trim();
if rest.starts_with("%%{") {
return Err(RenderError::Unsupported);
}
if rest.starts_with("%%") {
continue;
}
// Mermaid #name;/#number; and HTML &name;/&#number; need decoding. Reject before
// statement splitting can discard the semicolon and turn them into literal text.
if rest.match_indices(['#', '&']).any(|(index, _)| {
let after = &rest[index + 1..];
let len = after
.bytes()
.take_while(|b| b.is_ascii_alphanumeric() || *b == b'_')
.count();
len > 0 && after[len..].starts_with(';')
}) {
return Err(RenderError::Unsupported);
}
while !rest.is_empty() {
let header = result.first().copied().unwrap_or("");
if header == "classDiagram"
&& (class_body
|| rest.starts_with("class ")
&& rest
.split_once('{')
.is_some_and(|(declaration, _)| !declaration.contains(';')))
{
// Compact bodies are not supported here; let the class parser reject them.
class_body = rest != "}";
result.push(rest);
break;
}
let colon_text = matches!(header, "classDiagram" | "stateDiagram" | "stateDiagram-v2");
let flowchart = matches!(
header.split_whitespace().next(),
Some("flowchart" | "graph")
);
let mut close = None;
let mut quoted = false;
let mut literal = header == "sequenceDiagram";
let end = rest.char_indices().find_map(|(index, ch)| {
match ch {
'"' if !literal => quoted = !quoted,
':' if colon_text && !quoted => literal = true,
';' if !quoted && close.is_none() => return Some(index),
ch if flowchart && !quoted => {
close = match (close, ch) {
(Some(end), ch) if end == ch => None,
(None, '[') => Some(']'),
(None, '{') => Some('}'),
(None, '|') => Some('|'),
(close, _) => close,
};
}
_ => {}
}
None
});
let (statement, remaining) = match end {
Some(end) => (&rest[..end], &rest[end + 1..]),
None => (rest, ""),
};
if !statement.trim().is_empty() {
result.push(statement.trim());
}
rest = remaining.trim();
}
}
Ok(result)
}
/// Consume a flowchart label up to its closing delimiter, protecting delimiters inside a leading
/// quoted token. Returns the raw label (including quotes) and untouched remaining syntax.
/// Quotes do not use backslash escaping in Mermaid; entities remain unsupported.
pub(super) fn delimited_label<'a>(
text: &'a str,
close: &str,
) -> Result<(&'a str, &'a str), RenderError> {
if let Some(quoted) = text.strip_prefix('"') {
let end = quoted.find('"').ok_or(RenderError::Unsupported)? + 2;
let rest = text[end..]
.strip_prefix(close)
.ok_or(RenderError::Unsupported)?;
Ok((&text[..end], rest))
} else {
text.split_once(close).ok_or(RenderError::Unsupported)
}
}
+42 -5
View File
@@ -37,14 +37,47 @@ fn stadiums_with_other_shapes_in_every_direction() {
#[test]
fn quoted_flowchart_labels_match_unquoted_labels() {
let quoted = r#"A["Review & confirm"] -->|"Yes & continue"| B{"Ready?"}
let quoted = r#"A["Review & confirm;"] -->|"Yes & continue"| B{"Ready?"}
B --> C(["Checkout"])
A[Review & confirm]"#;
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(),
);
assert_eq!(
render(&format!("graph TD; {quoted}"), /*max_width*/ 180).unwrap(),
render(&format!("graph TD; {unquoted}"), /*max_width*/ 180).unwrap(),
);
let source = r#"graph TD;A["chimpansen hoppar ()[]"] -->|"x | y; z"| B{"x < 3?"};"#;
let statements = super::syntax::statements(source).unwrap();
assert_eq!(
super::parse::parse(statements[0], &statements[1..]).unwrap(),
super::Graph {
nodes: vec![
super::Node {
id: "A".into(),
label: "chimpansen hoppar ()[]".into(),
shape: super::Shape::Rectangle,
declared: true,
members: Vec::new(),
},
super::Node {
id: "B".into(),
label: "x < 3?".into(),
shape: super::Shape::Decision,
declared: true,
members: Vec::new(),
},
],
edges: vec![super::Edge::directed(
/*from*/ 0,
/*to*/ 1,
"x | y; z".into()
)],
..super::Graph::default()
}
);
for (quoted, label) in [
(r#"A["(Database)"]"#, "(Database)"),
(r#"A["/Input/"]"#, "/Input/"),
@@ -65,9 +98,12 @@ A[Review & confirm]"#;
#[test]
fn entity_labels_keep_source_fallback() {
for entity in ["&amp;", "&#38;", "&#x26;"] {
for entity in ["&amp;", "&#38;", "&#x26;", "#9829;", "#semi;"] {
for source in [
format!("sequenceDiagram\nA->>B: {entity}"),
format!("classDiagram\nclass A {{\n{entity}\n}}"),
format!("stateDiagram-v2; A-->B: {entity}"),
format!("erDiagram\nA {{\nstring value \"{entity}\"\n}}"),
format!("flowchart TD; A[\"{entity}\"]"),
format!("flowchart TD; A -->|\"{entity}\"| B"),
] {
@@ -102,6 +138,7 @@ fn quoted_flowchart_labels_reject_malformed_and_unsafe_text() {
"\"embedded\"quote\"",
"\"\"",
"\"<b>HTML</b>\"",
"\"before <b\"",
"\"\u{1b}\"",
] {
for source in [
@@ -119,7 +156,7 @@ fn quoted_flowchart_labels_reject_malformed_and_unsafe_text() {
#[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";
let source = "flowchart TD\nA[\"Go []; &\"] --> B{\"x < 3?\"}\nB -->|\"y|n\"| 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";
assert_snapshot!(render(source, /*max_width*/ 100).unwrap());
}
@@ -165,7 +202,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[foo;bar]",
"flowchart TD; A[لا]",
"flowchart TD; A -->|yes┐| B",
] {
@@ -182,6 +218,7 @@ fn source_graph_and_width_limits() {
for source in [
" ".repeat(16 * 1024 + 1),
format!("graph TD; A[{}]", "x".repeat(41)),
format!("graph TD; A[\"{}\"]", "[]".repeat(21)),
format!("graph TD; A([{}])", "x".repeat(41)),
format!(
"graph TD; {}",