Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions codex-rs/mermaid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ production dependency is needed.

| Family | Supported syntax |
| --- | --- |
| `flowchart`, `graph` | TD/TB, BT, LR, RL; rectangle and decision labels; directed and labeled `-->` edges; chains, branches, merges, loops |
| `flowchart`, `graph` | TD/TB, BT, LR, RL; rectangle `[label]`, decision `{label}`, and stadium `([label])` nodes; 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 |
Expand All @@ -32,7 +32,7 @@ on any error; the library never returns a partial diagram.

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
join routes. Decisions use `◇` inside a box; stadiums use rounded box corners. 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.

Expand Down
17 changes: 12 additions & 5 deletions codex-rs/mermaid/src/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use super::Direction;
use super::Graph;
use super::RenderError;
use super::Role;
use super::Shape;
use super::Span;
use super::output::Cell;
use super::output::finish;
Expand All @@ -18,7 +19,7 @@ pub(super) fn render(graph: &Graph, max_width: usize) -> Result<Vec<Vec<Span>>,
.nodes
.iter()
.map(|node| {
let mut lines = vec![if node.decision {
let mut lines = vec![if node.shape == Shape::Decision {
format!("◇ {}", node.label)
} else {
node.label.clone()
Expand Down Expand Up @@ -105,10 +106,14 @@ pub(super) fn render(graph: &Graph, max_width: usize) -> Result<Vec<Vec<Span>>,
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('┘'));
let [top_left, top_right, bottom_left, bottom_right] = match graph.nodes[i].shape {
Shape::Stadium => ['╭', '╮', '╰', '╯'],
Shape::Rectangle | Shape::Decision => ['┌', '┐', '└', '┘'],
};
canvas.set(/*across*/ 0, start, Cell::node(top_left));
canvas.set(box_cross - 1, start, Cell::node(top_right));
canvas.set(/*across*/ 0, end, Cell::node(bottom_left));
canvas.set(box_cross - 1, end, Cell::node(bottom_right));
for x in 1..box_cross - 1 {
canvas.set(x, start, Cell::node('─'));
canvas.set(x, end, Cell::node('─'));
Expand Down Expand Up @@ -226,6 +231,8 @@ fn transpose(ch: char) -> char {
'┆' => '┄',
'┐' => '└',
'└' => '┐',
'╮' => '╰',
'╰' => '╮',
'├' => '┬',
'┬' => '├',
'◄' => '▲',
Expand Down
15 changes: 13 additions & 2 deletions codex-rs/mermaid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,22 @@ impl Direction {
}
}

/// Node shapes supported by the terminal renderer.
#[derive(Debug, PartialEq, Eq)]
enum Shape {
/// A rectangular node, such as `A[Work]`.
Rectangle,
/// A decision node (`A{Done?}`), rendered with a `◇` marker inside a box.
Decision,
/// A stadium node (`A([Start])`), rendered as a box with rounded corners.
Stadium,
}

#[derive(Debug, PartialEq, Eq)]
struct Node {
id: String,
label: String,
decision: bool,
shape: Shape,
declared: bool,
members: Vec<String>,
}
Expand Down Expand Up @@ -159,7 +170,7 @@ impl Graph {
self.nodes.push(Node {
id: id.to_owned(),
label: id.to_owned(),
decision: false,
shape: Shape::Rectangle,
declared: false,
members: Vec::new(),
});
Expand Down
24 changes: 12 additions & 12 deletions codex-rs/mermaid/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use super::Graph;
use super::MAX_EDGES;
use super::MAX_LABEL;
use super::RenderError;
use super::Shape;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;

Expand Down Expand Up @@ -59,25 +60,24 @@ fn node(rest: &mut &str, graph: &mut Graph) -> Result<usize, RenderError> {
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 == '{'))
}
Some('[') => Some(("[", "]", Shape::Rectangle)),
Some('{') => Some(("{", "}", Shape::Decision)),
Some('(') if rest.starts_with("([") => Some(("([", "])", Shape::Stadium)),
_ => None,
};
let index = graph.node(id)?;
if let Some((label, decision)) = declaration {
if let Some((open, close, shape)) = declaration {
let (label, remaining) = rest[open.len()..]
.split_once(close)
.ok_or(RenderError::Unsupported)?;
check_label(label)?;
*rest = remaining;
let node = &mut graph.nodes[index];
if node.declared && (node.label != label || node.decision != decision) {
if node.declared && (node.label != label || node.shape != shape) {
return Err(RenderError::Unsupported);
}
node.label = label.to_owned();
node.decision = decision;
node.shape = shape;
node.declared = true;
}
Ok(index)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
source: mermaid/src/tests.rs
expression: "cases.join(\"\\n\\n\")"
---
TD
╭─────────╮
│ 请求 │
│ ├─────┐
│ ├◄────╪───┐
╰─────────╯ │ │
│ │
│ │
┌─────────┐ │ │
│ Work │ │ │
│ ├◄────┘ │
│ ├───────┐ │
└─────────┘ │ │
│ │
│ │
┌─────────┐ │ │
│ ◇ Done? │ │ │
│ ├◄──────┘ │
│ ├─────────┘
└─────────┘

BT
┌─────────┐
│ ◇ Done? │
│ ├◄──────┐
│ ├───────╪─┐
└─────────┘ │ │
│ │
│ │
┌─────────┐ │ │
│ Work │ │ │
│ ├◄────┐ │ │
│ ├─────╪─┘ │
└─────────┘ │ │
│ │
│ │
╭─────────╮ │ │
│ 请求 │ │ │
│ ├─────┘ │
│ ├◄────────┘
╰─────────╯

LR
╭──────╮ ┌──────┐ ┌─────────┐
│ 请求 │ │ Work │ │ ◇ Done? │
╰┬─┬───╯ └┬─┬───┘ └┬─┬──────┘
│ ▲ ▲ │ ▲ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
└─╪───────┘ │ │ │
│ │ │ │
│ └───────┘ │
│ │
└───────────────────┘

RL
┌─────────┐ ┌──────┐ ╭──────╮
│ ◇ Done? │ │ Work │ │ 请求 │
└┬─┬──────┘ └┬─┬───┘ ╰┬─┬───╯
▲ │ ▲ │ │ ▲
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ └─╪───────┘ │
│ │ │ │
└─╪────────────┘ │
│ │
└──────────────────────┘
41 changes: 41 additions & 0 deletions codex-rs/mermaid/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@ use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use unicode_width::UnicodeWidthStr;

#[test]
fn stadium_declarations_and_references() {
let graph =
super::parse::parse("flowchart TD", &["A", "A([Ready?])", "A", "A([Ready?])"]).unwrap();
assert_eq!(
graph.nodes,
vec![super::Node {
id: "A".to_owned(),
label: "Ready?".to_owned(),
shape: super::Shape::Stadium,
declared: true,
members: Vec::new(),
}]
);
}

#[test]
fn stadiums_with_other_shapes_in_every_direction() {
let mut cases = Vec::new();
for direction in ["TD", "BT", "LR", "RL"] {
let source =
format!("flowchart {direction}; A([请求]) --> B[Work] --> C{{Done?}}; C --> A");
let output = render(&source, /*max_width*/ 100).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));
cases.push(format!("{direction}\n{output}"));
}
assert_snapshot!(cases.join("\n\n"));
}

#[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";
Expand Down Expand Up @@ -31,6 +62,15 @@ fn rejects_partial_or_unsupported_input() {
"flowchart TD; A[unclosed",
"flowchart TD; click A",
"flowchart TD; A((circle))",
"flowchart TD; A(rounded)",
"flowchart TD; A([unclosed]",
"flowchart TD; A([unclosed)",
"flowchart TD; A([label]) trailing",
"flowchart TD; A([])",
"flowchart TD; A([nested[label]])",
"flowchart TD; A([one]); A([two])",
"flowchart TD; A([same]); A[same]",
"flowchart TD; A{same}; A([same])",
"flowchart TD; A -->|unclosed B",
"flowchart TD; A[\"quoted\"]",
"flowchart TD; A[foo;bar]",
Expand All @@ -50,6 +90,7 @@ fn source_graph_and_width_limits() {
for source in [
" ".repeat(16 * 1024 + 1),
format!("graph TD; A[{}]", "x".repeat(41)),
format!("graph TD; A([{}])", "x".repeat(41)),
format!(
"graph TD; {}",
(0..17).map(|n| format!("N{n};")).collect::<String>()
Expand Down
30 changes: 30 additions & 0 deletions codex-rs/tui/src/markdown_render/mermaid_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,42 @@ fn mermaid_nested_fences_and_unicode() {
assert_snapshot!(markdown_text(source, /*width*/ 60));
}

#[test]
fn mermaid_stadium_flowchart() {
let source = "```mermaid
flowchart TD
A([What should I work on?]) --> B{Anything urgent?}
B -->|Yes| C[Handle the urgent task]
B -->|No| D{Have a clear goal?}
D -->|No| E[Pick one useful outcome]
E --> F[Choose the smallest next step]
D -->|Yes| F
F --> G[Focus for 25 minutes]
C --> H{Done?}
G --> H
H -->|No| I[Take a short break]
I --> F
H -->|Yes| J([Celebrate. Stretch. Repeat.])
```";
let output = markdown_text(source, /*width*/ 100);
assert!(output.starts_with('╭'));
assert_snapshot!(output);
assert_eq!(
markdown_text(source, /*width*/ 40),
markdown_text(
&source.replacen("mermaid", "unknown", /*count*/ 1),
/*width*/ 40,
)
);
}

#[test]
fn mermaid_unclosed_invalid_unsupported_and_wide_blocks_keep_source() {
for (source, width) in [
("```mermaid\nflowchart LR\nA --> B\n", 80),
("````mermaid\nflowchart LR\nA --> B\n```\n", 80),
("```mermaid\nflowchart LR\nA[unfinished\n```", 80),
("```mermaid\nflowchart TD\nA([unfinished]\n```", 80),
("```mermaid\npie\n\"Cats\": 2\n```", 80),
("```mermaid\nflowchart LR\nA[Request] --> B[Reply]\n```", 8),
("> ```mermaid\n> flowchart LR\n> A --> B\n", 80),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
source: tui/src/markdown_render/mermaid_tests.rs
expression: output
---
╭───────────────────────────────╮
│ What should I work on? │
│ ├────────┐
╰───────────────────────────────╯ │
│
│
┌───────────────────────────────┐ │
│ ◇ Anything urgent? │ │
│ ├◄───────┘
│ ├──Yes─────┐
│ ├──No──────╪─┐
└───────────────────────────────┘ │ │
│ │
│ │
┌───────────────────────────────┐ │ │
│ Handle the urgent task │ │ │
│ ├◄─────────┘ │
│ ├────────────╪─────────┐
└───────────────────────────────┘ │ │
│ │
│ │
┌───────────────────────────────┐ │ │
│ ◇ Have a clear goal? │ │ │
│ ├◄───────────┘ │
│ ├──No──────────┐ │
│ ├──Yes─────────╪───┐ │
└───────────────────────────────┘ │ │ │
│ │ │
│ │ │
┌───────────────────────────────┐ │ │ │
│ Pick one useful outcome │ │ │ │
│ ├◄─────────────┘ │ │
│ ├────────────────┐ │ │
└───────────────────────────────┘ │ │ │
│ │ │
│ │ │
┌───────────────────────────────┐ │ │ │
│ Choose the smallest next step │ │ │ │
│ ├◄───────────────┘ │ │
│ ├◄─────────────────┘ │
│ ├────────────────────┐ │
│ ├◄───────────────────╪─╪─────┐
└───────────────────────────────┘ │ │ │
│ │ │
│ │ │
┌───────────────────────────────┐ │ │ │
│ Focus for 25 minutes │ │ │ │
│ ├◄───────────────────┘ │ │
│ ├──────────────────────╪─┐ │
└───────────────────────────────┘ │ │ │
│ │ │
│ │ │
┌───────────────────────────────┐ │ │ │
│ ◇ Done? │ │ │ │
│ ├◄─────────────────────┘ │ │
│ ├◄───────────────────────┘ │
│ ├──No──────────────────────┐ │
│ ├──Yes─────────────────────╪─╪─┐
└───────────────────────────────┘ │ │ │
│ │ │
│ │ │
┌───────────────────────────────┐ │ │ │
│ Take a short break │ │ │ │
│ ├◄─────────────────────────┘ │ │
│ ├────────────────────────────┘ │
└───────────────────────────────┘ │
│
│
╭───────────────────────────────╮ │
│ Celebrate. Stretch. Repeat. │ │
│ ├◄─────────────────────────────┘
╰───────────────────────────────╯
Loading