From 7c709f0ffd5c7eea752737bad397a1585c2176a1 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 16 Sep 2026 00:27:17 +0000 Subject: [PATCH] Add a bounded Mermaid text renderer (#45817) ## What changed Add `codex-mermaid`, a standalone crate that renders supported subsets of flowchart, sequence, state, class, and ER diagrams as Unicode text. Expose `render` for plain text and `render_spans` for semantic node, edge, and text spans that callers can style. Enforce source, diagram, canvas, and display-width limits. Return errors for unsupported input or exceeded limits without producing partial diagrams, leaving source fallback to callers. Include documentation and a stdin rendering example. ## Testing Add snapshots for each diagram family, checks for relationship endpoints, Unicode labels, semantic spans, truncated input, and size limits, plus edge reconstruction for all 512 directed three-node graphs in all four layout directions. GitOrigin-RevId: 5b4e65d9152abce5d833e4e6b12113a5a21f700e --- codex-rs/Cargo.lock | 9 + codex-rs/Cargo.toml | 1 + codex-rs/mermaid/BUILD.bazel | 7 + codex-rs/mermaid/Cargo.toml | 20 + codex-rs/mermaid/README.md | 73 ++++ codex-rs/mermaid/examples/render.rs | 17 + codex-rs/mermaid/src/draw.rs | 253 ++++++++++++ codex-rs/mermaid/src/families_tests.rs | 359 ++++++++++++++++++ codex-rs/mermaid/src/lib.rs | 176 +++++++++ codex-rs/mermaid/src/output.rs | 69 ++++ codex-rs/mermaid/src/parse.rs | 143 +++++++ codex-rs/mermaid/src/relations.rs | 225 +++++++++++ codex-rs/mermaid/src/sequence.rs | 306 +++++++++++++++ .../codex_mermaid__families_tests__LR.snap | 17 + .../codex_mermaid__families_tests__class.snap | 46 +++ .../codex_mermaid__families_tests__er.snap | 45 +++ ...dex_mermaid__families_tests__sequence.snap | 60 +++ .../codex_mermaid__families_tests__state.snap | 70 ++++ ...tests__branches_merges_and_retry_loop.snap | 61 +++ codex-rs/mermaid/src/state.rs | 109 ++++++ codex-rs/mermaid/src/tests.rs | 193 ++++++++++ 21 files changed, 2259 insertions(+) create mode 100644 codex-rs/mermaid/BUILD.bazel create mode 100644 codex-rs/mermaid/Cargo.toml create mode 100644 codex-rs/mermaid/README.md create mode 100644 codex-rs/mermaid/examples/render.rs create mode 100644 codex-rs/mermaid/src/draw.rs create mode 100644 codex-rs/mermaid/src/families_tests.rs create mode 100644 codex-rs/mermaid/src/lib.rs create mode 100644 codex-rs/mermaid/src/output.rs create mode 100644 codex-rs/mermaid/src/parse.rs create mode 100644 codex-rs/mermaid/src/relations.rs create mode 100644 codex-rs/mermaid/src/sequence.rs create mode 100644 codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__LR.snap create mode 100644 codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap create mode 100644 codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__er.snap create mode 100644 codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap create mode 100644 codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap create mode 100644 codex-rs/mermaid/src/snapshots/codex_mermaid__tests__branches_merges_and_retry_loop.snap create mode 100644 codex-rs/mermaid/src/state.rs create mode 100644 codex-rs/mermaid/src/tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cdb51f237f..760969fbfe 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3996,6 +3996,15 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-mermaid" +version = "0.0.0" +dependencies = [ + "insta", + "pretty_assertions", + "unicode-width 0.2.1", +] + [[package]] name = "codex-message-history" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 21d86ee677..f4ecb1ce04 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -82,6 +82,7 @@ members = [ "codex-mcp", "memories/read", "memories/write", + "mermaid", "model-provider-info", "mxc-sandbox", "models-manager", diff --git a/codex-rs/mermaid/BUILD.bazel b/codex-rs/mermaid/BUILD.bazel new file mode 100644 index 0000000000..5b00c5a993 --- /dev/null +++ b/codex-rs/mermaid/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "mermaid", + crate_name = "codex_mermaid", + test_data_extra = glob(["src/snapshots/**"]), +) diff --git a/codex-rs/mermaid/Cargo.toml b/codex-rs/mermaid/Cargo.toml new file mode 100644 index 0000000000..b01437b433 --- /dev/null +++ b/codex-rs/mermaid/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codex-mermaid" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_mermaid" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +unicode-width = { workspace = true } + +[dev-dependencies] +insta = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/mermaid/README.md b/codex-rs/mermaid/README.md new file mode 100644 index 0000000000..65c20e817c --- /dev/null +++ b/codex-rs/mermaid/README.md @@ -0,0 +1,73 @@ +# codex-mermaid + +Standalone, bounded Mermaid text renderer. +It uses the existing `unicode-width` dependency; no Mermaid runtime or new external +production dependency is needed. + +## Supported subsets + +| Family | Supported syntax | +| --- | --- | +| `flowchart`, `graph` | TD/TB, BT, LR, RL; rectangle and decision labels; directed and labeled `-->` edges; chains, branches, merges, loops | +| `sequenceDiagram` | Implicit participants, `participant`/`actor`, aliases, `->`, `->>`, `-->`, `-->>`, `-x`, `--x`, self-messages, `Note over A[,B]`, nested `loop`/`alt`/`opt`/`critical`/`break`, one labeled `else` per `alt` | +| `stateDiagram-v2`, `stateDiagram` | Flat states, `state "label" as ID`, descriptions, directed transitions with optional labels, initial/final `[*]`, direction declarations | +| `classDiagram` | `class ID`, multiline member bodies, `ID : member`, solid/dashed links, association, inheritance, composition, aggregation, dependency, realization, quoted endpoint cardinalities, relationship labels, direction declarations | +| `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. + +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, +and ligatures with non-additive widths return errors. Callers should retain source +on any error; the library never returns a partial diagram. + +## Layout and notation + +Graph nodes appear in declaration/first-reference order, in the requested direction. +Each edge gets its own lane and endpoint positions. Crossings use `╪` and never +join routes. Decisions use `◇` inside a box. Horizontal layouts +reserve a text gutter for every endpoint, which can make connected graphs wide; +the caller receives `TooWide` if the complete output does not fit. + +Class links use arrows into the referenced class, `◁`/`△` for inheritance/realization, +`◆` for composition, and `◇` for aggregation. Endpoint cardinalities appear in +parentheses next to the appropriate class or entity. ER cardinalities are written +as `1`, `0..1`, `1..many`, or `0..many`. Solid ER links are identifying; dashed links +are non-identifying. Class members and ER keys/comments appear verbatim as readable +text, rather than renderer metadata. + +Sequence messages retain chronological order. `->>`/`-->>` use `▶`/`◀` arrowheads, +`->`/`-->` have no arrowhead, and `-x`/`--x` use a cross endpoint. Solid and dashed +strokes remain distinct. Lifelines crossed by a message use `┼`; this is not a recipient. +Control fragments have nested frames and explicit branch labels. Notes span their +named participants. States use separate `● initial` and `◎ final` nodes. + +## Limits and evaluation + +All inputs are limited to 16 KiB. Graphs allow 16 nodes, 24 edges, and 16 members per +node. Sequences allow 8 participants, 64 events (including fragment boundaries), +and 4 fragment levels. Source labels and identifiers are limited to 40 display +cells/ASCII bytes respectively. Rendered canvases are capped at 65,536 cells, +independent of the caller's maximum width. The library performs no I/O. + +From `codex-rs`, preview a file, optionally specifying the available width: + +```sh +cargo run -p codex-mermaid --example render -- 180 < diagram.mmd +``` + +Run `just test -p codex-mermaid --lib`. Coverage includes complex snapshots for every +family, relationship endpoints, all ER cardinalities, width/error bounds, +truncated input, and reconstruction of every edge in all 512 directed three-node +graphs in each of the four layout directions. + + +`render_spans` returns the same layout as lines of semantic `Node`, `Edge`, and +`Text` spans. Callers apply their own theme; the crate never emits ANSI escapes. diff --git a/codex-rs/mermaid/examples/render.rs b/codex-rs/mermaid/examples/render.rs new file mode 100644 index 0000000000..2a19e74f0b --- /dev/null +++ b/codex-rs/mermaid/examples/render.rs @@ -0,0 +1,17 @@ +//! Render a diagram from standard input for manual prototype evaluation. + +use std::io::Read; + +fn main() -> Result<(), Box> { + let mut source = String::new(); + std::io::stdin() + .take(16 * 1024 + 1) + .read_to_string(&mut source)?; + let max_width = std::env::args() + .nth(1) + .map(|value| value.parse()) + .transpose()? + .unwrap_or(120); + println!("{}", codex_mermaid::render(&source, max_width)?); + Ok(()) +} diff --git a/codex-rs/mermaid/src/draw.rs b/codex-rs/mermaid/src/draw.rs new file mode 100644 index 0000000000..0068b047a7 --- /dev/null +++ b/codex-rs/mermaid/src/draw.rs @@ -0,0 +1,253 @@ +//! Orthogonal routes with unique lanes and endpoint positions, in all four directions. +//! +//! Routes never share segments. Crossings are explicitly marked; labels occupy reserved gutters. + +use super::Direction; +use super::Graph; +use super::RenderError; +use super::Role; +use super::Span; +use super::output::Cell; +use super::output::finish; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +pub(super) fn render(graph: &Graph, max_width: usize) -> Result>, RenderError> { + let horizontal = matches!(graph.direction, Direction::Right | Direction::Left); + let labels = graph + .nodes + .iter() + .map(|node| { + let mut lines = vec![if node.decision { + format!("◇ {}", node.label) + } else { + node.label.clone() + }]; + if !node.members.is_empty() { + lines.push("─".repeat(node.label.width())); + lines.extend(node.members.iter().cloned()); + } + lines + }) + .collect::>(); + let label_width = graph + .edges + .iter() + .flat_map(|edge| [&edge.label, &edge.target_label]) + .map(|label| label.width()) + .max() + .unwrap_or(0); + let box_cross = if horizontal { + labels.iter().map(Vec::len).max().unwrap_or(0) + 2 + } else { + labels + .iter() + .flatten() + .map(|label| label.width()) + .max() + .unwrap_or(0) + + 4 + }; + let mut counts = vec![0; graph.nodes.len()]; + let mut ports = Vec::new(); + for edge in &graph.edges { + let source = counts[edge.from]; + counts[edge.from] += 1; + let target = counts[edge.to]; + counts[edge.to] += 1; + ports.push((source, target)); + } + let stride = if horizontal { label_width + 2 } else { 1 }; + let sizes = labels + .iter() + .enumerate() + .map(|(i, lines)| { + if horizontal { + (lines.iter().map(|line| line.width()).max().unwrap_or(0) + 4) + .max(counts[i] * stride + 2) + } else { + lines.len() + counts[i] + 2 + } + }) + .collect::>(); + let mut starts = vec![0; graph.nodes.len()]; + let mut along = 0; + for i in 0..graph.nodes.len() { + let i = if matches!(graph.direction, Direction::Up | Direction::Left) { + graph.nodes.len() - 1 - i + } else { + i + }; + starts[i] = along; + along += sizes[i] + 2; + } + let first_lane = box_cross + if horizontal { 4 } else { label_width + 5 }; + let across = if graph.edges.is_empty() { + box_cross + } else { + first_lane + graph.edges.len() * 2 - 1 + }; + let (width, height) = if horizontal { + (along - 2, across) + } else { + (across, along - 2) + }; + if width > max_width { + return Err(RenderError::TooWide); + } + if width * height > super::MAX_CELLS { + return Err(RenderError::Limit); + } + let mut canvas = Canvas { + cells: vec![vec![Cell::edge(' '); width]; height], + horizontal, + }; + for (i, lines) in labels.iter().enumerate() { + let start = starts[i]; + let end = start + sizes[i] - 1; + canvas.set(/*across*/ 0, start, Cell::node('┌')); + canvas.set(box_cross - 1, start, Cell::node('┐')); + canvas.set(/*across*/ 0, end, Cell::node('└')); + canvas.set(box_cross - 1, end, Cell::node('┘')); + for x in 1..box_cross - 1 { + canvas.set(x, start, Cell::node('─')); + canvas.set(x, end, Cell::node('─')); + } + for y in start + 1..end { + canvas.set(/*across*/ 0, y, Cell::node('│')); + canvas.set(box_cross - 1, y, Cell::node('│')); + } + for (j, line) in lines.iter().enumerate() { + let (x, y) = if horizontal { + (start + 2, j + 1) + } else { + (2, start + j + 1) + }; + put_text(&mut canvas.cells[y], x, line)?; + } + } + let endpoints = graph + .edges + .iter() + .enumerate() + .map(|(i, edge)| { + let offset = |node: usize, port: usize| { + starts[node] + + if horizontal { + 1 + port * stride + } else { + labels[node].len() + 1 + port + } + }; + (offset(edge.from, ports[i].0), offset(edge.to, ports[i].1)) + }) + .collect::>(); + // Paint lanes first so every crossing is independent of iteration order. + for (i, edge) in graph.edges.iter().enumerate() { + let (source, target) = endpoints[i]; + for y in source.min(target) + 1..source.max(target) { + canvas.set( + first_lane + 2 * i, + y, + Cell::edge(if edge.dashed { '┆' } else { '│' }), + ); + } + } + for (i, edge) in graph.edges.iter().enumerate() { + let (source, target) = endpoints[i]; + let lane = first_lane + 2 * i; + for y in [source, target] { + for x in box_cross..lane { + let ch = if matches!(canvas.get(x, y), '│' | '┆') { + '╪' + } else if edge.dashed { + '┄' + } else { + '─' + }; + canvas.set(x, y, Cell::edge(ch)); + } + } + canvas.set(box_cross - 1, source, Cell::edge('├')); + canvas.set(box_cross - 1, target, Cell::edge('├')); + canvas.set(box_cross, source, Cell::edge(edge.source_tip)); + canvas.set(box_cross, target, Cell::edge(edge.target_tip)); + canvas.set( + lane, + source, + Cell::edge(if source < target { '┐' } else { '┘' }), + ); + canvas.set( + lane, + target, + Cell::edge(if source < target { '┘' } else { '┐' }), + ); + for (port, label) in [(source, &edge.label), (target, &edge.target_label)] { + let (x, y) = if horizontal { + (port + 1, box_cross + 1) + } else { + (box_cross + 2, port) + }; + put_text(&mut canvas.cells[y], x, label)?; + } + } + Ok(finish(canvas.cells)) +} + +struct Canvas { + cells: Vec>, + horizontal: bool, +} + +impl Canvas { + fn set(&mut self, across: usize, along: usize, mut cell: Cell) { + if self.horizontal { + cell.symbol = transpose(cell.symbol); + self.cells[across][along] = cell; + } else { + self.cells[along][across] = cell; + } + } + + fn get(&self, across: usize, along: usize) -> char { + if self.horizontal { + transpose(self.cells[across][along].symbol) + } else { + self.cells[along][across].symbol + } + } +} + +fn transpose(ch: char) -> char { + match ch { + '─' => '│', + '│' => '─', + '┄' => '┆', + '┆' => '┄', + '┐' => '└', + '└' => '┐', + '├' => '┬', + '┬' => '├', + '◄' => '▲', + '▲' => '◄', + '◁' => '△', + '△' => '◁', + other => other, + } +} + +pub(super) fn put_text(row: &mut [Cell], mut column: usize, text: &str) -> Result<(), RenderError> { + for ch in text.chars() { + let width = UnicodeWidthChar::width(ch).ok_or(RenderError::Unsupported)?; + row[column] = Cell { + symbol: ch, + role: Role::Text, + }; + row[column + 1..column + width].fill(Cell { + symbol: '\0', + role: Role::Text, + }); + column += width; + } + Ok(()) +} diff --git a/codex-rs/mermaid/src/families_tests.rs b/codex-rs/mermaid/src/families_tests.rs new file mode 100644 index 0000000000..176c031e3a --- /dev/null +++ b/codex-rs/mermaid/src/families_tests.rs @@ -0,0 +1,359 @@ +//! End-to-end grammar, topology, and rendered output checks for each supported family. + +use super::RenderError; +use super::render; +use pretty_assertions::assert_eq; +use unicode_width::UnicodeWidthStr; + +const SEQUENCE: &str = "sequenceDiagram + actor U as Buyer + participant A as API + participant S as 库存 + participant P as Payments + U->>A: Place order + A->>S: Reserve items + S-->>A: Reservation + opt Items reserved + loop Up to 3 attempts + A->>P: Charge card + P->>P: Check fraud + P-->>A: Payment status + alt Approved + Note over A,P: Payment recorded + A-->>U: Order confirmed + else Declined + A->>S: Release items + A-->>U: Payment failed + end + end + end"; + +const STATE: &str = "stateDiagram-v2 + state \"Payment pending\" as Charging + [*] --> Draft + Draft --> Validating: submit + Validating --> Charging: valid + Validating --> Rejected: invalid + Charging --> Packing: paid + Charging --> Rejected: declined + Packing --> Shipped: dispatch + Shipped --> Delivered: received + Delivered --> [*] + Rejected --> Draft: revise + Charging: Retry up to 3 times"; + +const CLASS: &str = "classDiagram + class Order { + +String id + +Status status + +submit() + +cancel() + } + class LineItem { + +int quantity + +Decimal price + +subtotal() + } + class Payment { + +Decimal amount + +authorize() + } + class CardPayment { + +String lastFour + +authorize() + } + Order \"1\" *-- \"1..*\" LineItem : contains + Order \"1\" --> \"1\" Payment : pays with + Payment <|-- CardPayment + CardPayment ..> Order : updates"; + +const ER: &str = "erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||..o{ LINE_ITEM : appears_in + CUSTOMER { + int id PK + string email UK + string name + } + ORDER { + int id PK + int customer_id FK + string status + } + LINE_ITEM { + int order_id PK, FK \"order key\" + int product_id PK, FK + int quantity + } + PRODUCT { + int id PK + string name + decimal price + }"; + +#[test] +fn complex_families() { + for (name, source) in [ + ("sequence", SEQUENCE), + ("state", STATE), + ("class", CLASS), + ("er", ER), + ] { + let output = render(source, /*max_width*/ 180).unwrap(); + let width = output.lines().map(UnicodeWidthStr::width).max().unwrap(); + assert_eq!(render(source, width), Ok(output.clone())); + assert_eq!(render(source, width - 1), Err(RenderError::TooWide)); + insta::assert_snapshot!(name, output); + } +} + +#[test] +fn unicode_labels_and_later_declarations() { + for direction in ["TD", "BT", "LR", "RL"] { + let source = format!( + "%% heading\ngraph {direction}; A -->|准备| B; A[请求]; B{{Réponse?}}; B -->|retry| A; B --> C[Ship 🚀]" + ); + let output = render(&source, /*max_width*/ 160).unwrap(); + for label in ["请求", "Réponse?", "Ship 🚀", "准备", "retry"] { + assert!(output.contains(label), "{direction}: {label}"); + } + let width = output.lines().map(UnicodeWidthStr::width).max().unwrap(); + assert_eq!(render(&source, width), Ok(output.clone())); + assert_eq!(render(&source, width - 1), Err(RenderError::TooWide)); + if direction == "LR" { + insta::assert_snapshot!("LR", output); + } + } +} + +#[test] +fn class_relationship_endpoints() { + for (operator, source_tip, target_tip, dashed) in [ + ("<|--", '◁', '─', false), + ("*--", '◆', '─', false), + ("o--", '◇', '─', false), + ("-->", '─', '◄', false), + ("--", '─', '─', false), + ("..>", '─', '◄', true), + ("..|>", '─', '◁', true), + ("..", '─', '─', true), + ("<--", '◄', '─', false), + ("--*", '─', '◆', false), + ("--o", '─', '◇', false), + ("--|>", '─', '◁', false), + ] { + let output = render( + &format!("classDiagram; A \"one\" {operator} \"many\" B : uses"), + /*max_width*/ 100, + ) + .unwrap(); + let ports = output + .lines() + .filter(|line| line.contains('├')) + .collect::>(); + assert!( + ports[0].contains(&format!("├{source_tip}")), + "{operator}: {output}" + ); + assert!( + ports[1].contains(&format!("├{target_tip}")), + "{operator}: {output}" + ); + assert!(ports[0].contains("(one) uses")); + assert!(ports[1].contains("(many)")); + assert_eq!(output.contains('┆'), dashed); + } +} + +#[test] +fn er_cardinalities() { + for (left, source_card) in [ + ("||", "1"), + ("|o", "0..1"), + ("}|", "1..many"), + ("}o", "0..many"), + ] { + for (right, target_card) in [ + ("||", "1"), + ("o|", "0..1"), + ("|{", "1..many"), + ("o{", "0..many"), + ] { + let output = render( + &format!("erDiagram; A {left}--{right} B : owns"), + /*max_width*/ 100, + ) + .unwrap(); + let ports = output + .lines() + .filter(|line| line.contains('├')) + .collect::>(); + assert!(ports[0].contains(&format!("({source_card}) owns"))); + assert!(ports[1].contains(&format!("({target_card})"))); + } + } +} + +#[test] +fn rejects_incomplete_and_unsupported_families() { + for source in [ + "sequenceDiagram; A->>B: hello; nonsense", + "sequenceDiagram; A->>B: hello; activate B", + "sequenceDiagram; participant A as x; participant A as y", + "sequenceDiagram; alt ready; A->>B: hi", + "sequenceDiagram; A->>B: hi; end", + "sequenceDiagram; loop retry; else no; end", + "sequenceDiagram; alt one; else two; else three; end", + "sequenceDiagram; A->>B:
", + "sequenceDiagram; A->>B: \u{1b}[31m", + "sequenceDiagram; participant A as e\u{301}", + "stateDiagram-v2; state Processing {; A-->B; }", + "stateDiagram-v2; A --> B: ok; garbage text", + "stateDiagram-v2; state \"First\" as A; state \"Second\" as A", + "stateDiagram-v2; accDescr: Order lifecycle; [*] --> Ready", + "stateDiagram; ACCDESCR: Order lifecycle; [*] --> Ready", + "stateDiagram-v2; A:::highlight; A --> B", + "stateDiagram-v2; A --> B:::highlight", + "classDiagram; accTitle: Account model; class Account", + "classDiagram; class A {; +foo()", + "classDiagram; class A; A --? B", + "classDiagram; A --> B; click A", + "classDiagram; class A {; +; }", + "erDiagram; A ||--o{ B", + "erDiagram; A ||--?? B : owns", + "erDiagram; A {; int x KEY; }", + "erDiagram; A {; int x PK \"open comment; }", + "erDiagram; A {; int x; }; A ||--|| B : uses; trailing junk", + "erDiagram; accDescr {; A model; }; CUSTOMER", + "erDiagram; ACCDESCR {; A model; }; CUSTOMER", + "erDiagram; A ||--|| B:::highlight : owns", + "%%{init: {}}%%\nclassDiagram; class A", + ] { + assert_eq!( + render(source, /*max_width*/ 200), + Err(RenderError::Unsupported), + "{source}" + ); + } +} + +#[test] +fn metadata_names_and_style_text_remain_valid_in_content() { + for source in [ + "classDiagram; class accTitle {; +id; }; class accDescr {; +id; }", + "classDiagram; class A; A: ::: literal; A --> B: ::: literal", + "erDiagram; accTitle {; int id; }", + "erDiagram; A ||--|| B: ::: literal", + "stateDiagram-v2; A: text ::: literal; A --> B: ::: literal", + ] { + assert!(render(source, /*max_width*/ 200).is_ok(), "{source}"); + } +} + +#[test] +fn family_limits() { + for source in [ + format!( + "sequenceDiagram; {}", + (0..9) + .map(|i| format!("participant P{i};")) + .collect::() + ), + format!("sequenceDiagram; {}", "A->>B: msg;".repeat(65)), + format!( + "sequenceDiagram; {} A->>B: msg; {}", + "loop retry;".repeat(5), + "end;".repeat(5) + ), + format!("classDiagram; class A {{; {} }}", "+field;".repeat(17)), + format!("erDiagram; A {{; {} }}", "int id;".repeat(17)), + format!("stateDiagram-v2; {}", "A --> B;".repeat(25)), + ] { + assert_eq!( + render(&source, /*max_width*/ usize::MAX), + Err(RenderError::Limit), + "{source}" + ); + } +} + +#[test] +fn truncated_sources_and_terminal_widths() { + // Exercise incomplete streamed source at every UTF-8 boundary, including inside control blocks. + for source in [SEQUENCE, STATE, CLASS, ER, "sequenceDiagram; A->>B: 请求"] { + for end in source.char_indices().map(|(index, _)| index) { + if let Ok(output) = render(&source[..end], /*max_width*/ 180) { + assert!(output.lines().all(|line| line.width() <= 180)); + assert!(!output.chars().any(|ch| ch.is_control() && ch != '\n')); + } + } + } + for source in [ + "sequenceDiagram; A->>B: hi", + "sequenceDiagram; A->>A: self", + "sequenceDiagram; Note over A: memo", + ] { + let output = render(source, /*max_width*/ 100).unwrap(); + let width = output.lines().map(UnicodeWidthStr::width).max().unwrap(); + assert_eq!(render(source, width), Ok(output)); + assert_eq!(render(source, width - 1), Err(RenderError::TooWide)); + } +} + +#[test] +fn sequence_arrows_preserve_sender_recipient_and_style() { + for (operator, forward, reverse, dashed) in [ + ("->>", '▶', '◀', false), + ("-->>", '▶', '◀', true), + ("->", '─', '─', false), + ("-->", '┄', '┄', true), + ("-x", 'x', 'x', false), + ("--x", 'x', 'x', true), + ] { + for (from, to, tip) in [ + ("A", "B", forward), + ("B", "A", reverse), + ("B", "B", reverse), + ] { + let output = render( + &format!( + "sequenceDiagram; participant A; participant B; {from}{operator}{to}: msg" + ), + /*max_width*/ 100, + ) + .unwrap(); + let rows = output + .lines() + .map(|line| line.chars().collect::>()) + .collect::>(); + let a = rows[1].iter().position(|ch| *ch == 'A').unwrap(); + let b = rows[1].iter().position(|ch| *ch == 'B').unwrap(); + let recipient = if to == "A" { a } else { b }; + let row = if from == to { 5 } else { 4 }; + assert_eq!(rows[row][recipient], tip, "{from}{operator}{to}"); + assert_eq!(rows[row].contains(&'┄'), dashed); + } + } +} + +#[test] +fn canvas_limit_applies_even_with_unlimited_caller_width() { + let graph = format!( + "graph LR; {}", + format!("A-->|{}|B;", "x".repeat(40)).repeat(24) + ); + let sequence = format!( + "sequenceDiagram; {} {}", + (0..8) + .map(|i| format!("participant P{i};")) + .collect::(), + format!("P0->>P7: {};", "x".repeat(40)).repeat(64) + ); + for source in [graph, sequence] { + assert_eq!( + render(&source, /*max_width*/ usize::MAX), + Err(RenderError::Limit) + ); + } +} diff --git a/codex-rs/mermaid/src/lib.rs b/codex-rs/mermaid/src/lib.rs new file mode 100644 index 0000000000..a6475a3a35 --- /dev/null +++ b/codex-rs/mermaid/src/lib.rs @@ -0,0 +1,176 @@ +//! Bounded, terminal-native Mermaid diagram prototype. +//! +//! Supports flowcharts, sequences, states, classes, and entity relationships. +//! Graph edges own separate lanes and endpoint positions. Crossings never join routes. Unsupported +//! syntax and outputs exceeding the caller's width return errors, leaving source fallback to +//! the caller. This crate performs no I/O and does not depend on a Mermaid implementation. + +mod draw; +mod output; +mod parse; +mod relations; +mod sequence; +mod state; + +pub use output::Role; +pub use output::Span; +use std::fmt; + +const MAX_SOURCE: usize = 16 * 1024; +const MAX_NODES: usize = 16; +const MAX_EDGES: usize = 24; +const MAX_LABEL: usize = 40; +const MAX_CELLS: usize = 64 * 1024; + +/// A diagram cannot be faithfully represented by this bounded prototype. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderError { + /// Syntax or text is outside the explicitly supported subset. + Unsupported, + /// A source, diagram, label, or canvas limit was exceeded. + Limit, + /// The complete diagram exceeds the supplied display width. + TooWide, +} + +impl fmt::Display for RenderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Unsupported => "unsupported Mermaid syntax or label", + Self::Limit => "diagram exceeds prototype limits", + Self::TooWide => "diagram exceeds available display width", + }) + } +} + +impl std::error::Error for RenderError {} + +/// Render a bounded subset of Mermaid as plain Unicode text. +/// +/// Supports flowcharts, sequence diagrams, flat state diagrams, class diagrams, and ER diagrams. +/// See the crate README for each grammar and its limits. Unknown syntax, unsafe terminal text, +/// and diagrams exceeding `max_width` return errors; no partial result is returned. +pub fn render(source: &str, max_width: usize) -> Result { + Ok(render_spans(source, max_width)? + .into_iter() + .map(|line| line.into_iter().map(|span| span.text).collect::()) + .collect::>() + .join("\n")) +} + +/// Render the same bounded diagram as lines of semantic spans for caller-provided styling. +pub fn render_spans(source: &str, max_width: usize) -> Result>, RenderError> { + 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 (header, body) = statements.split_first().ok_or(RenderError::Unsupported)?; + match *header { + "sequenceDiagram" => sequence::render(body, max_width), + "stateDiagram-v2" | "stateDiagram" | "classDiagram" | "erDiagram" => { + draw::render(&relations::parse(header, body)?, max_width) + } + _ => draw::render(&parse::parse(header, body)?, max_width), + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum Direction { + #[default] + Down, + Up, + Right, + Left, +} + +impl Direction { + fn parse(text: &str) -> Result { + match text { + "TD" | "TB" => Ok(Self::Down), + "BT" => Ok(Self::Up), + "LR" => Ok(Self::Right), + "RL" => Ok(Self::Left), + _ => Err(RenderError::Unsupported), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Node { + id: String, + label: String, + decision: bool, + declared: bool, + members: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +struct Edge { + from: usize, + to: usize, + label: String, + target_label: String, + source_tip: char, + target_tip: char, + dashed: bool, +} + +impl Edge { + fn directed(from: usize, to: usize, label: String) -> Self { + Self { + from, + to, + label, + target_label: String::new(), + source_tip: '─', + target_tip: '◄', + dashed: false, + } + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct Graph { + direction: Direction, + nodes: Vec, + edges: Vec, +} + +impl Graph { + fn node(&mut self, id: &str) -> Result { + if let Some(index) = self.nodes.iter().position(|node| node.id == id) { + return Ok(index); + } + if self.nodes.len() == MAX_NODES { + return Err(RenderError::Limit); + } + self.nodes.push(Node { + id: id.to_owned(), + label: id.to_owned(), + decision: false, + declared: false, + members: Vec::new(), + }); + Ok(self.nodes.len() - 1) + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "families_tests.rs"] +mod families_tests; diff --git a/codex-rs/mermaid/src/output.rs b/codex-rs/mermaid/src/output.rs new file mode 100644 index 0000000000..8e78af515e --- /dev/null +++ b/codex-rs/mermaid/src/output.rs @@ -0,0 +1,69 @@ +//! Semantic spans for theme-independent diagram output; drawing cells never contain ANSI escapes. + +/// The diagram element a caller can style with its own theme. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Node, + Edge, + Text, +} + +/// Adjacent characters with one semantic role, independent of terminal styling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Span { + pub text: String, + pub role: Role, +} + +#[derive(Clone, Copy)] +pub(super) struct Cell { + pub symbol: char, + pub role: Role, +} + +impl Cell { + pub(super) fn edge(symbol: char) -> Self { + Self { + symbol, + role: Role::Edge, + } + } + + pub(super) fn node(symbol: char) -> Self { + Self { + symbol, + role: Role::Node, + } + } +} + +pub(super) fn finish(rows: Vec>) -> Vec> { + rows.into_iter() + .map(|row| { + let mut spans: Vec = Vec::new(); + for Cell { symbol, role } in row { + if symbol == '\0' { + continue; + } + if let Some(last) = spans.last_mut() + && last.role == role + { + last.text.push(symbol); + } else { + spans.push(Span { + text: symbol.to_string(), + role, + }); + } + } + while let Some(last) = spans.last_mut() { + last.text.truncate(last.text.trim_end().len()); + if !last.text.is_empty() { + break; + } + spans.pop(); + } + spans + }) + .collect() +} diff --git a/codex-rs/mermaid/src/parse.rs b/codex-rs/mermaid/src/parse.rs new file mode 100644 index 0000000000..157e69fbbf --- /dev/null +++ b/codex-rs/mermaid/src/parse.rs @@ -0,0 +1,143 @@ +//! Strict parser for a small flowchart grammar; every non-comment byte must be consumed. + +use super::Direction; +use super::Edge; +use super::Graph; +use super::MAX_EDGES; +use super::MAX_LABEL; +use super::RenderError; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +pub(super) fn parse(header: &str, body: &[&str]) -> Result { + let tokens = header.split_whitespace().collect::>(); + let ["flowchart" | "graph", direction] = tokens.as_slice() else { + return Err(RenderError::Unsupported); + }; + let mut graph = Graph { + direction: Direction::parse(direction)?, + ..Graph::default() + }; + for statement in body { + let mut rest = *statement; + let mut from = node(&mut rest, &mut graph)?; + while !rest.trim_start().is_empty() { + rest = rest + .trim_start() + .strip_prefix("-->") + .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)?; + check_label(label)?; + rest = remaining; + label.to_owned() + } else { + String::new() + }; + let to = node(&mut rest, &mut graph)?; + if graph.edges.len() == MAX_EDGES { + return Err(RenderError::Limit); + } + graph.edges.push(Edge::directed(from, to, label)); + from = to; + } + } + if graph.nodes.is_empty() { + return Err(RenderError::Unsupported); + } + Ok(graph) +} + +fn node(rest: &mut &str, graph: &mut Graph) -> Result { + let id = identifier(rest)?; + // Reserved constructs must not be interpreted as ordinary node declarations. + if matches!( + id, + "end" | "subgraph" | "direction" | "style" | "class" | "classDef" | "linkStyle" | "click" + ) { + return Err(RenderError::Unsupported); + } + let declaration = match rest.chars().next() { + Some(open @ ('[' | '{')) => { + let close = if open == '[' { ']' } else { '}' }; + let (label, remaining) = rest[1..] + .split_once(close) + .ok_or(RenderError::Unsupported)?; + check_label(label)?; + *rest = remaining; + Some((label, open == '{')) + } + _ => None, + }; + let index = graph.node(id)?; + if let Some((label, decision)) = declaration { + let node = &mut graph.nodes[index]; + if node.declared && (node.label != label || node.decision != decision) { + return Err(RenderError::Unsupported); + } + node.label = label.to_owned(); + node.decision = decision; + node.declared = true; + } + Ok(index) +} + +pub(super) fn check_label(label: &str) -> Result<(), RenderError> { + if label.trim().is_empty() + || label.chars().any(|ch| { + ch.is_control() + || matches!( + ch, + '[' | ']' + | '{' + | '}' + | '|' + | '<' + | '>' + | '&' + | '"' + | '\\' + | '┌' + | '┐' + | '└' + | '┘' + | '├' + | '┤' + | '╪' + | '◄' + ) + || UnicodeWidthChar::width(ch).is_none_or(|width| width == 0) + }) + { + return Err(RenderError::Unsupported); + } + // Labels are drawn one Unicode scalar at a time. Reject ligatures whose string width differs + // from those scalar widths rather than misaligning borders or underallocating the canvas. + if label + .chars() + .filter_map(UnicodeWidthChar::width) + .sum::() + != label.width() + { + return Err(RenderError::Unsupported); + } + if UnicodeWidthStr::width(label) > MAX_LABEL { + return Err(RenderError::Limit); + } + Ok(()) +} + +pub(super) fn identifier<'a>(rest: &mut &'a str) -> Result<&'a str, RenderError> { + *rest = rest.trim_start(); + let len = rest + .bytes() + .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_') + .count(); + let id = &rest[..len]; + if id.is_empty() || !id.as_bytes()[0].is_ascii_alphabetic() || id.len() > MAX_LABEL { + return Err(RenderError::Unsupported); + } + *rest = &rest[len..]; + Ok(id) +} diff --git a/codex-rs/mermaid/src/relations.rs b/codex-rs/mermaid/src/relations.rs new file mode 100644 index 0000000000..c13f79e151 --- /dev/null +++ b/codex-rs/mermaid/src/relations.rs @@ -0,0 +1,225 @@ +//! Class and ER grammars, preserving members, endpoint cardinalities, and relationship kinds. + +use super::Direction; +use super::Edge; +use super::Graph; +use super::MAX_EDGES; +use super::RenderError; +use super::parse::check_label; +use super::parse::identifier; + +pub(super) fn parse(header: &str, body: &[&str]) -> Result { + if matches!(header, "stateDiagram" | "stateDiagram-v2") { + return super::state::parse(body); + } + let er = header == "erDiagram"; + let mut graph = Graph::default(); + let mut block = None; + let mut direction = false; + for &line in body { + if let Some(index) = block { + if line == "}" { + block = None; + } else { + let member = if er { + attribute(line)? + } else { + check_label(line)?; + line.to_owned() + }; + let node: &mut super::Node = &mut graph.nodes[index]; + if node.members.len() == 16 { + return Err(RenderError::Limit); + } + node.members.push(member); + } + continue; + } + if let Some(value) = line.strip_prefix("direction ") { + if direction { + return Err(RenderError::Unsupported); + } + graph.direction = Direction::parse(value.trim())?; + direction = true; + continue; + } + let declaration = !er && line.starts_with("class "); + let mut rest = line.strip_prefix("class ").filter(|_| !er).unwrap_or(line); + let id = identifier(&mut rest)?; + rest = rest.trim_start(); + let title = id == "accTitle" || er && id.eq_ignore_ascii_case("accTitle"); + let description = id == "accDescr" || er && id.eq_ignore_ascii_case("accDescr"); + if !declaration + && (title && rest.starts_with(':') || description && rest.starts_with([':', '{'])) + { + return Err(RenderError::Unsupported); + } + let from = graph.node(id)?; + if rest == "{" && (er || declaration) { + block = Some(from); + continue; + } + if declaration || (er && rest.is_empty()) { + if !rest.is_empty() { + return Err(RenderError::Unsupported); + } + continue; + } + if !er + && let Some(member) = rest + .strip_prefix(':') + .filter(|text| !text.starts_with("::")) + { + let member = member.trim(); + check_label(member)?; + if graph.nodes[from].members.len() == 16 { + return Err(RenderError::Limit); + } + graph.nodes[from].members.push(member.to_owned()); + continue; + } + let source_card = if er { + String::new() + } else { + cardinality(&mut rest)? + }; + rest = rest.trim_start(); + let split = rest + .find("--") + .into_iter() + .chain(rest.find("..")) + .min() + .ok_or(RenderError::Unsupported)?; + if split > 2 { + return Err(RenderError::Unsupported); + } + let left = &rest[..split]; + let dashed = &rest[split..split + 2] == ".."; + rest = &rest[split + 2..]; + let (source_tip, target_tip, source_card, target_card) = if er { + let source_card = match left { + "||" => "1", + "|o" => "0..1", + "}|" => "1..many", + "}o" => "0..many", + _ => return Err(RenderError::Unsupported), + } + .to_owned(); + let right = rest.get(..2).ok_or(RenderError::Unsupported)?; + let target_card = match right { + "||" => "1", + "o|" => "0..1", + "|{" => "1..many", + "o{" => "0..many", + _ => return Err(RenderError::Unsupported), + } + .to_owned(); + rest = &rest[2..]; + ('─', '─', source_card, target_card) + } else { + let source_tip = match left { + "" => '─', + "<" => '◄', + "<|" => '◁', + "*" => '◆', + "o" => '◇', + _ => return Err(RenderError::Unsupported), + }; + let mut target_tip = '─'; + for (token, tip) in [("|>", '◁'), (">", '◄'), ("*", '◆'), ("o", '◇')] { + if let Some(after) = rest.strip_prefix(token) { + target_tip = tip; + rest = after; + break; + } + } + let target_card = cardinality(&mut rest)?; + (source_tip, target_tip, source_card, target_card) + }; + let to = graph.node(identifier(&mut rest)?)?; + rest = rest.trim(); + let label = if let Some(label) = rest + .strip_prefix(':') + .filter(|text| !text.starts_with("::")) + { + let label = label.trim(); + check_label(label)?; + label.to_owned() + } else if !rest.is_empty() || er { + return Err(RenderError::Unsupported); + } else { + String::new() + }; + if graph.edges.len() == MAX_EDGES { + return Err(RenderError::Limit); + } + let label = match (source_card.is_empty(), label.is_empty()) { + (true, _) => label, + (false, true) => format!("({source_card})"), + (false, false) => format!("({source_card}) {label}"), + }; + graph.edges.push(Edge { + from, + to, + label, + source_tip, + target_tip, + dashed, + target_label: if target_card.is_empty() { + target_card + } else { + format!("({target_card})") + }, + }); + } + if block.is_some() || graph.nodes.is_empty() { + return Err(RenderError::Unsupported); + } + Ok(graph) +} + +fn cardinality(rest: &mut &str) -> Result { + *rest = rest.trim_start(); + if let Some(after) = rest.strip_prefix('"') { + let (value, after) = after.split_once('"').ok_or(RenderError::Unsupported)?; + check_label(value)?; + *rest = after; + Ok(value.to_owned()) + } else { + Ok(String::new()) + } +} + +fn attribute(line: &str) -> Result { + let mut rest = line; + let data_type = identifier(&mut rest)?; + if !rest.starts_with(char::is_whitespace) { + return Err(RenderError::Unsupported); + } + 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)?; + check_label(comment)?; + (keys.trim(), Some(comment)) + } else { + (rest.trim(), None) + }; + if !keys.is_empty() + && !keys + .split(',') + .all(|key| matches!(key.trim(), "PK" | "FK" | "UK")) + { + return Err(RenderError::Unsupported); + } + let mut result = format!("{data_type} {name}"); + if !keys.is_empty() { + result.push(' '); + result.push_str(keys); + } + if let Some(comment) = comment { + result.push_str(" — "); + result.push_str(comment); + } + check_label(&result)?; + Ok(result) +} diff --git a/codex-rs/mermaid/src/sequence.rs b/codex-rs/mermaid/src/sequence.rs new file mode 100644 index 0000000000..f075117e7a --- /dev/null +++ b/codex-rs/mermaid/src/sequence.rs @@ -0,0 +1,306 @@ +//! Bounded sequence timelines with ordered messages and explicitly nested control fragments. + +use super::RenderError; +use super::Span; +use super::draw::put_text; +use super::output::Cell; +use super::output::finish; +use super::parse::check_label; +use super::parse::identifier; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug)] +enum Event { + Message { + from: usize, + to: usize, + text: String, + dashed: bool, + arrow: char, + }, + Open(String), + Branch(String), + Close, + Note { + left: usize, + right: usize, + text: String, + }, +} + +pub(super) fn render(body: &[&str], max_width: usize) -> Result>, RenderError> { + let mut people: Vec<(String, String, bool)> = Vec::new(); + let mut events = Vec::new(); + let mut blocks = Vec::new(); + for &line in body { + let mut rest = line; + if let Some(after) = line + .strip_prefix("participant ") + .or_else(|| line.strip_prefix("actor ")) + { + rest = after; + let id = identifier(&mut rest)?; + let label = if let Some(label) = rest.trim_start().strip_prefix("as ") { + check_label(label)?; + label + } else if rest.trim().is_empty() { + id + } else { + return Err(RenderError::Unsupported); + }; + let index = participant(&mut people, id)?; + if people[index].2 { + return Err(RenderError::Unsupported); + } + people[index].1 = if line.starts_with("actor ") { + format!("{label} (actor)") + } else { + label.to_owned() + }; + people[index].2 = true; + continue; + } + let event = if line == "end" { + blocks.pop().ok_or(RenderError::Unsupported)?; + Event::Close + } else if let Some(text) = line.strip_prefix("else ") { + let Some(("alt", branched)) = blocks.last_mut() else { + return Err(RenderError::Unsupported); + }; + if *branched { + return Err(RenderError::Unsupported); + } + *branched = true; + check_label(text)?; + Event::Branch(format!("else {text}")) + } else if let Some((kind @ ("loop" | "alt" | "opt" | "critical" | "break"), text)) = + line.split_once(' ') + { + check_label(text)?; + if blocks.len() == 4 { + return Err(RenderError::Limit); + } + blocks.push((kind, false)); + Event::Open(format!("{kind} {text}")) + } else if let Some(after) = line + .strip_prefix("Note over ") + .or_else(|| line.strip_prefix("note over ")) + { + rest = after; + let left = participant(&mut people, identifier(&mut rest)?)?; + let right = if let Some(after) = rest.trim_start().strip_prefix(',') { + rest = after; + participant(&mut people, identifier(&mut rest)?)? + } else { + left + }; + let text = rest + .trim_start() + .strip_prefix(':') + .ok_or(RenderError::Unsupported)? + .trim(); + check_label(text)?; + Event::Note { + left: left.min(right), + right: left.max(right), + text: text.to_owned(), + } + } else { + let from = participant(&mut people, identifier(&mut rest)?)?; + rest = rest.trim_start(); + let mut operator = None; + for (token, dashed, arrow) in [ + ("-->>", true, '▶'), + ("->>", false, '▶'), + ("-->", true, '┄'), + ("->", false, '─'), + ("--x", true, 'x'), + ("-x", false, 'x'), + ] { + if let Some(after) = rest.strip_prefix(token) { + rest = after; + operator = Some((dashed, arrow)); + break; + } + } + let (dashed, arrow) = operator.ok_or(RenderError::Unsupported)?; + let to = participant(&mut people, identifier(&mut rest)?)?; + let text = rest + .trim_start() + .strip_prefix(':') + .ok_or(RenderError::Unsupported)? + .trim(); + check_label(text)?; + Event::Message { + from, + to, + text: text.to_owned(), + dashed, + arrow, + } + }; + if events.len() == 64 { + return Err(RenderError::Limit); + } + events.push(event); + } + if !blocks.is_empty() || people.is_empty() { + return Err(RenderError::Unsupported); + } + let name_width = people + .iter() + .map(|(_, label, _)| label.width()) + .max() + .unwrap_or(0); + let text_width = events + .iter() + .map(|event| match event { + Event::Message { text, .. } | Event::Open(text) | Event::Branch(text) => text.width(), + Event::Note { text, .. } => text.width() + 6, + Event::Close => 0, + }) + .max() + .unwrap_or(0); + let box_width = name_width + 4; + let stride = (box_width + 2).max(text_width + 4); + // Four frame levels consume eight columns on each side, separate from participant lifelines. + let centers = (0..people.len()) + .map(|i| 10 + box_width / 2 + stride * i) + .collect::>(); + let last = centers[people.len() - 1]; + let width = (last + box_width - box_width / 2 + 10).max(last + text_width + 14); + if width * (3 + 4 * events.len()) > super::MAX_CELLS { + return Err(RenderError::Limit); + } + let mut rows = vec![vec![Cell::edge(' '); width]; 3]; + for (i, (_, name, _)) in people.iter().enumerate() { + let left = centers[i] - box_width / 2; + let right = left + box_width - 1; + rows[0][left] = Cell::node('┌'); + rows[0][right] = Cell::node('┐'); + rows[2][left] = Cell::node('└'); + rows[2][right] = Cell::node('┘'); + rows[0][left + 1..right].fill(Cell::node('─')); + rows[2][left + 1..right].fill(Cell::node('─')); + rows[1][left] = Cell::node('│'); + rows[1][right] = Cell::node('│'); + put_text(&mut rows[1], left + 2, name)?; + } + let mut depth = 0; + for event in events { + let count = if matches!(event, Event::Message { from, to, .. } if from == to) { + 4 + } else { + 3 + }; + let top = rows.len(); + rows.extend((0..count).map(|_| { + let mut row = vec![Cell::edge(' '); width]; + for x in ¢ers { + row[*x] = Cell::edge('│'); + } + for level in 0..depth { + row[level * 2] = Cell::node('│'); + row[width - 1 - level * 2] = Cell::node('│'); + } + row + })); + match event { + Event::Message { + from, + to, + text, + dashed, + arrow, + } => { + let (a, b) = (centers[from], centers[to]); + let arrow = if a >= b && arrow == '▶' { + '◀' + } else { + arrow + }; + let stroke = if dashed { '┄' } else { '─' }; + put_text(&mut rows[top], a.min(b) + 2, &text)?; + if a == b { + rows[top + 1][a..a + 4].fill(Cell::edge(stroke)); + rows[top + 1][a] = Cell::edge('├'); + rows[top + 1][a + 4] = Cell::edge('┐'); + rows[top + 2][a..a + 4].fill(Cell::edge(stroke)); + rows[top + 2][a + 4] = Cell::edge('┘'); + rows[top + 2][a] = Cell::edge(arrow); + } else { + rows[top + 1][a.min(b)..=a.max(b)].fill(Cell::edge(stroke)); + for x in ¢ers { + if *x > a.min(b) && *x < a.max(b) { + rows[top + 1][*x] = Cell::edge('┼'); + } + } + rows[top + 1][a] = Cell::edge(if a < b { '├' } else { '┤' }); + rows[top + 1][b] = Cell::edge(arrow); + } + } + Event::Note { left, right, text } => { + let label = format!("Note: {text}"); + let start = centers[left]; + let end = centers[right].max(start + label.width() + 3); + rows[top][start..=end].fill(Cell::node('─')); + rows[top + 2][start..=end].fill(Cell::node('─')); + rows[top][start] = Cell::node('┌'); + rows[top][end] = Cell::node('┐'); + rows[top + 2][start] = Cell::node('└'); + rows[top + 2][end] = Cell::node('┘'); + rows[top + 1][start..=end].fill(Cell::node(' ')); + rows[top + 1][start] = Cell::node('│'); + rows[top + 1][end] = Cell::node('│'); + put_text(&mut rows[top + 1], start + 2, &label)?; + } + Event::Open(label) | Event::Branch(label) => { + let opening = !label.starts_with("else "); + if opening { + depth += 1; + } + let left = (depth - 1) * 2; + let right = width - 1 - left; + rows[top][left..=right].fill(Cell::node('─')); + rows[top][left] = Cell::node(if opening { '┌' } else { '├' }); + rows[top][right] = Cell::node(if opening { '┐' } else { '┤' }); + put_text(&mut rows[top], left + 2, &label)?; + for row in &mut rows[top + 1..top + 3] { + row[left] = Cell::node('│'); + row[right] = Cell::node('│'); + } + } + Event::Close => { + depth -= 1; + let left = depth * 2; + let right = width - 1 - left; + rows[top][left..=right].fill(Cell::node('─')); + rows[top][left] = Cell::node('└'); + rows[top][right] = Cell::node('┘'); + for row in &mut rows[top + 1..top + 3] { + row[left] = Cell::node(' '); + row[right] = Cell::node(' '); + } + } + } + } + if rows.iter().any(|row| { + row.iter() + .rposition(|cell| cell.symbol != ' ') + .is_some_and(|x| x >= max_width) + }) { + return Err(RenderError::TooWide); + } + Ok(finish(rows)) +} + +fn participant(people: &mut Vec<(String, String, bool)>, id: &str) -> Result { + if let Some(index) = people.iter().position(|(name, _, _)| name == id) { + return Ok(index); + } + if people.len() == 8 { + return Err(RenderError::Limit); + } + people.push((id.to_owned(), id.to_owned(), false)); + Ok(people.len() - 1) +} diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__LR.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__LR.snap new file mode 100644 index 0000000000..a06e3e246a --- /dev/null +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__LR.snap @@ -0,0 +1,17 @@ +--- +source: mermaid/src/families_tests.rs +assertion_line: 125 +expression: output +--- +┌──────────────┐ ┌─────────────────────┐ ┌─────────┐ +│ 请求 │ │ ◇ Réponse? │ │ Ship 🚀 │ +└┬──────┬──────┘ └┬──────┬──────┬──────┘ └┬────────┘ + │ ▲ ▲ │ │ ▲ + │准备 │ │ │retry │ │ + │ │ │ │ │ │ + │ │ │ │ │ │ + └──────╪──────────┘ │ │ │ + │ │ │ │ + └─────────────────┘ │ │ + │ │ + └──────────┘ 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 new file mode 100644 index 0000000000..054ec8f946 --- /dev/null +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap @@ -0,0 +1,46 @@ +--- +source: mermaid/src/families_tests.rs +assertion_line: 100 +expression: output +--- +┌──────────────────┐ +│ Order │ +│ ───── │ +│ +String id │ +│ +Status status │ +│ +submit() │ +│ +cancel() │ +│ ├◆─(1) contains────┐ +│ ├──(1) pays with───╪─┐ +│ ├◄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄╪┄╪┄┄┄┐ +└──────────────────┘ │ │ ┆ + │ │ ┆ + │ │ ┆ +┌──────────────────┐ │ │ ┆ +│ LineItem │ │ │ ┆ +│ ──────── │ │ │ ┆ +│ +int quantity │ │ │ ┆ +│ +Decimal price │ │ │ ┆ +│ +subtotal() │ │ │ ┆ +│ ├──(1..*)──────────┘ │ ┆ +└──────────────────┘ │ ┆ + │ ┆ + │ ┆ +┌──────────────────┐ │ ┆ +│ Payment │ │ ┆ +│ ─────── │ │ ┆ +│ +Decimal amount │ │ ┆ +│ +authorize() │ │ ┆ +│ ├◄─(1)───────────────┘ ┆ +│ ├◁─────────────────────┐ ┆ +└──────────────────┘ │ ┆ + │ ┆ + │ ┆ +┌──────────────────┐ │ ┆ +│ CardPayment │ │ ┆ +│ ─────────── │ │ ┆ +│ +String lastFour │ │ ┆ +│ +authorize() │ │ ┆ +│ ├──────────────────────┘ ┆ +│ ├─┄updates┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ +└──────────────────┘ diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__er.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__er.snap new file mode 100644 index 0000000000..ca7699d1f0 --- /dev/null +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__er.snap @@ -0,0 +1,45 @@ +--- +source: mermaid/src/families_tests.rs +assertion_line: 107 +expression: output +--- +┌─────────────────────────────────┐ +│ CUSTOMER │ +│ ──────── │ +│ int id PK │ +│ string email UK │ +│ string name │ +│ ├──(1) places───────┐ +└─────────────────────────────────┘ │ + │ + │ +┌─────────────────────────────────┐ │ +│ ORDER │ │ +│ ───── │ │ +│ int id PK │ │ +│ int customer_id FK │ │ +│ string status │ │ +│ ├──(0..many)────────┘ +│ ├──(1) contains───────┐ +└─────────────────────────────────┘ │ + │ + │ +┌─────────────────────────────────┐ │ +│ LINE_ITEM │ │ +│ ───────── │ │ +│ int order_id PK, FK — order key │ │ +│ int product_id PK, FK │ │ +│ int quantity │ │ +│ ├──(1..many)──────────┘ +│ ├─┄(0..many)┄┄┄┄┄┄┄┄┄┄┄┄┐ +└─────────────────────────────────┘ ┆ + ┆ + ┆ +┌─────────────────────────────────┐ ┆ +│ PRODUCT │ ┆ +│ ─────── │ ┆ +│ int id PK │ ┆ +│ string name │ ┆ +│ decimal price │ ┆ +│ ├─┄(1) appears_in┄┄┄┄┄┄┄┘ +└─────────────────────────────────┘ 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 new file mode 100644 index 0000000000..9646e74e13 --- /dev/null +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap @@ -0,0 +1,60 @@ +--- +source: mermaid/src/families_tests.rs +assertion_line: 107 +expression: output +--- + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ Buyer (actor) │ │ API │ │ 库存 │ │ Payments │ + └───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘ + │ Place order │ │ │ + ├─────────────────────────▶ │ │ + │ │ │ │ + │ │ Reserve items │ │ + │ ├─────────────────────────▶ │ + │ │ │ │ + │ │ Reservation │ │ + │ ◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │ + │ │ │ │ +┌─opt Items reserved───────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ ┌─loop Up to 3 attempts────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ Charge card │ │ │ │ +│ │ │ ├─────────────────────────┼─────────────────────────▶ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ │ Check fraud │ │ +│ │ │ │ │ ├───┐ │ │ +│ │ │ │ │ ◀───┘ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ Payment status │ │ │ │ +│ │ │ ◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │ │ +│ │ │ │ │ │ │ │ +│ │ ┌─alt Approved─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ ┌───────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ │ Note: Payment recorded │ │ │ │ +│ │ │ │ └───────────────────────────────────────────────────┘ │ │ │ +│ │ │ │ Order confirmed │ │ │ │ │ │ +│ │ │ ◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ ├─else Declined────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ │ Release items │ │ │ │ │ +│ │ │ │ ├─────────────────────────▶ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ Payment failed │ │ │ │ │ │ +│ │ │ ◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ │ │ │ +│ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + │ │ │ │ + │ │ │ │ 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 new file mode 100644 index 0000000000..23ccd97f5d --- /dev/null +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap @@ -0,0 +1,70 @@ +--- +source: mermaid/src/families_tests.rs +assertion_line: 100 +expression: output +--- +┌─────────────────────┐ +│ Payment pending │ +│ ─────────────── │ +│ Retry up to 3 times │ +│ ├◄────────────────┐ +│ ├──paid───────────╪───┐ +│ ├──declined───────╪───╪─┐ +└─────────────────────┘ │ │ │ + │ │ │ + │ │ │ +┌─────────────────────┐ │ │ │ +│ ● initial │ │ │ │ +│ ├─────────────┐ │ │ │ +└─────────────────────┘ │ │ │ │ + │ │ │ │ + │ │ │ │ +┌─────────────────────┐ │ │ │ │ +│ Draft │ │ │ │ │ +│ ├◄────────────┘ │ │ │ +│ ├──submit───────┐ │ │ │ +│ ├◄──────────────╪─╪───╪─╪───────┐ +└─────────────────────┘ │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +┌─────────────────────┐ │ │ │ │ │ +│ Validating │ │ │ │ │ │ +│ ├◄──────────────┘ │ │ │ │ +│ ├──valid──────────┘ │ │ │ +│ ├──invalid──────────┐ │ │ │ +└─────────────────────┘ │ │ │ │ + │ │ │ │ + │ │ │ │ +┌─────────────────────┐ │ │ │ │ +│ Rejected │ │ │ │ │ +│ ├◄──────────────────┘ │ │ │ +│ ├◄────────────────────╪─┘ │ +│ ├──revise─────────────╪─────────┘ +└─────────────────────┘ │ + │ + │ +┌─────────────────────┐ │ +│ Packing │ │ +│ ├◄────────────────────┘ +│ ├──dispatch───────────────┐ +└─────────────────────┘ │ + │ + │ +┌─────────────────────┐ │ +│ Shipped │ │ +│ ├◄────────────────────────┘ +│ ├──received─────────────────┐ +└─────────────────────┘ │ + │ + │ +┌─────────────────────┐ │ +│ Delivered │ │ +│ ├◄──────────────────────────┘ +│ ├─────────────────────────────┐ +└─────────────────────┘ │ + │ + │ +┌─────────────────────┐ │ +│ ◎ final │ │ +│ ├◄────────────────────────────┘ +└─────────────────────┘ 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 new file mode 100644 index 0000000000..70ceaeb6a0 --- /dev/null +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__branches_merges_and_retry_loop.snap @@ -0,0 +1,61 @@ +--- +source: mermaid/src/tests.rs +assertion_line: 10 +expression: "render(source, 100).unwrap()" +--- +┌───────────────┐ +│ Checkout │ +│ ├────────┐ +└───────────────┘ │ + │ + │ +┌───────────────┐ │ +│ ◇ In stock? │ │ +│ ├◄───────┘ +│ ├──yes─────┐ +│ ├──no──────╪─┐ +└───────────────┘ │ │ + │ │ + │ │ +┌───────────────┐ │ │ +│ Reserve │ │ │ +│ ├◄─────────┘ │ +│ ├────────────╪─┐ +└───────────────┘ │ │ + │ │ + │ │ +┌───────────────┐ │ │ +│ Waitlist │ │ │ +│ ├◄───────────┘ │ +│ ├──────────────╪───────┐ +└───────────────┘ │ │ + │ │ + │ │ +┌───────────────┐ │ │ +│ ◇ Paid? │ │ │ +│ ├◄─────────────┘ │ +│ ├──yes───────────┐ │ +│ ├──no────────────╪─┐ │ +│ ├◄───────────────╪─╪─┐ │ +└───────────────┘ │ │ │ │ + │ │ │ │ + │ │ │ │ +┌───────────────┐ │ │ │ │ +│ Ship │ │ │ │ │ +│ ├◄───────────────┘ │ │ │ +│ ├──────────────────╪─╪─╪─┐ +└───────────────┘ │ │ │ │ + │ │ │ │ + │ │ │ │ +┌───────────────┐ │ │ │ │ +│ Retry payment │ │ │ │ │ +│ ├◄─────────────────┘ │ │ │ +│ ├────────────────────┘ │ │ +└───────────────┘ │ │ + │ │ + │ │ +┌───────────────┐ │ │ +│ Notify buyer │ │ │ +│ ├◄─────────────────────┘ │ +│ ├◄───────────────────────┘ +└───────────────┘ diff --git a/codex-rs/mermaid/src/state.rs b/codex-rs/mermaid/src/state.rs new file mode 100644 index 0000000000..077fd987be --- /dev/null +++ b/codex-rs/mermaid/src/state.rs @@ -0,0 +1,109 @@ +//! Flat state machines, with distinct initial/final pseudostates and labeled transitions. + +use super::Direction; +use super::Edge; +use super::Graph; +use super::MAX_EDGES; +use super::RenderError; +use super::parse::check_label; +use super::parse::identifier; + +pub(super) fn parse(body: &[&str]) -> Result { + let mut graph = Graph::default(); + let mut direction = false; + for &line in body { + if matches!(line, "state" | "direction" | "note" | "end" | "hide") { + return Err(RenderError::Unsupported); + } + if let Some(value) = line.strip_prefix("direction ") { + if direction { + return Err(RenderError::Unsupported); + } + graph.direction = Direction::parse(value.trim())?; + direction = true; + continue; + } + let mut rest = line; + if let Some(after) = rest.strip_prefix("state \"") { + let (label, after) = after.split_once('"').ok_or(RenderError::Unsupported)?; + check_label(label)?; + rest = after + .trim_start() + .strip_prefix("as ") + .ok_or(RenderError::Unsupported)?; + let index = graph.node(identifier(&mut rest)?)?; + if !rest.trim().is_empty() || graph.nodes[index].declared { + return Err(RenderError::Unsupported); + } + graph.nodes[index].label = label.to_owned(); + graph.nodes[index].declared = true; + continue; + } + let from = if let Some(after) = rest.strip_prefix("[*]") { + rest = after; + let index = graph.node("[initial]")?; + graph.nodes[index].label = "● initial".to_owned(); + index + } else { + let id = identifier(&mut rest)?; + if (id.eq_ignore_ascii_case("accTitle") || id.eq_ignore_ascii_case("accDescr")) + && rest.trim_start().starts_with(':') + { + return Err(RenderError::Unsupported); + } + graph.node(id)? + }; + rest = rest.trim(); + if rest.is_empty() && graph.nodes[from].id != "[initial]" { + continue; + } + if let Some(description) = rest + .strip_prefix(':') + .filter(|text| !text.starts_with("::")) + { + if graph.nodes[from].id == "[initial]" { + return Err(RenderError::Unsupported); + } + let description = description.trim(); + check_label(description)?; + if graph.nodes[from].members.len() == 16 { + return Err(RenderError::Limit); + } + graph.nodes[from].members.push(description.to_owned()); + continue; + } + rest = rest + .strip_prefix("-->") + .ok_or(RenderError::Unsupported)? + .trim_start(); + let to = if let Some(after) = rest.strip_prefix("[*]") { + rest = after; + let index = graph.node("[final]")?; + graph.nodes[index].label = "◎ final".to_owned(); + index + } else { + graph.node(identifier(&mut rest)?)? + }; + let label = if let Some(label) = rest + .trim() + .strip_prefix(':') + .filter(|text| !text.starts_with("::")) + { + let label = label.trim(); + check_label(label)?; + label.to_owned() + } else if rest.trim().is_empty() { + String::new() + } else { + return Err(RenderError::Unsupported); + }; + if graph.edges.len() == MAX_EDGES { + return Err(RenderError::Limit); + } + graph.edges.push(Edge::directed(from, to, label)); + } + if graph.nodes.is_empty() { + return Err(RenderError::Unsupported); + } + Ok(graph) +} diff --git a/codex-rs/mermaid/src/tests.rs b/codex-rs/mermaid/src/tests.rs new file mode 100644 index 0000000000..7daf9935ca --- /dev/null +++ b/codex-rs/mermaid/src/tests.rs @@ -0,0 +1,193 @@ +use super::RenderError; +use super::render; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +use unicode_width::UnicodeWidthStr; + +#[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"; + assert_snapshot!(render(source, /*max_width*/ 100).unwrap()); +} + +#[test] +fn rejects_partial_or_unsupported_input() { + for source in [ + "flowchart TD; subgraph X; A; end", + "flowchart TD; A --> B; garbage syntax", + "flowchart TD; A -.-> B", + "flowchart TD; A & B --> C", + "flowchart TD; A[one]; A[two]", + "flowchart TD; A[HTML]", + "flowchart TD; A[]", + "flowchart TD; A[\u{1b}]", + "flowchart TD; A[e\u{301}]", + "flowchart TD; A[👍🏽]", + "flowchart TD; A[👩\u{200d}💻]", + "flowchart TD; A[✈\u{fe0f}]", + "flowchart TD; A[zero\u{200b}width]", + "flowchart TD; A[left\u{202e}right]", + "flowchart TD; A[\u{2066}isolated\u{2069}]", + "flowchart TD; A[unclosed", + "flowchart TD; click A", + "flowchart TD; A((circle))", + "flowchart TD; A -->|unclosed B", + "flowchart TD; A[\"quoted\"]", + "flowchart TD; A[foo;bar]", + "flowchart TD; A[لا]", + "flowchart TD; A -->|yes┐| B", + ] { + assert_eq!( + render(source, /*max_width*/ 100), + Err(RenderError::Unsupported), + "{source:?}" + ); + } +} + +#[test] +fn source_graph_and_width_limits() { + for source in [ + " ".repeat(16 * 1024 + 1), + format!("graph TD; A[{}]", "x".repeat(41)), + format!( + "graph TD; {}", + (0..17).map(|n| format!("N{n};")).collect::() + ), + format!("graph TD; {}", "A-->B;".repeat(25)), + ] { + assert_eq!(render(&source, /*max_width*/ 200), Err(RenderError::Limit)); + } + let output = render("graph TD; A --> B", /*max_width*/ 100).unwrap(); + let width = output.lines().map(UnicodeWidthStr::width).max().unwrap(); + assert_eq!(render("graph TD; A --> B", width), Ok(output)); + assert_eq!( + render("graph TD; A --> B", width - 1), + Err(RenderError::TooWide) + ); + assert_eq!( + render("graph TD; A", /*max_width*/ 0), + Err(RenderError::TooWide) + ); +} + +#[test] +fn reconstruct_every_edge_from_rendered_paths() { + // All 512 directed graphs on three nodes, including self-loops, cycles, fan-in and fan-out. + // Reconstruct connections from the emitted glyphs without consulting the renderer's layout. + for (mask, direction) in + (0u16..512).flat_map(|mask| ["TD", "BT", "LR", "RL"].map(|direction| (mask, direction))) + { + let mut source = format!("graph {direction}; A; B; C;"); + let mut expected = Vec::new(); + for from in 0..3 { + for to in 0..3 { + if mask & (1 << (from * 3 + to)) != 0 { + let a = char::from(b'A' + from); + let b = char::from(b'A' + to); + source.push_str(&format!("{a}-->{b};")); + expected.push((a, b)); + } + } + } + let output = render(&source, /*max_width*/ 100).unwrap(); + let mut rows = output + .lines() + .map(|line| line.chars().collect::>()) + .collect::>(); + if matches!(direction, "LR" | "RL") { + let width = rows.iter().map(Vec::len).max().unwrap(); + rows = (0..width) + .map(|x| { + rows.iter() + .map(|row| match row.get(x).copied().unwrap_or(' ') { + '─' => '│', + '│' => '─', + '┐' => '└', + '└' => '┐', + '┬' => '├', + '▲' => '◄', + other => other, + }) + .collect() + }) + .collect(); + } + let mut order = Vec::new(); + let mut owners = vec![' '; rows.len()]; + for (top, row) in rows.iter().enumerate() { + if row.first() != Some(&'┌') { + continue; + } + let bottom = (top + 1..rows.len()) + .find(|y| rows[*y].first() == Some(&'└')) + .unwrap(); + let owner = rows[top..=bottom] + .iter() + .flatten() + .find(|ch| matches!(ch, 'A' | 'B' | 'C')) + .unwrap(); + owners[top..=bottom].fill(*owner); + order.push(*owner); + } + assert_eq!( + order, + if matches!(direction, "BT" | "RL") { + vec!['C', 'B', 'A'] + } else { + vec!['A', 'B', 'C'] + } + ); + let mut actual = Vec::new(); + for (y, row) in rows.iter().enumerate() { + if let Some(port) = row.windows(2).position(|pair| pair == ['├', '─']) { + let lane = (port + 1..row.len()) + .find(|x| matches!(row[*x], '┐' | '┘')) + .unwrap(); + let mut target = y; + loop { + target = if row[lane] == '┐' { + target + 1 + } else { + target - 1 + }; + let ch = rows[target][lane]; + if matches!(ch, '┘' | '┐') { + break; + } + assert!(matches!(ch, '│' | '╪'), "broken vertical path: {source}"); + } + assert_eq!(&rows[target][port..port + 2], &['├', '◄']); + assert!( + rows[target][port + 2..lane] + .iter() + .all(|ch| matches!(ch, '─' | '╪')) + ); + actual.push((owners[y], owners[target])); + } + } + actual.sort_unstable(); + assert_eq!(actual, expected, "{source}"); + } +} + +#[test] +fn semantic_spans_distinguish_labels_from_matching_endpoint_glyphs() { + use super::Role; + + let lines = super::render_spans("sequenceDiagram; A-xB: x", /*max_width*/ 100).unwrap(); + let roles = lines + .iter() + .flatten() + .flat_map(|span| span.text.chars().filter(|ch| *ch == 'x').map(|_| span.role)) + .collect::>(); + assert_eq!(roles, vec![Role::Text, Role::Edge]); + assert_eq!( + lines[0] + .iter() + .find(|span| span.text.contains('┌')) + .unwrap() + .role, + Role::Node + ); +}