Skip to content

Commit 374db9e

Browse files
committed
fix ampersand in node labels splitting nodes incorrectly
1 parent d1bf282 commit 374db9e

File tree

2 files changed

+99
-20
lines changed

2 files changed

+99
-20
lines changed

src/lib.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,4 +388,38 @@ mod tests {
388388
assert!(svg.contains("Dogs"));
389389
assert!(!svg.contains("Syntax error in text"));
390390
}
391+
392+
#[test]
393+
fn test_ampersand_in_node_labels_renders_valid_svg() {
394+
let svg = render(
395+
r#"flowchart LR
396+
A["Agent reads artifacts & computes deps"] --> B["List & select changes"]"#,
397+
)
398+
.unwrap();
399+
assert!(svg.contains("<svg"));
400+
assert!(svg.contains("</svg>"));
401+
// The `&` should be XML-escaped to `&amp;` in the SVG output
402+
assert!(
403+
svg.contains("&amp;"),
404+
"SVG should contain XML-escaped ampersand"
405+
);
406+
// The label text may be wrapped across <tspan> elements, so check
407+
// that the escaped ampersand appears in the rendered text content.
408+
assert!(
409+
svg.contains("artifacts &amp;") || svg.contains("artifacts &amp; computes"),
410+
"Source label should contain escaped ampersand near 'artifacts'"
411+
);
412+
assert!(
413+
svg.contains("List &amp; select"),
414+
"Target label should be intact with escaped ampersand"
415+
);
416+
// Verify both nodes exist (not split into phantom nodes)
417+
// The SVG should have exactly 2 rectangle shapes for the 2 nodes
418+
let rect_count = svg.matches("<rect").count();
419+
// background rect + 2 node rects = 3
420+
assert!(
421+
rect_count >= 3,
422+
"Expected at least 3 rects (1 bg + 2 nodes), got {rect_count}"
423+
);
424+
}
391425
}

src/parser.rs

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -363,21 +363,34 @@ fn parse_flowchart(input: &str) -> Result<ParseOutput> {
363363
Ok(ParseOutput { graph, init_config })
364364
}
365365

366+
/// Split on `&` that is outside brackets, parens, braces, and quotes.
367+
fn split_ampersand_aware(input: &str) -> Vec<&str> {
368+
let masked = mask_bracket_content(input);
369+
let mut parts = Vec::new();
370+
let mut start = 0;
371+
for (i, ch) in masked.char_indices() {
372+
if ch == '&' {
373+
let part = input[start..i].trim();
374+
if !part.is_empty() {
375+
parts.push(part);
376+
}
377+
start = i + 1;
378+
}
379+
}
380+
let last = input[start..].trim();
381+
if !last.is_empty() {
382+
parts.push(last);
383+
}
384+
parts
385+
}
386+
366387
fn add_flowchart_edge(line: &str, graph: &mut Graph, subgraph_stack: &[usize]) -> bool {
367388
let Some((left, label, right, edge_meta)) = parse_edge_line(line) else {
368389
return false;
369390
};
370391

371-
let sources: Vec<&str> = left
372-
.split('&')
373-
.map(|part| part.trim())
374-
.filter(|part| !part.is_empty())
375-
.collect();
376-
let targets: Vec<&str> = right
377-
.split('&')
378-
.map(|part| part.trim())
379-
.filter(|part| !part.is_empty())
380-
.collect();
392+
let sources = split_ampersand_aware(&left);
393+
let targets = split_ampersand_aware(&right);
381394

382395
let mut source_ids = Vec::new();
383396
for source in sources {
@@ -3589,16 +3602,8 @@ fn parse_block_diagram(input: &str) -> Result<ParseOutput> {
35893602
continue;
35903603
}
35913604
if let Some((left, label, right, edge_meta)) = parse_edge_line(line) {
3592-
let sources: Vec<&str> = left
3593-
.split('&')
3594-
.map(|part| part.trim())
3595-
.filter(|part| !part.is_empty())
3596-
.collect();
3597-
let targets: Vec<&str> = right
3598-
.split('&')
3599-
.map(|part| part.trim())
3600-
.filter(|part| !part.is_empty())
3601-
.collect();
3605+
let sources = split_ampersand_aware(&left);
3606+
let targets = split_ampersand_aware(&right);
36023607

36033608
for source in &sources {
36043609
let (source_id, source_label, source_shape, source_classes) =
@@ -6054,6 +6059,46 @@ mod tests {
60546059
assert!(parsed.graph.nodes.contains_key("C"));
60556060
}
60566061

6062+
#[test]
6063+
fn parse_ampersand_in_source_label() {
6064+
let input = r#"flowchart LR
6065+
A["Agent reads artifacts & computes deps"] --> B"#;
6066+
let parsed = parse_mermaid(input).unwrap();
6067+
assert_eq!(parsed.graph.edges.len(), 1);
6068+
assert_eq!(parsed.graph.nodes.len(), 2);
6069+
assert!(parsed.graph.nodes.contains_key("A"));
6070+
assert!(parsed.graph.nodes.contains_key("B"));
6071+
assert_eq!(
6072+
parsed.graph.nodes["A"].label,
6073+
"Agent reads artifacts & computes deps"
6074+
);
6075+
}
6076+
6077+
#[test]
6078+
fn parse_ampersand_in_target_label() {
6079+
let input = r#"flowchart LR
6080+
A --> B["List & select changes"]"#;
6081+
let parsed = parse_mermaid(input).unwrap();
6082+
assert_eq!(parsed.graph.edges.len(), 1);
6083+
assert_eq!(parsed.graph.nodes.len(), 2);
6084+
assert!(parsed.graph.nodes.contains_key("A"));
6085+
assert!(parsed.graph.nodes.contains_key("B"));
6086+
assert_eq!(parsed.graph.nodes["B"].label, "List & select changes");
6087+
}
6088+
6089+
#[test]
6090+
fn parse_parallel_ampersand_with_label_ampersand() {
6091+
let input = r#"flowchart LR
6092+
A["foo & bar"] & B --> C"#;
6093+
let parsed = parse_mermaid(input).unwrap();
6094+
assert_eq!(parsed.graph.edges.len(), 2);
6095+
assert_eq!(parsed.graph.nodes.len(), 3);
6096+
assert!(parsed.graph.nodes.contains_key("A"));
6097+
assert!(parsed.graph.nodes.contains_key("B"));
6098+
assert!(parsed.graph.nodes.contains_key("C"));
6099+
assert_eq!(parsed.graph.nodes["A"].label, "foo & bar");
6100+
}
6101+
60576102
#[test]
60586103
fn parse_subgraph_style() {
60596104
let input = "flowchart LR\nclassDef hot fill:#f00,stroke:#0f0\nsubgraph SG[Group]:::hot\nA --> B\nend\nclass SG hot\nstyle SG fill:#faf,stroke:#111";

0 commit comments

Comments
 (0)