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}