Skip to main content

abstracttui_mermaid/
lib.rs

1//! # abstracttui-mermaid
2//!
3//! Honest-subset mermaid rendering for
4//! [AbstractTUI](https://docs.rs/abstracttui) (backlog 0450): a
5//! hand-rolled parser over an EXHAUSTIVE spelling table, flowcharts
6//! compiled to [`abstracttui-graph`](https://docs.rs/abstracttui-graph)
7//! layout, solverless sequence diagrams, and an ATOMIC fallback — a
8//! diagram either renders whole or renders as the code fence it
9//! already is, plus a notice naming the first unsupported construct.
10//! Partial rendering of a half-understood diagram misleads; the code
11//! block never lies.
12//!
13//! ## The subset table (the contract)
14//!
15//! The YES rows enumerate accepted SPELLINGS; any spelling outside
16//! them triggers the atomic fallback naming the first unrecognized
17//! line. Growth = new table rows with tests, never silent acceptance.
18//!
19//! | Mermaid | v1 | Accepted spellings (exhaustive) | Behavior |
20//! | --- | --- | --- | --- |
21//! | `flowchart` / `graph` TD/TB/LR/BT/RL | YES | header keyword + direction token only | layered layout (BT/RL as transposes) |
22//! | Node shapes | YES | `id`, `id[text]`, `id(text)`, `id{text}`, `id([text])`, `id((text))`, `id[[text]]`, `id[(text)]`, `id{{text}}`, `id>text]`; quoted `"text"` inside brackets | cards; shape = accent + badge sigil (see below) |
23//! | Edges | YES | any body length (`-->`, `--->`, `---`, `-----`), dotted (`-.->`, `-..->`, `-.-`), thick (`==>`, `===`); heads `>`, `x`, `o`; `<-->` bidirectional | strokes; dotted/thick as stroke styles |
24//! | Edge labels | YES | postfix `\|label\|` and infix (`-- label -->`, `-. label .->`, `== label ==>`) | drawn centred in the rank corridor |
25//! | Edge chaining, `&` groups | YES | `A --> B --> C`; `A & B --> C & D` (cross product) | one edge per link |
26//! | `subgraph` | FLATTENED | `subgraph id`, `subgraph id [Title]`, nested, `direction` inside | members render, the GROUP BOX does not, WITH a notice |
27//! | `sequenceDiagram` | YES | `participant id [as alias]`; messages `->>`, `-->>`, `->`, `-->` with `: text`; `Note left of/right of/over` | deterministic columns/rows — no solver |
28//! | sequence blocks | YES | `alt` + `else`, `opt`, `loop`, `par` + `and`, nested | a labeled frame with dashed branch dividers |
29//! | sequence activations | YES | `activate id` / `deactivate id`, and the `->>+` / `-->>-` suffixes | a bar on the lifeline; nested bars step right |
30//! | sequence `rect`/`critical`/`break`/`box` | NO | — | atomic fallback |
31//! | `stateDiagram-v2` (flat) | YES (stretch) | `[*]`, `id`, `id : label`, `-->` with `: label` | compiles to the flowchart engine |
32//! | `classDiagram`, `erDiagram`, `gantt`, `pie`, `journey`, `mindmap`, `timeline`, `gitGraph` | NO | — | atomic fallback |
33//! | `classDef`, `style`, `click`, `linkStyle`, `class`, `direction`, `%%{init}` | IGNORED | recognized-and-dropped WITH a notice; `%%` comments drop silently | render proceeds |
34//!
35//! Lexical notes (normalization, not new constructs): `;` is a
36//! statement terminator (split like newlines); `%%` comments strip to
37//! end of line (quote-aware); the statement scanner is quote- and
38//! bracket-aware, so an arrow inside a label is text on EITHER side.
39//!
40//! Link spelling follows mermaid's own rule, which is the only way to
41//! tell a chain from an infix label: a body of exactly TWO dashes
42//! (`--`, `==`) opens a LABELLED link whose text runs to the closing
43//! body, while THREE or more (`---`, `----`) is a complete open link.
44//! So `A -- B --> C` is one labelled edge and `A --- B --> C` is a
45//! two-edge chain.
46//!
47//! Sequence blocks are a TREE in the IR ([`Block`] holding
48//! [`Branch`]es), not a start/else/end token stream: the parser
49//! balances them once and names any mismatch (`alt` never closed, a
50//! stray `end`, `else` in a `par`), so layout and rendering recurse
51//! over something that cannot be malformed. The `+`/`-` message
52//! suffixes are sugar and expand to the same
53//! [`SeqItem::Activate`]/[`SeqItem::Deactivate`] the keywords produce —
54//! one concept reaches the renderer, not two spellings of it.
55//!
56//! Sequence frames are chrome drawn UNDER the conversation: where a
57//! frame's border crosses a lifeline or an activation bar, the border
58//! wins the cell, because one cell cannot show both and a broken frame
59//! reads worse than a broken line. Nesting is capped at 64 — deeper
60//! input falls back by name rather than recursing the renderer into a
61//! stack overflow, which no `catch_unwind` can save a host from.
62//!
63//! Labeled downgrades (rendered, and said out loud in a notice): the
64//! `x` and `o` arrowheads and `<-->` bidirectionality have no cell
65//! glyph and draw as plain arrows; `<br/>` in a label flattens to a
66//! word break because a card is one line; a `subgraph` renders its
67//! members without the group box.
68//!
69//! ## Shape mapping (cell-honest)
70//!
71//! Terminal cards do not rotate into diamonds; mermaid shapes arrive
72//! as the card's ACCENT KIND + a badge sigil:
73//!
74//! | Spelling | Kind | Badge |
75//! | --- | --- | --- |
76//! | `id[text]` / bare `id` | (plain card) | — |
77//! | `id(text)` | `rounded` | ○ |
78//! | `id{text}` | `decision` | ◆ |
79//! | `id([text])` | `stadium` | ◎ |
80//! | `id((text))` | `rounded` | ● |
81//! | `id[[text]]` | `stadium` | ▤ |
82//! | `id[(text)]` | `stadium` | ⌸ |
83//! | `id{{text}}` | `decision` | ◈ |
84//! | `id>text]` | `decision` | ▷ |
85//!
86//! Documented v1 notes: open links (`---`) compile with the `open`
87//! style hint, which `GraphView` renders as an arrowless stroke;
88//! sequence self-messages render as a small right-side loop.
89//!
90//! Declaration rule: layout order is FIRST mention, and the LAST
91//! explicit declaration decides a node's shape and text — what
92//! mermaid itself renders when an id is declared twice. A bare
93//! mention is not a declaration and never resets one. (Participants
94//! keep the first-explicit-wins alias rule: a message that
95//! auto-registered an id is enriched by the declaration that
96//! follows.)
97//!
98//! ## Fallback + escape hatch
99//!
100//! [`MermaidView`] renders unsupported sources as the verbatim code
101//! fence + one notice + an optional
102//! [mermaid.live](https://mermaid.live) link ([`live_link_url`]) —
103//! the code travels in the URL fragment, never to a server.
104//!
105//! ```
106//! use abstracttui_mermaid::{parse, Diagram};
107//!
108//! let ok = parse("graph TD\n  A[Start] -->|go| B{Choice}");
109//! assert!(matches!(ok, Ok(Diagram::Flowchart(_))));
110//!
111//! // A chain, an infix label and a flattened group all parse:
112//! let grouped = parse("graph LR\n  subgraph one\n  A -- go --> B --> C\n  end");
113//! assert!(matches!(grouped, Ok(Diagram::Flowchart(_))));
114//!
115//! // What is NOT in the table still falls back, naming its line:
116//! let no = parse("graph TD\n  A --> B\n  gantt title Nope");
117//! let err = no.unwrap_err();
118//! assert_eq!(err.line_no, 3);
119//! ```
120
121#![forbid(unsafe_code)]
122#![warn(missing_docs)]
123
124mod compile;
125mod fence;
126mod flowchart;
127pub mod ir;
128mod lines;
129mod seq_layout;
130mod seq_render;
131mod sequence;
132mod state;
133mod view;
134
135pub use compile::{shape_badge, shape_kind, to_graph};
136pub use fence::MermaidFence;
137pub use ir::{
138    Block, BlockKind, Branch, Diagram, EdgeKind, FlowEdge, FlowNode, FlowchartIr, Message,
139    MessageKind, NodeShape, Note, NoteAnchor, Participant, SeqItem, SequenceIr, Unsupported,
140};
141pub use seq_render::SeqStyle;
142pub use view::{live_link_url, MermaidView};
143
144// The graph-contract types a flowchart compiles into, re-exported so
145// consumers need not name the graph crate for the common path.
146pub use abstracttui_graph::{Direction, GraphDesc, LayeredOpts};
147
148use lines::statements;
149
150/// Parse mermaid source against the subset table: a whole [`Diagram`]
151/// or the named [`Unsupported`] verdict — never a partial acceptance.
152pub fn parse(source: &str) -> Result<Diagram, Unsupported> {
153    let (stmts, notices) = statements(source);
154    let Some(header) = stmts.first() else {
155        return Err(Unsupported::new(1, "", "empty source: no diagram header"));
156    };
157    let mut tokens = header.text.split_whitespace();
158    let kw = tokens.next().unwrap_or("").trim_end_matches(':');
159    let rest: Vec<&str> = tokens.collect();
160    let body = &stmts[1..];
161
162    match kw {
163        "flowchart" | "graph" => {
164            let dir = match rest.as_slice() {
165                [d] => match *d {
166                    "TD" | "TB" => Direction::TopDown,
167                    "LR" => Direction::LeftRight,
168                    "BT" => Direction::BottomTop,
169                    "RL" => Direction::RightLeft,
170                    other => {
171                        return Err(Unsupported::new(
172                            header.line_no,
173                            &header.text,
174                            format!("unsupported direction `{other}` (TD/TB/LR/BT/RL)"),
175                        ))
176                    }
177                },
178                _ => {
179                    return Err(Unsupported::new(
180                        header.line_no,
181                        &header.text,
182                        "header must be exactly `flowchart <dir>` / `graph <dir>`",
183                    ))
184                }
185            };
186            flowchart::parse_flowchart(dir, body, notices).map(Diagram::Flowchart)
187        }
188        "sequenceDiagram" if rest.is_empty() => {
189            sequence::parse_sequence(body, notices).map(Diagram::Sequence)
190        }
191        "stateDiagram-v2" if rest.is_empty() => {
192            state::parse_state(body, notices).map(Diagram::Flowchart)
193        }
194        "classDiagram" | "erDiagram" | "gantt" | "pie" | "journey" | "mindmap" | "timeline"
195        | "gitGraph" | "stateDiagram" | "quadrantChart" | "requirementDiagram" | "C4Context"
196        | "sankey-beta" | "xychart-beta" | "block-beta" | "packet-beta" | "kanban"
197        | "architecture-beta" => Err(Unsupported::new(
198            header.line_no,
199            &header.text,
200            format!("diagram kind `{kw}` is not in the v1 subset"),
201        )),
202        _ => Err(Unsupported::new(
203            header.line_no,
204            &header.text,
205            "unrecognized diagram header",
206        )),
207    }
208}