mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
| '|'
|
||||
| '<'
|
||||
| '>'
|
||||
| '&'
|
||||
| '"'
|
||||
| '\\'
|
||||
| '┌'
|
||||
|
||||
@@ -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", "ed.lines().collect::<Vec<_>>()).unwrap(),
|
||||
super::parse::parse("flowchart TD", &unquoted.lines().collect::<Vec<_>>()).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_labels_keep_source_fallback() {
|
||||
for entity in ["&", "&", "&"] {
|
||||
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!("%% &\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: &\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
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/markdown_render/mermaid_tests.rs
|
||||
expression: output
|
||||
---
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌───────────────────┐
|
||||
│ Your saved order │ │ Review & confirm │ │ DoorDash checkout │
|
||||
└┬─────────────────┘ └┬─┬───────────────┘ └┬──────────────────┘
|
||||
│ ▲ │ ▲
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
└─────────────────────┘ │ │
|
||||
│ │
|
||||
└───────────────────┘
|
||||
Reference in New Issue
Block a user