mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
{
|
||||
|
||||
@@ -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──────────┐ │ │ │
|
||||
└─────────────────────┘ │ │ │ │
|
||||
│ │ │ │
|
||||
|
||||
+3
-4
@@ -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──────╪─┐
|
||||
└───────────────┘ │ │
|
||||
│ │
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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::<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 ["&", "&", "&"] {
|
||||
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\"",
|
||||
"\"\"",
|
||||
"\"<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; {}",
|
||||
|
||||
Reference in New Issue
Block a user