From a543f24fde93fc7ec2e272854a17e8da75246483 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 17:58:33 +0200 Subject: [PATCH 1/8] feat(mermaid): draw flowcharts with a layered layout Flowcharts were printed as a list of edges, so a node with several edges appeared once per edge and LR was ignored. They are now parsed fully (shapes, every link kind, labels, chains, & groups, subgraphs, comments, style lines, quoted labels,
, entities) and laid out Sugiyama-style on a character grid: cycle breaking, longest-path ranks, dummy nodes, barycenter crossing reduction with subgraph members kept together, isotonic-regression coordinates, orthogonal routing with shared fan-in and fan-out tracks, edge labels as nodes of their own, and arrowheads. Each node is drawn once, with a shape-specific outline; TD/TB/BT/LR/RL are honoured; subgraphs get a titled frame when their members stay together, otherwise their membership is listed under the diagram. Diagrams that do not fit narrow their labels, then LR falls back to top down, then to a compact list; over 200 nodes or 400 edges the list is used directly. Output never exceeds the width. The mermaid module becomes a directory (canvas, engine, graph model, flowchart parser, text helpers, tests) with a fixture corpus under tests/fixtures/mermaid. Snapshots with flowcharts change accordingly. --- src/layout/mermaid/canvas.rs | 315 ++++ src/layout/mermaid/engine.rs | 1616 +++++++++++++++++ src/layout/mermaid/flowchart.rs | 605 ++++++ src/layout/mermaid/graph.rs | 317 ++++ src/layout/{mermaid.rs => mermaid/mod.rs} | 606 ++++--- src/layout/mermaid/tests.rs | 459 +++++ src/layout/mermaid/text.rs | 305 ++++ tests/fixtures/mermaid/flowchart-chaining.mmd | 4 + .../fixtures/mermaid/flowchart-christmas.mmd | 6 + tests/fixtures/mermaid/flowchart-cycles.mmd | 6 + tests/fixtures/mermaid/flowchart-ink-cicd.mmd | 9 + .../fixtures/mermaid/flowchart-ink-simple.mmd | 8 + tests/fixtures/mermaid/flowchart-links.mmd | 13 + .../mermaid/flowchart-nested-subgraphs.mmd | 14 + tests/fixtures/mermaid/flowchart-shapes.mmd | 13 + .../fixtures/mermaid/flowchart-subgraphs.mmd | 14 + tests/fixtures/mermaid/flowchart-text.mmd | 15 + tests/mermaid.rs | 68 + .../plain_snapshots__plain_diagrams_md.snap | 133 +- .../plain_snapshots__plain_test_md.snap | 36 +- 20 files changed, 4272 insertions(+), 290 deletions(-) create mode 100644 src/layout/mermaid/canvas.rs create mode 100644 src/layout/mermaid/engine.rs create mode 100644 src/layout/mermaid/flowchart.rs create mode 100644 src/layout/mermaid/graph.rs rename src/layout/{mermaid.rs => mermaid/mod.rs} (54%) create mode 100644 src/layout/mermaid/tests.rs create mode 100644 src/layout/mermaid/text.rs create mode 100644 tests/fixtures/mermaid/flowchart-chaining.mmd create mode 100644 tests/fixtures/mermaid/flowchart-christmas.mmd create mode 100644 tests/fixtures/mermaid/flowchart-cycles.mmd create mode 100644 tests/fixtures/mermaid/flowchart-ink-cicd.mmd create mode 100644 tests/fixtures/mermaid/flowchart-ink-simple.mmd create mode 100644 tests/fixtures/mermaid/flowchart-links.mmd create mode 100644 tests/fixtures/mermaid/flowchart-nested-subgraphs.mmd create mode 100644 tests/fixtures/mermaid/flowchart-shapes.mmd create mode 100644 tests/fixtures/mermaid/flowchart-subgraphs.mmd create mode 100644 tests/fixtures/mermaid/flowchart-text.mmd create mode 100644 tests/mermaid.rs diff --git a/src/layout/mermaid/canvas.rs b/src/layout/mermaid/canvas.rs new file mode 100644 index 0000000..42e507a --- /dev/null +++ b/src/layout/mermaid/canvas.rs @@ -0,0 +1,315 @@ +//! A character grid that diagrams are drawn on. Lines are stored as +//! direction bits per cell and turned into box-drawing glyphs at the end, so +//! crossings, corners and junctions come out right whatever order things are +//! drawn in. + +use super::graph::LineStyle; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +pub const UP: u8 = 1; +pub const DOWN: u8 = 2; +pub const LEFT: u8 = 4; +pub const RIGHT: u8 = 8; + +/// What a cell is drawn as, for colouring. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Class { + /// Subgraph frames and other chrome. + Frame, + /// Edges and their markers. + Edge, + /// Node outlines. + Node, + /// Text inside nodes. + Text, + /// Edge labels and annotations. + Label, + /// Titles (subgraph names, class names, section headers). + Title, +} + +#[derive(Debug, Clone)] +enum Cell { + Empty, + Line { + bits: u8, + /// Bits drawn with a double line (subroutine borders). + dbl: u8, + style: LineStyle, + class: Class, + }, + Glyph { + text: String, + class: Class, + }, + /// Right half of a double-width glyph. + Cont, +} + +pub struct Canvas { + pub rows: usize, + pub cols: usize, + cells: Vec, +} + +impl Canvas { + pub fn new(rows: usize, cols: usize) -> Self { + Canvas { + rows, + cols, + cells: vec![Cell::Empty; rows * cols], + } + } + + fn at(&mut self, r: usize, c: usize) -> Option<&mut Cell> { + if r < self.rows && c < self.cols { + Some(&mut self.cells[r * self.cols + c]) + } else { + None + } + } + + /// True when (r, c) holds nothing but a horizontal line. + pub fn plain_h(&self, r: usize, c: usize) -> bool { + r < self.rows + && c < self.cols + && matches!(self.cells[r * self.cols + c], Cell::Line { bits, .. } if bits == LEFT | RIGHT) + } + + /// Add line `bits` to a cell. Glyph and text cells keep their glyph. + pub fn add_bits(&mut self, r: usize, c: usize, bits: u8, style: LineStyle, class: Class) { + let Some(cell) = self.at(r, c) else { return }; + match cell { + Cell::Empty => { + *cell = Cell::Line { + bits, + dbl: 0, + style, + class, + } + } + Cell::Line { + bits: b, + style: s, + class: k, + .. + } => { + *b |= bits; + if class == Class::Edge { + *s = style; + } + if class > *k { + *k = class; + } + } + Cell::Glyph { .. } | Cell::Cont => {} + } + } + + /// Mark a cell's bits as double-line. + pub fn add_double(&mut self, r: usize, c: usize, bits: u8, class: Class) { + self.add_bits(r, c, bits, LineStyle::Solid, class); + if let Some(Cell::Line { dbl, .. }) = self.at(r, c) { + *dbl |= bits; + } + } + + /// Draw an axis-aligned line between two cells (inclusive). + pub fn line( + &mut self, + r0: usize, + c0: usize, + r1: usize, + c1: usize, + style: LineStyle, + class: Class, + ) { + if r0 == r1 { + let (a, b) = (c0.min(c1), c0.max(c1)); + for c in a..b { + self.add_bits(r0, c, RIGHT, style, class); + self.add_bits(r0, c + 1, LEFT, style, class); + } + } else if c0 == c1 { + let (a, b) = (r0.min(r1), r0.max(r1)); + for r in a..b { + self.add_bits(r, c0, DOWN, style, class); + self.add_bits(r + 1, c0, UP, style, class); + } + } + } + + /// Draw a polyline through `pts` (each step axis-aligned). + pub fn path(&mut self, pts: &[(usize, usize)], style: LineStyle, class: Class) { + for w in pts.windows(2) { + self.line(w[0].0, w[0].1, w[1].0, w[1].1, style, class); + } + } + + /// Put a single glyph (one grapheme) at a cell, replacing what is there. + pub fn glyph(&mut self, r: usize, c: usize, g: &str, class: Class) { + let w = UnicodeWidthStr::width(g); + if w == 0 || c + w > self.cols || r >= self.rows { + return; + } + // Never leave half of a double-width glyph behind. + for cc in c..c + w { + self.clear_wide(r, cc); + } + if let Some(cell) = self.at(r, c) { + *cell = Cell::Glyph { + text: g.to_string(), + class, + }; + } + if w == 2 { + if let Some(cell) = self.at(r, c + 1) { + *cell = Cell::Cont; + } + } + } + + /// Clear the double-width glyph that covers (r, c), if any. + fn clear_wide(&mut self, r: usize, c: usize) { + let i = r * self.cols + c; + match &self.cells[i] { + Cell::Cont if c > 0 => { + self.cells[i] = Cell::Empty; + self.cells[i - 1] = Cell::Empty; + } + Cell::Glyph { text, .. } if UnicodeWidthStr::width(text.as_str()) == 2 => { + self.cells[i] = Cell::Empty; + if c + 1 < self.cols { + self.cells[i + 1] = Cell::Empty; + } + } + _ => {} + } + } + + /// Write text starting at (r, c), clipped at `max_c` (exclusive). + pub fn text(&mut self, r: usize, c: usize, s: &str, max_c: usize, class: Class) { + let mut col = c; + let limit = max_c.min(self.cols); + for g in s.graphemes(true) { + let w = UnicodeWidthStr::width(g); + if w == 0 { + continue; + } + if col + w > limit { + break; + } + self.glyph(r, col, g, class); + col += w; + } + } + + /// The finished rows: each a list of (text, class) runs, with trailing + /// blanks kept so every row is exactly `cols` wide. + pub fn render(&self) -> Vec> { + let mut out = Vec::with_capacity(self.rows); + for r in 0..self.rows { + let mut runs: Vec<(String, Class)> = Vec::new(); + for c in 0..self.cols { + let (s, class): (String, Class) = match &self.cells[r * self.cols + c] { + Cell::Empty => (" ".into(), Class::Frame), + Cell::Cont => continue, + Cell::Glyph { text, class } => (text.clone(), *class), + Cell::Line { + bits, + dbl, + style, + class, + } => (line_glyph(*bits, *dbl, *style).to_string(), *class), + }; + match runs.last_mut() { + Some((t, k)) if *k == class || s == " " => t.push_str(&s), + _ => runs.push((s, class)), + } + } + out.push(runs); + } + out + } +} + +/// Box-drawing glyph for a set of direction bits. +fn line_glyph(bits: u8, dbl: u8, style: LineStyle) -> char { + if dbl != 0 { + let extra = bits & !dbl; + if dbl & (LEFT | RIGHT) != 0 && dbl & (UP | DOWN) == 0 { + return match extra & (UP | DOWN) { + 0 => '═', + DOWN => '╤', + UP => '╧', + _ => '╪', + }; + } + if dbl & (UP | DOWN) != 0 && dbl & (LEFT | RIGHT) == 0 { + return match extra & (LEFT | RIGHT) { + 0 => '║', + RIGHT => '╟', + LEFT => '╢', + _ => '╫', + }; + } + } + let vertical = UP | DOWN; + let horizontal = LEFT | RIGHT; + match bits { + b if b == vertical || b == UP || b == DOWN => match style { + LineStyle::Dotted => '┆', + LineStyle::Thick => '┃', + _ => '│', + }, + b if b == horizontal || b == LEFT || b == RIGHT => match style { + LineStyle::Dotted => '┄', + LineStyle::Thick => '━', + _ => '─', + }, + b if b == DOWN | RIGHT => '┌', + b if b == DOWN | LEFT => '┐', + b if b == UP | RIGHT => '└', + b if b == UP | LEFT => '┘', + b if b == vertical | RIGHT => '├', + b if b == vertical | LEFT => '┤', + b if b == horizontal | DOWN => '┬', + b if b == horizontal | UP => '┴', + b if b == vertical | horizontal => '┼', + _ => ' ', + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plain(c: &Canvas) -> Vec { + c.render() + .into_iter() + .map(|runs| runs.into_iter().map(|(t, _)| t).collect()) + .collect() + } + + #[test] + fn lines_merge_into_junctions() { + let mut c = Canvas::new(3, 5); + c.line(1, 0, 1, 4, LineStyle::Solid, Class::Edge); + c.line(0, 2, 2, 2, LineStyle::Solid, Class::Edge); + c.path(&[(0, 0), (0, 1)], LineStyle::Solid, Class::Edge); + assert_eq!(plain(&c), vec!["──│ ", "──┼──", " │ "]); + } + + #[test] + fn wide_glyphs_take_two_cells() { + let mut c = Canvas::new(1, 4); + c.text(0, 0, "日本", 4, Class::Text); + assert_eq!(plain(&c), vec!["日本"]); + let mut c = Canvas::new(1, 3); + c.text(0, 0, "日本", 3, Class::Text); + assert_eq!(plain(&c), vec!["日 "]); + c.glyph(0, 1, "x", Class::Text); + assert_eq!(plain(&c), vec![" x "]); + } +} diff --git a/src/layout/mermaid/engine.rs b/src/layout/mermaid/engine.rs new file mode 100644 index 0000000..42f4acc --- /dev/null +++ b/src/layout/mermaid/engine.rs @@ -0,0 +1,1616 @@ +//! Layered (Sugiyama-style) graph layout on a character grid. +//! +//! 1. Self-loops are set aside; cycles are broken by reversing DFS back +//! edges. +//! 2. Ranks by longest path (sources pulled down next to their targets). +//! When any edge has a label every edge spans two ranks, and the label +//! becomes a node of its own in the middle rank, so the layout makes room +//! for it like for any other node. +//! 3. Edges spanning several ranks get one dummy node per rank. +//! 4. Crossings are reduced with barycenter sweeps (members of a subgraph +//! are kept contiguous in every rank), keeping the best order seen. +//! 5. Cross-axis coordinates: repeated weighted isotonic regression +//! (pool-adjacent-violators) towards each node's neighbours, which centres +//! parents over children and straightens long edges. +//! 6. Edges between adjacent ranks are routed orthogonally through the gap +//! between them, each source's fan-out sharing one horizontal track. +//! +//! The engine works in (main, cross) coordinates — main runs along the +//! ranks — and maps them to (row, col) at the end, so `LR` is the same code +//! as `TD`. `BT` and `RL` flip the ranks. + +use super::canvas::{Canvas, Class, DOWN, LEFT, RIGHT, UP}; +use super::graph::{Dir, Graph, LineStyle, Marker, Shape}; +use super::text; + +/// Diagrams with more nodes than this are listed, not laid out. +pub const MAX_NODES: usize = 200; +/// Diagrams with more edges than this are listed, not laid out. +pub const MAX_EDGES: usize = 400; +/// Cap on layout nodes including the dummies long edges create. +const MAX_LAYOUT_NODES: usize = 4000; +/// Cap on canvas cells. +const MAX_CELLS: usize = 4_000_000; + +/// Knobs one layout attempt runs with. +#[derive(Debug, Clone, Copy)] +pub struct Params { + /// Wrap node labels to this many columns. + pub wrap: usize, + /// Space between neighbouring nodes in a rank. + pub sep: usize, + pub dir: Dir, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Fail { + /// Over the node, edge or cell budget. + TooBig, + /// Laid out wider than the space available (the width it needed). + TooWide(usize), +} + +/// A drawn diagram. +pub struct Drawn { + pub rows: Vec>, + /// Subgraph frames could not be drawn without overlapping other nodes; + /// membership is listed in `notes` instead. + pub notes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Real(usize), + Dummy, + Label, +} + +struct LNode { + kind: Kind, + /// Edge a dummy or label node belongs to. + edge: usize, + rank: usize, + cluster_path: Vec, + cross: usize, + main: usize, + ups: Vec, + downs: Vec, +} + +struct Seg { + up: usize, + down: usize, + edge: usize, + /// Port offsets (cross axis, relative to the node's cross start) at the + /// up and down ends. + port_up: usize, + port_down: usize, +} + +struct EdgeInfo { + /// The edge's `from` is drawn at the bottom (it points against the + /// ranks: a broken cycle, or a `BT`/`RL` diagram). + rev: bool, + /// Reversed to break a cycle. + cyc: bool, + label: Vec, +} + +/// Geometry of a real node's box. +struct NodeBox { + w: usize, + h: usize, + /// Title lines. + lines: Vec, + /// Further compartments (wrapped rows). + sections: Vec>, + /// Self-loop label, when the node has a self-loop. + loop_label: Option, +} + +pub fn layout(g: &Graph, p: &Params, avail: usize) -> Result { + if g.nodes.len() > MAX_NODES || g.edges.len() > MAX_EDGES || g.nodes.is_empty() { + return Err(Fail::TooBig); + } + let horiz = p.dir.horizontal(); + let n = g.nodes.len(); + + // ── Boxes and self-loops ── + let mut loops: Vec> = vec![Vec::new(); n]; + let mut edges: Vec = Vec::new(); + for (i, e) in g.edges.iter().enumerate() { + if e.from == e.to { + loops[e.from].push(e.label.replace('\n', " ")); + } else { + edges.push(i); + } + } + let mut boxes: Vec = g + .nodes + .iter() + .enumerate() + .map(|(i, node)| { + node_box( + node.shape, + &node.label, + &node.sections, + p.wrap, + horiz, + &loops[i], + ) + }) + .collect(); + + // ── Cycle breaking ── + let mut out_adj: Vec> = vec![Vec::new(); n]; + for &ei in &edges { + out_adj[g.edges[ei].from].push(ei); + } + let mut cyc = vec![false; g.edges.len()]; + { + let mut state = vec![0u8; n]; + for s in 0..n { + if state[s] != 0 { + continue; + } + let mut stack: Vec<(usize, usize)> = vec![(s, 0)]; + state[s] = 1; + while let Some(&mut (v, ref mut k)) = stack.last_mut() { + if *k < out_adj[v].len() { + let ei = out_adj[v][*k]; + *k += 1; + let t = g.edges[ei].to; + match state[t] { + 0 => { + state[t] = 1; + stack.push((t, 0)); + } + 1 => cyc[ei] = true, + _ => {} + } + } else { + state[v] = 2; + stack.pop(); + } + } + } + } + + // ── Ranks ── + let labelled = |ei: usize| { + let e = &g.edges[ei]; + !e.label.is_empty() || e.cards.is_some() + }; + let minlen = if edges.iter().any(|&ei| labelled(ei)) { + 2 + } else { + 1 + }; + let dag: Vec<(usize, usize)> = edges + .iter() + .map(|&ei| { + let e = &g.edges[ei]; + if cyc[ei] { + (e.to, e.from) + } else { + (e.from, e.to) + } + }) + .collect(); + let mut rank = vec![0usize; n]; + { + let mut indeg = vec![0usize; n]; + let mut succ: Vec> = vec![Vec::new(); n]; + for &(a, b) in &dag { + indeg[b] += 1; + succ[a].push(b); + } + let mut queue: std::collections::VecDeque = + (0..n).filter(|&v| indeg[v] == 0).collect(); + let mut order = Vec::with_capacity(n); + let mut deg = indeg.clone(); + while let Some(v) = queue.pop_front() { + order.push(v); + for &w in &succ[v] { + rank[w] = rank[w].max(rank[v] + minlen); + deg[w] -= 1; + if deg[w] == 0 { + queue.push_back(w); + } + } + } + // Pull sources down next to their nearest target. + for &v in &order { + if indeg[v] == 0 && !succ[v].is_empty() { + let lowest = succ[v].iter().map(|&w| rank[w]).min().unwrap_or(minlen); + rank[v] = lowest.saturating_sub(minlen); + } + } + } + let max_rank = rank.iter().copied().max().unwrap_or(0); + if p.dir.reversed() { + for r in &mut rank { + *r = max_rank - *r; + } + } + let nranks = max_rank + 1; + + // ── Layout nodes, dummies, labels ── + let node_path: Vec> = g + .nodes + .iter() + .map(|nd| g.cluster_path(nd.cluster)) + .collect(); + let mut lnodes: Vec = (0..n) + .map(|i| LNode { + kind: Kind::Real(i), + edge: usize::MAX, + rank: rank[i], + cluster_path: node_path[i].clone(), + cross: 0, + main: 0, + ups: Vec::new(), + downs: Vec::new(), + }) + .collect(); + let mut segs: Vec = Vec::new(); + let mut einfo: Vec> = (0..g.edges.len()).map(|_| None).collect(); + for &ei in &edges { + let e = &g.edges[ei]; + let (top, bottom) = if rank[e.from] < rank[e.to] { + (e.from, e.to) + } else { + (e.to, e.from) + }; + let rev = top != e.from; + // Label text, with cardinalities ordered top to bottom. + let mut parts: Vec = Vec::new(); + let (c_top, c_bottom) = match &e.cards { + Some((a, b)) if rev => (b.clone(), a.clone()), + Some((a, b)) => (a.clone(), b.clone()), + None => (String::new(), String::new()), + }; + if horiz { + let joined = [c_top.as_str(), e.label.as_str(), c_bottom.as_str()] + .iter() + .filter(|s| !s.is_empty()) + .map(|s| s.replace('\n', " ")) + .collect::>() + .join(" "); + if !joined.is_empty() { + parts.extend(text::wrap(&joined, p.wrap)); + } + } else { + for s in [&c_top, &e.label, &c_bottom] { + if !s.is_empty() { + parts.extend(text::wrap(s, p.wrap)); + } + } + } + // Common subgraph of both ends. + let pa = &node_path[e.from]; + let pb = &node_path[e.to]; + let common: Vec = pa + .iter() + .zip(pb.iter()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| *a) + .collect(); + let (r0, r1) = (rank[top], rank[bottom]); + let label_rank = if parts.is_empty() { + usize::MAX + } else { + let mid = r0 + (r1 - r0) / 2; + if (mid - r0) % 2 == 0 && mid > r0 + 1 { + mid - 1 + } else { + mid + } + }; + let mut prev = top; + for r in r0 + 1..=r1 { + let cur = if r == r1 { + bottom + } else { + if lnodes.len() >= MAX_LAYOUT_NODES { + return Err(Fail::TooBig); + } + lnodes.push(LNode { + kind: if r == label_rank { + Kind::Label + } else { + Kind::Dummy + }, + edge: ei, + rank: r, + cluster_path: common.clone(), + cross: 0, + main: 0, + ups: Vec::new(), + downs: Vec::new(), + }); + lnodes.len() - 1 + }; + let si = segs.len(); + segs.push(Seg { + up: prev, + down: cur, + edge: ei, + port_up: 0, + port_down: 0, + }); + lnodes[prev].downs.push(si); + lnodes[cur].ups.push(si); + prev = cur; + } + einfo[ei] = Some(EdgeInfo { + rev, + cyc: cyc[ei], + label: parts, + }); + } + let info = |ei: usize| einfo[ei].as_ref().expect("edge info for a laid-out edge"); + + // ── Crossing reduction ── + let mut ranks: Vec> = vec![Vec::new(); nranks]; + for (i, ln) in lnodes.iter().enumerate() { + ranks[ln.rank].push(i); + } + let mut pos = vec![0usize; lnodes.len()]; + let set_pos = |ranks: &Vec>, pos: &mut Vec| { + for rk in ranks { + for (i, &l) in rk.iter().enumerate() { + pos[l] = i; + } + } + }; + // Group subgraph members together from the start. + for rk in &mut ranks { + let mut items: Vec<(usize, f64)> = + rk.iter().enumerate().map(|(i, &l)| (l, i as f64)).collect(); + group_sort(&mut items, &lnodes, 0); + *rk = items.into_iter().map(|(l, _)| l).collect(); + } + set_pos(&ranks, &mut pos); + let mut best = ranks.clone(); + let mut best_cross = count_crossings(&ranks, &lnodes, &segs, &pos); + let budget = 2_000_000usize; + let iters = (budget / (segs.len() + lnodes.len() + 1)).clamp(1, 12); + let mut stale = 0; + for it in 0..iters { + if best_cross == 0 { + break; + } + let down = it % 2 == 0; + let order: Vec = if down { + (1..nranks).collect() + } else { + (0..nranks.saturating_sub(1)).rev().collect() + }; + for r in order { + let mut items: Vec<(usize, f64)> = ranks[r] + .iter() + .map(|&l| { + let nb: Vec = if down { + lnodes[l].ups.iter().map(|&s| pos[segs[s].up]).collect() + } else { + lnodes[l].downs.iter().map(|&s| pos[segs[s].down]).collect() + }; + let key = if nb.is_empty() { + pos[l] as f64 + } else { + nb.iter().sum::() as f64 / nb.len() as f64 + }; + (l, key) + }) + .collect(); + group_sort(&mut items, &lnodes, 0); + ranks[r] = items.into_iter().map(|(l, _)| l).collect(); + for (i, &l) in ranks[r].iter().enumerate() { + pos[l] = i; + } + } + let c = count_crossings(&ranks, &lnodes, &segs, &pos); + if c < best_cross { + best_cross = c; + best = ranks.clone(); + stale = 0; + } else { + stale += 1; + if stale >= 4 { + break; + } + } + } + let ranks = best; + set_pos(&ranks, &mut pos); + + // ── Ports ── + // A real node's edges on one side share a port when they look the same + // at that end (marker, line style, cycle-reversed or not); different + // kinds get their own ports, two cells apart. + for v in 0..n { + // Groups per side: (down side?, ordered list of segment groups). + let mut sides: Vec<(bool, Vec>)> = Vec::new(); + for down_side in [true, false] { + let list: &Vec = if down_side { + &lnodes[v].downs + } else { + &lnodes[v].ups + }; + if list.is_empty() { + continue; + } + let mut groups: Vec<((Marker, bool, LineStyle), Vec)> = Vec::new(); + for &s in list { + let ei = segs[s].edge; + let e = &g.edges[ei]; + let inf = info(ei); + let marker = end_marker(e.start, e.end, inf.rev, down_side); + let key = (marker, inf.cyc, e.style); + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, v)) => v.push(s), + None => groups.push((key, vec![s])), + } + } + let mut groups: Vec> = groups.into_iter().map(|(_, v)| v).collect(); + if g.nodes[v].shape.is_glyph() { + // Too small for more than one port. + groups = vec![groups.concat()]; + } + // Order ports by where the other ends sit. + let key = |ss: &Vec| { + ss.iter() + .map(|&s| pos[if down_side { segs[s].down } else { segs[s].up }] as f64) + .sum::() + / ss.len() as f64 + }; + groups.sort_by(|a, b| key(a).total_cmp(&key(b))); + sides.push((down_side, groups)); + } + let most = sides.iter().map(|(_, gs)| gs.len()).max().unwrap_or(0); + let b = &mut boxes[v]; + let size = if horiz { &mut b.h } else { &mut b.w }; + if !g.nodes[v].shape.is_glyph() && *size < 2 * most + 1 { + *size = 2 * most + 1; + } + let center = (*size - 1) / 2; + for (down_side, groups) in sides { + let ng = groups.len(); + for (k, ss) in groups.into_iter().enumerate() { + let off = (center + 2 * k).saturating_sub(ng - 1); + for s in ss { + if down_side { + segs[s].port_up = off; + } else { + segs[s].port_down = off; + } + } + } + } + } + + // ── Extents ── + for ln in lnodes.iter_mut() { + match ln.kind { + Kind::Real(v) => { + let b = &boxes[v]; + let has_loop = b.loop_label.is_some(); + let lw = b.loop_label.as_deref().map_or(0, text::width); + if horiz { + ln.cross = b.h + usize::from(has_loop); + ln.main = if has_loop && lw > 0 { + b.w.max(5 + lw) + } else { + b.w + }; + } else { + ln.cross = b.w + + if has_loop { + if lw > 0 { + 3 + lw + } else { + 2 + } + } else { + 0 + }; + ln.main = b.h; + } + } + Kind::Dummy => { + ln.cross = 1; + ln.main = 0; + } + Kind::Label => { + let lines = &info(ln.edge).label; + let lw = lines.iter().map(|l| text::width(l)).max().unwrap_or(0); + if horiz { + ln.cross = lines.len(); + ln.main = lw + 2; + } else { + ln.cross = lw + 2; + ln.main = lines.len(); + } + } + } + } + let port_of = |l: usize, s: usize| -> f64 { + let seg = &segs[s]; + match lnodes[l].kind { + Kind::Real(_) => { + (if seg.up == l { + seg.port_up + } else { + seg.port_down + }) as f64 + } + Kind::Dummy => 0.0, + Kind::Label => ((lnodes[l].cross - 1) / 2) as f64, + } + }; + + // ── Cross-axis coordinates ── + let sep_between = |a: usize, b: usize| -> usize { + let (pa, pb) = (&lnodes[a].cluster_path, &lnodes[b].cluster_path); + let common = pa.iter().zip(pb.iter()).take_while(|(x, y)| x == y).count(); + let frames = (pa.len() - common) + (pb.len() - common); + let base = if lnodes[a].kind == Kind::Dummy || lnodes[b].kind == Kind::Dummy { + 1 + } else { + p.sep + }; + base + 2 * frames + }; + let mut x = vec![0f64; lnodes.len()]; + for rk in &ranks { + let mut cur = 0usize; + for (i, &l) in rk.iter().enumerate() { + if i > 0 { + cur += sep_between(rk[i - 1], l); + } + x[l] = cur as f64; + cur += lnodes[l].cross; + } + } + let weight = |a: usize, b: usize| -> f64 { + let real = |l: usize| matches!(lnodes[l].kind, Kind::Real(_)); + match (real(a), real(b)) { + (true, true) => 1.0, + (false, false) => 8.0, + _ => 2.0, + } + }; + let place = |x: &mut Vec, rk: &[usize], use_up: bool, use_down: bool| { + if rk.is_empty() { + return; + } + let mut desired = Vec::with_capacity(rk.len()); + let mut weights = Vec::with_capacity(rk.len()); + let mut offsets = Vec::with_capacity(rk.len()); + let mut off = 0usize; + for (i, &l) in rk.iter().enumerate() { + if i > 0 { + off += lnodes[rk[i - 1]].cross + sep_between(rk[i - 1], l); + } + let mut sw = 0.0; + let mut sx = 0.0; + let mut add = |s: usize, other: usize| { + let w = weight(l, other); + sw += w; + sx += w * (x[other] + port_of(other, s) - port_of(l, s)); + }; + if use_up { + for &s in &lnodes[l].ups { + add(s, segs[s].up); + } + } + if use_down { + for &s in &lnodes[l].downs { + add(s, segs[s].down); + } + } + if sw == 0.0 { + desired.push(x[l] - off as f64); + weights.push(0.1); + } else { + desired.push(sx / sw - off as f64); + weights.push(sw); + } + offsets.push(off as f64); + } + let y = pava(&desired, &weights); + for (i, &l) in rk.iter().enumerate() { + x[l] = y[i] + offsets[i]; + } + }; + let coord_iters = if lnodes.len() > 1500 { 3 } else { 8 }; + for _ in 0..coord_iters { + for rk in ranks.iter().skip(1) { + place(&mut x, rk, true, false); + } + for rk in ranks.iter().rev().skip(1) { + place(&mut x, rk, false, true); + } + } + // Last pass top-down in whole columns: each rank lines up under the + // (already integer) rank above, so chains come out straight. + let mut xi = vec![0i64; lnodes.len()]; + for (r, rk) in ranks.iter().enumerate() { + if r > 0 { + place(&mut x, rk, true, false); + } + for (i, &l) in rk.iter().enumerate() { + let want = x[l].round() as i64; + xi[l] = if i == 0 { + want + } else { + let prev = rk[i - 1]; + want.max(xi[prev] + (lnodes[prev].cross + sep_between(prev, l)) as i64) + }; + x[l] = xi[l] as f64; + } + } + + // Straighten: move a node sideways (pushing its neighbours along when + // they are in the way) when that lines up more edges than it bends — + // rounding and packing leave one-column jogs. + let node_cost = |xi: &Vec, l: usize, at: i64| -> f64 { + lnodes[l] + .ups + .iter() + .map(|&s| (s, segs[s].up)) + .chain(lnodes[l].downs.iter().map(|&s| (s, segs[s].down))) + .map(|(s, o)| { + let a = at as f64 + port_of(l, s); + let b = xi[o] as f64 + port_of(o, s); + // Ties go to a straight arrival at the lower node. + let bias = if segs[s].up == l && !matches!(lnodes[l].kind, Kind::Real(_)) { + 1.01 + } else { + 1.0 + }; + bias * weight(l, o) * (a - b).abs() + }) + .sum() + }; + for _ in 0..4 { + let mut moved = false; + for rk in &ranks { + for i in 0..rk.len() { + let l = rk[i]; + let targets: Vec = lnodes[l] + .ups + .iter() + .map(|&s| (s, segs[s].up)) + .chain(lnodes[l].downs.iter().map(|&s| (s, segs[s].down))) + .map(|(s, o)| (xi[o] as f64 + port_of(o, s) - port_of(l, s)).round() as i64) + .collect(); + let mut best: Option<(f64, Vec<(usize, i64)>)> = None; + for at in targets { + if at == xi[l] || at < 0 { + continue; + } + // The nodes that move, with their new positions. + let mut moves = vec![(l, at)]; + if at > xi[l] { + let mut edge = at + lnodes[l].cross as i64; + for j in i + 1..rk.len() { + let q = rk[j]; + let need = edge + sep_between(rk[j - 1], q) as i64; + if xi[q] >= need { + break; + } + moves.push((q, need)); + edge = need + lnodes[q].cross as i64; + } + } else { + let mut left = at; + for j in (0..i).rev() { + let q = rk[j]; + let need = left - (lnodes[q].cross + sep_between(q, rk[j + 1])) as i64; + if xi[q] <= need { + break; + } + moves.push((q, need)); + left = need; + } + } + let delta: f64 = moves + .iter() + .map(|&(q, to)| node_cost(&xi, q, to) - node_cost(&xi, q, xi[q])) + .sum(); + if delta < -1e-9 && best.as_ref().is_none_or(|(d, _)| delta < *d) { + best = Some((delta, moves)); + } + } + if let Some((_, moves)) = best { + for (q, to) in moves { + xi[q] = to; + } + moved = true; + } + } + } + if !moved { + break; + } + } + + // ── Subgraph frames ── + let nclusters = g.clusters.len(); + let depth_of = |c: usize| g.cluster_path(Some(c)).len(); + let mut by_depth: Vec = (0..nclusters).collect(); + by_depth.sort_by_key(|&c| std::cmp::Reverse(depth_of(c))); + let title_w = |c: usize| { + let cl = &g.clusters[c]; + text::width(if cl.title.is_empty() { + &cl.id + } else { + &cl.title + }) + }; + // Per cluster: cross range and rank range, built from the innermost out. + // `widen` makes room for the title in top-down layouts. + let compute = |widen: bool| -> Vec> { + let mut frame: Vec> = vec![None; nclusters]; + for &c in &by_depth { + let mut range: Option<(i64, i64, usize, usize)> = None; + let grow = + |r: &mut Option<(i64, i64, usize, usize)>, a: i64, b: i64, r0: usize, r1: usize| { + *r = Some(match *r { + None => (a, b, r0, r1), + Some((x0, x1, s0, s1)) => (x0.min(a), x1.max(b), s0.min(r0), s1.max(r1)), + }); + }; + for (l, ln) in lnodes.iter().enumerate() { + if ln.cluster_path.last() == Some(&c) { + grow( + &mut range, + xi[l], + xi[l] + ln.cross as i64 - 1, + ln.rank, + ln.rank, + ); + } + } + for (d, cl) in g.clusters.iter().enumerate() { + if cl.parent == Some(c) && d != c { + if let Some((a, b, r0, r1)) = frame[d] { + grow(&mut range, a, b, r0, r1); + } + } + } + frame[c] = range.map(|(a, b, r0, r1)| { + let (a, mut b) = (a - 2, b + 2); + if widen && !horiz { + b = b.max(a + title_w(c).min(40) as i64 + 5); + } + (a, b, r0, r1) + }); + } + frame + }; + let in_cluster = |l: usize, c: usize| lnodes[l].cluster_path.contains(&c); + let valid = |frame: &[Option<(i64, i64, usize, usize)>]| -> bool { + for c in 0..nclusters { + let Some((c0, c1, r0, r1)) = frame[c] else { + continue; + }; + for (l, ln) in lnodes.iter().enumerate() { + if ln.rank < r0 || ln.rank > r1 || in_cluster(l, c) { + continue; + } + let (a, b) = (xi[l], xi[l] + ln.cross as i64 - 1); + let hit = match ln.kind { + Kind::Dummy => a == c0 || a == c1, + _ => a <= c1 && b >= c0, + }; + if hit { + return false; + } + } + let pc = g.cluster_path(Some(c)); + for (d, fd) in frame.iter().enumerate().skip(c + 1) { + let Some((d0, d1, s0, s1)) = *fd else { + continue; + }; + if pc.contains(&d) || g.cluster_path(Some(d)).contains(&c) { + continue; + } + if s0 <= r1 && r0 <= s1 && d0 <= c1 && c0 <= d1 { + return false; + } + } + } + true + }; + let mut frame = compute(true); + let mut frames_ok = valid(&frame); + if !frames_ok { + frame = compute(false); + frames_ok = valid(&frame); + } + let mut notes = Vec::new(); + if !frames_ok { + for (c, cl) in g.clusters.iter().enumerate() { + let members: Vec = (0..n) + .filter(|&v| node_path[v].contains(&c)) + .map(|v| g.nodes[v].label.replace('\n', " ")) + .collect(); + if !members.is_empty() { + let title = if cl.title.is_empty() { + &cl.id + } else { + &cl.title + }; + notes.push(format!( + "{}: {}", + title.replace('\n', " "), + members.join(", ") + )); + } + } + frame.iter_mut().for_each(|f| *f = None); + } + + // Shift so everything starts at cross 0. + let min_x = lnodes + .iter() + .enumerate() + .map(|(l, _)| xi[l]) + .chain(frame.iter().flatten().map(|f| f.0)) + .min() + .unwrap_or(0); + for v in xi.iter_mut() { + *v -= min_x; + } + for f in frame.iter_mut().flatten() { + f.0 -= min_x; + f.1 -= min_x; + } + let cross_total = lnodes + .iter() + .enumerate() + .map(|(l, ln)| xi[l] as usize + ln.cross) + .chain(frame.iter().flatten().map(|f| f.1 as usize + 1)) + .max() + .unwrap_or(1); + let xs: Vec = xi.iter().map(|&v| v.max(0) as usize).collect(); + + // Width check before the expensive part, for top-down layouts. + if !horiz && cross_total > avail { + return Err(Fail::TooWide(cross_total)); + } + + // ── Routing tracks per gap ── + let seg_cols = |s: usize| -> (usize, usize) { + let sg = &segs[s]; + ( + xs[sg.up] + port_of(sg.up, s) as usize, + xs[sg.down] + port_of(sg.down, s) as usize, + ) + }; + let mut gap_segs: Vec> = vec![Vec::new(); nranks.saturating_sub(1)]; + for (s, sg) in segs.iter().enumerate() { + gap_segs[lnodes[sg.up].rank].push(s); + } + let mut track_of = vec![0usize; segs.len()]; + let mut ntracks = vec![0usize; gap_segs.len()]; + for (gi, list) in gap_segs.iter().enumerate() { + // Nets share a horizontal track: segments leaving the same port + // (fan-out), and single segments from different ports arriving at + // the same port (fan-in). + let mut nets: Vec> = Vec::new(); + for &s in list { + let (cu, _) = seg_cols(s); + match nets + .iter_mut() + .find(|ss| seg_cols(ss[0]).0 == cu && segs[ss[0]].up == segs[s].up) + { + Some(ss) => ss.push(s), + None => nets.push(vec![s]), + } + } + let mut merged: Vec> = Vec::new(); + for net in nets { + let single = net.len() == 1; + let target = |s: usize| (segs[s].down, seg_cols(s).1); + let into = if single { + merged.iter_mut().find(|m| { + m.iter().all(|&t| target(t) == target(net[0])) + && m.iter().all(|&t| segs[t].up != segs[net[0]].up) + }) + } else { + None + }; + match into { + Some(m) => m.push(net[0]), + None => merged.push(net), + } + } + // A merged group must be all single fan-in segments or one fan-out + // net; a fan-out net never absorbs others (the find above only + // matches groups whose every segment has the same target). + let nets = merged; + let cols_of = |ss: &Vec| -> (Vec, Vec) { + let mut srcs: Vec = ss.iter().map(|&s| seg_cols(s).0).collect(); + let mut dsts: Vec = ss.iter().map(|&s| seg_cols(s).1).collect(); + srcs.sort_unstable(); + srcs.dedup(); + dsts.sort_unstable(); + dsts.dedup(); + (srcs, dsts) + }; + let info_net: Vec<(usize, usize, Vec, Vec)> = nets + .iter() + .map(|ss| { + let (srcs, dsts) = cols_of(ss); + let lo = srcs.iter().chain(dsts.iter()).copied().min().unwrap_or(0); + let hi = srcs.iter().chain(dsts.iter()).copied().max().unwrap_or(0); + (lo, hi, srcs, dsts) + }) + .collect(); + let routed: Vec = (0..nets.len()) + .filter(|&i| info_net[i].0 != info_net[i].1) + .collect(); + // Net a must sit above net b when one of a's source columns is one + // of b's drop columns (otherwise the two verticals would overlap). + let mut preds: Vec> = vec![Vec::new(); nets.len()]; + for &a in &routed { + for &b in &routed { + if a != b && info_net[a].2.iter().any(|c| info_net[b].3.contains(c)) { + preds[b].push(a); + } + } + } + let mut order: Vec = Vec::new(); + let mut done = vec![false; nets.len()]; + let mut remaining: Vec = routed.clone(); + remaining.sort_by_key(|&i| (info_net[i].0, info_net[i].1)); + while !remaining.is_empty() { + let pick = remaining + .iter() + .position(|&i| preds[i].iter().all(|&q| done[q])) + .unwrap_or(0); + let i = remaining.remove(pick); + done[i] = true; + order.push(i); + } + let mut track = vec![usize::MAX; nets.len()]; + let mut used: Vec> = Vec::new(); + for &i in &order { + let lo_bound = preds[i] + .iter() + .filter(|&&q| track[q] != usize::MAX) + .map(|&q| track[q] + 1) + .max() + .unwrap_or(0); + let (a, b) = (info_net[i].0, info_net[i].1); + let mut t = lo_bound; + loop { + if t >= used.len() { + used.resize(t + 1, Vec::new()); + } + if used[t].iter().all(|&(c, d)| b + 1 < c || d + 1 < a) { + break; + } + t += 1; + } + used[t].push((a, b)); + track[i] = t; + for &s in &nets[i] { + track_of[s] = t; + } + } + ntracks[gi] = used.len(); + } + + // ── Main-axis positions ── + let mut rank_ext = vec![0usize; nranks]; + for ln in &lnodes { + rank_ext[ln.rank] = rank_ext[ln.rank].max(ln.main); + } + // A gap needs its last row for arrowheads (and as spacing) unless its + // tracks already separate the ranks and nothing below is a real node. + let needs_arrow_row = |gi: usize| { + ntracks[gi] == 0 + || gap_segs[gi] + .iter() + .any(|&s| matches!(lnodes[segs[s].down].kind, Kind::Real(_))) + }; + let frame_list: Vec = (0..nclusters).filter(|&c| frame[c].is_some()).collect(); + let fr = |c: usize| frame[c].expect("frame"); + let mut frame_top = vec![0usize; nclusters]; + let mut frame_bottom = vec![0usize; nclusters]; + let mut rank_start = vec![0usize; nranks]; + let mut track_start = vec![0usize; gap_segs.len()]; + let mut cursor = 0usize; + // Frames starting (ending) at a rank, grouped into one row per nesting + // depth: siblings share a row, an outer frame's row comes first on the + // outside. + let rows_at = |r: usize, top: bool| -> Vec> { + let mut v: Vec<(usize, usize)> = frame_list + .iter() + .copied() + .filter(|&c| if top { fr(c).2 == r } else { fr(c).3 == r }) + .map(|c| (depth_of(c), c)) + .collect(); + v.sort(); + if !top { + v.reverse(); + } + let mut out: Vec> = Vec::new(); + let mut last = usize::MAX; + for (d, c) in v { + if d != last { + out.push(Vec::new()); + last = d; + } + if let Some(row) = out.last_mut() { + row.push(c); + } + } + out + }; + let has_upper_marker = |gi: usize| { + gap_segs[gi].iter().any(|&s| { + let sg = &segs[s]; + matches!(lnodes[sg.up].kind, Kind::Real(_)) && { + let e = &g.edges[sg.edge]; + end_marker(e.start, e.end, info(sg.edge).rev, true) != Marker::None + } + }) + }; + for row in rows_at(0, true) { + for c in row { + frame_top[c] = cursor; + } + cursor += 1; + } + for r in 0..nranks { + rank_start[r] = cursor; + cursor += rank_ext[r]; + if r + 1 < nranks { + if has_upper_marker(r) { + cursor += 1; + } + for row in rows_at(r, false) { + for c in row { + frame_bottom[c] = cursor; + } + cursor += 1; + } + track_start[r] = cursor; + cursor += ntracks[r]; + for row in rows_at(r + 1, true) { + for c in row { + frame_top[c] = cursor; + } + cursor += 1; + } + if needs_arrow_row(r) { + cursor += 1; + } + } else { + for row in rows_at(r, false) { + for c in row { + frame_bottom[c] = cursor; + } + cursor += 1; + } + } + } + let main_total = cursor; + let (rows, cols) = if horiz { + (cross_total, main_total) + } else { + (main_total, cross_total) + }; + if cols > avail { + return Err(Fail::TooWide(cols)); + } + if rows.saturating_mul(cols) > MAX_CELLS { + return Err(Fail::TooBig); + } + let at = |m: usize, c: usize| -> (usize, usize) { + if horiz { + (c, m) + } else { + (m, c) + } + }; + let mut cv = Canvas::new(rows, cols); + + // ── Draw: frames ── + for &c in &frame_list { + let (c0, c1, _, _) = fr(c); + let (m0, m1) = (frame_top[c], frame_bottom[c]); + let (c0, c1) = (c0 as usize, c1 as usize); + let corners = [at(m0, c0), at(m0, c1), at(m1, c1), at(m1, c0), at(m0, c0)]; + cv.path(&corners, LineStyle::Solid, Class::Frame); + } + + // ── Draw: edges ── + let mut markers: Vec<(usize, usize, &'static str)> = Vec::new(); + for (s, sg) in segs.iter().enumerate() { + let e = &g.edges[sg.edge]; + let inf = info(sg.edge); + let (cu, cl) = seg_cols(s); + let gi = lnodes[sg.up].rank; + if e.style == LineStyle::Invisible { + continue; + } + let start = match lnodes[sg.up].kind { + Kind::Real(v) => { + let border = rank_start[gi] + if horiz { boxes[v].w } else { boxes[v].h } - 1; + let mk = end_marker(e.start, e.end, inf.rev, true); + if mk != Marker::None { + markers.push((border + 1, cu, marker_glyph(mk, horiz, false))); + } + if mk != Marker::None || g.nodes[v].shape.is_glyph() { + border + 1 + } else { + border + } + } + _ => rank_start[gi], + }; + let entry = rank_start[gi + 1]; + let end = match lnodes[sg.down].kind { + Kind::Real(v) => { + let mk = end_marker(e.start, e.end, inf.rev, false); + if mk != Marker::None { + markers.push((entry - 1, cl, marker_glyph(mk, horiz, true))); + } + if mk != Marker::None || g.nodes[v].shape.is_glyph() { + entry - 1 + } else { + entry + } + } + _ => entry, + }; + let pts: Vec<(usize, usize)> = if cu == cl { + vec![at(start, cu), at(end, cl)] + } else { + let t = track_start[gi] + track_of[s]; + vec![at(start, cu), at(t, cu), at(t, cl), at(end, cl)] + }; + cv.path(&pts, e.style, Class::Edge); + } + + // ── Draw: nodes ── + for (l, ln) in lnodes.iter().enumerate() { + let Kind::Real(v) = ln.kind else { continue }; + let b = &boxes[v]; + let (r, c) = at(rank_start[ln.rank], xs[l]); + draw_box(&mut cv, r, c, b, g.nodes[v].shape, horiz); + if let Some(lbl) = &b.loop_label { + if !g.nodes[v].shape.is_glyph() { + if horiz { + let (br, bc) = (r + b.h - 1, c); + cv.path( + &[ + (br, bc + 1), + (br + 1, bc + 1), + (br + 1, bc + 3), + (br, bc + 3), + ], + LineStyle::Solid, + Class::Edge, + ); + cv.glyph(br, bc + 3, "▲", Class::Edge); + cv.text(br + 1, bc + 5, lbl, cols, Class::Label); + } else { + let rc = c + b.w - 1; + cv.path( + &[ + (r + 1, rc), + (r + 1, rc + 2), + (r + 2, rc + 2), + (r + 2, rc + 1), + ], + LineStyle::Solid, + Class::Edge, + ); + cv.glyph(r + 2, rc + 1, "◀", Class::Edge); + cv.text(r + 1, rc + 4, lbl, cols, Class::Label); + } + } + } + } + for (m, c, glyph) in markers { + let (r, cc) = at(m, c); + cv.glyph(r, cc, glyph, Class::Edge); + } + + // ── Draw: edge labels ── + for (l, ln) in lnodes.iter().enumerate() { + if ln.kind != Kind::Label { + continue; + } + let lines = &info(ln.edge).label; + let lw = lines.iter().map(|s| text::width(s)).max().unwrap_or(0); + for (i, line) in lines.iter().enumerate() { + let pad = lw - text::width(line); + let body = format!( + "{}{}{}", + " ".repeat(pad / 2), + line, + " ".repeat(pad - pad / 2) + ); + if horiz { + let (r, c) = (xs[l] + i, rank_start[ln.rank]); + cv.text(r, c, &format!(" {body} "), cols, Class::Label); + } else { + let (r, c) = (rank_start[ln.rank] + i, xs[l] + 1); + cv.text(r, c, &body, cols, Class::Label); + } + } + } + + // ── Draw: frame titles ── + for &c in &frame_list { + let (c0, c1, _, _) = fr(c); + let cl = &g.clusters[c]; + let title = if cl.title.is_empty() { + &cl.id + } else { + &cl.title + }; + let title = title.replace('\n', " "); + // Top border first, else the bottom one; on either, the first + // stretch of plain border (edges cross frames) that fits the title, + // else the longest. + let (rows_try, col, room) = if horiz { + ( + [c0 as usize, c1 as usize], + frame_top[c] + 2, + frame_bottom[c].saturating_sub(frame_top[c] + 3), + ) + } else { + ( + [frame_top[c], frame_bottom[c]], + c0 as usize + 2, + ((c1 - c0) as usize).saturating_sub(3), + ) + }; + let want = text::width(&title) + 2; + let mut best = (0usize, col, rows_try[0]); + for r in rows_try { + let mut run_start = col; + for cc in col..=col + room { + if cc < col + room && cv.plain_h(r, cc) { + continue; + } + let len = cc - run_start; + if len > best.0 && best.0 < want { + best = (len, run_start, r); + } + run_start = cc + 1; + } + } + let (len, at, r) = best; + if len >= 3 { + let t = text::truncate(&title, len.min(want) - 2); + cv.text(r, at, &format!(" {t} "), at + len, Class::Title); + } + } + + Ok(Drawn { + rows: cv.render(), + notes, + }) +} + +/// The marker drawn where an edge meets the node at its top end +/// (`at_top`) or bottom end. +fn end_marker(start: Marker, end: Marker, rev: bool, at_top: bool) -> Marker { + // Not reversed: `from` is at the top. + if at_top != rev { + start + } else { + end + } +} + +fn marker_glyph(m: Marker, horiz: bool, forward: bool) -> &'static str { + match (m, horiz, forward) { + (Marker::Arrow, false, true) => "▼", + (Marker::Arrow, false, false) => "▲", + (Marker::Arrow, true, true) => "▶", + (Marker::Arrow, true, false) => "◀", + (Marker::Triangle, false, true) => "▽", + (Marker::Triangle, false, false) => "△", + (Marker::Triangle, true, true) => "▷", + (Marker::Triangle, true, false) => "◁", + (Marker::DiamondFilled, ..) => "◆", + (Marker::DiamondOpen, ..) => "◇", + (Marker::Circle, ..) => "○", + (Marker::Cross, ..) => "×", + (Marker::None, ..) => "", + } +} + +/// Size a node's box and wrap its text. +fn node_box( + shape: Shape, + label: &str, + sections: &[Vec], + wrap: usize, + horiz: bool, + loops: &[String], +) -> NodeBox { + let loop_label = if loops.is_empty() { + None + } else { + let named: Vec<&str> = loops + .iter() + .map(String::as_str) + .filter(|l| !l.is_empty()) + .collect(); + Some(text::truncate(&named.join(", "), wrap.max(6))) + }; + let (w, h, lines, secs) = match shape { + Shape::Start | Shape::End | Shape::Choice => (1, 1, Vec::new(), Vec::new()), + Shape::Bar => { + if horiz { + (1, 3, Vec::new(), Vec::new()) + } else { + (5, 1, Vec::new(), Vec::new()) + } + } + Shape::Sections => { + let lines = text::wrap(label, wrap); + let member_wrap = wrap + wrap / 2; + let secs: Vec> = sections + .iter() + .filter(|s| !s.is_empty()) + .map(|s| { + s.iter() + .flat_map(|row| text::wrap(row, member_wrap)) + .collect() + }) + .collect(); + let tw = lines + .iter() + .chain(secs.iter().flatten()) + .map(|l| text::width(l)) + .max() + .unwrap_or(1); + let th = lines.len() + secs.iter().map(|s| s.len() + 1).sum::(); + (tw + 4, th + 2, lines, secs) + } + _ => { + let lines = text::wrap(label, wrap); + let tw = lines + .iter() + .map(|l| text::width(l)) + .max() + .unwrap_or(1) + .max(1); + let extra = usize::from(shape == Shape::Cylinder); + (tw + 4, lines.len() + 2 + extra, lines, Vec::new()) + } + }; + let (mut w, mut h) = (w, h); + if loop_label.is_some() && !shape.is_glyph() { + if horiz { + w = w.max(5); + } else { + h = h.max(4); + } + } + NodeBox { + w, + h, + lines, + sections: secs, + loop_label, + } +} + +/// Draw a node box with its text at screen position (r, c). +fn draw_box(cv: &mut Canvas, r: usize, c: usize, b: &NodeBox, shape: Shape, horiz: bool) { + let (w, h) = (b.w, b.h); + match shape { + Shape::Start => return cv.glyph(r, c, "●", Class::Node), + Shape::End => return cv.glyph(r, c, "◉", Class::Node), + Shape::Choice => return cv.glyph(r, c, "◇", Class::Node), + Shape::Bar => { + for i in 0..w.max(h) { + if horiz { + cv.glyph(r + i, c, "┃", Class::Node); + } else { + cv.glyph(r, c + i, "━", Class::Node); + } + } + return; + } + _ => {} + } + let (r1, c1) = (r + h - 1, c + w - 1); + if shape == Shape::Subroutine { + for cc in c + 1..c1 { + cv.add_double(r, cc, LEFT | RIGHT, Class::Node); + cv.add_double(r1, cc, LEFT | RIGHT, Class::Node); + } + for rr in r + 1..r1 { + cv.add_double(rr, c, UP | DOWN, Class::Node); + cv.add_double(rr, c1, UP | DOWN, Class::Node); + } + } else { + cv.line(r, c, r, c1, LineStyle::Solid, Class::Node); + cv.line(r1, c, r1, c1, LineStyle::Solid, Class::Node); + cv.line(r, c, r1, c, LineStyle::Solid, Class::Node); + cv.line(r, c1, r1, c1, LineStyle::Solid, Class::Node); + } + let corners: [&str; 4] = match shape { + Shape::Round | Shape::Stadium | Shape::Circle | Shape::Cylinder => ["╭", "╮", "╰", "╯"], + Shape::Diamond | Shape::Hexagon => ["╱", "╲", "╲", "╱"], + Shape::Subroutine => ["╔", "╗", "╚", "╝"], + _ => ["┌", "┐", "└", "┘"], + }; + cv.glyph(r, c, corners[0], Class::Node); + cv.glyph(r, c1, corners[1], Class::Node); + cv.glyph(r1, c, corners[2], Class::Node); + cv.glyph(r1, c1, corners[3], Class::Node); + let mid = r + (h - 1) / 2; + match shape { + Shape::Stadium | Shape::Circle => { + for rr in r + 1..r1 { + cv.glyph(rr, c, "(", Class::Node); + cv.glyph(rr, c1, ")", Class::Node); + } + } + Shape::Diamond | Shape::Hexagon => { + cv.glyph(mid, c, "<", Class::Node); + cv.glyph(mid, c1, ">", Class::Node); + } + Shape::Flag => cv.glyph(mid, c, ">", Class::Node), + Shape::Slant(a, z) => { + for rr in r + 1..r1 { + cv.glyph(rr, c, &a.to_string(), Class::Node); + cv.glyph(rr, c1, &z.to_string(), Class::Node); + } + } + _ => {} + } + let inner = w - 2; + let center = |cv: &mut Canvas, row: usize, s: &str, class: Class| { + let off = (inner.saturating_sub(text::width(s))) / 2; + cv.text(row, c + 1 + off, s, c1, class); + }; + if shape == Shape::Sections { + let mut row = r + 1; + for l in &b.lines { + center(cv, row, l, Class::Title); + row += 1; + } + for sec in &b.sections { + cv.add_bits(row, c, RIGHT, LineStyle::Solid, Class::Node); + cv.add_bits(row, c1, LEFT, LineStyle::Solid, Class::Node); + cv.line(row, c, row, c1, LineStyle::Solid, Class::Node); + row += 1; + for l in sec { + cv.text(row, c + 2, l, c1, Class::Text); + row += 1; + } + } + return; + } + let mut top = r + 1; + let mut interior = h - 2; + if shape == Shape::Cylinder { + cv.line(r + 1, c, r + 1, c1, LineStyle::Solid, Class::Node); + top += 1; + interior -= 1; + } + let start = top + (interior - b.lines.len().min(interior)) / 2; + for (i, l) in b.lines.iter().enumerate() { + center(cv, start + i, l, Class::Text); + } +} + +/// Weighted isotonic regression (pool adjacent violators): the +/// non-decreasing sequence closest to `d` in weighted least squares. +fn pava(d: &[f64], w: &[f64]) -> Vec { + // (weighted sum, total weight, count) + let mut blocks: Vec<(f64, f64, usize)> = Vec::with_capacity(d.len()); + for i in 0..d.len() { + blocks.push((d[i] * w[i], w[i], 1)); + while blocks.len() >= 2 { + let (s1, w1, n1) = blocks[blocks.len() - 1]; + let (s0, w0, n0) = blocks[blocks.len() - 2]; + if s0 / w0 > s1 / w1 { + blocks.pop(); + let last = blocks.len() - 1; + blocks[last] = (s0 + s1, w0 + w1, n0 + n1); + } else { + break; + } + } + } + let mut out = Vec::with_capacity(d.len()); + for (s, w, n) in blocks { + out.extend(std::iter::repeat_n(s / w, n)); + } + out +} + +/// Layout nodes with their sort keys. +type Keyed = Vec<(usize, f64)>; + +/// Sort a rank by key, keeping every subgraph's members contiguous: groups +/// (a subgraph, or a single node outside any) are ordered by their mean key, +/// and members recursively within their group. +fn group_sort(items: &mut Keyed, lnodes: &[LNode], depth: usize) { + if items.len() <= 1 { + return; + } + // Group by cluster at this depth; nodes without one are singletons. + let mut groups: Vec<(Option, Keyed)> = Vec::new(); + for &(l, k) in items.iter() { + let c = lnodes[l].cluster_path.get(depth).copied(); + match c { + Some(c) => match groups.iter_mut().find(|(g, _)| *g == Some(c)) { + Some((_, v)) => v.push((l, k)), + None => groups.push((Some(c), vec![(l, k)])), + }, + None => groups.push((None, vec![(l, k)])), + } + } + let mut keyed: Vec<(f64, usize, Option, Keyed)> = groups + .into_iter() + .enumerate() + .map(|(i, (c, v))| { + let k = v.iter().map(|(_, k)| k).sum::() / v.len() as f64; + (k, i, c, v) + }) + .collect(); + keyed.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + items.clear(); + for (_, _, c, mut v) in keyed { + if c.is_some() && depth < 64 { + group_sort(&mut v, lnodes, depth + 1); + } + items.extend(v); + } +} + +/// Edge crossings between every pair of adjacent ranks. +fn count_crossings(ranks: &[Vec], lnodes: &[LNode], segs: &[Seg], pos: &[usize]) -> usize { + let mut total = 0; + for rk in ranks { + let mut pairs: Vec<(usize, usize)> = Vec::new(); + for &l in rk { + for &s in &lnodes[l].downs { + pairs.push((pos[l], pos[segs[s].down])); + } + } + if pairs.len() < 2 { + continue; + } + pairs.sort_unstable(); + // Count inversions in the lower positions with a Fenwick tree. + let size = pairs.iter().map(|p| p.1).max().unwrap_or(0) + 2; + let mut tree = vec![0usize; size + 1]; + for (seen, &(_, b)) in pairs.iter().enumerate() { + // Number already inserted with lower position <= b. + let mut i = b + 1; + let mut le = 0; + while i > 0 { + le += tree[i]; + i &= i - 1; + } + total += seen - le; + let mut i = b + 1; + while i <= size { + tree[i] += 1; + i += i & i.wrapping_neg(); + } + } + } + total +} diff --git a/src/layout/mermaid/flowchart.rs b/src/layout/mermaid/flowchart.rs new file mode 100644 index 0000000..1c71e41 --- /dev/null +++ b/src/layout/mermaid/flowchart.rs @@ -0,0 +1,605 @@ +//! Parser for `graph` / `flowchart` diagrams. + +use super::graph::{Dir, Graph, LineStyle, Marker, Shape}; +use super::text::decode_label; + +/// Parse a flowchart. `header` is the rest of the first line after the +/// `graph`/`flowchart` keyword (the direction); `body` the remaining lines. +pub fn parse(header: &str, body: &[String]) -> Graph { + let dir = header + .split_whitespace() + .next() + .and_then(Dir::parse) + .unwrap_or(Dir::Down); + let mut g = Graph::new(dir); + let mut stack: Vec = Vec::new(); + // A header may carry a statement after `;` (`graph TD; A-->B`). + let mut lines: Vec = Vec::new(); + if let Some((_, rest)) = header.split_once(';') { + lines.push(rest.to_string()); + } + lines.extend(body.iter().cloned()); + for line in &lines { + for stmt in split_statements(line) { + statement(&mut g, &mut stack, stmt.trim()); + } + } + g.redirect_cluster_edges(); + g +} + +/// Split a line on `;` outside quotes, brackets and `|edge labels|`. +fn split_statements(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut depth = 0i32; + let mut quote = false; + let mut pipe = false; + for c in line.chars() { + match c { + '"' => quote = !quote, + '|' if !quote && depth == 0 => pipe = !pipe, + '[' | '(' | '{' if !quote && !pipe => depth += 1, + ']' | ')' | '}' if !quote && !pipe => depth = (depth - 1).max(0), + ';' if !quote && !pipe && depth == 0 => { + out.push(std::mem::take(&mut cur)); + continue; + } + _ => {} + } + cur.push(c); + } + out.push(cur); + out +} + +fn keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> { + let rest = s.strip_prefix(kw)?; + if rest.is_empty() || rest.starts_with(char::is_whitespace) { + Some(rest.trim()) + } else { + None + } +} + +fn statement(g: &mut Graph, stack: &mut Vec, s: &str) { + if s.is_empty() || s.starts_with("%%") { + return; + } + if let Some(rest) = keyword(s, "subgraph") { + let (id, title) = subgraph_title(rest); + let c = g.add_cluster(&id, &title, stack.last().copied()); + stack.push(c); + return; + } + if s == "end" { + stack.pop(); + return; + } + for kw in [ + "direction", + "classDef", + "class", + "style", + "linkStyle", + "click", + "accTitle", + "accDescr", + ] { + if keyword(s, kw).is_some() || s.starts_with(&format!("{kw}:")) { + return; + } + } + chain(g, stack.last().copied(), s); +} + +/// `id`, `id [Title]`, `id["Title"]`, `"Title"`, or `Several words`. +fn subgraph_title(rest: &str) -> (String, String) { + let rest = rest.trim(); + if rest.starts_with('"') { + let t = decode_label(rest); + return (t.clone(), t); + } + if let Some(open) = rest.find('[') { + let id = rest[..open].trim().to_string(); + let inner = rest[open + 1..].trim_end().trim_end_matches(']'); + return (id, decode_label(inner)); + } + let id = rest.split_whitespace().next().unwrap_or("").to_string(); + (id, decode_label(rest)) +} + +struct Cursor<'a> { + s: &'a [char], + i: usize, +} + +impl Cursor<'_> { + fn peek(&self) -> Option { + self.s.get(self.i).copied() + } + fn at(&self, k: usize) -> Option { + self.s.get(self.i + k).copied() + } + fn starts(&self, pat: &str) -> bool { + pat.chars() + .enumerate() + .all(|(k, c)| self.s.get(self.i + k) == Some(&c)) + } + fn skip_ws(&mut self) { + while self.peek().is_some_and(char::is_whitespace) { + self.i += 1; + } + } + fn rest(&self) -> String { + self.s[self.i.min(self.s.len())..].iter().collect() + } + /// Text up to the first occurrence of `pat`, consuming the pattern too. + fn until(&mut self, pat: &str) -> Option { + let start = self.i; + while self.i < self.s.len() { + if self.starts(pat) { + let t: String = self.s[start..self.i].iter().collect(); + self.i += pat.chars().count(); + return Some(t); + } + self.i += 1; + } + self.i = start; + None + } +} + +fn is_id_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || (!c.is_ascii() && !c.is_whitespace() && !c.is_control()) +} + +struct NodeRef { + id: String, + shape: Option<(String, Shape)>, +} + +struct Link { + style: LineStyle, + start: Marker, + end: Marker, + label: String, +} + +fn chain(g: &mut Graph, cluster: Option, s: &str) { + let chars: Vec = s.chars().collect(); + let mut cur = Cursor { s: &chars, i: 0 }; + let mut prev = node_group(&mut cur); + if prev.is_empty() { + return; + } + let mut prev_ids: Vec = prev.drain(..).map(|n| register(g, cluster, n)).collect(); + loop { + cur.skip_ws(); + let Some(link) = link(&mut cur) else { break }; + let next = node_group(&mut cur); + if next.is_empty() { + break; + } + let ids: Vec = next.into_iter().map(|n| register(g, cluster, n)).collect(); + for &a in &prev_ids { + for &b in &ids { + g.add_edge(a, b, &link.label, link.style, link.start, link.end); + } + } + prev_ids = ids; + } +} + +fn register(g: &mut Graph, cluster: Option, n: NodeRef) -> usize { + match n.shape { + Some((label, shape)) => g.declare(&n.id, &label, shape, cluster), + None => g.touch(&n.id, cluster), + } +} + +fn node_group(cur: &mut Cursor) -> Vec { + let mut out = Vec::new(); + loop { + cur.skip_ws(); + let Some(n) = node(cur) else { break }; + out.push(n); + cur.skip_ws(); + if cur.peek() == Some('&') { + cur.i += 1; + continue; + } + break; + } + out +} + +fn node(cur: &mut Cursor) -> Option { + let start = cur.i; + while let Some(c) = cur.peek() { + let dash_in_id = + c == '-' && cur.i > start && cur.at(1).is_some_and(|n| n.is_alphanumeric()); + if is_id_char(c) || dash_in_id { + cur.i += 1; + } else { + break; + } + } + // A lone `o`/`x` right before a link body is a link head, not a node. + if cur.i == start { + return None; + } + let id: String = cur.s[start..cur.i].iter().collect(); + let shapes: [(&str, &str, Shape); 13] = [ + ("(((", ")))", Shape::Circle), + ("((", "))", Shape::Circle), + ("([", "])", Shape::Stadium), + ("[[", "]]", Shape::Subroutine), + ("[(", ")]", Shape::Cylinder), + ("{{", "}}", Shape::Hexagon), + ("[/", "/]", Shape::Slant('/', '/')), + ("[\\", "\\]", Shape::Slant('\\', '\\')), + ("(", ")", Shape::Round), + ("[", "]", Shape::Rect), + ("{", "}", Shape::Diamond), + (">", "]", Shape::Flag), + ("@{", "}", Shape::Rect), + ]; + let mut shape = None; + for (open, close, sh) in shapes { + if !cur.starts(open) { + continue; + } + cur.i += open.chars().count(); + if open == "@{" { + let body = cur.until("}").unwrap_or_else(|| { + let r = cur.rest(); + cur.i = cur.s.len(); + r + }); + shape = Some(shape_data(&body, &id)); + break; + } + let label = if cur.peek() == Some('"') { + cur.i += 1; + let q = cur.until("\"").unwrap_or_default(); + // Skip to the closer. + let _ = cur.until(close); + q + } else if matches!(sh, Shape::Slant(..)) { + // `[/text\]` and `[\text/]` are trapezoids. + let other = if open == "[/" { "\\]" } else { "/]" }; + let save = cur.i; + let a = cur.until(close).map(|t| (t, cur.i)); + cur.i = save; + let b = cur.until(other).map(|t| (t, cur.i)); + match (a, b) { + (Some((t, ia)), Some((u, ib))) => { + if ia <= ib { + cur.i = ia; + t + } else { + cur.i = ib; + let l = open.chars().nth(1).unwrap_or('/'); + let r = other.chars().next().unwrap_or('\\'); + shape = Some((decode_label(&u), Shape::Slant(l, r))); + break; + } + } + (Some((t, ia)), None) => { + cur.i = ia; + t + } + (None, Some((u, ib))) => { + cur.i = ib; + let l = open.chars().nth(1).unwrap_or('/'); + let r = other.chars().next().unwrap_or('\\'); + shape = Some((decode_label(&u), Shape::Slant(l, r))); + break; + } + (None, None) => { + let r = cur.rest(); + cur.i = cur.s.len(); + r + } + } + } else { + match cur.until(close) { + Some(t) => t, + None => { + let r = cur.rest(); + cur.i = cur.s.len(); + r + } + } + }; + shape = Some((decode_label(&label), sh)); + break; + } + // `:::className` + if cur.starts(":::") { + cur.i += 3; + while cur.peek().is_some_and(|c| is_id_char(c) || c == '-') { + cur.i += 1; + } + } + Some(NodeRef { id, shape }) +} + +/// `@{ shape: diam, label: "Text" }` +fn shape_data(body: &str, id: &str) -> (String, Shape) { + let mut label = id.to_string(); + let mut shape = Shape::Rect; + for part in body.split(',') { + let Some((k, v)) = part.split_once(':') else { + continue; + }; + let v = v.trim(); + match k.trim() { + "label" => label = decode_label(v), + "shape" => { + shape = match v.trim_matches('"') { + "rounded" | "event" => Shape::Round, + "stadium" | "pill" | "terminal" => Shape::Stadium, + "circle" | "circ" | "dbl-circ" | "double-circle" => Shape::Circle, + "diam" | "diamond" | "decision" | "question" => Shape::Diamond, + "hex" | "hexagon" | "prepare" => Shape::Hexagon, + "cyl" | "db" | "database" | "cylinder" => Shape::Cylinder, + "subproc" | "subprocess" | "subroutine" | "fr-rect" => Shape::Subroutine, + _ => Shape::Rect, + } + } + _ => {} + } + } + (label, shape) +} + +/// A link token (`-->`, `-.->`, `==>`, `--o`, `<-->`, `-- text -->`, …) +/// with an optional `|label|`. +fn link(cur: &mut Cursor) -> Option { + let save = cur.i; + let mut start = Marker::None; + match cur.peek() { + Some('<') => { + start = Marker::Arrow; + cur.i += 1; + } + Some('o') if matches!(cur.at(1), Some('-' | '=' | '.')) => { + start = Marker::Circle; + cur.i += 1; + } + Some('x') if matches!(cur.at(1), Some('-' | '=' | '.')) => { + start = Marker::Cross; + cur.i += 1; + } + _ => {} + } + let body_start = cur.i; + while matches!(cur.peek(), Some('-' | '=' | '.' | '~')) { + cur.i += 1; + } + let body: String = cur.s[body_start..cur.i].iter().collect(); + if body.chars().count() < 2 || body.starts_with('.') { + cur.i = save; + return None; + } + let style_of = |b: &str| { + if b.contains('~') { + LineStyle::Invisible + } else if b.contains('=') { + LineStyle::Thick + } else if b.contains('.') { + LineStyle::Dotted + } else { + LineStyle::Solid + } + }; + let mut style = style_of(&body); + let mut label = String::new(); + let mut end = head(cur); + // `-- text -->`, `-. text .->`, `== text ==>` + if end == Marker::None + && matches!(body.as_str(), "--" | "==" | "-.") + && cur.peek().is_some_and(char::is_whitespace) + { + let closers: &[&str] = match body.as_str() { + "--" => &["-->", "---", "--o", "--x"], + "==" => &["==>", "===", "==o", "==x"], + _ => &[".->", ".-"], + }; + let text_start = cur.i; + let mut found = None; + let mut k = cur.i; + 'scan: while k < cur.s.len() { + for c in closers { + let probe = Cursor { s: cur.s, i: k }; + if probe.starts(c) { + found = Some(k); + break 'scan; + } + } + k += 1; + } + if let Some(k) = found { + label = cur.s[text_start..k].iter().collect::(); + cur.i = k; + while matches!(cur.peek(), Some('-' | '=' | '.')) { + cur.i += 1; + } + end = head(cur); + if body == "-." { + style = LineStyle::Dotted; + } + } + } + cur.skip_ws(); + if cur.peek() == Some('|') { + cur.i += 1; + if let Some(t) = cur.until("|") { + label = t; + } + } + Some(Link { + style, + start, + end, + label: decode_label(&label), + }) +} + +fn head(cur: &mut Cursor) -> Marker { + match cur.peek() { + Some('>') => { + cur.i += 1; + Marker::Arrow + } + Some(c @ ('o' | 'x')) if !cur.at(1).is_some_and(is_id_char) => { + cur.i += 1; + if c == 'o' { + Marker::Circle + } else { + Marker::Cross + } + } + _ => Marker::None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_src(src: &str) -> Graph { + let mut lines = src.lines(); + let header = lines.next().unwrap_or(""); + let header = header.split_once(' ').map_or("", |(_, r)| r); + let body: Vec = lines.map(str::to_string).collect(); + parse(header, &body) + } + + fn edges(g: &Graph) -> Vec<(String, String, String)> { + g.edges + .iter() + .map(|e| { + ( + g.nodes[e.from].id.clone(), + g.nodes[e.to].id.clone(), + e.label.clone(), + ) + }) + .collect() + } + + #[test] + fn shapes_and_labels() { + let g = parse_src( + "graph TD\nA[rect] --> B(round)\nC([stadium]) --> D{diamond}\nE((circle)) --> F>flag]\nG[[sub]] --> H[(db)]\nI{{hex}} --> J[/para/]\nK[/trap\\] --> L[\"quoted [x] (y)\"]", + ); + let shapes: Vec<(String, String, Shape)> = g + .nodes + .iter() + .map(|n| (n.id.clone(), n.label.clone(), n.shape)) + .collect(); + assert_eq!(shapes[0], ("A".into(), "rect".into(), Shape::Rect)); + assert_eq!(shapes[1], ("B".into(), "round".into(), Shape::Round)); + assert_eq!(shapes[2], ("C".into(), "stadium".into(), Shape::Stadium)); + assert_eq!(shapes[3], ("D".into(), "diamond".into(), Shape::Diamond)); + assert_eq!(shapes[4], ("E".into(), "circle".into(), Shape::Circle)); + assert_eq!(shapes[5], ("F".into(), "flag".into(), Shape::Flag)); + assert_eq!(shapes[6], ("G".into(), "sub".into(), Shape::Subroutine)); + assert_eq!(shapes[7], ("H".into(), "db".into(), Shape::Cylinder)); + assert_eq!(shapes[8], ("I".into(), "hex".into(), Shape::Hexagon)); + assert_eq!( + shapes[9], + ("J".into(), "para".into(), Shape::Slant('/', '/')) + ); + assert_eq!( + shapes[10], + ("K".into(), "trap".into(), Shape::Slant('/', '\\')) + ); + assert_eq!( + shapes[11], + ("L".into(), "quoted [x] (y)".into(), Shape::Rect) + ); + } + + #[test] + fn link_kinds() { + let g = parse_src( + "flowchart LR\nA --> B\nB --- C\nC -.-> D\nD ==> E\nE --o F\nF --x G\nG <--> H\nH -->|yes| I\nI -- some text --> J\nJ -. dotted .-> K\nK == thick ==> L\nL ~~~ M", + ); + assert_eq!(g.dir, Dir::Right); + let e = &g.edges; + assert_eq!((e[0].style, e[0].end), (LineStyle::Solid, Marker::Arrow)); + assert_eq!((e[1].style, e[1].end), (LineStyle::Solid, Marker::None)); + assert_eq!((e[2].style, e[2].end), (LineStyle::Dotted, Marker::Arrow)); + assert_eq!((e[3].style, e[3].end), (LineStyle::Thick, Marker::Arrow)); + assert_eq!(e[4].end, Marker::Circle); + assert_eq!(e[5].end, Marker::Cross); + assert_eq!((e[6].start, e[6].end), (Marker::Arrow, Marker::Arrow)); + assert_eq!(e[7].label, "yes"); + assert_eq!( + (e[8].label.as_str(), e[8].end), + ("some text", Marker::Arrow) + ); + assert_eq!( + (e[9].label.as_str(), e[9].style), + ("dotted", LineStyle::Dotted) + ); + assert_eq!( + (e[10].label.as_str(), e[10].style), + ("thick", LineStyle::Thick) + ); + assert_eq!(e[11].style, LineStyle::Invisible); + assert_eq!(g.nodes.len(), 13); + } + + #[test] + fn chains_groups_subgraphs_and_ignored_lines() { + let g = parse_src( + "graph TB\n%% a comment\nclassDef red fill:#f00\nA --> B --> C\nA & B --> D & E\nsubgraph one [First One]\n X --> Y\n subgraph two\n Z\n end\nend\nstyle A fill:#f9f\nclick A callback\nlinkStyle 0 stroke:#f00\nclass A red\nA:::red --> X", + ); + assert_eq!( + edges(&g), + vec![ + ("A".into(), "B".into(), "".into()), + ("B".into(), "C".into(), "".into()), + ("A".into(), "D".into(), "".into()), + ("A".into(), "E".into(), "".into()), + ("B".into(), "D".into(), "".into()), + ("B".into(), "E".into(), "".into()), + ("X".into(), "Y".into(), "".into()), + ("A".into(), "X".into(), "".into()), + ] + ); + assert_eq!(g.clusters.len(), 2); + assert_eq!(g.clusters[0].title, "First One"); + assert_eq!(g.clusters[1].parent, Some(0)); + let z = g.find("Z").unwrap(); + assert_eq!(g.nodes[z].cluster, Some(1)); + assert_eq!(g.nodes[g.find("X").unwrap()].cluster, Some(0)); + assert_eq!(g.nodes[g.find("A").unwrap()].cluster, None); + } + + #[test] + fn semicolons_and_breaks() { + let g = parse_src("graph TD;\nA[\"one
two\"]-->B;B-->C;"); + assert_eq!(g.nodes[0].label, "one\ntwo"); + assert_eq!(g.edges.len(), 2); + } + + #[test] + fn edges_to_a_subgraph_attach_to_its_members() { + let g = parse_src("graph TD\nsubgraph S\nA --> B\nend\nX --> S\nS --> Y"); + assert!(g.find("S").is_none()); + assert_eq!( + edges(&g), + vec![ + ("A".into(), "B".into(), "".into()), + ("X".into(), "A".into(), "".into()), + ("B".into(), "Y".into(), "".into()), + ] + ); + } +} diff --git a/src/layout/mermaid/graph.rs b/src/layout/mermaid/graph.rs new file mode 100644 index 0000000..8ff4b1c --- /dev/null +++ b/src/layout/mermaid/graph.rs @@ -0,0 +1,317 @@ +//! The graph model every node-and-edge diagram (flowchart, state, class, +//! ER) is parsed into, and that the layered layout engine draws. + +use std::collections::BTreeMap; + +/// Which way the ranks run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dir { + /// Top to bottom (`TD`, `TB`). + Down, + /// Bottom to top (`BT`). + Up, + /// Left to right (`LR`). + Right, + /// Right to left (`RL`). + Left, +} + +impl Dir { + /// Parse a mermaid direction token (`TD`, `TB`, `BT`, `LR`, `RL`, + /// and the arrow forms `v ^ > <`). + pub fn parse(tok: &str) -> Option { + Some( + match tok + .trim() + .trim_end_matches(';') + .to_ascii_uppercase() + .as_str() + { + "TD" | "TB" | "V" => Dir::Down, + "BT" | "^" => Dir::Up, + "LR" | ">" => Dir::Right, + "RL" | "<" => Dir::Left, + _ => return None, + }, + ) + } + + /// Ranks advance horizontally. + pub fn horizontal(self) -> bool { + matches!(self, Dir::Right | Dir::Left) + } + + /// Ranks are laid out in reverse (`BT`, `RL`). + pub fn reversed(self) -> bool { + matches!(self, Dir::Up | Dir::Left) + } +} + +/// How a node is drawn. +#[allow(dead_code)] // glyph shapes arrive with state diagrams +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shape { + Rect, + Round, + Stadium, + Circle, + Diamond, + Hexagon, + Subroutine, + Cylinder, + /// `>text]` + Flag, + /// Parallelograms and trapezoids, with their left and right side glyphs. + Slant(char, char), + /// A box split into compartments (class and ER entities): the first + /// section is the centred title, the others left-aligned rows. + Sections, + /// State-diagram start `[*]`: a single `●`. + Start, + /// State-diagram end `[*]`: a single `◉`. + End, + /// `<>`: a single `◇`. + Choice, + /// `<>` / `<>`: a short bar. + Bar, +} + +impl Shape { + /// Drawn as a bare glyph rather than a bordered box. + pub fn is_glyph(self) -> bool { + matches!(self, Shape::Start | Shape::End | Shape::Choice | Shape::Bar) + } +} + +/// Line style of an edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LineStyle { + Solid, + Dotted, + Thick, + /// `~~~`: affects layout, not drawn. + Invisible, +} + +/// What an edge end looks like where it meets its node. +#[allow(dead_code)] // relationship markers arrive with class diagrams +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Marker { + None, + Arrow, + Circle, + Cross, + /// Hollow triangle: inheritance / realization. + Triangle, + /// Filled diamond: composition. + DiamondFilled, + /// Hollow diamond: aggregation. + DiamondOpen, +} + +#[derive(Debug, Clone)] +pub struct Node { + pub id: String, + /// Display text; `\n` separates lines. + pub label: String, + pub shape: Shape, + /// For [`Shape::Sections`]: the compartments after the title section + /// (the title is `label`). Each is a list of rows. + pub sections: Vec>, + /// Innermost subgraph this node belongs to. + pub cluster: Option, + /// The node's shape or label was given explicitly (not just mentioned). + pub declared: bool, +} + +#[derive(Debug, Clone)] +pub struct Edge { + pub from: usize, + pub to: usize, + pub label: String, + pub style: LineStyle, + /// Marker at the `from` end. + pub start: Marker, + /// Marker at the `to` end. + pub end: Marker, + /// Text cardinalities at the (`from`, `to`) ends (class and ER). + pub cards: Option<(String, String)>, +} + +#[derive(Debug, Clone)] +pub struct Cluster { + pub id: String, + pub title: String, + pub parent: Option, +} + +/// A parsed diagram ready for layout. +#[derive(Debug, Clone)] +pub struct Graph { + pub dir: Dir, + pub nodes: Vec, + pub edges: Vec, + pub clusters: Vec, + /// Lines shown under the diagram (notes, legends). + pub notes: Vec, + index: BTreeMap, +} + +impl Graph { + pub fn new(dir: Dir) -> Self { + Graph { + dir, + nodes: Vec::new(), + edges: Vec::new(), + clusters: Vec::new(), + notes: Vec::new(), + index: BTreeMap::new(), + } + } + + pub fn find(&self, id: &str) -> Option { + self.index.get(id).copied() + } + + /// The node with `id`, created (labelled with its id) on first mention. + /// `cluster` is the subgraph the mention appears in: a node belongs to + /// the first subgraph it is mentioned inside. + pub fn touch(&mut self, id: &str, cluster: Option) -> usize { + if let Some(&i) = self.index.get(id) { + if self.nodes[i].cluster.is_none() { + self.nodes[i].cluster = cluster; + } + return i; + } + let i = self.nodes.len(); + self.nodes.push(Node { + id: id.to_string(), + label: id.to_string(), + shape: Shape::Rect, + sections: Vec::new(), + cluster, + declared: false, + }); + self.index.insert(id.to_string(), i); + i + } + + /// Mention `id` and set its label and shape. + pub fn declare( + &mut self, + id: &str, + label: &str, + shape: Shape, + cluster: Option, + ) -> usize { + let i = self.touch(id, cluster); + self.nodes[i].label = label.to_string(); + self.nodes[i].shape = shape; + self.nodes[i].declared = true; + i + } + + pub fn add_edge( + &mut self, + from: usize, + to: usize, + label: &str, + style: LineStyle, + start: Marker, + end: Marker, + ) { + self.edges.push(Edge { + from, + to, + label: label.to_string(), + style, + start, + end, + cards: None, + }); + } + + pub fn add_cluster(&mut self, id: &str, title: &str, parent: Option) -> usize { + self.clusters.push(Cluster { + id: id.to_string(), + title: title.to_string(), + parent, + }); + self.clusters.len() - 1 + } + + /// Ancestors of a cluster from the outermost down to `c` itself. + pub fn cluster_path(&self, c: Option) -> Vec { + let mut path = Vec::new(); + let mut cur = c; + while let Some(i) = cur { + if path.len() > 64 || path.contains(&i) { + break; + } + path.push(i); + cur = self.clusters.get(i).and_then(|k| k.parent); + } + path.reverse(); + path + } + + /// Edges that name a subgraph (`A --> sub1`) attach to the subgraph's + /// first member (or, for edges leaving it, its last): there is no node + /// to draw for a subgraph id. Nodes only ever mentioned by such an id + /// are dropped. + pub fn redirect_cluster_edges(&mut self) { + let mut redirect: BTreeMap = BTreeMap::new(); + for (ci, cl) in self.clusters.iter().enumerate() { + let Some(n) = self.find(&cl.id) else { continue }; + if self.nodes[n].declared { + continue; + } + let members: Vec = (0..self.nodes.len()) + .filter(|&m| m != n && self.cluster_path(self.nodes[m].cluster).contains(&ci)) + .collect(); + if let (Some(&first), Some(&last)) = (members.first(), members.last()) { + redirect.insert(n, (first, last)); + } + } + if redirect.is_empty() { + return; + } + for e in &mut self.edges { + if let Some(&(_, last)) = redirect.get(&e.from) { + e.from = last; + } + if let Some(&(first, _)) = redirect.get(&e.to) { + e.to = first; + } + } + self.remove_nodes(&redirect.keys().copied().collect::>()); + } + + /// Remove nodes (and any edges touching them), renumbering the rest. + pub fn remove_nodes(&mut self, gone: &[usize]) { + if gone.is_empty() { + return; + } + let mut map = vec![usize::MAX; self.nodes.len()]; + let mut kept = Vec::new(); + for (i, n) in self.nodes.drain(..).enumerate() { + if !gone.contains(&i) { + map[i] = kept.len(); + kept.push(n); + } + } + self.nodes = kept; + self.edges + .retain(|e| map[e.from] != usize::MAX && map[e.to] != usize::MAX); + for e in &mut self.edges { + e.from = map[e.from]; + e.to = map[e.to]; + } + self.index = self + .nodes + .iter() + .enumerate() + .map(|(i, n)| (n.id.clone(), i)) + .collect(); + } +} diff --git a/src/layout/mermaid.rs b/src/layout/mermaid/mod.rs similarity index 54% rename from src/layout/mermaid.rs rename to src/layout/mermaid/mod.rs index 478596c..cadb9ee 100644 --- a/src/layout/mermaid.rs +++ b/src/layout/mermaid/mod.rs @@ -1,18 +1,52 @@ +//! Mermaid diagrams drawn as terminal text. +//! +//! Flowcharts, state, class and ER diagrams are parsed into one graph model +//! ([`graph`]) and drawn by a layered layout engine ([`engine`]) on a +//! character grid ([`canvas`]). Sequence diagrams, pie and Gantt charts and +//! mindmaps have renderers of their own; other diagram types are shown as +//! their source in a labelled box. +//! +//! Every renderer must fit the width it is given, finish quickly on hostile +//! input, and produce the same output for the same input. + +mod canvas; +mod engine; +mod flowchart; +mod graph; +mod text; + +#[cfg(test)] +mod tests; + use super::{SpanStyle, StyledLine, StyledSpan}; use crate::theme::Theme; +use canvas::Class; +use graph::{Dir, Graph, Marker}; use unicode_width::UnicodeWidthStr; /// Parse and render a mermaid diagram as styled terminal text. -/// This is a built-in ASCII renderer for common diagram types. pub fn render_mermaid(source: &str, theme: &Theme, width: usize, margin: usize) -> Vec { + render_mermaid_with(source, theme, width, margin, crate::glyphs::current().ascii) +} + +/// [`render_mermaid`] with the glyph set given explicitly (`ascii`: draw with +/// 7-bit stand-ins). For tests; the reader uses the process-wide setting. +#[doc(hidden)] +pub fn render_mermaid_with( + source: &str, + theme: &Theme, + width: usize, + margin: usize, + ascii: bool, +) -> Vec { let mut lines = render_diagram(source, theme, width, margin); // The diagram renderers draw with box and arrow characters; in ASCII // mode translate them once here rather than in every renderer. (Each // stand-in has the same width, except the note icon, which ends a line.) - if crate::glyphs::current().ascii { + if ascii { for line in &mut lines { for span in &mut line.spans { - if let std::borrow::Cow::Owned(t) = crate::glyphs::asciify(&span.text) { + if let std::borrow::Cow::Owned(t) = asciify(&span.text) { span.text = t; } } @@ -21,8 +55,110 @@ pub fn render_mermaid(source: &str, theme: &Theme, width: usize, margin: usize) lines } +/// ASCII stand-ins for every drawing character the diagram renderers emit +/// (each the same width as what it replaces), on top of the shared set in +/// [`crate::glyphs::asciify`]. +fn asciify(text: &str) -> std::borrow::Cow<'_, str> { + fn stand_in(c: char) -> Option { + Some(match c { + '─' | '┄' | '╌' => '-', + '━' | '═' => '=', + '│' | '┃' | '║' => '|', + '┆' => ':', + '┌' | '┐' | '└' | '┘' | '├' | '┤' | '┬' | '┴' | '┼' | '╭' | '╮' | '╰' | '╯' | '╔' + | '╗' | '╚' | '╝' | '╤' | '╧' | '╪' | '╟' | '╢' | '╫' => '+', + '╱' => '/', + '╲' => '\\', + '▼' | '▽' => 'v', + '▲' | '△' => '^', + '▶' | '▷' => '>', + '◀' | '◁' => '<', + '◆' | '●' | '•' | '◦' => '*', + '◇' | '○' => 'o', + '◉' => '@', + '×' => 'x', + '█' | '▌' | '▎' => '#', + '▓' => '=', + '░' => '.', + '…' => '~', + '·' => '.', + _ => return None, + }) + } + let mapped: std::borrow::Cow<'_, str> = if text.chars().any(|c| stand_in(c).is_some()) { + std::borrow::Cow::Owned(text.chars().map(|c| stand_in(c).unwrap_or(c)).collect()) + } else { + std::borrow::Cow::Borrowed(text) + }; + match crate::glyphs::asciify(&mapped) { + std::borrow::Cow::Owned(s) => std::borrow::Cow::Owned(s), + std::borrow::Cow::Borrowed(_) => mapped, + } +} + +/// The parts of a diagram's source: an optional frontmatter title, the +/// keyword on its first line, the rest of that line, and the body lines. +struct Source { + title: Option, + keyword: String, + header: String, + body: Vec, +} + +fn split_source(src: &str) -> Source { + let mut lines = src.lines().peekable(); + let mut title = None; + // Skip blank lines and directives before a frontmatter block. + while lines + .peek() + .is_some_and(|l| l.trim().is_empty() || l.trim().starts_with("%%")) + { + lines.next(); + } + if lines.peek().is_some_and(|l| l.trim() == "---") { + lines.next(); + for l in lines.by_ref() { + let t = l.trim(); + if t == "---" { + break; + } + if let Some(v) = t.strip_prefix("title:") { + title = Some(text::decode_label(v)); + } + } + } + let mut keyword = String::new(); + let mut header = String::new(); + let mut body = Vec::new(); + for l in lines { + let t = l.trim(); + if keyword.is_empty() { + if t.is_empty() || t.starts_with("%%") { + continue; + } + let (k, rest) = t.split_once(char::is_whitespace).unwrap_or((t, "")); + // `graph TD;` / `graph;` + let (k, rest) = match k.split_once(';') { + Some((k, r)) => (k, format!(";{r} {rest}")), + None => (k, rest.to_string()), + }; + keyword = k.to_string(); + header = rest.trim().to_string(); + continue; + } + body.push(l.to_string()); + } + Source { + title, + keyword, + header, + body, + } +} + fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> Vec { - let trimmed = source.trim(); + let cleaned = text::clean_source(source); + let src = split_source(&cleaned); let diagram_color = &theme.colors.heading2; let text_color = &theme.colors.code_fg; let border_color = &theme.colors.table_border; @@ -31,194 +167,287 @@ fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> V let margin_str = " ".repeat(margin); let chart_width = width.min(56); + // The legacy renderers below read the source from its first line. + let legacy = || { + let mut s = format!("{} {}\n", src.keyword, src.header); + for l in &src.body { + s.push_str(l); + s.push('\n'); + } + s + }; - // Detect diagram type - if trimmed.starts_with("graph") || trimmed.starts_with("flowchart") { - render_flowchart( - trimmed, - diagram_color, - text_color, - border_color, - arrow_color, - &margin_str, - chart_width, - ) - } else if trimmed.starts_with("sequenceDiagram") { - render_sequence( - trimmed, + if let Some((g, title)) = parse_graph(&src) { + return render_graph(&g, &title, theme, width, margin); + } + match src.keyword.as_str() { + "sequenceDiagram" => render_sequence( + &legacy(), diagram_color, text_color, border_color, arrow_color, &margin_str, chart_width, - ) - } else if trimmed.starts_with("pie") { - render_pie(trimmed, theme, &margin_str, chart_width) - } else if trimmed.starts_with("gantt") { - render_gantt(trimmed, theme, &margin_str, chart_width) - } else { - render_unknown( - trimmed, + ), + "pie" => render_pie(&legacy(), theme, &margin_str, chart_width), + "gantt" => render_gantt(&legacy(), theme, &margin_str, chart_width), + _ => render_unknown( + &legacy(), border_color, text_color, diagram_color, &margin_str, chart_width, - ) + ), } } -fn render_flowchart( - source: &str, - diagram_color: &str, - text_color: &str, - border_color: &str, - arrow_color: &str, - margin: &str, - width: usize, -) -> Vec { - let mut lines = Vec::new(); - let mut nodes: Vec<(String, String)> = Vec::new(); // (id, label) - let mut edges: Vec<(String, String, String)> = Vec::new(); // (from, to, label) - - for raw_line in source.lines().skip(1) { - let l = raw_line.trim(); - if l.is_empty() { - continue; +/// Parse a node-and-edge diagram into the graph model, with its title. +fn parse_graph(src: &Source) -> Option<(Graph, String)> { + let (g, kind) = match src.keyword.as_str() { + "graph" | "flowchart" | "flowchart-elk" => { + (flowchart::parse(&src.header, &src.body), "flowchart") } + _ => return None, + }; + Some((g, src.title.clone().unwrap_or_else(|| kind.to_string()))) +} - // Parse edges: A --> B, A -->|text| B, A --- B - if let Some((from_part, rest)) = split_arrow(l) { - let from_id = extract_node_id(&from_part); - let from_label = extract_node_label(&from_part); - let (edge_label, to_part) = extract_edge_label(rest); - let to_id = extract_node_id(&to_part); - let to_label = extract_node_label(&to_part); - - // Register nodes - if !from_id.is_empty() && !nodes.iter().any(|(id, _)| id == &from_id) { - nodes.push((from_id.clone(), from_label)); - } - if !to_id.is_empty() && !nodes.iter().any(|(id, _)| id == &to_id) { - nodes.push((to_id.clone(), to_label)); - } - edges.push((from_id, to_id, edge_label)); - } else if !l.starts_with("graph") && !l.starts_with("flowchart") { - // Standalone node definition - let id = extract_node_id(l); - let label = extract_node_label(l); - if !id.is_empty() && !nodes.iter().any(|(nid, _)| nid == &id) { - nodes.push((id, label)); - } - } +/// Colour and attributes for a canvas cell class. +fn class_style(theme: &Theme, class: Class) -> SpanStyle { + let c = &theme.colors; + let (fg, bold, italic) = match class { + Class::Frame => (&c.table_border, false, false), + Class::Edge => (&c.heading3, false, false), + Class::Node => (&c.heading2, false, false), + Class::Text => (&c.code_fg, true, false), + Class::Label => (&c.code_fg, false, true), + Class::Title => (&c.heading2, true, false), + }; + SpanStyle { + fg: Some(fg.clone()), + bold, + italic, + ..Default::default() } +} - lines.push(make_header( - "flowchart", - border_color, - diagram_color, - margin, - width, - )); +/// Lay out a node-and-edge diagram, trying narrower label wraps and tighter +/// spacing until it fits; `LR`/`RL` diagrams that cannot fit are drawn top +/// down, and a diagram that still cannot fit (or is too big to lay out) is +/// listed edge by edge. +fn render_graph( + g: &Graph, + title: &str, + theme: &Theme, + width: usize, + margin: usize, +) -> Vec { + if g.nodes.is_empty() { + let mut lines = framed(title, &[], &g.notes, theme, width, margin); + lines.push(StyledLine::empty()); + return lines; + } + if let Some(drawn) = lay_out(g, width) { + let mut notes = drawn.notes; + notes.extend(g.notes.iter().cloned()); + let mut lines = framed(title, &drawn.rows, ¬es, theme, width, margin); + lines.push(StyledLine::empty()); + return lines; + } + let mut lines = edge_list(g, title, theme, width, margin); + lines.push(StyledLine::empty()); + lines +} - // Render nodes vertically with arrows between them - for (i, edge) in edges.iter().enumerate() { - let from_label = nodes - .iter() - .find(|(id, _)| id == &edge.0) - .map(|(_, l)| l.as_str()) - .unwrap_or(&edge.0); - let to_label = nodes - .iter() - .find(|(id, _)| id == &edge.1) - .map(|(_, l)| l.as_str()) - .unwrap_or(&edge.1); - - // From node box (only render if first edge or different from previous) - if i == 0 || (i > 0 && edges[i - 1].1 != edge.0) { - lines.push(make_node_line(from_label, border_color, text_color, margin)); +/// The layout attempts for a graph at `width` (the frame included): the +/// first that fits, or `None` when the diagram has to be listed instead. +fn lay_out(g: &Graph, width: usize) -> Option { + let avail = width.saturating_sub(4); + let mut dirs = vec![g.dir]; + if g.dir.horizontal() { + dirs.push(Dir::Down); + } + let attempts: [(usize, usize); 5] = [(28, 2), (20, 2), (14, 1), (10, 1), (6, 1)]; + for dir in dirs { + for (wrap, sep) in attempts { + let sep = if dir.horizontal() { 1 } else { sep }; + let p = engine::Params { wrap, sep, dir }; + match engine::layout(g, &p, avail) { + Ok(drawn) => return Some(drawn), + Err(engine::Fail::TooBig) => return None, + Err(engine::Fail::TooWide(_)) => {} + } } + } + None +} - // Arrow - let mut arrow_line = StyledLine::new(); - push_margin(&mut arrow_line, margin); - arrow_line.push(StyledSpan { - text: "│ ".to_string(), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - - let arrow_text = if edge.2.is_empty() { - " │".to_string() - } else { - format!(" │ {}", edge.2) +/// The last-resort form: every node with its outgoing edges, one per line. +fn edge_list( + g: &Graph, + title: &str, + theme: &Theme, + width: usize, + margin: usize, +) -> Vec { + let inner = width.saturating_sub(4).max(8); + let mut out: Vec> = Vec::new(); + let one_line = |s: &str| s.replace('\n', " "); + let push_wrapped = + |out: &mut Vec>, indent: usize, parts: Vec<(String, Class)>| { + // Wrap the concatenated text, keeping the first part's class for + // the arrow and the last for the target. + let full: String = parts.iter().map(|(t, _)| t.as_str()).collect(); + let lines = text::wrap(&full, inner.saturating_sub(indent).max(4)); + for (i, l) in lines.into_iter().enumerate() { + let pad = " ".repeat(if i == 0 { indent } else { indent + 2 }); + let class = if i == 0 { + parts[0].1 + } else { + parts[parts.len() - 1].1 + }; + if i == 0 && parts.len() > 1 { + // Re-split the first line at the arrow boundary when it is intact. + let head = &parts[0].0; + if let Some(rest) = l.strip_prefix(head.as_str()) { + out.push(vec![ + (pad, Class::Frame), + (head.clone(), parts[0].1), + (rest.to_string(), parts[1].1), + ]); + continue; + } + } + out.push(vec![(pad, Class::Frame), (l, class)]); + } }; - arrow_line.push(StyledSpan { - text: arrow_text, - style: SpanStyle { - fg: Some(arrow_color.to_string()), - ..Default::default() - }, - }); - lines.push(arrow_line); - - let mut arrow_head = StyledLine::new(); - push_margin(&mut arrow_head, margin); - arrow_head.push(StyledSpan { - text: "│ ".to_string(), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - arrow_head.push(StyledSpan { - text: " ▼".to_string(), - style: SpanStyle { - fg: Some(arrow_color.to_string()), - ..Default::default() - }, - }); - lines.push(arrow_head); - - // To node box - lines.push(make_node_line(to_label, border_color, text_color, margin)); + let mut has_edges = vec![false; g.nodes.len()]; + for e in &g.edges { + has_edges[e.from] = true; + has_edges[e.to] = true; } - - if edges.is_empty() { - for (_, label) in &nodes { - lines.push(make_node_line(label, border_color, text_color, margin)); + for (i, n) in g.nodes.iter().enumerate() { + let outgoing: Vec<&graph::Edge> = g.edges.iter().filter(|e| e.from == i).collect(); + if outgoing.is_empty() && has_edges[i] { + continue; + } + push_wrapped(&mut out, 0, vec![(one_line(&n.label), Class::Text)]); + for e in outgoing { + let head = if e.end == Marker::None { + "──" + } else { + "──▶" + }; + let arrow = if e.label.is_empty() { + format!("{head} ") + } else { + format!("──{}{} ", one_line(&e.label), head) + }; + push_wrapped( + &mut out, + 2, + vec![ + (arrow, Class::Edge), + (one_line(&g.nodes[e.to].label), Class::Text), + ], + ); } } - - lines.push(make_footer(border_color, margin, width)); - lines.push(StyledLine::empty()); - - lines + framed(title, &out, &g.notes, theme, width, margin) } -fn make_node_line(label: &str, border_color: &str, text_color: &str, margin: &str) -> StyledLine { - // Single-line box representation: │ [ Label ] - let mut result = StyledLine::new(); - push_margin(&mut result, margin); - result.push(StyledSpan { - text: "│ ".to_string(), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - result.push(StyledSpan { - text: format!("[ {} ]", label), +/// Wrap drawn rows in the diagram frame: a titled top border, `│` sides and +/// a bottom border, as wide as the content needs (at most `width`). Notes +/// follow the rows inside the frame. +fn framed( + title: &str, + rows: &[Vec<(String, Class)>], + notes: &[String], + theme: &Theme, + width: usize, + margin: usize, +) -> Vec { + let border = &theme.colors.table_border; + let margin_str = " ".repeat(margin); + let row_w = |r: &Vec<(String, Class)>| r.iter().map(|(t, _)| text::width(t)).sum::(); + let content_w = rows.iter().map(row_w).max().unwrap_or(0); + let note_lines: Vec = notes + .iter() + .flat_map(|n| text::wrap(n, width.saturating_sub(4).max(4))) + .collect(); + let notes_w = note_lines.iter().map(|l| text::width(l)).max().unwrap_or(0); + let title_w = text::width(title); + let frame_w = (content_w.max(notes_w) + 4) + .max(title_w + 6) + .max(20) + .min(width); + let inner = frame_w.saturating_sub(4); + let title = text::truncate(title, frame_w.saturating_sub(6)); + let mut lines = vec![make_header( + &title, + border, + &theme.colors.heading2, + &margin_str, + frame_w, + )]; + let side = |s: &str| StyledSpan { + text: s.to_string(), style: SpanStyle { - fg: Some(text_color.to_string()), - bold: true, + fg: Some(border.clone()), ..Default::default() }, - }); - result + }; + let mut emit = |runs: Vec, w: usize| { + let mut line = StyledLine::new(); + push_margin(&mut line, &margin_str); + line.push(side("│ ")); + for r in runs { + line.push(r); + } + line.push(StyledSpan { + text: " ".repeat(inner.saturating_sub(w)), + style: SpanStyle::default(), + }); + line.push(side(" │")); + lines.push(line); + }; + let offset = (inner.saturating_sub(content_w)) / 2; + for r in rows { + let w = row_w(r); + let mut runs = Vec::new(); + if offset > 0 { + runs.push(StyledSpan { + text: " ".repeat(offset), + style: SpanStyle::default(), + }); + } + // Trailing blanks are padding; keep rows within the frame. + for (t, class) in r { + runs.push(StyledSpan { + text: t.clone(), + style: class_style(theme, *class), + }); + } + emit(runs, w + offset); + } + if !note_lines.is_empty() && !rows.is_empty() { + emit(Vec::new(), 0); + } + for l in note_lines { + let l = text::truncate(&l, inner); + let w = text::width(&l); + emit( + vec![StyledSpan { + text: l, + style: class_style(theme, Class::Label), + }], + w, + ); + } + lines.push(make_footer(border, &margin_str, frame_w)); + lines } fn render_sequence( @@ -627,59 +856,6 @@ fn push_margin(line: &mut StyledLine, margin: &str) { } } -fn split_arrow(line: &str) -> Option<(String, &str)> { - let arrows = ["-->", "---", "-.-", "==>", "-.->", "->>"]; - for arrow in &arrows { - if let Some(pos) = line.find(arrow) { - let from = line[..pos].trim().to_string(); - let rest = &line[pos + arrow.len()..]; - return Some((from, rest.trim())); - } - } - None -} - -fn extract_node_id(s: &str) -> String { - let s = s.trim(); - // Handle A[Label], A(Label), A{Label}, A((Label)) - if let Some(pos) = s.find(['[', '(', '{']) { - s[..pos].trim().to_string() - } else { - s.to_string() - } -} - -fn extract_node_label(s: &str) -> String { - let s = s.trim(); - // Extract label from brackets: A[Label] -> Label - for (open, close) in &[('[', ']'), ('(', ')'), ('{', '}')] { - if let Some(start) = s.find(*open) { - if let Some(end) = s.rfind(*close) { - if end > start { - let label = &s[start + 1..end]; - // Handle double brackets ((Label)) - let label = label.trim_start_matches('(').trim_end_matches(')'); - return label.to_string(); - } - } - } - } - extract_node_id(s) -} - -fn extract_edge_label(rest: &str) -> (String, String) { - let rest = rest.trim(); - // Handle |label| syntax - if let Some(rest_after_pipe) = rest.strip_prefix('|') { - if let Some(end) = rest_after_pipe.find('|') { - let label = rest_after_pipe[..end].to_string(); - let to = rest_after_pipe[end + 1..].trim().to_string(); - return (label, to); - } - } - (String::new(), rest.to_string()) -} - fn parse_sequence_line(line: &str) -> (String, String, String, String) { let arrows = ["-->>", "->>", "-->", "->"]; for arrow in &arrows { @@ -706,7 +882,7 @@ fn parse_sequence_line(line: &str) -> (String, String, String, String) { } #[cfg(test)] -mod tests { +mod header_tests { use super::*; fn line_width(line: &StyledLine) -> usize { diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs new file mode 100644 index 0000000..fb1b6e8 --- /dev/null +++ b/src/layout/mermaid/tests.rs @@ -0,0 +1,459 @@ +//! Rendering tests: exact output for small diagrams (so a layout change +//! shows up as a readable diff) and properties every diagram in the corpus +//! under `tests/fixtures/mermaid/` must keep. + +use super::*; +use std::time::{Duration, Instant}; + +fn theme() -> Theme { + crate::theme::resolve_theme("dark") +} + +/// The rendered lines as plain text, trailing blanks trimmed. +fn render(src: &str, width: usize) -> String { + render_opts(src, width, false) +} + +fn render_opts(src: &str, width: usize, ascii: bool) -> String { + let lines = render_mermaid_with(src, &theme(), width, 0, ascii); + let mut out: Vec = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.text.as_str()) + .collect::() + .trim_end() + .to_string() + }) + .collect(); + while out.last().is_some_and(|l| l.is_empty()) { + out.pop(); + } + out.join("\n") +} + +/// Compare with an expected block written with a leading newline. +fn expect(src: &str, width: usize, expected: &str) { + let got = render(src, width); + let want = expected.strip_prefix('\n').unwrap_or(expected).trim_end(); + assert_eq!(got, want, "\n--- got ---\n{got}\n--- want ---\n{want}\n"); +} + +fn corpus() -> Vec<(String, String)> { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mermaid"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .expect("fixture dir") + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "mmd")) + .collect(); + files.sort(); + files + .into_iter() + .map(|p| { + let name = p.file_name().unwrap().to_string_lossy().to_string(); + (name, std::fs::read_to_string(&p).unwrap()) + }) + .collect() +} + +fn parsed(src: &str) -> Option { + parse_graph(&split_source(&text::clean_source(src))).map(|(g, _)| g) +} + +// ── Flowcharts: exact output ── + +#[test] +fn two_nodes_top_down() { + expect( + "graph TD\nA-->B", + 40, + r" +╭─ flowchart ──────╮ +│ ┌───┐ │ +│ │ A │ │ +│ └─┬─┘ │ +│ ▼ │ +│ ┌───┐ │ +│ │ B │ │ +│ └───┘ │ +╰──────────────────╯", + ); +} + +#[test] +fn shared_nodes_are_drawn_once_and_fan_out_on_one_track() { + expect( + "flowchart TD\nA[Start] --> B{Check}\nB -->|ok| C[Done]\nB -->|retry| A", + 50, + r" +╭─ flowchart ──────╮ +│ ┌───────┐ │ +│ │ Start │ │ +│ └──┬────┘ │ +│ │ ▲ │ +│ ┌┘ └─┐ │ +│ │ retry │ +│ └─┐ ┌┘ │ +│ ▼ │ │ +│ ╱────┴──╲ │ +│ < Check > │ +│ ╲───┬───╱ │ +│ │ │ +│ ok │ +│ ▼ │ +│ ┌──────┐ │ +│ │ Done │ │ +│ └──────┘ │ +╰──────────────────╯", + ); +} + +#[test] +fn bottom_up_fan_in_shares_a_track() { + expect( + "graph BT\nA --> B & C", + 40, + r" +╭─ flowchart ──────╮ +│ ┌───┐ ┌───┐ │ +│ │ B │ │ C │ │ +│ └───┘ └───┘ │ +│ ▲ ▲ │ +│ └───┬──┘ │ +│ │ │ +│ ┌─┴─┐ │ +│ │ A │ │ +│ └───┘ │ +╰──────────────────╯", + ); +} + +#[test] +fn subgraph_is_a_titled_frame() { + expect( + "flowchart TB\nsubgraph S [Inside]\nA --> B\nend\nC --> A", + 40, + r" +╭─ flowchart ──────╮ +│ ┌───┐ │ +│ │ C │ │ +│ └─┬─┘ │ +│ ┌───┼──────┐ │ +│ │ ▼ │ │ +│ │ ┌───┐ │ │ +│ │ │ A │ │ │ +│ │ └─┬─┘ │ │ +│ │ ▼ │ │ +│ │ ┌───┐ │ │ +│ │ │ B │ │ │ +│ │ └───┘ │ │ +│ └─ Inside ─┘ │ +╰──────────────────╯", + ); +} + +#[test] +fn left_to_right_with_labels_shapes_and_styles() { + expect( + "graph LR\nA[Start] --> B{Ok?}\nB -->|yes| C[Done]\nB -.-> D((Retry))", + 60, + r" +╭─ flowchart ─────────────────────────╮ +│ ┌──────┐ │ +│ ╱─────╲ ┌▶│ Done │ │ +│ ┌───────┐ │ ├─ yes ┘ └──────┘ │ +│ │ Start ├─▶< Ok? > │ +│ └───────┘ │ ├┐ ╭───────╮ │ +│ ╲─────╱└┄┄┄┄┄┄▶( Retry ) │ +│ ╰───────╯ │ +╰─────────────────────────────────────╯", + ); +} + +#[test] +fn left_to_right_too_wide_is_drawn_top_down() { + expect( + "graph LR\nA[A long first label] --> B[Another long label] --> C[Third label here]", + 30, + r" +╭─ flowchart ────────────╮ +│ ┌────────────────────┐ │ +│ │ A long first label │ │ +│ └─────────┬──────────┘ │ +│ ▼ │ +│ ┌────────────────────┐ │ +│ │ Another long label │ │ +│ └─────────┬──────────┘ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Third label here │ │ +│ └──────────────────┘ │ +╰────────────────────────╯", + ); +} + +#[test] +fn self_loops_and_markers() { + expect( + "graph TD\nA -->|again| A\nA --o B\nB <--> C\nC --x D", + 40, + r" +╭─ flowchart ──────╮ +│ ┌───┐ │ +│ │ A ├─┐ again │ +│ │ │◀┘ │ +│ └─┬─┘ │ +│ ○ │ +│ ┌───┐ │ +│ │ B │ │ +│ └───┘ │ +│ ▲ │ +│ ▼ │ +│ ┌───┐ │ +│ │ C │ │ +│ └─┬─┘ │ +│ × │ +│ ┌───┐ │ +│ │ D │ │ +│ └───┘ │ +╰──────────────────╯", + ); +} + +#[test] +fn too_wide_even_top_down_falls_back_to_a_list() { + let src = format!( + "graph TD\n{}", + (0..12) + .map(|i| format!("Hub --> N{i}[Node number {i}]")) + .collect::>() + .join("\n") + ); + let out = render(&src, 40); + assert!(out.contains("│ Hub "), "{out}"); + assert!(out.contains("──▶ Node number 0"), "{out}"); + for line in out.lines() { + assert!(text::width(line) <= 40, "{line}"); + } +} + +#[test] +fn ascii_mode_is_seven_bit() { + let out = render_opts( + "graph LR\nA[Start] --> B{Ok?}\nB -->|yes| C[(Done)]\nB -.-> D((Retry))\nD ==> E[[Sub]]\nsubgraph S\nE\nend\nE --o A", + 60, + true, + ); + assert!(out.is_ascii(), "{out}"); + assert!(out.contains("Start")); +} + +#[test] +fn control_bytes_in_labels_never_reach_the_output() { + for src in [ + "graph TD\nA[\u{1b}]2;pwned\u{7}evil] --> B[\u{1b}[31mred]", + "graph TD\nA -->|\u{1b}[2J| B", + "graph TD\nsubgraph \u{9b}31m title\nA\nend", + ] { + let lines = render_mermaid_with(src, &theme(), 60, 0, false); + for l in &lines { + for s in &l.spans { + assert!( + !s.text + .chars() + .any(|c| c.is_control() || ('\u{80}'..='\u{9f}').contains(&c)), + "{:?}", + s.text + ); + } + } + let out = render(src, 60); + assert!( + out.contains("evil") || out.contains("[2J") || out.contains("title"), + "{out}" + ); + } +} + +// ── Properties over the corpus ── + +const WIDTHS: [usize; 5] = [24, 40, 60, 80, 120]; + +#[test] +fn no_line_is_wider_than_the_width() { + for (name, src) in corpus() { + for w in WIDTHS { + for ascii in [false, true] { + for line in render_mermaid_with(&src, &theme(), w, 2, ascii) { + let lw = line.width(); + assert!( + lw <= w + 2, + "{name} at {w}: {lw} columns\n{}", + render(&src, w) + ); + } + } + } + } +} + +#[test] +fn output_is_deterministic() { + for (name, src) in corpus() { + for w in [40, 100] { + assert_eq!(render(&src, w), render(&src, w), "{name}"); + } + } +} + +#[test] +fn ascii_output_is_pure_ascii() { + for (name, src) in corpus() { + if !src.is_ascii() { + continue; + } + for w in [40, 100] { + let out = render_opts(&src, w, true); + assert!(out.is_ascii(), "{name}:\n{out}"); + } + } +} + +/// Every node label that is not part of some other text is drawn exactly +/// once when the diagram is laid out (not listed). +#[test] +fn every_node_is_drawn_exactly_once() { + let word_count = |hay: &str, needle: &str| -> usize { + let mut n = 0; + let mut from = 0; + while let Some(i) = hay[from..].find(needle) { + let at = from + i; + let before = hay[..at].chars().next_back(); + let after = hay[at + needle.len()..].chars().next(); + let edge = |c: Option| !c.is_some_and(|c| c.is_alphanumeric() || c == '_'); + if edge(before) && edge(after) { + n += 1; + } + from = at + needle.len(); + } + n + }; + let mut checked = 0; + for (name, src) in corpus() { + let Some(g) = parsed(&src) else { continue }; + // Only the drawing: frames that could not be drawn are listed as + // notes, which name their members again. + let Some(drawn) = lay_out(&g, 200) else { + continue; + }; + let out: String = drawn + .rows + .iter() + .map(|r| r.iter().map(|(t, _)| t.as_str()).collect::() + "\n") + .collect(); + let others: Vec = g + .edges + .iter() + .map(|e| e.label.clone()) + .chain(g.clusters.iter().map(|c| c.title.clone())) + .chain(g.notes.iter().cloned()) + .chain( + g.nodes + .iter() + .flat_map(|n| n.sections.iter().flatten().cloned()), + ) + .collect(); + for (i, node) in g.nodes.iter().enumerate() { + let label = &node.label; + if node.shape.is_glyph() + || label.contains('\n') + || text::width(label) > 20 + || label.is_empty() + { + continue; + } + let elsewhere = others.iter().any(|o| word_count(o, label) > 0) + || g.nodes + .iter() + .enumerate() + .any(|(j, n)| j != i && word_count(&n.label, label) > 0); + if elsewhere { + continue; + } + assert_eq!(word_count(&out, label), 1, "{name}: {label:?}\n{out}"); + checked += 1; + } + } + assert!(checked > 50, "only {checked} labels checked"); +} + +// ── Hostile input: bounded work ── + +fn assert_quick(src: &str, limit: Duration) { + let t = Instant::now(); + let lines = render_mermaid_with(src, &theme(), 100, 2, false); + let took = t.elapsed(); + assert!(took < limit, "took {took:?}"); + for l in &lines { + assert!(l.width() <= 102); + } +} + +#[test] +fn hostile_flowcharts_return_quickly() { + let limit = Duration::from_secs(5); + // 800 edges among 150 nodes: over the edge cap, listed. + let mut s = String::from("graph TD\n"); + for i in 0..800 { + s.push_str(&format!( + "N{} --> N{}\n", + (i * 37) % 150, + (i * 91 + 7) % 150 + )); + } + assert_quick(&s, limit); + // A 5000-node chain. + let mut s = String::from("graph LR\n"); + for i in 0..5000 { + s.push_str(&format!("C{i} --> C{}\n", i + 1)); + } + assert_quick(&s, limit); + // At the caps: 200 nodes, 400 labelled edges, with cycles. + let mut s = String::from("graph TD\n"); + for i in 0..400 { + s.push_str(&format!( + "E{} -->|x| E{}\n", + (i * 13) % 200, + (i * 7 + 3) % 200 + )); + } + assert_quick(&s, limit); + // A labelled chain of 199 with a back edge over all of it. + let mut s = String::from("graph TD\n"); + for i in 0..198 { + s.push_str(&format!("D{i} -->|l{i}| D{}\n", i + 1)); + } + s.push_str("D198 --> D0\n"); + assert_quick(&s, limit); + // Self-loops, a node named like its subgraph, a subgraph in itself. + assert_quick( + "graph TD\nsubgraph A\nA --> A\nend\nsubgraph S\nsubgraph S\nX-->X\nend\nend\nS-->S", + limit, + ); + // 120 nested subgraphs. + let mut s = String::from("graph TD\n"); + for i in 0..120 { + s.push_str(&format!("subgraph G{i}\nK{i} --> K{}\n", i + 1)); + } + for _ in 0..120 { + s.push_str("end\n"); + } + assert_quick(&s, limit); + // Unbalanced brackets and quotes, stray links. + assert_quick( + "graph TD\nA[[[(((\"\nB --> --> -->\n-->\n|||\nC{{{{ --> D", + limit, + ); +} diff --git a/src/layout/mermaid/text.rs b/src/layout/mermaid/text.rs new file mode 100644 index 0000000..6630ced --- /dev/null +++ b/src/layout/mermaid/text.rs @@ -0,0 +1,305 @@ +//! Text helpers shared by the diagram renderers: label decoding, display +//! width, wrapping and truncation. Everything here measures in terminal +//! columns, grapheme by grapheme, the same way the canvas places text. + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// Display width of `s` in terminal columns, summed per grapheme cluster +/// (the unit the canvas places), so measurement and placement agree. +pub fn width(s: &str) -> usize { + s.graphemes(true).map(UnicodeWidthStr::width).sum() +} + +/// Make diagram source safe to measure: drop terminal control characters +/// (ESC, C0, C1, DEL — a label is untrusted document text), turn tabs into +/// spaces and CRLF into LF. The layout sanitizer runs again on the output; +/// this pass exists so control bytes never skew width arithmetic. +pub fn clean_source(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + for c in src.chars() { + match c { + '\n' => out.push('\n'), + '\t' => out.push_str(" "), + c if c.is_control() || ('\u{80}'..='\u{9f}').contains(&c) => {} + // Zero-width format characters that some terminals render + // unpredictably (bidi overrides, isolates). + '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' => {} + c => out.push(c), + } + } + out +} + +/// Decode a mermaid label as written in the source into display text: +/// surrounding quotes and markdown-string backticks removed, `
` turned +/// into a line break, simple HTML tags dropped, HTML entities and mermaid's +/// `#name;` / `#123;` codes decoded. Lines are trimmed; the result uses `\n` +/// between lines. +pub fn decode_label(raw: &str) -> String { + let mut s = raw.trim(); + if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { + s = &s[1..s.len() - 1]; + } + let mut s = s.trim().to_string(); + // Markdown strings: "`**bold** text`". + if s.len() >= 2 && s.starts_with('`') && s.ends_with('`') { + s = s[1..s.len() - 1].replace("**", ""); + } + let s = replace_tags(&s); + let s = decode_entities(&s); + // Font Awesome icon tokens (`fa:fa-car`) have no terminal rendering. + let is_icon = |w: &&str| { + w.split_once(":fa-") + .is_some_and(|(p, _)| matches!(p, "fa" | "fab" | "fas" | "far" | "fal" | "fak")) + }; + let lines: Vec = s + .split('\n') + .map(|l| { + l.split_whitespace() + .filter(|w| !is_icon(w)) + .collect::>() + .join(" ") + }) + .collect(); + // Drop leading/trailing empty lines but keep interior ones. + let start = lines + .iter() + .position(|l| !l.is_empty()) + .unwrap_or(lines.len()); + let end = lines + .iter() + .rposition(|l| !l.is_empty()) + .map_or(start, |e| e + 1); + lines[start..end].join("\n") +} + +/// `
`, `
`, `
` become line breaks; other simple tags +/// (``, ``, ``) are removed. Anything that does not look +/// like a tag is kept as text. +fn replace_tags(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(open) = rest.find('<') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + let Some(close) = after.find('>') else { + out.push_str(&rest[open..]); + return out; + }; + // A tag starts right after `<` (`a < b > c` is text). + if !after.starts_with(|c: char| c.is_ascii_alphabetic() || c == '/') { + out.push('<'); + rest = after; + continue; + } + let inner = after[..close].trim(); + let name = inner.trim_start_matches('/').trim_end_matches('/').trim(); + let tag = name.split(|c: char| c.is_whitespace()).next().unwrap_or(""); + let is_tag = !tag.is_empty() + && tag.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && tag.chars().all(|c| c.is_ascii_alphanumeric()); + if is_tag { + if tag.eq_ignore_ascii_case("br") { + out.push('\n'); + } + rest = &after[close + 1..]; + } else { + out.push('<'); + rest = after; + } + } + out.push_str(rest); + out +} + +fn named_entity(name: &str) -> Option { + Some(match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + "nbsp" => ' ', + "hash" | "num" => '#', + "semi" => ';', + "colon" => ':', + "copy" => '©', + "reg" => '®', + "deg" => '°', + "hellip" => '…', + "mdash" => '—', + "ndash" => '–', + "larr" => '←', + "rarr" => '→', + "uarr" => '↑', + "darr" => '↓', + "hearts" => '♥', + _ => return None, + }) +} + +/// Decode `&name;`, `{`, `` and mermaid's `#name;` / `#123;`. +/// Decoded control characters are dropped. +fn decode_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let chars: Vec = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '&' || c == '#' { + // Look ahead for `;` within a short window. + let mut j = i + 1; + let start = if c == '&' && chars.get(j) == Some(&'#') { + j += 1; + j + } else { + j + }; + while j < chars.len() && j - i <= 10 && chars[j].is_ascii_alphanumeric() { + j += 1; + } + if j < chars.len() && chars[j] == ';' && j > start { + let body: String = chars[start..j].iter().collect(); + let numeric = c == '#' || start == i + 2; + let decoded = if numeric { + if let Some(hex) = body.strip_prefix(['x', 'X']) { + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) + } else if body.chars().all(|d| d.is_ascii_digit()) { + body.parse::().ok().and_then(char::from_u32) + } else if c == '#' { + named_entity(&body) + } else { + None + } + } else { + named_entity(&body) + }; + if let Some(d) = decoded { + if !d.is_control() { + out.push(d); + } + i = j + 1; + continue; + } + } + } + out.push(c); + i += 1; + } + out +} + +/// Word-wrap `text` (which may contain `\n`) to at most `max` columns per +/// line. Words longer than `max` are broken at grapheme boundaries. Always +/// returns at least one line. +pub fn wrap(text: &str, max: usize) -> Vec { + let max = max.max(1); + let mut out = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + let mut lw = 0; + for word in para.split(' ').filter(|w| !w.is_empty()) { + let ww = width(word); + if ww > max { + if lw > 0 { + out.push(std::mem::take(&mut line)); + lw = 0; + } + for g in word.graphemes(true) { + let gw = UnicodeWidthStr::width(g); + if lw + gw > max && lw > 0 { + out.push(std::mem::take(&mut line)); + lw = 0; + } + line.push_str(g); + lw += gw; + } + continue; + } + let need = if lw == 0 { ww } else { lw + 1 + ww }; + if need > max { + out.push(std::mem::take(&mut line)); + line.push_str(word); + lw = ww; + } else { + if lw > 0 { + line.push(' '); + } + line.push_str(word); + lw = need; + } + } + out.push(line); + } + if out.is_empty() { + out.push(String::new()); + } + out +} + +/// Cut `s` to at most `max` columns, ending in `…` when anything was cut. +pub fn truncate(s: &str, max: usize) -> String { + if width(s) <= max { + return s.to_string(); + } + if max == 0 { + return String::new(); + } + let mut out = String::new(); + let mut w = 0; + for g in s.graphemes(true) { + let gw = UnicodeWidthStr::width(g); + if w + gw > max - 1 { + break; + } + out.push_str(g); + w += gw; + } + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_decode_quotes_breaks_and_entities() { + assert_eq!(decode_label("\"Hello
world\""), "Hello\nworld"); + assert_eq!(decode_label("a
b
c"), "a\nb\nc"); + assert_eq!( + decode_label("A #quot;quoted#quot; & done"), + "A \"quoted\" & done" + ); + assert_eq!(decode_label("#9829; love"), "♥ love"); + assert_eq!(decode_label("bold text"), "bold text"); + assert_eq!(decode_label("a < b > c"), "a < b > c"); + assert_eq!(decode_label("\"`**md** string`\""), "md string"); + assert_eq!( + decode_label("xy"), + "xy", + "decoded controls are dropped" + ); + } + + #[test] + fn wrap_breaks_words_and_long_tokens() { + assert_eq!(wrap("one two three", 7), vec!["one two", "three"]); + assert_eq!(wrap("abcdefghij", 4), vec!["abcd", "efgh", "ij"]); + assert_eq!(wrap("a\nb", 10), vec!["a", "b"]); + assert_eq!(wrap("日本語テキスト", 6), vec!["日本語", "テキス", "ト"]); + assert_eq!(wrap("", 5), vec![""]); + } + + #[test] + fn truncate_marks_the_cut() { + assert_eq!(truncate("abcdef", 4), "abc…"); + assert_eq!(truncate("abc", 4), "abc"); + } + + #[test] + fn clean_source_strips_controls() { + assert_eq!(clean_source("a\u{1b}[31mb\tc\r\n"), "a[31mb c\n"); + } +} diff --git a/tests/fixtures/mermaid/flowchart-chaining.mmd b/tests/fixtures/mermaid/flowchart-chaining.mmd new file mode 100644 index 0000000..5fccc54 --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-chaining.mmd @@ -0,0 +1,4 @@ +flowchart TB + A -- text --> B -- text2 --> C + a --> b & c --> d + X & Y --> P & Q diff --git a/tests/fixtures/mermaid/flowchart-christmas.mmd b/tests/fixtures/mermaid/flowchart-christmas.mmd new file mode 100644 index 0000000..e2bf078 --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-christmas.mmd @@ -0,0 +1,6 @@ +flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car] diff --git a/tests/fixtures/mermaid/flowchart-cycles.mmd b/tests/fixtures/mermaid/flowchart-cycles.mmd new file mode 100644 index 0000000..7b7b727 --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-cycles.mmd @@ -0,0 +1,6 @@ +graph RL + Start --> Check{Valid?} + Check -->|no| Fix[Fix it] + Fix --> Check + Check -->|yes| Done((Done)) + Done --> Done diff --git a/tests/fixtures/mermaid/flowchart-ink-cicd.mmd b/tests/fixtures/mermaid/flowchart-ink-cicd.mmd new file mode 100644 index 0000000..65fe215 --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-ink-cicd.mmd @@ -0,0 +1,9 @@ +flowchart LR + A[Push Code] --> B[Run Tests] + B --> C{Tests Pass?} + C -->|Yes| D[Build Docker] + C -->|No| E[Notify Team] + D --> F[Deploy Staging] + F --> G{QA Approved?} + G -->|Yes| H[Deploy Production] + G -->|No| E diff --git a/tests/fixtures/mermaid/flowchart-ink-simple.mmd b/tests/fixtures/mermaid/flowchart-ink-simple.mmd new file mode 100644 index 0000000..4a7d084 --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-ink-simple.mmd @@ -0,0 +1,8 @@ +flowchart TD + A[User Request] --> B{Auth Check} + B -->|Valid| C[Process Request] + B -->|Invalid| D[Return 401] + C --> E[Query Database] + E --> F{Results?} + F -->|Found| G[Return Data] + F -->|Empty| H[Return 404] diff --git a/tests/fixtures/mermaid/flowchart-links.mmd b/tests/fixtures/mermaid/flowchart-links.mmd new file mode 100644 index 0000000..2985efe --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-links.mmd @@ -0,0 +1,13 @@ +flowchart LR + A-->B + B --- C + C -- This is the text! --- D + D -->|Arrow text| E + E -.-> F + F -. Dotted text .-> G + G ==> H + H == Thick text ==> I + I ~~~ J + J --o K + K --x L + L <--> M diff --git a/tests/fixtures/mermaid/flowchart-nested-subgraphs.mmd b/tests/fixtures/mermaid/flowchart-nested-subgraphs.mmd new file mode 100644 index 0000000..4481e6e --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-nested-subgraphs.mmd @@ -0,0 +1,14 @@ +flowchart LR + subgraph TOP + direction TB + subgraph B1 + direction RL + i1 -->f1 + end + subgraph B2 + direction BT + i2 -->f2 + end + end + A --> TOP --> B + B1 --> B2 diff --git a/tests/fixtures/mermaid/flowchart-shapes.mmd b/tests/fixtures/mermaid/flowchart-shapes.mmd new file mode 100644 index 0000000..b636dcb --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-shapes.mmd @@ -0,0 +1,13 @@ +flowchart LR + id1[Rectangle] --> id2(Rounded) + id2 --> id3([Stadium]) + id3 --> id4[[Subroutine]] + id4 --> id5[(Database)] + id5 --> id6((Circle)) + id6 --> id7>Asymmetric] + id7 --> id8{Rhombus} + id8 --> id9{{Hexagon}} + id9 --> id10[/Parallelogram/] + id10 --> id11[\Alt parallelogram\] + id11 --> id12[/Trapezoid\] + id12 --> id13[\Alt trapezoid/] diff --git a/tests/fixtures/mermaid/flowchart-subgraphs.mmd b/tests/fixtures/mermaid/flowchart-subgraphs.mmd new file mode 100644 index 0000000..581f3eb --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-subgraphs.mmd @@ -0,0 +1,14 @@ +flowchart TB + c1-->a2 + subgraph one + a1-->a2 + end + subgraph two + b1-->b2 + end + subgraph three + c1-->c2 + end + one --> two + three --> two + two --> c2 diff --git a/tests/fixtures/mermaid/flowchart-text.mmd b/tests/fixtures/mermaid/flowchart-text.mmd new file mode 100644 index 0000000..eedcd21 --- /dev/null +++ b/tests/fixtures/mermaid/flowchart-text.mmd @@ -0,0 +1,15 @@ +%%{init: {"flowchart": {"htmlLabels": false}} }%% +--- +title: Labels and entities +--- +graph BT + %% comments are ignored + q["A label with (parens) and [brackets]"] --> e["Entity #quot;codes#quot; & more"] + e --> br["First line
Second line"] + br --> md["`**Markdown** string`"] + md --> q + classDef green fill:#9f6,stroke:#333,stroke-width:2px; + class q green + style e fill:#f9f + linkStyle 0 stroke:#ff3 + click q callback "Tooltip" diff --git a/tests/mermaid.rs b/tests/mermaid.rs new file mode 100644 index 0000000..3f80e1c --- /dev/null +++ b/tests/mermaid.rs @@ -0,0 +1,68 @@ +//! Mermaid diagrams through the whole document pipeline: what reaches the +//! terminal in `--plain` output. + +use ink_md::render::plain::render_plain_with_color; +use ink_md::{Args, Spacing}; + +fn args(width: u16) -> Args { + Args { + inputs: vec![], + theme: "dark".to_string(), + width: Some(width), + slides: false, + plain: true, + watch: false, + toc: false, + images: ink_md::image::ImageMode::Off, + image_protocol: ink_md::graphics::ProtocolChoice::HalfBlocks, + frontmatter: false, + spacing: Spacing::Normal, + mouse_capture: true, + clipboard: ink_md::clipboard::ClipboardMode::Off, + } +} + +fn plain(md: &str, width: u16) -> String { + render_plain_with_color(md, &args(width), false).unwrap() +} + +/// A node label is untrusted document text: an ESC (or any other control +/// byte) in it must not reach the terminal, whichever diagram type and +/// wherever in the diagram it appears. +#[test] +fn escape_bytes_in_diagram_labels_are_stripped() { + let esc = "\u{1b}"; + for body in [ + format!("graph TD\n A[{esc}]0;evil title{esc}\\\\ok] --> B[{esc}[2Jcleared]"), + format!("graph LR\n A -->|{esc}[31mred| B"), + format!("graph TD\n subgraph s[{esc}]8;;http://x{esc}\\\\frame]\n A\n end"), + format!("stateDiagram-v2\n [*] --> S{esc}[1m1\n S{esc}[1m1 : desc{esc}[0m"), + format!("classDiagram\n class A{esc}x {{\n +f{esc}[31m() int\n }}"), + format!("erDiagram\n A{esc}B ||--o{{ C : {esc}[5mlabel"), + format!("gantt\n title {esc}[2J\n section {esc}[31ms\n task{esc} : a1, 2024-01-01, 3d"), + format!("mindmap\n root{esc}[31m\n child{esc}]2;x"), + format!("journey\n title {esc}[2J"), + format!("sequenceDiagram\n A->>B: {esc}[2Jhi"), + ] { + let md = format!("```mermaid\n{body}\n```\n"); + let out = plain(&md, 80); + assert!(!out.contains(esc), "ESC survived in:\n{out}"); + assert!( + !out.chars() + .any(|c| c.is_control() && c != '\n' && c != '\t'), + "control byte survived in:\n{out}" + ); + } +} + +/// Plain output of a diagram never exceeds the requested width. +#[test] +fn diagrams_fit_the_requested_width() { + let md = "```mermaid\nflowchart LR\n A[Push Code] --> B[Run Tests] --> C{Tests Pass?}\n C -->|Yes| D[Build Docker] --> E[Deploy]\n C -->|No| F[Notify Team]\n```\n"; + for width in [30u16, 50, 80, 120] { + for line in plain(md, width).lines() { + let w = unicode_width::UnicodeWidthStr::width(line); + assert!(w <= width as usize, "{w} > {width}: {line}"); + } + } +} diff --git a/tests/snapshots/plain_snapshots__plain_diagrams_md.snap b/tests/snapshots/plain_snapshots__plain_diagrams_md.snap index e1c59aa..1f7c289 100644 --- a/tests/snapshots/plain_snapshots__plain_diagrams_md.snap +++ b/tests/snapshots/plain_snapshots__plain_diagrams_md.snap @@ -1,6 +1,5 @@ --- source: tests/plain_snapshots.rs -assertion_line: 44 expression: rendered --- @@ -14,67 +13,85 @@ expression: rendered ▎ Simple Flow - ╭─ flowchart ──────────────────────────────────────────╮ - │ [ User Request ] - │  │ - │  ▼ - │ [ Auth Check ] - │  │ Valid - │  ▼ - │ [ Process Request ] - │ [ Auth Check ] - │  │ Invalid - │  ▼ - │ [ Return 401 ] - │ [ Process Request ] - │  │ - │  ▼ - │ [ Query Database ] - │  │ - │  ▼ - │ [ Results? ] - │  │ Found - │  ▼ - │ [ Return Data ] - │ [ Results? ] - │  │ Empty - │  ▼ - │ [ Return 404 ] - ╰──────────────────────────────────────────────────────╯ + ╭─ flowchart ────────────────────────────────╮ + │  ┌──────────────┐  │ + │  │ User Request │  │ + │  └──────┬───────┘  │ + │  │  │ + │  ▼  │ + │  ╱────────────╲  │ + │  < Auth Check >  │ + │  ╲─────┬──────╱  │ + │  ┌────┴────────┐  │ + │  Valid Invalid  │ + │  ┌───┘ │  │ + │  ▼ ▼  │ + │  ┌─────────────────┐ ┌────────────┐ │ + │  │ Process Request │ │ Return 401 │ │ + │  └────────┬────────┘ └────────────┘ │ + │  │  │ + │  ▼  │ + │  ┌────────────────┐  │ + │  │ Query Database │  │ + │  └───────┬────────┘  │ + │  │  │ + │  ▼  │ + │  ╱──────────╲  │ + │  < Results? >  │ + │  ╲────┬─────╱  │ + │  ┌────┴──────┐  │ + │  Found Empty  │ + │  ┌───┘ │  │ + │  ▼ ▼  │ + │ ┌─────────────┐ ┌────────────┐  │ + │ │ Return Data │ │ Return 404 │  │ + │ └─────────────┘ └────────────┘  │ + ╰────────────────────────────────────────────╯ ▎ CI/CD Pipeline - ╭─ flowchart ──────────────────────────────────────────╮ - │ [ Push Code ] - │  │ - │  ▼ - │ [ Run Tests ] - │  │ - │  ▼ - │ [ Tests Pass? ] - │  │ Yes - │  ▼ - │ [ Build Docker ] - │ [ Tests Pass? ] - │  │ No - │  ▼ - │ [ Notify Team ] - │ [ Build Docker ] - │  │ - │  ▼ - │ [ Deploy Staging ] - │  │ - │  ▼ - │ [ QA Approved? ] - │  │ Yes - │  ▼ - │ [ Deploy Production ] - │ [ QA Approved? ] - │  │ No - │  ▼ - │ [ Notify Team ] - ╰──────────────────────────────────────────────────────╯ + ╭─ flowchart ────────────────────────────╮ + │  ┌───────────┐  │ + │  │ Push Code │  │ + │  └─────┬─────┘  │ + │  │  │ + │  ▼  │ + │  ┌───────────┐  │ + │  │ Run Tests │  │ + │  └─────┬─────┘  │ + │  │  │ + │  ▼  │ + │  ╱─────────────╲  │ + │  < Tests Pass? >  │ + │  ╲──────┬──────╱  │ + │  ┌─┴─┐  │ + │  Yes │  │ + │  ┌────┘ └┐  │ + │  ▼ │  │ + │  ┌──────────────┐ │  │ + │  │ Build Docker │ │  │ + │  └──────┬───────┘ │  │ + │  │ │  │ + │  │ No  │ + │  ┌┘ │  │ + │  ▼ │  │ + │  ┌────────────────┐ │  │ + │  │ Deploy Staging │ │  │ + │  └───────┬────────┘ │  │ + │  │ │  │ + │  ▼ │  │ + │  ╱──────────────╲ │  │ + │  < QA Approved? > │  │ + │  ╲──────┬───────╱ │  │ + │  ┌──┴──┐ │  │ + │  Yes No │  │ + │  ┌──────┘ └──────┬┘  │ + │  ▼ ▼  │ + │ ┌───────────────────┐ ┌─────────────┐ │ + │ │ Deploy Production │ │ Notify Team │ │ + │ └───────────────────┘ └─────────────┘ │ + ╰────────────────────────────────────────╯ diff --git a/tests/snapshots/plain_snapshots__plain_test_md.snap b/tests/snapshots/plain_snapshots__plain_test_md.snap index 1d0af50..5616f32 100644 --- a/tests/snapshots/plain_snapshots__plain_test_md.snap +++ b/tests/snapshots/plain_snapshots__plain_test_md.snap @@ -1,6 +1,5 @@ --- source: tests/plain_snapshots.rs -assertion_line: 30 expression: rendered --- @@ -127,22 +126,25 @@ expression: rendered ▎ Flowchart - ╭─ flowchart ──────────────────────────────────────────╮ - │ [ Start ] - │  │ - │  ▼ - │ [ Is it working? ] - │  │ Yes - │  ▼ - │ [ Great! ] - │ [ Is it working? ] - │  │ No - │  ▼ - │ [ Debug ] - │  │ - │  ▼ - │ [ Is it working? ] - ╰──────────────────────────────────────────────────────╯ + ╭─ flowchart ─────────────╮ + │  ┌───────┐  │ + │  │ Start │  │ + │  └───┬───┘  │ + │  │  │ + │  ▼  │ + │  ╱────────────────╲ │ + │  < Is it working? > │ + │  ╲──────┬─────────╱ │ + │  │ ▲  │ + │  ┌────┴┐│  │ + │  │ │└──┐  │ + │  Yes No │  │ + │  ┌──┘ └─┐ │  │ + │  ▼ ▼ │  │ + │ ┌────────┐ ┌────┴──┐  │ + │ │ Great! │ │ Debug │  │ + │ └────────┘ └───────┘  │ + ╰─────────────────────────╯ ▎ Sequence Diagram From 1125a0d31ca893ca3082c219a13734833e7edb41 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:01:04 +0200 Subject: [PATCH 2/8] feat(mermaid): render state diagrams with the layout engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stateDiagram and stateDiagram-v2 printed as raw source. States, transitions with labels, 'state "…" as id', 'id : description', composite states (as titled frames, with their own [*]), <>, <> and <>, direction and notes are parsed into the graph model and drawn by the flowchart engine: [*] is ● as a source and ◉ as a target, choice a ◇, fork/join a bar. Transitions to a composite land on its start state and leave from its end state. Notes are listed under the diagram. --- src/layout/mermaid/engine.rs | 15 +- src/layout/mermaid/graph.rs | 1 - src/layout/mermaid/mod.rs | 2 + src/layout/mermaid/state.rs | 258 +++++++++++++++++++ src/layout/mermaid/tests.rs | 30 +++ tests/fixtures/mermaid/state-basic.mmd | 8 + tests/fixtures/mermaid/state-choice-fork.mmd | 17 ++ tests/fixtures/mermaid/state-composite.mmd | 14 + tests/fixtures/mermaid/state-notes.mmd | 11 + 9 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 src/layout/mermaid/state.rs create mode 100644 tests/fixtures/mermaid/state-basic.mmd create mode 100644 tests/fixtures/mermaid/state-choice-fork.mmd create mode 100644 tests/fixtures/mermaid/state-composite.mmd create mode 100644 tests/fixtures/mermaid/state-notes.mmd diff --git a/src/layout/mermaid/engine.rs b/src/layout/mermaid/engine.rs index 42f4acc..a4227cc 100644 --- a/src/layout/mermaid/engine.rs +++ b/src/layout/mermaid/engine.rs @@ -1053,13 +1053,20 @@ pub fn layout(g: &Graph, p: &Params, avail: usize) -> Result { } out }; + // A row at the top of the gap for markers at upper nodes, and to show + // the line leaving a bare glyph (`●`) before it turns. let has_upper_marker = |gi: usize| { gap_segs[gi].iter().any(|&s| { let sg = &segs[s]; - matches!(lnodes[sg.up].kind, Kind::Real(_)) && { - let e = &g.edges[sg.edge]; - end_marker(e.start, e.end, info(sg.edge).rev, true) != Marker::None - } + let Kind::Real(v) = lnodes[sg.up].kind else { + return false; + }; + let e = &g.edges[sg.edge]; + end_marker(e.start, e.end, info(sg.edge).rev, true) != Marker::None + || (g.nodes[v].shape.is_glyph() && { + let (a, b) = seg_cols(s); + a != b + }) }) }; for row in rows_at(0, true) { diff --git a/src/layout/mermaid/graph.rs b/src/layout/mermaid/graph.rs index 8ff4b1c..b76728b 100644 --- a/src/layout/mermaid/graph.rs +++ b/src/layout/mermaid/graph.rs @@ -48,7 +48,6 @@ impl Dir { } /// How a node is drawn. -#[allow(dead_code)] // glyph shapes arrive with state diagrams #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Shape { Rect, diff --git a/src/layout/mermaid/mod.rs b/src/layout/mermaid/mod.rs index cadb9ee..c9db5ca 100644 --- a/src/layout/mermaid/mod.rs +++ b/src/layout/mermaid/mod.rs @@ -13,6 +13,7 @@ mod canvas; mod engine; mod flowchart; mod graph; +mod state; mod text; #[cfg(test)] @@ -209,6 +210,7 @@ fn parse_graph(src: &Source) -> Option<(Graph, String)> { "graph" | "flowchart" | "flowchart-elk" => { (flowchart::parse(&src.header, &src.body), "flowchart") } + "stateDiagram" | "stateDiagram-v2" => (state::parse(&src.body), "state diagram"), _ => return None, }; Some((g, src.title.clone().unwrap_or_else(|| kind.to_string()))) diff --git a/src/layout/mermaid/state.rs b/src/layout/mermaid/state.rs new file mode 100644 index 0000000..f12eefd --- /dev/null +++ b/src/layout/mermaid/state.rs @@ -0,0 +1,258 @@ +//! Parser for `stateDiagram` / `stateDiagram-v2`. +//! +//! States become nodes, transitions edges, composite states subgraphs +//! (drawn as frames). `[*]` is a start (`●`) or end (`◉`) node, one of each +//! per composite state. Notes are listed under the diagram. + +use super::graph::{Dir, Graph, LineStyle, Marker, Shape}; +use super::text::decode_label; + +pub fn parse(body: &[String]) -> Graph { + let mut g = Graph::new(Dir::Down); + let mut stack: Vec = Vec::new(); + let mut note: Option<(String, Vec)> = None; + for raw in body { + let line = raw.trim(); + if let Some((target, lines)) = note.as_mut() { + if line == "end note" { + let text = lines.join(" "); + g.notes.push(format!("note on {target}: {text}")); + note = None; + } else if !line.is_empty() { + lines.push(decode_label(line)); + } + continue; + } + if line.is_empty() || line.starts_with("%%") || line == "--" { + continue; + } + let cluster = stack.last().copied(); + if line == "}" { + stack.pop(); + continue; + } + if let Some(rest) = word(line, "direction") { + if stack.is_empty() { + if let Some(d) = Dir::parse(rest) { + g.dir = d; + } + } + continue; + } + if let Some(rest) = word(line, "note") { + // `note right of X : text` or a block up to `end note`. + let (head, text) = match rest.split_once(':') { + Some((h, t)) => (h, Some(t)), + None => (rest, None), + }; + let target = head + .trim() + .trim_start_matches("right of") + .trim_start_matches("left of") + .trim() + .to_string(); + let target = g + .find(&target) + .map_or(target.clone(), |i| g.nodes[i].label.replace('\n', " ")); + match text { + Some(t) => g + .notes + .push(format!("note on {target}: {}", decode_label(t))), + None => note = Some((target, Vec::new())), + } + continue; + } + if [ + "classDef", "class", "style", "hide", "scale", "accTitle", "accDescr", "title", + ] + .iter() + .any(|k| word(line, k).is_some() || line.starts_with(&format!("{k}:"))) + { + continue; + } + if let Some(rest) = word(line, "state") { + state_statement(&mut g, &mut stack, cluster, rest); + continue; + } + if let Some((left, right)) = line.split_once("-->") { + let (right, label) = match right.split_once(':') { + Some((r, l)) => (r, decode_label(l)), + None => (right, String::new()), + }; + let a = endpoint(&mut g, cluster, left, true); + let b = endpoint(&mut g, cluster, right, false); + if let (Some(a), Some(b)) = (a, b) { + g.add_edge(a, b, &label, LineStyle::Solid, Marker::None, Marker::Arrow); + } + continue; + } + if let Some((id, desc)) = line.split_once(':') { + let id = strip_class(id); + if !id.is_empty() && !id.contains(char::is_whitespace) { + describe(&mut g, cluster, id, &decode_label(desc)); + } + continue; + } + let id = strip_class(line); + if !id.is_empty() && !id.contains(char::is_whitespace) { + g.touch(id, cluster); + } + } + g.redirect_cluster_edges(); + g +} + +fn word<'a>(s: &'a str, kw: &str) -> Option<&'a str> { + let rest = s.strip_prefix(kw)?; + (rest.is_empty() || rest.starts_with(char::is_whitespace)).then(|| rest.trim()) +} + +fn strip_class(s: &str) -> &str { + let s = s.trim(); + s.split_once(":::").map_or(s, |(a, _)| a).trim() +} + +/// A transition end: a state id, or `[*]` (a start node when it is the +/// source, an end node when it is the target; one of each per scope). +fn endpoint(g: &mut Graph, cluster: Option, s: &str, source: bool) -> Option { + let id = strip_class(s); + if id.is_empty() { + return None; + } + if id == "[*]" { + let scope = cluster.map_or(String::from("root"), |c| c.to_string()); + let (key, shape) = if source { + (format!("[*]start:{scope}"), Shape::Start) + } else { + (format!("[*]end:{scope}"), Shape::End) + }; + return Some(g.declare(&key, "", shape, cluster)); + } + Some(g.touch(id, cluster)) +} + +/// `id : description` — the description is what the state box shows. +fn describe(g: &mut Graph, cluster: Option, id: &str, desc: &str) { + let i = g.touch(id, cluster); + let node = &mut g.nodes[i]; + if node.label == node.id { + node.label = desc.to_string(); + } else { + node.label = format!("{}\n{desc}", node.label); + } + node.declared = true; +} + +/// The rest of a `state …` line. +fn state_statement(g: &mut Graph, stack: &mut Vec, cluster: Option, rest: &str) { + let opens = rest.ends_with('{'); + let rest = rest.trim_end_matches('{').trim(); + // `"Description" as id` or `id as "Description"` + let (id, desc) = if let Some((a, b)) = rest.split_once(" as ") { + let (a, b) = (a.trim(), b.trim()); + if a.starts_with('"') { + (strip_class(b).to_string(), Some(decode_label(a))) + } else { + (strip_class(a).to_string(), Some(decode_label(b))) + } + } else { + let (id, tail) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let tail = tail.trim(); + let id = strip_class(id).to_string(); + match tail { + "<>" => { + g.declare(&id, "", Shape::Choice, cluster); + return; + } + "<>" | "<>" => { + g.declare(&id, "", Shape::Bar, cluster); + return; + } + _ => {} + } + let desc = tail.strip_prefix(':').map(decode_label); + (id, desc) + }; + if id.is_empty() { + return; + } + if opens { + // A composite state: a frame titled with its description. + let title = match (&desc, g.find(&id)) { + (Some(d), _) => d.clone(), + (None, Some(i)) => g.nodes[i].label.clone(), + (None, None) => id.clone(), + }; + if let Some(i) = g.find(&id) { + g.nodes[i].declared = false; + } + let c = g.add_cluster(&id, &title, cluster); + stack.push(c); + return; + } + let i = g.touch(&id, cluster); + if let Some(d) = desc { + g.nodes[i].label = d; + g.nodes[i].declared = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_src(src: &str) -> Graph { + let body: Vec = src.lines().map(str::to_string).collect(); + parse(&body) + } + + #[test] + fn transitions_start_end_and_labels() { + let g = parse_src("[*] --> Still\nStill --> Moving : push\nMoving --> [*]"); + assert_eq!(g.nodes.len(), 4); + assert_eq!(g.nodes[0].shape, Shape::Start); + assert_eq!(g.nodes[3].shape, Shape::End); + assert_eq!(g.edges[1].label, "push"); + } + + #[test] + fn aliases_descriptions_and_pseudo_states() { + let g = parse_src( + "state \"A long name\" as s1\ns2 : Described\nstate fork_state <>\nstate c <>\nstate j <>\nnote right of s1 : a note\nnote left of s2\n two\n lines\nend note", + ); + assert_eq!(g.nodes[g.find("s1").unwrap()].label, "A long name"); + assert_eq!(g.nodes[g.find("s2").unwrap()].label, "Described"); + assert_eq!(g.nodes[g.find("fork_state").unwrap()].shape, Shape::Bar); + assert_eq!(g.nodes[g.find("c").unwrap()].shape, Shape::Choice); + assert_eq!( + g.notes, + vec![ + "note on A long name: a note", + "note on Described: two lines" + ] + ); + } + + #[test] + fn composite_states_are_subgraphs_with_their_own_start() { + let g = parse_src( + "[*] --> First\nstate First {\n [*] --> second\n second --> [*]\n}\nFirst --> Last", + ); + assert_eq!(g.clusters.len(), 1); + assert_eq!(g.clusters[0].title, "First"); + assert!( + g.find("First").is_none(), + "the composite is a frame, not a node" + ); + // [*] --> First lands on the inner start; First --> Last leaves from the inner end. + let label = |i: usize| (g.nodes[i].id.clone(), g.nodes[i].shape); + let e: Vec<_> = g + .edges + .iter() + .map(|e| (label(e.from), label(e.to))) + .collect(); + assert_eq!(e[0].1 .1, Shape::Start); + assert_eq!(e.last().unwrap().0 .1, Shape::End); + assert_eq!(e.last().unwrap().1 .0, "Last"); + } +} diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs index fb1b6e8..d537548 100644 --- a/src/layout/mermaid/tests.rs +++ b/src/layout/mermaid/tests.rs @@ -277,6 +277,36 @@ fn control_bytes_in_labels_never_reach_the_output() { } } +// ── State diagrams ── + +#[test] +fn state_diagram_start_end_fork_and_back_edge() { + expect( + "stateDiagram-v2\n[*] --> Still\nStill --> Moving : push\nMoving --> Still\nMoving --> [*]\nstate fork <>\nStill --> fork", + 40, + r" +╭─ state diagram ───╮ +│ ● │ +│ │ │ +│ ▼ │ +│ ┌───────┐ │ +│ │ Still │ │ +│ └──┬────┘ │ +│ │ ▲ │ +│ ┌──┴─┼─────┐ │ +│ push │ │ │ +│ │ ┌──┘ │ │ +│ ▼ │ ▼ │ +│ ┌────┴───┐ ━━━━━ │ +│ │ Moving │ │ +│ └───┬────┘ │ +│ │ │ +│ ▼ │ +│ ◉ │ +╰───────────────────╯", + ); +} + // ── Properties over the corpus ── const WIDTHS: [usize; 5] = [24, 40, 60, 80, 120]; diff --git a/tests/fixtures/mermaid/state-basic.mmd b/tests/fixtures/mermaid/state-basic.mmd new file mode 100644 index 0000000..35a6b20 --- /dev/null +++ b/tests/fixtures/mermaid/state-basic.mmd @@ -0,0 +1,8 @@ +stateDiagram-v2 + [*] --> Still + Still --> [*] + + Still --> Moving + Moving --> Still + Moving --> Crash + Crash --> [*] diff --git a/tests/fixtures/mermaid/state-choice-fork.mmd b/tests/fixtures/mermaid/state-choice-fork.mmd new file mode 100644 index 0000000..278592c --- /dev/null +++ b/tests/fixtures/mermaid/state-choice-fork.mmd @@ -0,0 +1,17 @@ +stateDiagram-v2 + state if_state <> + [*] --> IsPositive + IsPositive --> if_state + if_state --> False: if n < 0 + if_state --> True : if n >= 0 + + state fork_state <> + [*] --> fork_state + fork_state --> State2 + fork_state --> State3 + + state join_state <> + State2 --> join_state + State3 --> join_state + join_state --> State4 + State4 --> [*] diff --git a/tests/fixtures/mermaid/state-composite.mmd b/tests/fixtures/mermaid/state-composite.mmd new file mode 100644 index 0000000..63b69e1 --- /dev/null +++ b/tests/fixtures/mermaid/state-composite.mmd @@ -0,0 +1,14 @@ +stateDiagram-v2 + [*] --> First + state First { + [*] --> second + second --> [*] + } + + [*] --> NamedComposite + NamedComposite: Another Composite + state NamedComposite { + [*] --> namedSimple + namedSimple --> [*] + namedSimple: Another simple + } diff --git a/tests/fixtures/mermaid/state-notes.mmd b/tests/fixtures/mermaid/state-notes.mmd new file mode 100644 index 0000000..52ad0bb --- /dev/null +++ b/tests/fixtures/mermaid/state-notes.mmd @@ -0,0 +1,11 @@ +stateDiagram + direction LR + state "This is a state description" as s2 + [*] --> s2 : start + s2 --> s3 : next + note right of s2 : Important information! You can write notes. + note left of s3 + A note can also + be defined like this + end note + s3 --> [*] From 28940ca144b08ac0eb1d0dda1bf90c84378f631b Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:04:37 +0200 Subject: [PATCH 3/8] feat(mermaid): render class diagrams as compartment boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classDiagram printed as raw source. Classes (bodies, 'Name : member' lines, generics as , <>, ["labels"]), namespaces (as frames), notes and every relationship form (<|--, *--, o--, -->, --, ..>, ..|>, .., two-way, with labels and "1"/"*" cardinalities) are parsed into the graph model. Each class is a box split into name, attributes and methods; relationship ends carry △ (inheritance, realization), ◆ (composition), ◇ (aggregation) or an arrowhead, dotted for the .. forms, and a legend line names the kinds used. --- src/layout/mermaid/class.rs | 406 +++++++++++++++++++++ src/layout/mermaid/graph.rs | 1 - src/layout/mermaid/mod.rs | 5 +- src/layout/mermaid/tests.rs | 38 ++ tests/fixtures/mermaid/class-animals.mmd | 26 ++ tests/fixtures/mermaid/class-bank.mmd | 23 ++ tests/fixtures/mermaid/class-relations.mmd | 10 + 7 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 src/layout/mermaid/class.rs create mode 100644 tests/fixtures/mermaid/class-animals.mmd create mode 100644 tests/fixtures/mermaid/class-bank.mmd create mode 100644 tests/fixtures/mermaid/class-relations.mmd diff --git a/src/layout/mermaid/class.rs b/src/layout/mermaid/class.rs new file mode 100644 index 0000000..f5820bf --- /dev/null +++ b/src/layout/mermaid/class.rs @@ -0,0 +1,406 @@ +//! Parser for `classDiagram`. +//! +//! Classes become three-part boxes (name, attributes, methods); +//! relationships become edges with an end glyph per kind: `△` inheritance +//! and realization, `◆` composition, `◇` aggregation, an arrowhead for +//! association and dependency (dotted lines for the `..` forms). A legend +//! line names the kinds used. Namespaces are drawn as frames. + +use super::graph::{Dir, Graph, LineStyle, Marker, Shape}; +use super::text::decode_label; +use std::collections::BTreeMap; + +#[derive(Default)] +struct Members { + annotations: Vec, + attrs: Vec, + methods: Vec, +} + +pub fn parse(body: &[String]) -> Graph { + let mut g = Graph::new(Dir::Down); + let mut members: BTreeMap = BTreeMap::new(); + // Open blocks: a class body (Some(node)) or a namespace (None). + let mut open: Vec> = Vec::new(); + let mut namespaces: Vec = Vec::new(); + for raw in body { + let line = raw.trim(); + if line.is_empty() || line.starts_with("%%") { + continue; + } + let cluster = namespaces.last().copied(); + if let Some(Some(class)) = open.last().copied() { + // Inside `class X { … }`. + for part in line.split('}') { + let part = part.trim(); + if !part.is_empty() { + add_member(members.entry(class).or_default(), part); + } + } + if line.contains('}') { + open.pop(); + } + continue; + } + if line.starts_with('}') { + if open.pop() == Some(None) { + namespaces.pop(); + } + continue; + } + if let Some(rest) = word(line, "direction") { + if let Some(d) = Dir::parse(rest) { + g.dir = d; + } + continue; + } + if let Some(rest) = word(line, "namespace") { + let name = rest.trim_end_matches('{').trim(); + let c = g.add_cluster(name, name, cluster); + namespaces.push(c); + open.push(None); + continue; + } + if let Some(rest) = word(line, "note") { + let (target, text) = match rest.strip_prefix("for ") { + Some(r) => { + let (t, x) = r.trim().split_once(char::is_whitespace).unwrap_or((r, "")); + (Some(t.to_string()), x) + } + None => (None, rest), + }; + let text = decode_label(text).replace("\\n", " "); + g.notes.push(match target { + Some(t) => format!("note for {t}: {text}"), + None => format!("note: {text}"), + }); + continue; + } + if [ + "link", "click", "callback", "cssClass", "style", "classDef", "accTitle", "accDescr", + "title", + ] + .iter() + .any(|k| word(line, k).is_some() || line.starts_with(&format!("{k}:"))) + { + continue; + } + if let Some(rest) = line.strip_prefix("<<") { + // `<> Animal` + if let Some((ann, name)) = rest.split_once(">>") { + let (id, _) = class_name(name.trim()); + if !id.is_empty() { + let i = touch_class(&mut g, &id, cluster); + members + .entry(i) + .or_default() + .annotations + .push(ann.trim().to_string()); + } + } + continue; + } + if let Some(rest) = word(line, "class") { + let opens = rest.ends_with('{') || rest.contains('{'); + let (head, inline) = match rest.split_once('{') { + Some((h, b)) => (h.trim(), Some(b)), + None => (rest, None), + }; + let (id, label) = class_name(head); + if id.is_empty() { + continue; + } + let i = touch_class(&mut g, &id, cluster); + if let Some(l) = label { + g.nodes[i].label = l; + } + if let Some(b) = inline { + for part in b.split('}') { + let part = part.trim(); + if !part.is_empty() { + add_member(members.entry(i).or_default(), part); + } + } + } + if opens && !rest.contains('}') { + open.push(Some(i)); + } + continue; + } + if relation(&mut g, cluster, line) { + continue; + } + if let Some((name, member)) = line.split_once(':') { + let (id, _) = class_name(name.trim()); + if !id.is_empty() && !id.contains(char::is_whitespace) { + let i = touch_class(&mut g, &id, cluster); + add_member(members.entry(i).or_default(), member.trim()); + } + continue; + } + let (id, _) = class_name(line); + if !id.is_empty() && !id.contains(char::is_whitespace) { + touch_class(&mut g, &id, cluster); + } + } + for (i, m) in members { + let node = &mut g.nodes[i]; + if !m.annotations.is_empty() { + let ann: Vec = m.annotations.iter().map(|a| format!("<<{a}>>")).collect(); + node.label = format!("{}\n{}", ann.join(" "), node.label); + } + node.sections = vec![m.attrs, m.methods]; + } + let legend = legend(&g); + if !legend.is_empty() { + g.notes.insert(0, legend); + } + g +} + +fn word<'a>(s: &'a str, kw: &str) -> Option<&'a str> { + let rest = s.strip_prefix(kw)?; + (rest.is_empty() || rest.starts_with(char::is_whitespace)).then(|| rest.trim()) +} + +/// `Name`, `Name~T~`, `Name["Label"]`, `Name:::css` → (id, display label). +fn class_name(s: &str) -> (String, Option) { + let s = s.trim(); + let s = s.split_once(":::").map_or(s, |(a, _)| a).trim(); + let (s, label) = match s.split_once('[') { + Some((a, b)) => (a.trim(), Some(decode_label(b.trim_end_matches(']')))), + None => (s, None), + }; + let s = s.trim_matches('`'); + let (id, generic) = match s.split_once('~') { + Some((a, b)) => (a.to_string(), Some(b.trim_end_matches('~').to_string())), + None => (s.to_string(), None), + }; + let label = label.or_else(|| generic.map(|g| format!("{id}<{}>", g.replace('~', "")))); + (id, label) +} + +fn touch_class(g: &mut Graph, id: &str, cluster: Option) -> usize { + let i = g.touch(id, cluster); + g.nodes[i].shape = Shape::Sections; + g.nodes[i].declared = true; + i +} + +fn add_member(m: &mut Members, raw: &str) { + let s = raw.trim(); + if let Some(a) = s.strip_prefix("<<").and_then(|r| r.strip_suffix(">>")) { + m.annotations.push(a.trim().to_string()); + return; + } + // `List~int~` is a generic: `List` (after decoding, so the angle + // brackets are not taken for HTML). + let s = decode_label(s); + let mut out = String::new(); + let mut open = false; + for c in s.chars() { + if c == '~' { + out.push(if open { '>' } else { '<' }); + open = !open; + } else { + out.push(c); + } + } + let text = out; + if text.is_empty() { + return; + } + if text.contains('(') { + m.methods.push(text); + } else { + m.attrs.push(text); + } +} + +/// `A <|-- B`, `A "1" *-- "many" B : label`, `A ..> B`, … Returns false +/// when the line is not a relationship. +fn relation(g: &mut Graph, cluster: Option, line: &str) -> bool { + // The operator: the first `--` or `..` outside quotes. + let bytes = line.as_bytes(); + let mut quote = false; + let mut op = None; + for i in 0..bytes.len().saturating_sub(1) { + match bytes[i] { + b'"' => quote = !quote, + b'-' | b'.' if !quote && bytes[i + 1] == bytes[i] => { + op = Some(i); + break; + } + _ => {} + } + } + let Some(i) = op else { return false }; + let style = if bytes[i] == b'.' { + LineStyle::Dotted + } else { + LineStyle::Solid + }; + let left = line[..i].trim_end(); + let right_all = line[i + 2..].trim_start(); + let (right, label) = match right_all.split_once(':') { + Some((r, l)) => (r.trim(), decode_label(l)), + None => (right_all.trim(), String::new()), + }; + let (left, start) = if let Some(l) = left.strip_suffix("<|") { + (l, Marker::Triangle) + } else if let Some(l) = left.strip_suffix('*') { + (l, Marker::DiamondFilled) + } else if let Some(l) = left.strip_suffix('<') { + (l, Marker::Arrow) + } else if let Some(l) = left.strip_suffix('o').filter(|l| l.ends_with([' ', '"'])) { + (l, Marker::DiamondOpen) + } else { + (left, Marker::None) + }; + let (right, end) = if let Some(r) = right.strip_prefix("|>") { + (r, Marker::Triangle) + } else if let Some(r) = right.strip_prefix('*') { + (r, Marker::DiamondFilled) + } else if let Some(r) = right.strip_prefix('>') { + (r, Marker::Arrow) + } else if let Some(r) = right + .strip_prefix('o') + .filter(|r| r.starts_with([' ', '"'])) + { + (r, Marker::DiamondOpen) + } else { + (right, Marker::None) + }; + let (left, card_l) = trailing_quote(left.trim()); + let (right, card_r) = leading_quote(right.trim()); + let (a, _) = class_name(left); + let (b, _) = class_name(right); + if a.is_empty() + || b.is_empty() + || a.contains(char::is_whitespace) + || b.contains(char::is_whitespace) + { + return false; + } + let ia = touch_class(g, &a, cluster); + let ib = touch_class(g, &b, cluster); + g.add_edge(ia, ib, &label, style, start, end); + if card_l.is_some() || card_r.is_some() { + if let Some(e) = g.edges.last_mut() { + e.cards = Some((card_l.unwrap_or_default(), card_r.unwrap_or_default())); + } + } + true +} + +fn trailing_quote(s: &str) -> (&str, Option) { + if let Some(body) = s.strip_suffix('"') { + if let Some(open) = body.rfind('"') { + return (body[..open].trim(), Some(body[open + 1..].to_string())); + } + } + (s, None) +} + +fn leading_quote(s: &str) -> (&str, Option) { + if let Some(body) = s.strip_prefix('"') { + if let Some(close) = body.find('"') { + return (body[close + 1..].trim(), Some(body[..close].to_string())); + } + } + (s, None) +} + +/// "△ inheritance · ◆ composition · …" for the relationship kinds in use. +fn legend(g: &Graph) -> String { + let mut kinds: Vec<&str> = Vec::new(); + let mut add = |k: &'static str| { + if !kinds.contains(&k) { + kinds.push(k); + } + }; + for e in &g.edges { + for m in [e.start, e.end] { + match (m, e.style) { + (Marker::Triangle, LineStyle::Dotted) => add("△ realization"), + (Marker::Triangle, _) => add("△ inheritance"), + (Marker::DiamondFilled, _) => add("◆ composition"), + (Marker::DiamondOpen, _) => add("◇ aggregation"), + (Marker::Arrow, LineStyle::Dotted) => add("▶ dependency"), + (Marker::Arrow, _) => add("▶ association"), + _ => {} + } + } + } + kinds.join(" · ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_src(src: &str) -> Graph { + let body: Vec = src.lines().map(str::to_string).collect(); + parse(&body) + } + + #[test] + fn members_bodies_annotations_and_generics() { + let g = parse_src( + "class Animal {\n <>\n +String name\n +eat() void\n}\nAnimal : +int age\nclass Square~Shape~{\n List~int~ ids\n}\n<> Repo\nclass Empty", + ); + let a = &g.nodes[g.find("Animal").unwrap()]; + assert_eq!(a.label, "<>\nAnimal"); + assert_eq!( + a.sections, + vec![ + vec!["+String name".to_string(), "+int age".into()], + vec!["+eat() void".to_string()] + ] + ); + let s = &g.nodes[g.find("Square").unwrap()]; + assert_eq!(s.label, "Square"); + assert_eq!(s.sections[0], vec!["List ids".to_string()]); + assert_eq!(g.nodes[g.find("Repo").unwrap()].label, "<>\nRepo"); + assert!(g.find("Empty").is_some()); + } + + #[test] + fn relationship_kinds_cardinalities_and_labels() { + let g = parse_src( + "A <|-- B\nC *-- D\nE o-- F\nG --> H\nI ..> J\nK ..|> L\nM -- N\nCustomer \"1\" --> \"*\" Ticket : buys\nP <|--|> Q", + ); + let k: Vec<(Marker, Marker, LineStyle)> = + g.edges.iter().map(|e| (e.start, e.end, e.style)).collect(); + assert_eq!(k[0], (Marker::Triangle, Marker::None, LineStyle::Solid)); + assert_eq!( + k[1], + (Marker::DiamondFilled, Marker::None, LineStyle::Solid) + ); + assert_eq!(k[2], (Marker::DiamondOpen, Marker::None, LineStyle::Solid)); + assert_eq!(k[3], (Marker::None, Marker::Arrow, LineStyle::Solid)); + assert_eq!(k[4], (Marker::None, Marker::Arrow, LineStyle::Dotted)); + assert_eq!(k[5], (Marker::None, Marker::Triangle, LineStyle::Dotted)); + assert_eq!(k[6], (Marker::None, Marker::None, LineStyle::Solid)); + assert_eq!(k[8], (Marker::Triangle, Marker::Triangle, LineStyle::Solid)); + let e = &g.edges[7]; + assert_eq!(e.label, "buys"); + assert_eq!(e.cards, Some(("1".into(), "*".into()))); + assert_eq!( + g.notes[0], + "△ inheritance · ◆ composition · ◇ aggregation · ▶ association · ▶ dependency · △ realization" + ); + } + + #[test] + fn namespaces_are_subgraphs() { + let g = parse_src("namespace Shapes {\n class Triangle\n class Square {\n int side\n }\n}\nclass Outside"); + assert_eq!(g.clusters.len(), 1); + assert_eq!(g.nodes[g.find("Square").unwrap()].cluster, Some(0)); + assert_eq!(g.nodes[g.find("Outside").unwrap()].cluster, None); + assert_eq!( + g.nodes[g.find("Square").unwrap()].sections[0], + vec!["int side".to_string()] + ); + } +} diff --git a/src/layout/mermaid/graph.rs b/src/layout/mermaid/graph.rs index b76728b..9277c3c 100644 --- a/src/layout/mermaid/graph.rs +++ b/src/layout/mermaid/graph.rs @@ -93,7 +93,6 @@ pub enum LineStyle { } /// What an edge end looks like where it meets its node. -#[allow(dead_code)] // relationship markers arrive with class diagrams #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Marker { None, diff --git a/src/layout/mermaid/mod.rs b/src/layout/mermaid/mod.rs index c9db5ca..c22f763 100644 --- a/src/layout/mermaid/mod.rs +++ b/src/layout/mermaid/mod.rs @@ -10,6 +10,7 @@ //! input, and produce the same output for the same input. mod canvas; +mod class; mod engine; mod flowchart; mod graph; @@ -62,7 +63,8 @@ pub fn render_mermaid_with( fn asciify(text: &str) -> std::borrow::Cow<'_, str> { fn stand_in(c: char) -> Option { Some(match c { - '─' | '┄' | '╌' => '-', + '─' | '╌' => '-', + '┄' => '.', '━' | '═' => '=', '│' | '┃' | '║' => '|', '┆' => ':', @@ -211,6 +213,7 @@ fn parse_graph(src: &Source) -> Option<(Graph, String)> { (flowchart::parse(&src.header, &src.body), "flowchart") } "stateDiagram" | "stateDiagram-v2" => (state::parse(&src.body), "state diagram"), + "classDiagram" | "classDiagram-v2" => (class::parse(&src.body), "class diagram"), _ => return None, }; Some((g, src.title.clone().unwrap_or_else(|| kind.to_string()))) diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs index d537548..395a227 100644 --- a/src/layout/mermaid/tests.rs +++ b/src/layout/mermaid/tests.rs @@ -307,6 +307,44 @@ fn state_diagram_start_end_fork_and_back_edge() { ); } +// ── Class diagrams ── + +#[test] +fn class_diagram_compartments_and_relationship_glyphs() { + expect( + "classDiagram\nclass Animal {\n <>\n +String name\n +eat() void\n}\nAnimal <|-- Dog\nDog *-- Tail\nDog \"1\" --> \"*\" Bone : chews", + 50, + r" +╭─ class diagram ───────────────────────────────╮ +│ ┌───────────────┐ │ +│ │ <> │ │ +│ │ Animal │ │ +│ ├───────────────┤ │ +│ │ +String name │ │ +│ ├───────────────┤ │ +│ │ +eat() void │ │ +│ └───────────────┘ │ +│ △ │ +│ │ │ +│ │ │ +│ ┌──┴──┐ │ +│ │ Dog │ │ +│ └───┬─┘ │ +│ ◆ │ │ +│ ┌──┘ └────┐ │ +│ │ 1 │ +│ │ chews │ +│ │ * │ +│ │ ▼ │ +│ ┌──┴───┐ ┌──────┐ │ +│ │ Tail │ │ Bone │ │ +│ └──────┘ └──────┘ │ +│ │ +│ △ inheritance · ◆ composition · ▶ association │ +╰───────────────────────────────────────────────╯", + ); +} + // ── Properties over the corpus ── const WIDTHS: [usize; 5] = [24, 40, 60, 80, 120]; diff --git a/tests/fixtures/mermaid/class-animals.mmd b/tests/fixtures/mermaid/class-animals.mmd new file mode 100644 index 0000000..5a7ce22 --- /dev/null +++ b/tests/fixtures/mermaid/class-animals.mmd @@ -0,0 +1,26 @@ +--- +title: Animal example +--- +classDiagram + note "From Duck till Zebra" + Animal <|-- Duck + note for Duck "can fly\ncan swim\ncan dive\ncan help in debugging" + Animal <|-- Fish + Animal <|-- Zebra + Animal : +int age + Animal : +String gender + Animal: +isMammal() + Animal: +mate() + class Duck{ + +String beakColor + +swim() + +quack() + } + class Fish{ + -int sizeInFeet + -canEat() + } + class Zebra{ + +bool is_wild + +run() + } diff --git a/tests/fixtures/mermaid/class-bank.mmd b/tests/fixtures/mermaid/class-bank.mmd new file mode 100644 index 0000000..0032bb7 --- /dev/null +++ b/tests/fixtures/mermaid/class-bank.mmd @@ -0,0 +1,23 @@ +classDiagram + class BankAccount{ + <> + +String owner + +BigDecimal balance + +deposit(amount) bool + +withdrawal(amount) int + } + class Shape~T~{ + <> + +List~T~ points + +area()$ double + } + Customer "1" --> "*" Ticket : buys + Student "1" --> "1..*" Course : takes + Galaxy --> "many" Star : Contains + BankAccount <|-- Savings + Shape~T~ <|.. Square + namespace Accounts { + class Savings + class Checking + } + BankAccount <|-- Checking diff --git a/tests/fixtures/mermaid/class-relations.mmd b/tests/fixtures/mermaid/class-relations.mmd new file mode 100644 index 0000000..bc05502 --- /dev/null +++ b/tests/fixtures/mermaid/class-relations.mmd @@ -0,0 +1,10 @@ +classDiagram + direction LR + classA <|-- classB : Inheritance + classC *-- classD : Composition + classE o-- classF : Aggregation + classG <-- classH : Association + classI -- classJ : Link + classK <.. classL : Dependency + classM <|.. classN : Realization + classO .. classP : Dashed From dc84d695c6aeb3481e4347217417cacaab9ce93d Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:05:15 +0200 Subject: [PATCH 4/8] feat(mermaid): render ER diagrams with attribute rows and cardinalities erDiagram printed as raw source. Entities (with attribute blocks and ["aliases"]) and relationships (||, |o, }o, }| ends, -- and .. lines, labels) are parsed into the graph model. Each entity is a box with a row per attribute ('type name PK,FK'); each relationship is a labelled edge whose label carries the cardinality of the end nearest it (1, 0..1, 0..*, 1..*), dotted when non-identifying. --- src/layout/mermaid/er.rs | 198 +++++++++++++++++++++++ src/layout/mermaid/mod.rs | 2 + src/layout/mermaid/tests.rs | 26 ++- tests/fixtures/mermaid/er-attributes.mmd | 18 +++ tests/fixtures/mermaid/er-orders.mmd | 4 + 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/layout/mermaid/er.rs create mode 100644 tests/fixtures/mermaid/er-attributes.mmd create mode 100644 tests/fixtures/mermaid/er-orders.mmd diff --git a/src/layout/mermaid/er.rs b/src/layout/mermaid/er.rs new file mode 100644 index 0000000..fc0616b --- /dev/null +++ b/src/layout/mermaid/er.rs @@ -0,0 +1,198 @@ +//! Parser for `erDiagram`. +//! +//! Entities become boxes with one row per attribute; relationships become +//! labelled edges whose ends carry the crow's-foot cardinality as text +//! (`1`, `0..1`, `0..*`, `1..*`). Non-identifying (`..`) relationships are +//! dotted. + +use super::graph::{Dir, Graph, LineStyle, Marker, Shape}; +use super::text::decode_label; + +pub fn parse(body: &[String]) -> Graph { + let mut g = Graph::new(Dir::Down); + let mut block: Option = None; + let mut attrs: Vec<(usize, String)> = Vec::new(); + for raw in body { + let line = raw.trim(); + if line.is_empty() || line.starts_with("%%") { + continue; + } + if let Some(e) = block { + if line.starts_with('}') { + block = None; + } else { + attrs.push((e, attribute(line))); + } + continue; + } + if let Some(rest) = line.strip_prefix("direction") { + if let Some(d) = Dir::parse(rest) { + g.dir = d; + } + continue; + } + if [ + "classDef", "class", "style", "accTitle", "accDescr", "title", + ] + .iter() + .any(|k| line.starts_with(&format!("{k} ")) || line.starts_with(&format!("{k}:"))) + { + continue; + } + if relationship(&mut g, line) { + continue; + } + // `ENTITY {`, `ENTITY`, `p[Person] {` + let opens = line.ends_with('{'); + let head = line.trim_end_matches('{').trim(); + if let Some(i) = entity(&mut g, head) { + if opens { + block = Some(i); + } + } + } + for i in 0..g.nodes.len() { + let rows: Vec = attrs + .iter() + .filter(|(e, _)| *e == i) + .map(|(_, r)| r.clone()) + .filter(|r| !r.is_empty()) + .collect(); + g.nodes[i].sections = vec![rows]; + } + g +} + +/// An entity reference: `NAME`, `"NAME"`, `id[Alias]`, `id["Alias"]`. +fn entity(g: &mut Graph, s: &str) -> Option { + let s = s.trim(); + let (id, alias) = match s.split_once('[') { + Some((a, b)) => (a.trim(), Some(decode_label(b.trim_end_matches(']')))), + None => (s, None), + }; + let id = id.trim_matches('"'); + if id.is_empty() || id.contains(char::is_whitespace) && !s.starts_with('"') { + return None; + } + let i = g.touch(id, None); + g.nodes[i].shape = Shape::Sections; + g.nodes[i].declared = true; + if let Some(a) = alias { + g.nodes[i].label = a; + } + Some(i) +} + +/// `type name PK, FK "comment"` → `type name PK,FK`. +fn attribute(line: &str) -> String { + let line = match line.find('"') { + Some(q) => &line[..q], + None => line, + }; + let mut words: Vec<&str> = line.split_whitespace().collect(); + // Keys may be written `PK, FK`: glue them back together. + let mut out = Vec::new(); + while !words.is_empty() { + let w = words.remove(0); + if w.ends_with(',') && !words.is_empty() { + let next = words.remove(0); + out.push(format!("{w}{next}")); + } else { + out.push(w.to_string()); + } + } + decode_label(&out.join(" ").replace('~', "")) +} + +fn left_card(s: &str) -> Option<&'static str> { + Some(match s { + "|o" => "0..1", + "||" => "1", + "}o" => "0..*", + "}|" => "1..*", + _ => return None, + }) +} + +fn right_card(s: &str) -> Option<&'static str> { + Some(match s { + "o|" => "0..1", + "||" => "1", + "o{" => "0..*", + "|{" => "1..*", + _ => return None, + }) +} + +/// `A ||--o{ B : label` (`..` for non-identifying). False when the line +/// is not a relationship. +fn relationship(g: &mut Graph, line: &str) -> bool { + let (head, label) = match line.split_once(':') { + Some((h, l)) => (h.trim(), decode_label(l)), + None => (line, String::new()), + }; + let chars: Vec = head.chars().collect(); + let n = chars.len(); + for i in 2..n.saturating_sub(3) { + let mid: String = chars[i..i + 2].iter().collect(); + if mid != "--" && mid != ".." { + continue; + } + let l: String = chars[i - 2..i].iter().collect(); + let r: String = chars[i + 2..i + 4].iter().collect(); + let (Some(lc), Some(rc)) = (left_card(&l), right_card(&r)) else { + continue; + }; + let a: String = chars[..i - 2].iter().collect(); + let b: String = chars[i + 4..].iter().collect(); + let (Some(ia), Some(ib)) = (entity(g, &a), entity(g, &b)) else { + return false; + }; + let style = if mid == ".." { + LineStyle::Dotted + } else { + LineStyle::Solid + }; + g.add_edge(ia, ib, &label, style, Marker::None, Marker::None); + if let Some(e) = g.edges.last_mut() { + e.cards = Some((lc.to_string(), rc.to_string())); + } + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_src(src: &str) -> Graph { + let body: Vec = src.lines().map(str::to_string).collect(); + parse(&body) + } + + #[test] + fn entities_attributes_and_cardinalities() { + let g = parse_src( + "CUSTOMER ||--o{ ORDER : places\nORDER ||--|{ LINE-ITEM : contains\nCUSTOMER }|..|{ DELIVERY-ADDRESS : uses\nCUSTOMER {\n string name PK \"the name\"\n string email UK\n int orderId FK, PK\n}\np[Person] |o--o| CAR : \"drives\"", + ); + let c = &g.nodes[g.find("CUSTOMER").unwrap()]; + assert_eq!( + c.sections[0], + vec![ + "string name PK".to_string(), + "string email UK".into(), + "int orderId FK,PK".into() + ] + ); + let cards: Vec<_> = g.edges.iter().map(|e| e.cards.clone().unwrap()).collect(); + assert_eq!(cards[0], ("1".into(), "0..*".into())); + assert_eq!(cards[1], ("1".into(), "1..*".into())); + assert_eq!(cards[2], ("1..*".into(), "1..*".into())); + assert_eq!(cards[3], ("0..1".into(), "0..1".into())); + assert_eq!(g.edges[2].style, LineStyle::Dotted); + assert_eq!(g.edges[3].label, "drives"); + assert_eq!(g.nodes[g.find("p").unwrap()].label, "Person"); + assert!(g.find("LINE-ITEM").is_some()); + } +} diff --git a/src/layout/mermaid/mod.rs b/src/layout/mermaid/mod.rs index c22f763..6029457 100644 --- a/src/layout/mermaid/mod.rs +++ b/src/layout/mermaid/mod.rs @@ -12,6 +12,7 @@ mod canvas; mod class; mod engine; +mod er; mod flowchart; mod graph; mod state; @@ -214,6 +215,7 @@ fn parse_graph(src: &Source) -> Option<(Graph, String)> { } "stateDiagram" | "stateDiagram-v2" => (state::parse(&src.body), "state diagram"), "classDiagram" | "classDiagram-v2" => (class::parse(&src.body), "class diagram"), + "erDiagram" => (er::parse(&src.body), "ER diagram"), _ => return None, }; Some((g, src.title.clone().unwrap_or_else(|| kind.to_string()))) diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs index 395a227..4cbbc60 100644 --- a/src/layout/mermaid/tests.rs +++ b/src/layout/mermaid/tests.rs @@ -307,7 +307,7 @@ fn state_diagram_start_end_fork_and_back_edge() { ); } -// ── Class diagrams ── +// ── Class and ER diagrams ── #[test] fn class_diagram_compartments_and_relationship_glyphs() { @@ -345,6 +345,30 @@ fn class_diagram_compartments_and_relationship_glyphs() { ); } +#[test] +fn er_diagram_attributes_and_cardinalities() { + expect( + "erDiagram\nCUSTOMER ||--o{ ORDER : places\nCUSTOMER {\n string name PK\n}", + 50, + r" +╭─ ER diagram ───────╮ +│ ┌────────────────┐ │ +│ │ CUSTOMER │ │ +│ ├────────────────┤ │ +│ │ string name PK │ │ +│ └───────┬────────┘ │ +│ │ │ +│ 1 │ +│ places │ +│ 0..* │ +│ │ │ +│ ┌───┴───┐ │ +│ │ ORDER │ │ +│ └───────┘ │ +╰────────────────────╯", + ); +} + // ── Properties over the corpus ── const WIDTHS: [usize; 5] = [24, 40, 60, 80, 120]; diff --git a/tests/fixtures/mermaid/er-attributes.mmd b/tests/fixtures/mermaid/er-attributes.mmd new file mode 100644 index 0000000..39010f8 --- /dev/null +++ b/tests/fixtures/mermaid/er-attributes.mmd @@ -0,0 +1,18 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places + CUSTOMER { + string name + string custNumber PK + string sector + } + ORDER ||--|{ LINE-ITEM : contains + ORDER { + int orderNumber PK + string deliveryAddress + } + LINE-ITEM { + string productCode FK + int quantity + float pricePerUnit + } + p[Person] |o--o| CAR : "drives" diff --git a/tests/fixtures/mermaid/er-orders.mmd b/tests/fixtures/mermaid/er-orders.mmd new file mode 100644 index 0000000..cccd9e2 --- /dev/null +++ b/tests/fixtures/mermaid/er-orders.mmd @@ -0,0 +1,4 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains + CUSTOMER }|..|{ DELIVERY-ADDRESS : uses From ed670355b7d8f2fabadeb2c41d3b7be56b7e1292 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:09:11 +0200 Subject: [PATCH 5/8] feat(mermaid): draw Gantt charts on a scaled time axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gantt renderer printed task names with bars whose length came from the name, not the schedule. It now parses title, dateFormat (dates with optional times, HH:mm, Unix timestamps), sections, ids, explicit start and end dates, durations (ms s m h d w M y), 'after' and 'until' dependencies and the done/active/crit/milestone tags, and draws a time axis scaled to the width with ticks at round intervals, a section header per section and one bar per task: █ done, ▓ active, ░ planned, ◆ milestone, critical tasks in the warning colour. Task names sit in a left column. Work is linear in the number of tasks (3000-task test). Snapshot: the Product Roadmap chart in diagrams.md. --- src/layout/mermaid/canvas.rs | 4 + src/layout/mermaid/gantt.rs | 540 ++++++++++++++++++ src/layout/mermaid/mod.rs | 95 +-- src/layout/mermaid/tests.rs | 20 + tests/fixtures/mermaid/gantt-basic.mmd | 9 + tests/fixtures/mermaid/gantt-features.mmd | 30 + tests/fixtures/mermaid/gantt-hours.mmd | 8 + tests/fixtures/mermaid/gantt-ink-roadmap.mmd | 15 + .../plain_snapshots__plain_diagrams_md.snap | 30 +- 9 files changed, 652 insertions(+), 99 deletions(-) create mode 100644 src/layout/mermaid/gantt.rs create mode 100644 tests/fixtures/mermaid/gantt-basic.mmd create mode 100644 tests/fixtures/mermaid/gantt-features.mmd create mode 100644 tests/fixtures/mermaid/gantt-hours.mmd create mode 100644 tests/fixtures/mermaid/gantt-ink-roadmap.mmd diff --git a/src/layout/mermaid/canvas.rs b/src/layout/mermaid/canvas.rs index 42e507a..357abe8 100644 --- a/src/layout/mermaid/canvas.rs +++ b/src/layout/mermaid/canvas.rs @@ -27,6 +27,10 @@ pub enum Class { Label, /// Titles (subgraph names, class names, section headers). Title, + /// Ordinary text (Gantt task names). + Plain, + /// Highlighted items (critical Gantt tasks). + Alert, } #[derive(Debug, Clone)] diff --git a/src/layout/mermaid/gantt.rs b/src/layout/mermaid/gantt.rs new file mode 100644 index 0000000..d83a89c --- /dev/null +++ b/src/layout/mermaid/gantt.rs @@ -0,0 +1,540 @@ +//! Gantt charts: tasks on a time axis scaled to the available width. +//! +//! Dates (`YYYY-MM-DD`, optionally with a time; or `HH:mm` / Unix +//! timestamps per `dateFormat`), durations (`ms s m h d w M y`), `after` +//! and `until` dependencies, ids, and the `done` / `active` / `crit` / +//! `milestone` tags are understood. Each task is one row: its name in a +//! left column, then a bar — `█` done, `▓` active, `░` planned, `◆` a +//! milestone — with critical tasks in the warning colour. Work is linear in +//! the number of tasks. + +use super::canvas::Class; +use super::text; +use std::collections::BTreeMap; + +type Row = Vec<(String, Class)>; + +#[derive(Debug, Clone, PartialEq)] +struct Task { + name: String, + section: Option, + start: f64, + end: f64, + done: bool, + active: bool, + crit: bool, + milestone: bool, +} + +/// A parsed chart: title, section names, tasks. +#[derive(Debug, Default)] +struct Chart { + title: Option, + sections: Vec, + tasks: Vec, + /// Whether any date had a time of day (or the format is time-only). + timed: bool, +} + +/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// `HH:mm[:ss]` as a fraction of a day. +fn parse_clock(s: &str) -> Option { + let mut parts = s.split(':'); + let h: f64 = parts.next()?.trim().parse().ok()?; + let m: f64 = parts.next()?.trim().parse().ok()?; + let sec: f64 = parts.next().map_or(Some(0.0), |p| p.trim().parse().ok())?; + if !(0.0..48.0).contains(&h) || !(0.0..60.0).contains(&m) { + return None; + } + Some((h * 3600.0 + m * 60.0 + sec) / 86_400.0) +} + +/// A date in days since the epoch, and whether it had a time of day. +fn parse_date(s: &str, format: &str) -> Option<(f64, bool)> { + let s = s.trim(); + if format.trim() == "X" || format.trim() == "x" { + let v: f64 = s.parse().ok()?; + let secs = if format.trim() == "x" { v / 1000.0 } else { v }; + return Some((secs / 86_400.0, true)); + } + if !format.contains('Y') && format.contains('H') { + return parse_clock(s).map(|t| (t, true)); + } + // YYYY-MM-DD (also with / or .), then an optional time. + let (date, time) = match s.split_once(['T', ' ']) { + Some((d, t)) => (d, Some(t)), + None => (s, None), + }; + let mut it = date.split(['-', '/', '.']); + let y: i64 = it.next()?.parse().ok()?; + let m: i64 = it.next()?.parse().ok()?; + let d: i64 = it.next()?.parse().ok()?; + if it.next().is_some() + || !(1..=12).contains(&m) + || !(1..=31).contains(&d) + || !(1..=9999).contains(&y) + { + return None; + } + let day = days_from_civil(y, m, d) as f64; + match time.map(parse_clock) { + Some(Some(t)) => Some((day + t, true)), + Some(None) => None, + None => Some((day, false)), + } +} + +/// `3d`, `1.5w`, `24h`, `30m`, `500ms` in days. +fn parse_duration(s: &str) -> Option { + let s = s.trim(); + let split = s.find(|c: char| !(c.is_ascii_digit() || c == '.'))?; + let (num, unit) = s.split_at(split); + let n: f64 = num.parse().ok()?; + let days = match unit { + "ms" => n / 86_400_000.0, + "s" => n / 86_400.0, + "m" => n / 1440.0, + "h" => n / 24.0, + "d" => n, + "w" => n * 7.0, + "M" => n * 30.0, + "y" => n * 365.0, + _ => return None, + }; + (days.is_finite() && days >= 0.0).then_some(days) +} + +fn parse(title: Option, body: &[String]) -> Chart { + let mut chart = Chart { + title, + ..Default::default() + }; + let mut format = String::from("YYYY-MM-DD"); + let mut ids: BTreeMap = BTreeMap::new(); + let mut section: Option = None; + let mut prev_end: Option = None; + for raw in body { + let line = raw.trim(); + if line.is_empty() || line.starts_with("%%") { + continue; + } + let kw = |k: &str| { + line.strip_prefix(k) + .filter(|r| r.is_empty() || r.starts_with(char::is_whitespace)) + .map(str::trim) + }; + if let Some(t) = kw("title") { + chart.title = Some(text::decode_label(t)); + continue; + } + if let Some(f) = kw("dateFormat") { + format = f.to_string(); + if !format.contains('Y') && format.contains('H') { + chart.timed = true; + } + continue; + } + if let Some(s) = kw("section") { + chart.sections.push(text::decode_label(s)); + section = Some(chart.sections.len() - 1); + continue; + } + if [ + "axisFormat", + "tickInterval", + "excludes", + "includes", + "todayMarker", + "weekday", + "weekend", + "inclusiveEndDates", + "topAxis", + "displayMode", + "accTitle", + "accDescr", + "click", + ] + .iter() + .any(|k| kw(k).is_some() || line.starts_with(&format!("{k}:"))) + { + continue; + } + let Some((name, meta)) = line.split_once(':') else { + continue; + }; + let mut items: Vec<&str> = meta + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + let mut task = Task { + name: text::decode_label(name), + section, + start: 0.0, + end: 0.0, + done: false, + active: false, + crit: false, + milestone: false, + }; + while let Some(&tag) = items.first() { + match tag { + "done" => task.done = true, + "active" => task.active = true, + "crit" => task.crit = true, + "milestone" => task.milestone = true, + _ => break, + } + items.remove(0); + } + let looks_like_start = + |s: &str| s.starts_with("after ") || parse_date(s, &format).is_some(); + let (id, start_s, end_s) = match items.as_slice() { + [] => (None, None, None), + [e] => (None, None, Some(*e)), + [a, e] if looks_like_start(a) => (None, Some(*a), Some(*e)), + [id, e] => (Some(*id), None, Some(*e)), + [id, s, e, ..] => (Some(*id), Some(*s), Some(*e)), + }; + let end_of = |ids: &BTreeMap, tasks: &[Task], refs: &str| -> Option { + refs.split_whitespace() + .filter_map(|r| ids.get(r).map(|&i| tasks[i].end)) + .reduce(f64::max) + }; + let fallback = prev_end.unwrap_or(0.0); + task.start = match start_s { + Some(s) if s.starts_with("after ") => { + end_of(&ids, &chart.tasks, &s[6..]).unwrap_or(fallback) + } + Some(s) => match parse_date(s, &format) { + Some((d, timed)) => { + chart.timed |= timed; + d + } + None => fallback, + }, + None => fallback, + }; + task.end = match end_s { + Some(e) if e.starts_with("until ") => e[6..] + .split_whitespace() + .filter_map(|r| ids.get(r).map(|&i| chart.tasks[i].start)) + .reduce(f64::min) + .unwrap_or(task.start + 1.0), + Some(e) => match parse_duration(e) { + Some(d) => task.start + d, + None => match parse_date(e, &format) { + Some((d, timed)) => { + chart.timed |= timed; + // An end date is the end instant, as in mermaid. + d + } + None => task.start + 1.0, + }, + }, + None => task.start + 1.0, + }; + if task.end < task.start { + task.end = task.start; + } + if let Some(id) = id { + ids.insert(id.to_string(), chart.tasks.len()); + } + prev_end = Some(task.end); + chart.tasks.push(task); + } + chart +} + +/// Render a chart into rows `inner` columns wide at most, plus its title. +pub fn render(title: Option, body: &[String], inner: usize) -> (String, Vec) { + let chart = parse(title, body); + let title = chart.title.clone().unwrap_or_else(|| "gantt".to_string()); + let mut rows: Vec = Vec::new(); + if chart.tasks.is_empty() { + rows.push(vec![("(no tasks)".to_string(), Class::Label)]); + return (title, rows); + } + let inner = inner.max(12); + // Name column: indent (under a section) + name + a space. + let longest = chart + .tasks + .iter() + .map(|t| text::width(&t.name) + if t.section.is_some() { 3 } else { 1 }) + .max() + .unwrap_or(4); + let name_w = longest + .min((inner * 2 / 5).max(8)) + .min(inner.saturating_sub(10)); + let chart_w = inner.saturating_sub(name_w + 1).max(1); + let t0 = chart + .tasks + .iter() + .map(|t| t.start) + .fold(f64::INFINITY, f64::min); + let mut t1 = chart + .tasks + .iter() + .map(|t| t.end) + .fold(f64::NEG_INFINITY, f64::max); + if t1.is_nan() || t1 <= t0 { + t1 = t0 + 1.0; + } + let span = t1 - t0; + let col = |t: f64| -> usize { + let c = ((t - t0) / span * chart_w as f64).floor(); + (c.max(0.0) as usize).min(chart_w - 1) + }; + let col_end = |t: f64| -> usize { + let c = ((t - t0) / span * chart_w as f64).ceil(); + (c.max(0.0) as usize).min(chart_w) + }; + + // Axis: tick labels over a ruler. + let fmt_tick = |t: f64| -> String { + let day = t.floor() as i64; + let (y, m, d) = civil_from_days(day); + if chart.timed && span <= 3.0 { + let mins = ((t - t.floor()) * 1440.0).round() as i64; + format!("{:02}:{:02}", mins / 60 % 24, mins % 60) + } else if civil_from_days(t0.floor() as i64).0 == civil_from_days(t1.floor() as i64).0 { + format!("{m:02}-{d:02}") + } else { + format!("{y:04}-{m:02}-{d:02}") + } + }; + // Ticks at a round interval (minutes to years) leaving room for their + // labels. + let label_w = text::width(&fmt_tick(t0)) + 2; + let max_ticks = (chart_w / label_w).max(1) as f64; + const STEPS: [f64; 22] = [ + 1.0 / 1440.0, + 2.0 / 1440.0, + 5.0 / 1440.0, + 10.0 / 1440.0, + 15.0 / 1440.0, + 30.0 / 1440.0, + 1.0 / 24.0, + 2.0 / 24.0, + 3.0 / 24.0, + 6.0 / 24.0, + 12.0 / 24.0, + 1.0, + 2.0, + 7.0, + 14.0, + 30.0, + 61.0, + 91.0, + 182.0, + 365.0, + 730.0, + 3650.0, + ]; + let step = STEPS + .iter() + .copied() + .find(|st| span / st <= max_ticks) + .unwrap_or(span / max_ticks); + let mut labels = vec![' '; chart_w]; + let mut ruler: Vec = vec!['─'; chart_w]; + let put = |labels: &mut Vec, c: usize, t: f64| { + let lbl: Vec = fmt_tick(t).chars().collect(); + let at = c.min(chart_w.saturating_sub(lbl.len())); + let free = at + lbl.len() <= chart_w + && labels[at..at + lbl.len()].iter().all(|&ch| ch == ' ') + && (at == 0 || labels[at - 1] == ' ') + && labels.get(at + lbl.len()).is_none_or(|&ch| ch == ' '); + if free { + labels[at..at + lbl.len()].copy_from_slice(&lbl); + } + }; + // The start is always labelled. + put(&mut labels, 0, t0); + let ticks: Vec = if step >= 28.0 { + // Month starts, every `k` months. + let k = (step / 30.4).round().max(1.0) as i64; + let (mut y, mut m, d) = civil_from_days(t0.floor() as i64); + if d > 1 { + m += 1; + } + let mut out = Vec::new(); + while out.len() < 64 { + if m > 12 { + y += (m - 1) / 12; + m = (m - 1) % 12 + 1; + } + let t = days_from_civil(y, m, 1) as f64; + if t > t1 { + break; + } + out.push(t); + m += k; + } + out + } else { + // Weeks start on Monday (the epoch was a Thursday). + let offset = if step >= 7.0 { 4.0 } else { 0.0 }; + let mut t = ((t0 - offset) / step).ceil() * step + offset; + let mut out = Vec::new(); + while t <= t1 + 1e-9 && out.len() < 64 { + out.push(t); + t += step; + } + out + }; + for t in ticks { + let c = col(t); + ruler[c] = '┼'; + if c > 0 { + put(&mut labels, c, t); + } + } + ruler[0] = '├'; + ruler[chart_w - 1] = '┤'; + let pad = " ".repeat(name_w + 1); + rows.push(vec![ + (pad.clone(), Class::Frame), + (labels.iter().collect(), Class::Label), + ]); + rows.push(vec![ + (pad, Class::Frame), + (ruler.iter().collect(), Class::Frame), + ]); + + let mut current: Option> = None; + for t in &chart.tasks { + if current != Some(t.section) { + current = Some(t.section); + if let Some(s) = t.section { + rows.push(vec![( + text::truncate(&chart.sections[s], inner), + Class::Title, + )]); + } + } + let indent = if t.section.is_some() { 2 } else { 0 }; + let name = text::truncate(&t.name, name_w.saturating_sub(indent + 1).max(1)); + let name_cell = format!("{}{}", " ".repeat(indent), name); + let name_cell = format!( + "{name_cell}{}", + " ".repeat((name_w + 1).saturating_sub(text::width(&name_cell))) + ); + let class = if t.crit { + Class::Alert + } else if t.done { + Class::Node + } else if t.active { + Class::Title + } else { + Class::Edge + }; + let (a, glyph, len) = if t.milestone { + (col(t.start), "◆", 1) + } else { + let a = col(t.start); + let b = col_end(t.end).max(a + 1); + let g = if t.done { + "█" + } else if t.active { + "▓" + } else { + "░" + }; + (a, g, b - a) + }; + rows.push(vec![ + (name_cell, Class::Plain), + (" ".repeat(a), Class::Frame), + (glyph.repeat(len), class), + ]); + } + (title, rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(src: &str) -> Vec { + src.lines().map(str::to_string).collect() + } + + #[test] + fn civil_dates_round_trip() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(days_from_civil(2024, 2, 29), 19_782); + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + assert_eq!(civil_from_days(-1), (1969, 12, 31)); + } + + #[test] + fn dates_durations_dependencies_and_tags() { + let c = parse( + None, + &body( + "dateFormat YYYY-MM-DD\nsection A\nFirst :done, a1, 2024-01-01, 3d\nSecond :active, a2, after a1, 1w\nThird :crit, 2024-01-20, 2024-01-22\nFourth : 24h\nGate :milestone, m1, after a2, 0d\nLast :until m1", + ), + ); + let base = days_from_civil(2024, 1, 1) as f64; + let t = &c.tasks; + assert_eq!((t[0].start - base, t[0].end - base), (0.0, 3.0)); + assert!(t[0].done); + assert_eq!((t[1].start - base, t[1].end - base), (3.0, 10.0)); + assert!(t[1].active); + assert_eq!((t[2].start - base, t[2].end - base), (19.0, 21.0)); + assert!(t[2].crit); + assert_eq!((t[3].start - base, t[3].end - base), (21.0, 22.0)); + assert!(t[4].milestone); + assert_eq!(t[4].start - base, 10.0); + assert_eq!(t[5].end - base, 10.0); + assert_eq!(c.sections, vec!["A"]); + } + + #[test] + fn time_only_charts() { + let c = parse( + None, + &body("dateFormat HH:mm\nWake : 06:00, 30m\nRun : after x, 1h"), + ); + assert!((c.tasks[0].start - 0.25).abs() < 1e-9); + assert!((c.tasks[1].start - (0.25 + 30.0 / 1440.0)).abs() < 1e-9); + assert!(c.timed); + } + + #[test] + fn three_thousand_tasks_render_in_linear_time() { + let mut src = String::from("dateFormat YYYY-MM-DD\nsection S\n"); + src.push_str("t0 :a0, 2024-01-01, 1d\n"); + for i in 1..3000 { + src.push_str(&format!("t{i} :a{i}, after a{} a{}, 1d\n", i - 1, i / 2)); + } + let t = std::time::Instant::now(); + let (_, rows) = render(None, &body(&src), 80); + assert!(t.elapsed() < std::time::Duration::from_secs(3)); + assert_eq!(rows.len(), 3000 + 3); + } +} diff --git a/src/layout/mermaid/mod.rs b/src/layout/mermaid/mod.rs index 6029457..afc50b2 100644 --- a/src/layout/mermaid/mod.rs +++ b/src/layout/mermaid/mod.rs @@ -14,6 +14,7 @@ mod class; mod engine; mod er; mod flowchart; +mod gantt; mod graph; mod state; mod text; @@ -195,7 +196,13 @@ fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> V chart_width, ), "pie" => render_pie(&legacy(), theme, &margin_str, chart_width), - "gantt" => render_gantt(&legacy(), theme, &margin_str, chart_width), + "gantt" => { + let (title, rows) = + gantt::render(src.title.clone(), &src.body, width.saturating_sub(4)); + let mut lines = framed(&title, &rows, &[], theme, width, margin); + lines.push(StyledLine::empty()); + lines + } _ => render_unknown( &legacy(), border_color, @@ -231,6 +238,8 @@ fn class_style(theme: &Theme, class: Class) -> SpanStyle { Class::Text => (&c.code_fg, true, false), Class::Label => (&c.code_fg, false, true), Class::Title => (&c.heading2, true, false), + Class::Plain => (&c.code_fg, false, false), + Class::Alert => (&c.admonition_warning, true, false), }; SpanStyle { fg: Some(fg.clone()), @@ -661,90 +670,6 @@ fn render_pie(source: &str, theme: &Theme, margin: &str, width: usize) -> Vec Vec { - let mut lines = Vec::new(); - let border_color = &theme.colors.table_border; - let text_color = &theme.colors.code_fg; - let colors = [ - &theme.colors.heading1, - &theme.colors.heading2, - &theme.colors.heading3, - ]; - - let mut title = "Gantt Chart".to_string(); - let mut tasks: Vec = Vec::new(); - let mut current_section; - - for raw_line in source.lines().skip(1) { - let l = raw_line.trim(); - if l.starts_with("title ") { - title = l.strip_prefix("title ").unwrap_or("").to_string(); - } else if l.starts_with("section ") { - current_section = l.strip_prefix("section ").unwrap_or("").to_string(); - tasks.push(format!("§{current_section}")); - } else if l.contains(':') && !l.starts_with("dateFormat") && !l.starts_with("axisFormat") { - let parts: Vec<&str> = l.splitn(2, ':').collect(); - tasks.push(parts[0].trim().to_string()); - } - } - - lines.push(make_header( - &title, - border_color, - &theme.colors.heading2, - margin, - width, - )); - - let mut color_idx = 0; - for task in &tasks { - let mut line = StyledLine::new(); - push_margin(&mut line, margin); - line.push(StyledSpan { - text: "│ ".to_string(), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - - if let Some(section) = task.strip_prefix('§') { - line.push(StyledSpan { - text: format!(" ── {section} ──"), - style: SpanStyle { - fg: Some(text_color.to_string()), - bold: true, - ..Default::default() - }, - }); - } else { - let color = colors[color_idx % colors.len()]; - let bar_len = 8 + (task.len() % 8); // Vary bar width - line.push(StyledSpan { - text: format!(" {:>16} ", task), - style: SpanStyle { - fg: Some(text_color.to_string()), - ..Default::default() - }, - }); - line.push(StyledSpan { - text: "█".repeat(bar_len), - style: SpanStyle { - fg: Some(color.clone()), - ..Default::default() - }, - }); - color_idx += 1; - } - lines.push(line); - } - - lines.push(make_footer(border_color, margin, width)); - lines.push(StyledLine::empty()); - - lines -} - fn render_unknown( source: &str, border_color: &str, diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs index 4cbbc60..b11598a 100644 --- a/src/layout/mermaid/tests.rs +++ b/src/layout/mermaid/tests.rs @@ -369,6 +369,26 @@ fn er_diagram_attributes_and_cardinalities() { ); } +// ── Gantt charts ── + +#[test] +fn gantt_bars_on_a_scaled_axis() { + expect( + "gantt\ntitle Release\ndateFormat YYYY-MM-DD\nsection Build\nDesign :done, d1, 2024-03-04, 5d\nCode :active, c1, after d1, 10d\nReview :crit, after c1, 3d\nShip :milestone, after c1, 0d", + 50, + r" +╭─ Release ──────────────────────────────────────╮ +│ 03-04 03-11 03-18 │ +│ ├─────────────┼─────────────┼──────┤ │ +│ Build │ +│ Design ██████████ │ +│ Code ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ Review ░░░░░░ │ +│ Ship ◆ │ +╰────────────────────────────────────────────────╯", + ); +} + // ── Properties over the corpus ── const WIDTHS: [usize; 5] = [24, 40, 60, 80, 120]; diff --git a/tests/fixtures/mermaid/gantt-basic.mmd b/tests/fixtures/mermaid/gantt-basic.mmd new file mode 100644 index 0000000..aa489bd --- /dev/null +++ b/tests/fixtures/mermaid/gantt-basic.mmd @@ -0,0 +1,9 @@ +gantt + title A Gantt Diagram + dateFormat YYYY-MM-DD + section Section + A task :a1, 2014-01-01, 30d + Another task :after a1, 20d + section Another + Task in Another :2014-01-12, 12d + another task :24d diff --git a/tests/fixtures/mermaid/gantt-features.mmd b/tests/fixtures/mermaid/gantt-features.mmd new file mode 100644 index 0000000..b7befc9 --- /dev/null +++ b/tests/fixtures/mermaid/gantt-features.mmd @@ -0,0 +1,30 @@ +gantt + dateFormat YYYY-MM-DD + title Adding GANTT diagram functionality to mermaid + excludes weekends + %% (`excludes` accepts specific dates in YYYY-MM-DD format, days of the week ("sunday") or "weekends", but not the word "weekdays".) + + section A section + Completed task :done, des1, 2014-01-06,2014-01-08 + Active task :active, des2, 2014-01-09, 3d + Future task : des3, after des2, 5d + Future task2 : des4, after des3, 5d + + section Critical tasks + Completed task in the critical line :crit, done, 2014-01-06,24h + Implement parser and jison :crit, done, after des1, 2d + Create tests for parser :crit, active, 3d + Future task in critical line :crit, 5d + Create tests for renderer :2d + Add to mermaid :until isadded + Functionality added :milestone, isadded, 2014-01-25, 0d + + section Documentation + Describe gantt syntax :active, a1, after des1, 3d + Add gantt diagram to demo page :after a1 , 20h + Add another diagram to demo page :doc1, after a1 , 48h + + section Last section + Describe gantt syntax :after doc1, 3d + Add gantt diagram to demo page :20h + Add another diagram to demo page :48h diff --git a/tests/fixtures/mermaid/gantt-hours.mmd b/tests/fixtures/mermaid/gantt-hours.mmd new file mode 100644 index 0000000..29927d3 --- /dev/null +++ b/tests/fixtures/mermaid/gantt-hours.mmd @@ -0,0 +1,8 @@ +gantt + title A morning + dateFormat HH:mm + axisFormat %H:%M + Wake up : milestone, w, 06:00, 0m + Exercise : ex, 06:00, 45m + Breakfast : after ex, 30m + Commute : crit, 1h diff --git a/tests/fixtures/mermaid/gantt-ink-roadmap.mmd b/tests/fixtures/mermaid/gantt-ink-roadmap.mmd new file mode 100644 index 0000000..ae5fe07 --- /dev/null +++ b/tests/fixtures/mermaid/gantt-ink-roadmap.mmd @@ -0,0 +1,15 @@ +gantt + title Q2 2026 Roadmap + dateFormat YYYY-MM-DD + section Core + Markdown Parser : 2026-04-01, 14d + Layout Engine : 2026-04-10, 21d + Theme System : 2026-04-15, 10d + section Features + Search : 2026-05-01, 7d + TOC Panel : 2026-05-05, 5d + Image Support : 2026-05-10, 14d + section Polish + Performance Tuning : 2026-06-01, 10d + Documentation : 2026-06-05, 7d + Release v1.0 : 2026-06-15, 3d diff --git a/tests/snapshots/plain_snapshots__plain_diagrams_md.snap b/tests/snapshots/plain_snapshots__plain_diagrams_md.snap index 1f7c289..e8d703b 100644 --- a/tests/snapshots/plain_snapshots__plain_diagrams_md.snap +++ b/tests/snapshots/plain_snapshots__plain_diagrams_md.snap @@ -164,20 +164,22 @@ expression: rendered ▎ Product Roadmap - ╭─ Q2 2026 Roadmap ────────────────────────────────────╮ - │  ── Core ── - │  Markdown Parser ███████████████ - │  Layout Engine █████████████ - │  Theme System ████████████ - │  ── Features ── - │  Search ██████████████ - │  TOC Panel █████████ - │  Image Support █████████████ - │  ── Polish ── - │  Performance Tuning ██████████ - │  Documentation █████████████ - │  Release v1.0 ████████████ - ╰──────────────────────────────────────────────────────╯ + ╭─ Q2 2026 Roadmap ──────────────────────────────────────────────────────────╮ + │  04-01 04-13 04-27 05-11 05-25 06-08  │ + │  ├───────┼────────┼────────┼─────────┼────────┼─────┤ │ + │ Core  │ + │  Markdown Parser ░░░░░░░░░░  │ + │  Layout Engine  ░░░░░░░░░░░░░░  │ + │  Theme System  ░░░░░░░  │ + │ Features  │ + │  Search  ░░░░░  │ + │  TOC Panel  ░░░░  │ + │  Image Support  ░░░░░░░░░░  │ + │ Polish  │ + │  Performance Tuning  ░░░░░░░░  │ + │  Documentation  ░░░░░  │ + │  Release v1.0  ░░ │ + ╰────────────────────────────────────────────────────────────────────────────╯ From 5997f8b3969c516c9d5eb33c5dbfba2cfece3fc6 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:12:56 +0200 Subject: [PATCH 6/8] feat(mermaid): mindmaps as trees, unsupported types as titled source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagram types that are not drawn (journey, gitGraph, timeline, quadrantChart, sankey, C4, requirementDiagram, xychart, block, packet, kanban, architecture, …) were printed as bare text under a generic header. They now show their source, hard-wrapped to the width, in a box titled 'mermaid: (not rendered)'. Mindmaps are drawn as an indentation tree with ├─ └─ branches (shapes, icons and classes stripped, deep trees capped at half the width). Sequence diagrams and pie charts keep their output at the usual width; when narrower, pie bars shrink and over-wide lines wrap behind the left border, so no diagram line exceeds the width (ASCII mode included). A 'pie title …' first line now sets the title. --- src/layout/mermaid/mindmap.rs | 141 ++++++++++ src/layout/mermaid/mod.rs | 245 +++++++++++++----- src/layout/mermaid/tests.rs | 67 +++++ .../fixtures/mermaid/gitgraph-unsupported.mmd | 10 + .../fixtures/mermaid/journey-unsupported.mmd | 9 + tests/fixtures/mermaid/mindmap-docs.mmd | 17 ++ tests/fixtures/mermaid/pie-pets.mmd | 4 + tests/fixtures/mermaid/pie-product.mmd | 7 + .../fixtures/mermaid/sequence-alice-john.mmd | 4 + tests/fixtures/mermaid/sequence-ink-auth.mmd | 8 + 10 files changed, 443 insertions(+), 69 deletions(-) create mode 100644 src/layout/mermaid/mindmap.rs create mode 100644 tests/fixtures/mermaid/gitgraph-unsupported.mmd create mode 100644 tests/fixtures/mermaid/journey-unsupported.mmd create mode 100644 tests/fixtures/mermaid/mindmap-docs.mmd create mode 100644 tests/fixtures/mermaid/pie-pets.mmd create mode 100644 tests/fixtures/mermaid/pie-product.mmd create mode 100644 tests/fixtures/mermaid/sequence-alice-john.mmd create mode 100644 tests/fixtures/mermaid/sequence-ink-auth.mmd diff --git a/src/layout/mermaid/mindmap.rs b/src/layout/mermaid/mindmap.rs new file mode 100644 index 0000000..7a1f7a3 --- /dev/null +++ b/src/layout/mermaid/mindmap.rs @@ -0,0 +1,141 @@ +//! Mindmaps: an indentation tree, drawn with `├─` / `└─` branches. + +use super::canvas::Class; +use super::text; + +type Row = Vec<(String, Class)>; + +struct Node { + label: String, + children: Vec, +} + +/// Strip a node's shape (`id((text))`, `id[text]`, `))text((`, …) and +/// class, leaving its text. +fn node_text(s: &str) -> String { + let s = s.split_once(":::").map_or(s, |(a, _)| a).trim(); + // The text sits inside the first opening delimiter run and its match. + let open = s.find(['(', '[', ')', '{']); + let inner = match open { + Some(i) => { + let body = &s[i..]; + let body = body.trim_start_matches(['(', '[', ')', '{']); + body.trim_end_matches([')', ']', '(', '}']) + } + None => s, + }; + text::decode_label(inner) +} + +fn parse(body: &[String]) -> (Vec, Option) { + let mut nodes: Vec = Vec::new(); + // (indent, node) of the current ancestry. + let mut stack: Vec<(usize, usize)> = Vec::new(); + let mut root: Option = None; + for raw in body { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with("%%") || trimmed.starts_with("::") { + continue; + } + let indent = raw.len() - raw.trim_start().len(); + let label = node_text(trimmed); + let i = nodes.len(); + nodes.push(Node { + label, + children: Vec::new(), + }); + while stack.last().is_some_and(|&(d, _)| d >= indent) { + stack.pop(); + } + match stack.last() { + Some(&(_, parent)) => nodes[parent].children.push(i), + None => match root { + // A second top-level line hangs off the root. + Some(r) => nodes[r].children.push(i), + None => root = Some(i), + }, + } + stack.push((indent, i)); + } + (nodes, root) +} + +/// The tree as rows at most `inner` columns wide. +pub fn render(body: &[String], inner: usize) -> Vec { + let (nodes, root) = parse(body); + let Some(root) = root else { + return vec![vec![("(empty)".to_string(), Class::Label)]]; + }; + let inner = inner.max(8); + let mut rows = Vec::new(); + for l in text::wrap(&nodes[root].label, inner) { + rows.push(vec![(l, Class::Title)]); + } + // Iterative depth-first walk: (node, prefix for its children). + let mut stack: Vec<(usize, String, bool)> = Vec::new(); + for (k, &c) in nodes[root].children.iter().enumerate().rev() { + stack.push((c, String::new(), k + 1 == nodes[root].children.len())); + } + while let Some((n, prefix, last)) = stack.pop() { + // Deep trees stop indenting once half the width is used. + let prefix = if text::width(&prefix) + 3 > inner / 2 { + text::truncate(&prefix, inner / 2 - 3) + } else { + prefix + }; + let branch = if last { "└─ " } else { "├─ " }; + let cont = if last { " " } else { "│ " }; + let room = inner.saturating_sub(text::width(&prefix) + 3).max(4); + let class = if nodes[n].children.is_empty() { + Class::Plain + } else { + Class::Text + }; + for (i, l) in text::wrap(&nodes[n].label, room).into_iter().enumerate() { + let lead = format!("{prefix}{}", if i == 0 { branch } else { cont }); + rows.push(vec![(lead, Class::Edge), (l, class)]); + } + let child_prefix = format!("{prefix}{cont}"); + let kids = &nodes[n].children; + for (k, &c) in kids.iter().enumerate().rev() { + stack.push((c, child_prefix.clone(), k + 1 == kids.len())); + } + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plain(rows: &[Row]) -> String { + rows.iter() + .map(|r| r.iter().map(|(t, _)| t.as_str()).collect::()) + .collect::>() + .join("\n") + } + + #[test] + fn indentation_becomes_a_tree() { + let body: Vec = " root((Ideas))\n A[First]\n A1\n ::icon(fa fa-book)\n A2\n B))Second((\n B1:::urgent" + .lines() + .map(str::to_string) + .collect(); + assert_eq!( + plain(&render(&body, 40)), + "Ideas\n├─ First\n│ ├─ A1\n│ └─ A2\n└─ Second\n └─ B1" + ); + } + + #[test] + fn deep_and_wide_trees_stay_in_width() { + let body: Vec = (0..2000) + .map(|i| format!("{}node number {i}", " ".repeat(i % 300))) + .collect(); + let rows = render(&body, 40); + for r in &rows { + let w: usize = r.iter().map(|(t, _)| text::width(t)).sum(); + assert!(w <= 40, "{w}"); + } + } +} diff --git a/src/layout/mermaid/mod.rs b/src/layout/mermaid/mod.rs index afc50b2..94401ca 100644 --- a/src/layout/mermaid/mod.rs +++ b/src/layout/mermaid/mod.rs @@ -16,6 +16,7 @@ mod er; mod flowchart; mod gantt; mod graph; +mod mindmap; mod state; mod text; @@ -56,7 +57,15 @@ pub fn render_mermaid_with( } } } - lines + // The sequence and pie renderers lay out for 56 columns (and the ASCII + // note marker is wider than the icon): wrap what does not fit. + let bar = if ascii { "|" } else { "│" }; + fit_lines( + lines, + margin + width, + &format!("{}{bar} ", " ".repeat(margin)), + &theme.colors.table_border, + ) } /// ASCII stand-ins for every drawing character the diagram renderers emit @@ -161,6 +170,34 @@ fn split_source(src: &str) -> Source { } } +/// Diagram types mermaid knows that are not drawn here: shown as source. +const UNSUPPORTED: &[&str] = &[ + "journey", + "gitGraph", + "timeline", + "quadrantChart", + "sankey-beta", + "sankey", + "requirementDiagram", + "xychart-beta", + "xychart", + "block-beta", + "block", + "packet-beta", + "packet", + "kanban", + "architecture-beta", + "architecture", + "C4Context", + "C4Container", + "C4Component", + "C4Dynamic", + "C4Deployment", + "zenuml", + "radar-beta", + "treemap-beta", +]; + fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> Vec { let cleaned = text::clean_source(source); let src = split_source(&cleaned); @@ -174,7 +211,11 @@ fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> V let chart_width = width.min(56); // The legacy renderers below read the source from its first line. let legacy = || { - let mut s = format!("{} {}\n", src.keyword, src.header); + // `pie title X` carries the title on the first line. + let mut s = match src.header.strip_prefix("title ") { + Some(t) if src.keyword == "pie" => format!("pie\ntitle {t}\n"), + _ => format!("{} {}\n", src.keyword, src.header), + }; for l in &src.body { s.push_str(l); s.push('\n'); @@ -185,7 +226,7 @@ fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> V if let Some((g, title)) = parse_graph(&src) { return render_graph(&g, &title, theme, width, margin); } - match src.keyword.as_str() { + let mut lines = match src.keyword.as_str() { "sequenceDiagram" => render_sequence( &legacy(), diagram_color, @@ -199,19 +240,110 @@ fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> V "gantt" => { let (title, rows) = gantt::render(src.title.clone(), &src.body, width.saturating_sub(4)); - let mut lines = framed(&title, &rows, &[], theme, width, margin); - lines.push(StyledLine::empty()); - lines + framed(&title, &rows, &[], theme, width, margin) } - _ => render_unknown( - &legacy(), - border_color, - text_color, - diagram_color, - &margin_str, - chart_width, - ), + "mindmap" => { + let rows = mindmap::render(&src.body, width.saturating_sub(4)); + let title = src.title.clone().unwrap_or_else(|| "mindmap".to_string()); + framed_left(&title, &rows, theme, width, margin) + } + _ => unsupported(&src, theme, width, margin), + }; + if lines.last().is_some_and(|l| !l.spans.is_empty()) { + lines.push(StyledLine::empty()); + } + lines +} + +/// A diagram type that is not drawn: its source in a titled box. +fn unsupported(src: &Source, theme: &Theme, width: usize, margin: usize) -> Vec { + let inner = width.saturating_sub(4).max(1); + let kind = if src.keyword.is_empty() { + "diagram".to_string() + } else if UNSUPPORTED.contains(&src.keyword.as_str()) { + src.keyword.clone() + } else { + text::truncate(&src.keyword, 24) + }; + let title = format!("mermaid: {kind} (not rendered)"); + let mut rows: Vec> = Vec::new(); + let header = format!("{} {}", src.keyword, src.header); + for line in std::iter::once(header.trim_end()).chain(src.body.iter().map(|l| l.trim_end())) { + // Hard-wrap, keeping indentation (this is code, not prose). + let mut piece = String::new(); + let mut w = 0; + for g in unicode_segmentation::UnicodeSegmentation::graphemes(line, true) { + let gw = UnicodeWidthStr::width(g); + if w + gw > inner && w > 0 { + rows.push(vec![(std::mem::take(&mut piece), Class::Plain)]); + w = 0; + } + piece.push_str(g); + w += gw; + } + rows.push(vec![(piece, Class::Plain)]); + } + while rows + .last() + .is_some_and(|r| r.iter().all(|(t, _)| t.trim().is_empty())) + { + rows.pop(); } + framed_left(&title, &rows, theme, width, margin) +} + +/// Wrap any line wider than `max` columns: the overflow continues on the +/// next line behind the left border. +fn fit_lines(lines: Vec, max: usize, lead: &str, border: &str) -> Vec { + use unicode_segmentation::UnicodeSegmentation; + let mut out = Vec::with_capacity(lines.len()); + for line in lines { + if line.width() <= max { + out.push(line); + continue; + } + let lead_w = UnicodeWidthStr::width(lead); + let mut cur = StyledLine::new(); + let mut w = 0; + for span in line.spans { + let mut piece = String::new(); + for g in span.text.graphemes(true) { + let gw = UnicodeWidthStr::width(g); + if w + gw > max { + if !piece.is_empty() { + cur.push(StyledSpan { + text: std::mem::take(&mut piece), + style: span.style.clone(), + }); + } + out.push(std::mem::take(&mut cur)); + cur.push(StyledSpan { + text: lead.to_string(), + style: SpanStyle { + fg: Some(border.to_string()), + ..Default::default() + }, + }); + w = lead_w; + if w + gw > max { + // Not even one more grapheme fits behind the border. + w = 0; + cur = StyledLine::new(); + } + } + piece.push_str(g); + w += gw; + } + if !piece.is_empty() { + cur.push(StyledSpan { + text: piece, + style: span.style, + }); + } + } + out.push(cur); + } + out } /// Parse a node-and-edge diagram into the graph model, with its title. @@ -374,6 +506,17 @@ fn edge_list( framed(title, &out, &g.notes, theme, width, margin) } +/// [`framed`] with the rows left-aligned (text, not a drawing). +fn framed_left( + title: &str, + rows: &[Vec<(String, Class)>], + theme: &Theme, + width: usize, + margin: usize, +) -> Vec { + framed_impl(title, rows, &[], theme, width, margin, false) +} + /// Wrap drawn rows in the diagram frame: a titled top border, `│` sides and /// a bottom border, as wide as the content needs (at most `width`). Notes /// follow the rows inside the frame. @@ -384,6 +527,18 @@ fn framed( theme: &Theme, width: usize, margin: usize, +) -> Vec { + framed_impl(title, rows, notes, theme, width, margin, true) +} + +fn framed_impl( + title: &str, + rows: &[Vec<(String, Class)>], + notes: &[String], + theme: &Theme, + width: usize, + margin: usize, + center: bool, ) -> Vec { let border = &theme.colors.table_border; let margin_str = " ".repeat(margin); @@ -429,7 +584,11 @@ fn framed( line.push(side(" │")); lines.push(line); }; - let offset = (inner.saturating_sub(content_w)) / 2; + let offset = if center { + (inner.saturating_sub(content_w)) / 2 + } else { + 0 + }; for r in rows { let w = row_w(r); let mut runs = Vec::new(); @@ -620,7 +779,8 @@ fn render_pie(source: &str, theme: &Theme, margin: &str, width: usize) -> Vec 0.0 { value / total * 100.0 @@ -670,59 +830,6 @@ fn render_pie(source: &str, theme: &Theme, margin: &str, width: usize) -> Vec Vec { - let mut lines = Vec::new(); - - lines.push(make_header( - "mermaid diagram", - border_color, - title_color, - margin, - width, - )); - - for raw_line in source.lines() { - let mut line = StyledLine::new(); - push_margin(&mut line, margin); - line.push(StyledSpan { - text: "│ ".to_string(), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - line.push(StyledSpan { - text: raw_line.to_string(), - style: SpanStyle { - fg: Some(text_color.to_string()), - ..Default::default() - }, - }); - lines.push(line); - } - - let mut footer = StyledLine::new(); - push_margin(&mut footer, margin); - footer.push(StyledSpan { - text: format!("╰{}╯", "─".repeat(55)), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - lines.push(footer); - lines.push(StyledLine::empty()); - - lines -} - // --- Helpers --- /// Create a diagram header line: ╭─ title ─────────────╮ diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs index b11598a..ec51dc3 100644 --- a/src/layout/mermaid/tests.rs +++ b/src/layout/mermaid/tests.rs @@ -389,6 +389,73 @@ fn gantt_bars_on_a_scaled_axis() { ); } +// ── Everything else ── + +#[test] +fn unsupported_types_show_their_source_in_a_titled_box() { + expect( + "gitGraph\n commit\n branch develop", + 40, + r" +╭─ mermaid: gitGraph (not rendered) ─╮ +│ gitGraph │ +│ commit │ +│ branch develop │ +╰────────────────────────────────────╯", + ); + let out = render("somethingNew\nfoo --> bar", 40); + assert!( + out.starts_with("╭─ mermaid: somethingNew (not render"), + "{out}" + ); +} + +#[test] +fn mindmaps_are_trees() { + expect( + "mindmap\n root((Plan))\n Goals\n Ship\n Risks", + 40, + r" +╭─ mindmap ────────╮ +│ Plan │ +│ ├─ Goals │ +│ │ └─ Ship │ +│ └─ Risks │ +╰──────────────────╯", + ); +} + +#[test] +fn sequence_and_pie_wrap_when_narrow_and_keep_their_form_when_not() { + let seq = "sequenceDiagram\n Alice->>John: Hello John, how are you?"; + expect( + seq, + 60, + r" +╭─ sequence diagram ───────────────────────────────────╮ +│ Alice ──▶ John: Hello John, how are you? +╰──────────────────────────────────────────────────────╯", + ); + for line in render(seq, 24).lines() { + assert!(text::width(line) <= 24, "{line}"); + } + let pie = "pie title Pets\n \"Dogs\" : 3\n \"Cats\" : 1"; + expect( + pie, + 60, + r" +╭─ Pets ───────────────────────────────────────────────╮ +│ Dogs ██████████████████████ 75.0% +│ Cats ███████ 25.0% +╰──────────────────────────────────────────────────────╯", + ); +} + +#[test] +fn corpus_is_big_enough() { + assert!(corpus().len() >= 20, "{} diagrams", corpus().len()); +} + // ── Properties over the corpus ── const WIDTHS: [usize; 5] = [24, 40, 60, 80, 120]; diff --git a/tests/fixtures/mermaid/gitgraph-unsupported.mmd b/tests/fixtures/mermaid/gitgraph-unsupported.mmd new file mode 100644 index 0000000..d611811 --- /dev/null +++ b/tests/fixtures/mermaid/gitgraph-unsupported.mmd @@ -0,0 +1,10 @@ +gitGraph + commit + commit + branch develop + checkout develop + commit + commit + checkout main + merge develop + commit diff --git a/tests/fixtures/mermaid/journey-unsupported.mmd b/tests/fixtures/mermaid/journey-unsupported.mmd new file mode 100644 index 0000000..9f7cd6d --- /dev/null +++ b/tests/fixtures/mermaid/journey-unsupported.mmd @@ -0,0 +1,9 @@ +journey + title My working day + section Go to work + Make tea: 5: Me + Go upstairs: 3: Me + Do work: 1: Me, Cat + section Go home + Go downstairs: 5: Me + Sit down: 5: Me diff --git a/tests/fixtures/mermaid/mindmap-docs.mmd b/tests/fixtures/mermaid/mindmap-docs.mmd new file mode 100644 index 0000000..98a985b --- /dev/null +++ b/tests/fixtures/mermaid/mindmap-docs.mmd @@ -0,0 +1,17 @@ +mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid diff --git a/tests/fixtures/mermaid/pie-pets.mmd b/tests/fixtures/mermaid/pie-pets.mmd new file mode 100644 index 0000000..1e1fcd9 --- /dev/null +++ b/tests/fixtures/mermaid/pie-pets.mmd @@ -0,0 +1,4 @@ +pie title Pets adopted by volunteers + "Dogs" : 386 + "Cats" : 85 + "Rats" : 15 diff --git a/tests/fixtures/mermaid/pie-product.mmd b/tests/fixtures/mermaid/pie-product.mmd new file mode 100644 index 0000000..eaabc5c --- /dev/null +++ b/tests/fixtures/mermaid/pie-product.mmd @@ -0,0 +1,7 @@ +%%{init: {"pie": {"textPosition": 0.5}} }%% +pie showData + title Key elements in Product X + "Calcium" : 42.96 + "Potassium" : 50.05 + "Magnesium" : 10.01 + "Iron" : 5 diff --git a/tests/fixtures/mermaid/sequence-alice-john.mmd b/tests/fixtures/mermaid/sequence-alice-john.mmd new file mode 100644 index 0000000..be98693 --- /dev/null +++ b/tests/fixtures/mermaid/sequence-alice-john.mmd @@ -0,0 +1,4 @@ +sequenceDiagram + Alice->>John: Hello John, how are you? + John-->>Alice: Great! + Alice-)John: See you later! diff --git a/tests/fixtures/mermaid/sequence-ink-auth.mmd b/tests/fixtures/mermaid/sequence-ink-auth.mmd new file mode 100644 index 0000000..8e6eb07 --- /dev/null +++ b/tests/fixtures/mermaid/sequence-ink-auth.mmd @@ -0,0 +1,8 @@ +sequenceDiagram + Client->>API Gateway: POST /auth/login + API Gateway->>Auth Service: Validate Credentials + Auth Service->>Database: Query User + Database-->>Auth Service: User Record + Auth Service-->>API Gateway: JWT Token + API Gateway-->>Client: 200 OK + Token + Note over Client,API Gateway: The token is cached for an hour From ec2b420c298cec54789d9a719f3a9427cef5aa01 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:16:55 +0200 Subject: [PATCH 7/8] fix(mermaid): linear cluster bookkeeping, edge kinds in the list form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subgraph membership for edges that name a subgraph was computed per cluster per node (quadratic in thousands of subgraphs); it is now one pass. Diagrams with more than 200 subgraphs are listed rather than laid out, mindmap prefixes stop growing instead of being re-truncated per node, and the list form indexes outgoing edges instead of scanning them per node. The list form is left-aligned and now shows each edge's markers, line style and cardinalities (◁── for inheritance, ┄┄ for dotted, '1 ──buys── *▶'). --- src/layout/mermaid/engine.rs | 6 ++++- src/layout/mermaid/graph.rs | 27 ++++++++++++++----- src/layout/mermaid/mindmap.rs | 13 +++++----- src/layout/mermaid/mod.rs | 49 ++++++++++++++++++++++++++++------- 4 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/layout/mermaid/engine.rs b/src/layout/mermaid/engine.rs index a4227cc..9f1b864 100644 --- a/src/layout/mermaid/engine.rs +++ b/src/layout/mermaid/engine.rs @@ -109,7 +109,11 @@ struct NodeBox { } pub fn layout(g: &Graph, p: &Params, avail: usize) -> Result { - if g.nodes.len() > MAX_NODES || g.edges.len() > MAX_EDGES || g.nodes.is_empty() { + if g.nodes.len() > MAX_NODES + || g.edges.len() > MAX_EDGES + || g.clusters.len() > MAX_NODES + || g.nodes.is_empty() + { return Err(Fail::TooBig); } let horiz = p.dir.horizontal(); diff --git a/src/layout/mermaid/graph.rs b/src/layout/mermaid/graph.rs index 9277c3c..fea0929 100644 --- a/src/layout/mermaid/graph.rs +++ b/src/layout/mermaid/graph.rs @@ -258,16 +258,25 @@ impl Graph { /// to draw for a subgraph id. Nodes only ever mentioned by such an id /// are dropped. pub fn redirect_cluster_edges(&mut self) { + if self.clusters.is_empty() { + return; + } + // Members of every cluster, nested ones included, in node order. + let mut members: Vec> = vec![Vec::new(); self.clusters.len()]; + for (m, node) in self.nodes.iter().enumerate() { + for c in self.cluster_path(node.cluster) { + members[c].push(m); + } + } let mut redirect: BTreeMap = BTreeMap::new(); for (ci, cl) in self.clusters.iter().enumerate() { let Some(n) = self.find(&cl.id) else { continue }; - if self.nodes[n].declared { + if self.nodes[n].declared || redirect.contains_key(&n) { continue; } - let members: Vec = (0..self.nodes.len()) - .filter(|&m| m != n && self.cluster_path(self.nodes[m].cluster).contains(&ci)) - .collect(); - if let (Some(&first), Some(&last)) = (members.first(), members.last()) { + let mut inner = members[ci].iter().copied().filter(|&m| m != n); + if let Some(first) = inner.next() { + let last = inner.next_back().unwrap_or(first); redirect.insert(n, (first, last)); } } @@ -290,10 +299,16 @@ impl Graph { if gone.is_empty() { return; } + let mut drop = vec![false; self.nodes.len()]; + for &g in gone { + if let Some(d) = drop.get_mut(g) { + *d = true; + } + } let mut map = vec![usize::MAX; self.nodes.len()]; let mut kept = Vec::new(); for (i, n) in self.nodes.drain(..).enumerate() { - if !gone.contains(&i) { + if !drop[i] { map[i] = kept.len(); kept.push(n); } diff --git a/src/layout/mermaid/mindmap.rs b/src/layout/mermaid/mindmap.rs index 7a1f7a3..ea39a65 100644 --- a/src/layout/mermaid/mindmap.rs +++ b/src/layout/mermaid/mindmap.rs @@ -77,12 +77,6 @@ pub fn render(body: &[String], inner: usize) -> Vec { stack.push((c, String::new(), k + 1 == nodes[root].children.len())); } while let Some((n, prefix, last)) = stack.pop() { - // Deep trees stop indenting once half the width is used. - let prefix = if text::width(&prefix) + 3 > inner / 2 { - text::truncate(&prefix, inner / 2 - 3) - } else { - prefix - }; let branch = if last { "└─ " } else { "├─ " }; let cont = if last { " " } else { "│ " }; let room = inner.saturating_sub(text::width(&prefix) + 3).max(4); @@ -95,7 +89,12 @@ pub fn render(body: &[String], inner: usize) -> Vec { let lead = format!("{prefix}{}", if i == 0 { branch } else { cont }); rows.push(vec![(lead, Class::Edge), (l, class)]); } - let child_prefix = format!("{prefix}{cont}"); + // Deep trees stop indenting once half the width is used. + let child_prefix = if text::width(&prefix) + 6 > inner / 2 { + prefix.clone() + } else { + format!("{prefix}{cont}") + }; let kids = &nodes[n].children; for (k, &c) in kids.iter().enumerate().rev() { stack.push((c, child_prefix.clone(), k + 1 == kids.len())); diff --git a/src/layout/mermaid/mod.rs b/src/layout/mermaid/mod.rs index 94401ca..b4111b1 100644 --- a/src/layout/mermaid/mod.rs +++ b/src/layout/mermaid/mod.rs @@ -472,27 +472,56 @@ fn edge_list( } }; let mut has_edges = vec![false; g.nodes.len()]; + let mut outgoing_of: Vec> = vec![Vec::new(); g.nodes.len()]; for e in &g.edges { has_edges[e.from] = true; has_edges[e.to] = true; + outgoing_of[e.from].push(e); } for (i, n) in g.nodes.iter().enumerate() { - let outgoing: Vec<&graph::Edge> = g.edges.iter().filter(|e| e.from == i).collect(); + let outgoing = std::mem::take(&mut outgoing_of[i]); if outgoing.is_empty() && has_edges[i] { continue; } push_wrapped(&mut out, 0, vec![(one_line(&n.label), Class::Text)]); for e in outgoing { - let head = if e.end == Marker::None { - "──" - } else { - "──▶" + // The edge drawn left to right: its markers, line style, label. + let glyph = |m: Marker, at_end: bool| match m { + Marker::None => "", + Marker::Arrow => { + if at_end { + "▶" + } else { + "◀" + } + } + Marker::Triangle => { + if at_end { + "▷" + } else { + "◁" + } + } + Marker::DiamondFilled => "◆", + Marker::DiamondOpen => "◇", + Marker::Circle => "○", + Marker::Cross => "×", }; - let arrow = if e.label.is_empty() { - format!("{head} ") - } else { - format!("──{}{} ", one_line(&e.label), head) + let line = match e.style { + graph::LineStyle::Dotted => "┄┄", + graph::LineStyle::Thick => "━━", + _ => "──", }; + let mut arrow = format!("{}{line}", glyph(e.start, false)); + if !e.label.is_empty() { + arrow.push_str(&one_line(&e.label)); + arrow.push_str(line); + } + if let Some((a, b)) = &e.cards { + arrow = format!("{a} {arrow} {b}").trim().to_string(); + } + arrow.push_str(glyph(e.end, true)); + arrow.push(' '); push_wrapped( &mut out, 2, @@ -503,7 +532,7 @@ fn edge_list( ); } } - framed(title, &out, &g.notes, theme, width, margin) + framed_impl(title, &out, &g.notes, theme, width, margin, false) } /// [`framed`] with the rows left-aligned (text, not a drawing). From 7bab33ea8054c6385529e0e69ba233c9541fee38 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:16:55 +0200 Subject: [PATCH 8/8] docs(readme): list the mermaid diagram types that are drawn --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c0ce6e..11eec3d 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Remote (`http`/`https`) images are **not** fetched by default — a document you ### Mermaid diagrams -Flowcharts, sequence diagrams, pie charts, and Gantt charts rendered as ASCII art. No external tools needed. +Flowcharts (every node shape, link style and direction, with subgraphs drawn as frames), state diagrams, class diagrams and ER diagrams are laid out as real box-and-arrow drawings that fit your terminal width; Gantt charts get a scaled time axis, mindmaps a tree, and sequence diagrams and pie charts a compact text form. Other diagram types show their source in a labelled box. Box-drawing characters by default, plain ASCII with `--ascii`. No external tools needed. ### GitHub-style admonitions