mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
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:
9
codex-rs/Cargo.lock
generated
9
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -82,6 +82,7 @@ members = [
|
||||
"codex-mcp",
|
||||
"memories/read",
|
||||
"memories/write",
|
||||
"mermaid",
|
||||
"model-provider-info",
|
||||
"mxc-sandbox",
|
||||
"models-manager",
|
||||
|
||||
7
codex-rs/mermaid/BUILD.bazel
Normal file
7
codex-rs/mermaid/BUILD.bazel
Normal 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/**"]),
|
||||
)
|
||||
20
codex-rs/mermaid/Cargo.toml
Normal file
20
codex-rs/mermaid/Cargo.toml
Normal 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 }
|
||||
73
codex-rs/mermaid/README.md
Normal file
73
codex-rs/mermaid/README.md
Normal 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.
|
||||
17
codex-rs/mermaid/examples/render.rs
Normal file
17
codex-rs/mermaid/examples/render.rs
Normal 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(())
|
||||
}
|
||||
253
codex-rs/mermaid/src/draw.rs
Normal file
253
codex-rs/mermaid/src/draw.rs
Normal 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(())
|
||||
}
|
||||
359
codex-rs/mermaid/src/families_tests.rs
Normal file
359
codex-rs/mermaid/src/families_tests.rs
Normal 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
176
codex-rs/mermaid/src/lib.rs
Normal 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;
|
||||
69
codex-rs/mermaid/src/output.rs
Normal file
69
codex-rs/mermaid/src/output.rs
Normal 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()
|
||||
}
|
||||
143
codex-rs/mermaid/src/parse.rs
Normal file
143
codex-rs/mermaid/src/parse.rs
Normal 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)
|
||||
}
|
||||
225
codex-rs/mermaid/src/relations.rs
Normal file
225
codex-rs/mermaid/src/relations.rs
Normal 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)
|
||||
}
|
||||
306
codex-rs/mermaid/src/sequence.rs
Normal file
306
codex-rs/mermaid/src/sequence.rs
Normal 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 ¢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<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)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: mermaid/src/families_tests.rs
|
||||
assertion_line: 125
|
||||
expression: output
|
||||
---
|
||||
┌──────────────┐ ┌─────────────────────┐ ┌─────────┐
|
||||
│ 请求 │ │ ◇ Réponse? │ │ Ship 🚀 │
|
||||
└┬──────┬──────┘ └┬──────┬──────┬──────┘ └┬────────┘
|
||||
│ ▲ ▲ │ │ ▲
|
||||
│准备 │ │ │retry │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
└──────╪──────────┘ │ │ │
|
||||
│ │ │ │
|
||||
└─────────────────┘ │ │
|
||||
│ │
|
||||
└──────────┘
|
||||
@@ -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┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
|
||||
└──────────────────┘
|
||||
@@ -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┄┄┄┄┄┄┄┘
|
||||
└─────────────────────────────────┘
|
||||
@@ -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 │ │ │ │ │ │
|
||||
│ │ │ ◀┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
@@ -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 │ │
|
||||
│ ├◄────────────────────────────┘
|
||||
└─────────────────────┘
|
||||
@@ -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 │ │ │
|
||||
│ ├◄─────────────────────┘ │
|
||||
│ ├◄───────────────────────┘
|
||||
└───────────────┘
|
||||
109
codex-rs/mermaid/src/state.rs
Normal file
109
codex-rs/mermaid/src/state.rs
Normal 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)
|
||||
}
|
||||
193
codex-rs/mermaid/src/tests.rs
Normal file
193
codex-rs/mermaid/src/tests.rs
Normal 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[]",
|
||||
"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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user