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
This commit is contained in:
Eric Traut
2026-09-16 00:27:17 +00:00
committed by copyberry
parent 7f501cd334
commit 7c709f0ffd
21 changed files with 2259 additions and 0 deletions

9
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -82,6 +82,7 @@ members = [
"codex-mcp",
"memories/read",
"memories/write",
"mermaid",
"model-provider-info",
"mxc-sandbox",
"models-manager",

View File

@@ -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/**"]),
)

View File

@@ -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 }

View File

@@ -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.

View File

@@ -0,0 +1,17 @@
//! Render a diagram from standard input for manual prototype evaluation.
use std::io::Read;
fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}

View File

@@ -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<Vec<Vec<Span>>, 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
// 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<Vec<Cell>>,
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(())
}

View File

@@ -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::<Vec<_>>();
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::<Vec<_>>();
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: <br/>",
"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 {; +<html>; }",
"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::<String>()
),
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::<Vec<_>>())
.collect::<Vec<_>>();
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::<String>(),
format!("P0->>P7: {};", "x".repeat(40)).repeat(64)
);
for source in [graph, sequence] {
assert_eq!(
render(&source, /*max_width*/ usize::MAX),
Err(RenderError::Limit)
);
}
}

176
codex-rs/mermaid/src/lib.rs Normal file
View File

@@ -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<String, RenderError> {
Ok(render_spans(source, max_width)?
.into_iter()
.map(|line| line.into_iter().map(|span| span.text).collect::<String>())
.collect::<Vec<_>>()
.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<Vec<Vec<Span>>, 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::<Vec<_>>();
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<Self, RenderError> {
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<String>,
}
#[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<Node>,
edges: Vec<Edge>,
}
impl Graph {
fn node(&mut self, id: &str) -> Result<usize, RenderError> {
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;

View File

@@ -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<Cell>>) -> Vec<Vec<Span>> {
rows.into_iter()
.map(|row| {
let mut spans: Vec<Span> = 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()
}

View File

@@ -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<Graph, RenderError> {
let tokens = header.split_whitespace().collect::<Vec<_>>();
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<usize, RenderError> {
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::<usize>()
!= 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)
}

View File

@@ -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<Graph, RenderError> {
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<String, RenderError> {
*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<String, RenderError> {
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)
}

View File

@@ -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<Vec<Vec<Span>>, 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::<Vec<_>>();
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 &centers {
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 &centers {
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<usize, RenderError> {
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)
}

View File

@@ -0,0 +1,17 @@
---
source: mermaid/src/families_tests.rs
assertion_line: 125
expression: output
---
┌──────────────┐ ┌─────────────────────┐ ┌─────────┐
│ 请求 │ │ ◇ Réponse? │ │ Ship 🚀 │
└┬──────┬──────┘ └┬──────┬──────┬──────┘ └┬────────┘
│ ▲ ▲ │ │ ▲
│准备 │ │ │retry │ │
│ │ │ │ │ │
│ │ │ │ │ │
└──────╪──────────┘ │ │ │
│ │ │ │
└─────────────────┘ │ │
│ │
└──────────┘

View File

@@ -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┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
└──────────────────┘

View File

@@ -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┄┄┄┄┄┄┄┘
└─────────────────────────────────┘

View File

@@ -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 │ │ │ │ │ │
│ │ │ ◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ │ │ │ │ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
│ │ │ │
│ │ │ │

View File

@@ -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 │ │
│ ├◄────────────────────────────┘
└─────────────────────┘

View File

@@ -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 │ │ │
│ ├◄─────────────────────┘ │
│ ├◄───────────────────────┘
└───────────────┘

View File

@@ -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<Graph, RenderError> {
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)
}

View File

@@ -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[<b>HTML</b>]",
"flowchart TD; A[&#27;]",
"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::<String>()
),
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::<Vec<_>>())
.collect::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(roles, vec![Role::Text, Role::Edge]);
assert_eq!(
lines[0]
.iter()
.find(|span| span.text.contains('┌'))
.unwrap()
.role,
Role::Node
);
}