From 659b35f1316eda27ef61850dd0832c4a4e95c120 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 27 Sep 2026 23:32:58 +0000 Subject: [PATCH] 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 --- codex-rs/mermaid/README.md | 22 ++-- codex-rs/mermaid/src/families_tests.rs | 52 +++++++-- codex-rs/mermaid/src/lib.rs | 15 +-- codex-rs/mermaid/src/parse.rs | 52 ++++----- codex-rs/mermaid/src/relations.rs | 10 ++ .../codex_mermaid__families_tests__class.snap | 4 +- ...dex_mermaid__families_tests__sequence.snap | 5 +- .../codex_mermaid__families_tests__state.snap | 2 +- ...tests__branches_merges_and_retry_loop.snap | 7 +- codex-rs/mermaid/src/syntax.rs | 101 ++++++++++++++++++ codex-rs/mermaid/src/tests.rs | 47 +++++++- 11 files changed, 239 insertions(+), 78 deletions(-) create mode 100644 codex-rs/mermaid/src/syntax.rs diff --git a/codex-rs/mermaid/README.md b/codex-rs/mermaid/README.md index d7384ede4b..b7690ddf63 100644 --- a/codex-rs/mermaid/README.md +++ b/codex-rs/mermaid/README.md @@ -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, diff --git a/codex-rs/mermaid/src/families_tests.rs b/codex-rs/mermaid/src/families_tests.rs index 057adf1914..9b80b8545e 100644 --- a/codex-rs/mermaid/src/families_tests.rs +++ b/codex-rs/mermaid/src/families_tests.rs @@ -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 {; +; }", "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] diff --git a/codex-rs/mermaid/src/lib.rs b/codex-rs/mermaid/src/lib.rs index e381dc7285..b62fdfb7da 100644 --- a/codex-rs/mermaid/src/lib.rs +++ b/codex-rs/mermaid/src/lib.rs @@ -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>, 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::>(); - 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), diff --git a/codex-rs/mermaid/src/parse.rs b/codex-rs/mermaid/src/parse.rs index e07086f56c..c41d4eb17c 100644 --- a/codex-rs/mermaid/src/parse.rs +++ b/codex-rs/mermaid/src/parse.rs @@ -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 { .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 { }; 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) }) { diff --git a/codex-rs/mermaid/src/relations.rs b/codex-rs/mermaid/src/relations.rs index 9b44861be0..1b5957dc9a 100644 --- a/codex-rs/mermaid/src/relations.rs +++ b/codex-rs/mermaid/src/relations.rs @@ -24,6 +24,9 @@ pub(super) fn parse(header: &str, body: &[&str]) -> Result { 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 { .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 { 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 { diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap index 2f1688f249..01716ce17e 100644 --- a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap @@ -5,8 +5,8 @@ expression: output ┌──────────────────┐ │ order │ │ ───── │ -│ +String id │ -│ +Status status │ +│ +int[] ids │ +│ +String a;b │ │ +submit() │ │ +cancel() │ │ ├◆─(1) contains────┐ diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap index 9646e74e13..cc22eb898b 100644 --- a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap @@ -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───────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap index a2d15eb2d0..61fe7e6dbb 100644 --- a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap @@ -31,7 +31,7 @@ expression: output ┌─────────────────────┐ │ │ │ │ │ │ Validating │ │ │ │ │ │ │ ├◄──────────────┘ │ │ │ │ -│ ├──valid──────────┘ │ │ │ +│ ├──[ready]────────┘ │ │ │ │ ├──invalid──────────┐ │ │ │ └─────────────────────┘ │ │ │ │ │ │ │ │ diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__branches_merges_and_retry_loop.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__branches_merges_and_retry_loop.snap index 70ceaeb6a0..ecfb761fe4 100644 --- a/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__branches_merges_and_retry_loop.snap +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__branches_merges_and_retry_loop.snap @@ -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──────╪─┐ └───────────────┘ │ │ │ │ diff --git a/codex-rs/mermaid/src/syntax.rs b/codex-rs/mermaid/src/syntax.rs new file mode 100644 index 0000000000..34b7fbfb29 --- /dev/null +++ b/codex-rs/mermaid/src/syntax.rs @@ -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, 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) + } +} diff --git a/codex-rs/mermaid/src/tests.rs b/codex-rs/mermaid/src/tests.rs index 6ba9f7e7ee..369cfda014 100644 --- a/codex-rs/mermaid/src/tests.rs +++ b/codex-rs/mermaid/src/tests.rs @@ -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", "ed.lines().collect::>()).unwrap(), super::parse::parse("flowchart TD", &unquoted.lines().collect::>()).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 ["&", "&", "&"] { + for entity in ["&", "&", "&", "#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\"", "\"\"", "\"HTML\"", + "\"before 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; {}",