diff --git a/README.md b/README.md index 0203ab8..67a2f3a 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,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 diff --git a/src/layout/mermaid.rs b/src/layout/mermaid.rs deleted file mode 100644 index 478596c..0000000 --- a/src/layout/mermaid.rs +++ /dev/null @@ -1,733 +0,0 @@ -use super::{SpanStyle, StyledLine, StyledSpan}; -use crate::theme::Theme; -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 { - 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 { - for line in &mut lines { - for span in &mut line.spans { - if let std::borrow::Cow::Owned(t) = crate::glyphs::asciify(&span.text) { - span.text = t; - } - } - } - } - lines -} - -fn render_diagram(source: &str, theme: &Theme, width: usize, margin: usize) -> Vec { - let trimmed = source.trim(); - let diagram_color = &theme.colors.heading2; - let text_color = &theme.colors.code_fg; - let border_color = &theme.colors.table_border; - let arrow_color = &theme.colors.heading3; - - let margin_str = " ".repeat(margin); - - let chart_width = width.min(56); - - // 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, - 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, - 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 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)); - } - } - } - - lines.push(make_header( - "flowchart", - border_color, - diagram_color, - margin, - width, - )); - - // 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)); - } - - // 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) - }; - 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)); - } - - if edges.is_empty() { - for (_, label) in &nodes { - lines.push(make_node_line(label, border_color, text_color, margin)); - } - } - - lines.push(make_footer(border_color, margin, width)); - lines.push(StyledLine::empty()); - - lines -} - -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), - style: SpanStyle { - fg: Some(text_color.to_string()), - bold: true, - ..Default::default() - }, - }); - result -} - -fn render_sequence( - 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 participants: Vec = Vec::new(); - - lines.push(make_header( - "sequence diagram", - border_color, - diagram_color, - margin, - width, - )); - - for raw_line in source.lines().skip(1) { - let l = raw_line.trim(); - if l.is_empty() { - continue; - } - - if l.starts_with("participant ") { - let name = l.strip_prefix("participant ").unwrap_or("").trim(); - if !participants.contains(&name.to_string()) { - participants.push(name.to_string()); - } - } else if l.contains("->>") || l.contains("-->>") || l.contains("->") || l.contains("-->") { - // Parse: Alice->>Bob: Hello - let (arrow, from, to, msg) = parse_sequence_line(l); - let arrow_sym = if arrow.contains(">>") { - "──▶" - } else { - "───" - }; - - 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: format!("{from} "), - style: SpanStyle { - fg: Some(text_color.to_string()), - bold: true, - ..Default::default() - }, - }); - line.push(StyledSpan { - text: format!("{arrow_sym} "), - style: SpanStyle { - fg: Some(arrow_color.to_string()), - ..Default::default() - }, - }); - line.push(StyledSpan { - text: to.to_string(), - style: SpanStyle { - fg: Some(text_color.to_string()), - bold: true, - ..Default::default() - }, - }); - if !msg.is_empty() { - line.push(StyledSpan { - text: format!(": {msg}"), - style: SpanStyle { - fg: Some(text_color.to_string()), - italic: true, - ..Default::default() - }, - }); - } - lines.push(line); - } else if l.starts_with("Note") { - 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: format!(" 📝 {l}"), - style: SpanStyle { - fg: Some(text_color.to_string()), - italic: true, - ..Default::default() - }, - }); - lines.push(line); - } - } - - lines.push(make_footer(border_color, margin, width)); - lines.push(StyledLine::empty()); - - lines -} - -fn render_pie(source: &str, theme: &Theme, margin: &str, width: usize) -> Vec { - let mut lines = Vec::new(); - let border_color = &theme.colors.table_border; - let colors = [ - &theme.colors.heading1, - &theme.colors.heading2, - &theme.colors.heading3, - &theme.colors.heading4, - &theme.colors.heading5, - &theme.colors.heading6, - &theme.colors.admonition_note, - &theme.colors.admonition_tip, - ]; - - let mut title = "Pie Chart".to_string(); - let mut slices: Vec<(String, f64)> = Vec::new(); - - 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.contains(':') { - let parts: Vec<&str> = l.splitn(2, ':').collect(); - if parts.len() == 2 { - let label = parts[0].trim().trim_matches('"').to_string(); - if let Ok(val) = parts[1].trim().parse::() { - slices.push((label, val)); - } - } - } - } - - let total: f64 = slices.iter().map(|(_, v)| v).sum(); - - lines.push(make_header( - &title, - border_color, - &theme.colors.heading2, - margin, - width, - )); - - // Render as horizontal bar chart - let max_bar = 30usize; - for (i, (label, value)) in slices.iter().enumerate() { - let pct = if total > 0.0 { - value / total * 100.0 - } else { - 0.0 - }; - let bar_len = ((pct / 100.0) * max_bar as f64) as usize; - let color = colors[i % colors.len()]; - - 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: format!("{:>12} ", label), - style: SpanStyle { - fg: Some(color.clone()), - bold: true, - ..Default::default() - }, - }); - line.push(StyledSpan { - text: "█".repeat(bar_len), - style: SpanStyle { - fg: Some(color.clone()), - ..Default::default() - }, - }); - line.push(StyledSpan { - text: format!(" {:.1}%", pct), - style: SpanStyle { - fg: Some(color.clone()), - ..Default::default() - }, - }); - lines.push(line); - } - - lines.push(make_footer(border_color, margin, width)); - lines.push(StyledLine::empty()); - - lines -} - -fn render_gantt(source: &str, theme: &Theme, margin: &str, width: usize) -> 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, - text_color: &str, - title_color: &str, - margin: &str, - width: usize, -) -> 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 ─────────────╮ -fn make_header( - title: &str, - border_color: &str, - title_color: &str, - margin: &str, - width: usize, -) -> StyledLine { - 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: format!("{title} "), - style: SpanStyle { - fg: Some(title_color.to_string()), - bold: true, - ..Default::default() - }, - }); - // Display columns, not bytes: a CJK or emoji title occupies more columns - // than `len()` reports characters (and multibyte text fewer), which used - // to skew the top border. - let used = 3 + title.width() + 2; // "╭─ " + title + " ╮" - let remaining = width.saturating_sub(used); - line.push(StyledSpan { - text: format!("{}╮", "─".repeat(remaining)), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - line -} - -/// Create a diagram footer line: ╰─────────────────────╯ -fn make_footer(border_color: &str, margin: &str, width: usize) -> StyledLine { - let mut line = StyledLine::new(); - push_margin(&mut line, margin); - line.push(StyledSpan { - text: format!("╰{}╯", "─".repeat(width.saturating_sub(2))), - style: SpanStyle { - fg: Some(border_color.to_string()), - ..Default::default() - }, - }); - line -} - -fn push_margin(line: &mut StyledLine, margin: &str) { - if !margin.is_empty() { - line.push(StyledSpan { - text: margin.to_string(), - style: SpanStyle::default(), - }); - } -} - -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 { - if let Some(pos) = line.find(arrow) { - let from = line[..pos].trim().to_string(); - let after = &line[pos + arrow.len()..]; - let (to, msg) = if let Some(colon_pos) = after.find(':') { - ( - after[..colon_pos].trim().to_string(), - after[colon_pos + 1..].trim().to_string(), - ) - } else { - (after.trim().to_string(), String::new()) - }; - return (arrow.to_string(), from, to, msg); - } - } - ( - String::new(), - line.to_string(), - String::new(), - String::new(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn line_width(line: &StyledLine) -> usize { - line.spans.iter().map(|s| s.text.as_str().width()).sum() - } - - // The header border must be sized by the title's display columns, not its - // byte length: a CJK title (3 bytes but 2 columns per char) skewed the top - // border 10 columns off the footer. - #[test] - fn header_width_matches_footer_regardless_of_title_script() { - for title in ["flowchart", "日本語のパイ図", "⚠️ warning ⚠️", "Grüße"] { - let header = make_header(title, "#888888", "#ffffff", "", 56); - let footer = make_footer("#888888", "", 56); - assert_eq!( - line_width(&header), - 56, - "header for {title:?} is {} cols", - line_width(&header) - ); - assert_eq!(line_width(&header), line_width(&footer)); - } - } -} diff --git a/src/layout/mermaid/canvas.rs b/src/layout/mermaid/canvas.rs new file mode 100644 index 0000000..357abe8 --- /dev/null +++ b/src/layout/mermaid/canvas.rs @@ -0,0 +1,319 @@ +//! 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, + /// Ordinary text (Gantt task names). + Plain, + /// Highlighted items (critical Gantt tasks). + Alert, +} + +#[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/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/engine.rs b/src/layout/mermaid/engine.rs new file mode 100644 index 0000000..9f1b864 --- /dev/null +++ b/src/layout/mermaid/engine.rs @@ -0,0 +1,1627 @@ +//! 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.clusters.len() > MAX_NODES + || 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 + }; + // 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]; + 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) { + 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/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/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/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/graph.rs b/src/layout/mermaid/graph.rs new file mode 100644 index 0000000..fea0929 --- /dev/null +++ b/src/layout/mermaid/graph.rs @@ -0,0 +1,330 @@ +//! 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. +#[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. +#[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) { + 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 || redirect.contains_key(&n) { + continue; + } + 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)); + } + } + 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 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 !drop[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/mindmap.rs b/src/layout/mermaid/mindmap.rs new file mode 100644 index 0000000..ea39a65 --- /dev/null +++ b/src/layout/mermaid/mindmap.rs @@ -0,0 +1,140 @@ +//! 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() { + 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)]); + } + // 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())); + } + } + 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 new file mode 100644 index 0000000..b4111b1 --- /dev/null +++ b/src/layout/mermaid/mod.rs @@ -0,0 +1,977 @@ +//! 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 class; +mod engine; +mod er; +mod flowchart; +mod gantt; +mod graph; +mod mindmap; +mod state; +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. +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 ascii { + for line in &mut lines { + for span in &mut line.spans { + if let std::borrow::Cow::Owned(t) = asciify(&span.text) { + span.text = t; + } + } + } + } + // 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 +/// (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, + } +} + +/// 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); + let diagram_color = &theme.colors.heading2; + let text_color = &theme.colors.code_fg; + let border_color = &theme.colors.table_border; + let arrow_color = &theme.colors.heading3; + + let margin_str = " ".repeat(margin); + + let chart_width = width.min(56); + // The legacy renderers below read the source from its first line. + let legacy = || { + // `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'); + } + s + }; + + if let Some((g, title)) = parse_graph(&src) { + return render_graph(&g, &title, theme, width, margin); + } + let mut lines = match src.keyword.as_str() { + "sequenceDiagram" => render_sequence( + &legacy(), + diagram_color, + text_color, + border_color, + arrow_color, + &margin_str, + chart_width, + ), + "pie" => render_pie(&legacy(), theme, &margin_str, chart_width), + "gantt" => { + let (title, rows) = + gantt::render(src.title.clone(), &src.body, width.saturating_sub(4)); + framed(&title, &rows, &[], theme, width, margin) + } + "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. +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") + } + "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()))) +} + +/// 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), + Class::Plain => (&c.code_fg, false, false), + Class::Alert => (&c.admonition_warning, true, false), + }; + SpanStyle { + fg: Some(fg.clone()), + bold, + italic, + ..Default::default() + } +} + +/// 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 +} + +/// 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 +} + +/// 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)]); + } + }; + 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 = 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 { + // 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 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, + vec![ + (arrow, Class::Edge), + (one_line(&g.nodes[e.to].label), Class::Text), + ], + ); + } + } + framed_impl(title, &out, &g.notes, theme, width, margin, false) +} + +/// [`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. +fn framed( + title: &str, + rows: &[Vec<(String, Class)>], + notes: &[String], + 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); + 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(border.clone()), + ..Default::default() + }, + }; + 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 = if center { + (inner.saturating_sub(content_w)) / 2 + } else { + 0 + }; + 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( + 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 participants: Vec = Vec::new(); + + lines.push(make_header( + "sequence diagram", + border_color, + diagram_color, + margin, + width, + )); + + for raw_line in source.lines().skip(1) { + let l = raw_line.trim(); + if l.is_empty() { + continue; + } + + if l.starts_with("participant ") { + let name = l.strip_prefix("participant ").unwrap_or("").trim(); + if !participants.contains(&name.to_string()) { + participants.push(name.to_string()); + } + } else if l.contains("->>") || l.contains("-->>") || l.contains("->") || l.contains("-->") { + // Parse: Alice->>Bob: Hello + let (arrow, from, to, msg) = parse_sequence_line(l); + let arrow_sym = if arrow.contains(">>") { + "──▶" + } else { + "───" + }; + + 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: format!("{from} "), + style: SpanStyle { + fg: Some(text_color.to_string()), + bold: true, + ..Default::default() + }, + }); + line.push(StyledSpan { + text: format!("{arrow_sym} "), + style: SpanStyle { + fg: Some(arrow_color.to_string()), + ..Default::default() + }, + }); + line.push(StyledSpan { + text: to.to_string(), + style: SpanStyle { + fg: Some(text_color.to_string()), + bold: true, + ..Default::default() + }, + }); + if !msg.is_empty() { + line.push(StyledSpan { + text: format!(": {msg}"), + style: SpanStyle { + fg: Some(text_color.to_string()), + italic: true, + ..Default::default() + }, + }); + } + lines.push(line); + } else if l.starts_with("Note") { + 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: format!(" 📝 {l}"), + style: SpanStyle { + fg: Some(text_color.to_string()), + italic: true, + ..Default::default() + }, + }); + lines.push(line); + } + } + + lines.push(make_footer(border_color, margin, width)); + lines.push(StyledLine::empty()); + + lines +} + +fn render_pie(source: &str, theme: &Theme, margin: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let border_color = &theme.colors.table_border; + let colors = [ + &theme.colors.heading1, + &theme.colors.heading2, + &theme.colors.heading3, + &theme.colors.heading4, + &theme.colors.heading5, + &theme.colors.heading6, + &theme.colors.admonition_note, + &theme.colors.admonition_tip, + ]; + + let mut title = "Pie Chart".to_string(); + let mut slices: Vec<(String, f64)> = Vec::new(); + + 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.contains(':') { + let parts: Vec<&str> = l.splitn(2, ':').collect(); + if parts.len() == 2 { + let label = parts[0].trim().trim_matches('"').to_string(); + if let Ok(val) = parts[1].trim().parse::() { + slices.push((label, val)); + } + } + } + } + + let total: f64 = slices.iter().map(|(_, v)| v).sum(); + + lines.push(make_header( + &title, + border_color, + &theme.colors.heading2, + margin, + width, + )); + + // Render as horizontal bar chart + // 30 cells at the usual 56-column chart width; less when narrower. + let max_bar = 30usize.min(width.saturating_sub(26)).max(1); + for (i, (label, value)) in slices.iter().enumerate() { + let pct = if total > 0.0 { + value / total * 100.0 + } else { + 0.0 + }; + let bar_len = ((pct / 100.0) * max_bar as f64) as usize; + let color = colors[i % colors.len()]; + + 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: format!("{:>12} ", label), + style: SpanStyle { + fg: Some(color.clone()), + bold: true, + ..Default::default() + }, + }); + line.push(StyledSpan { + text: "█".repeat(bar_len), + style: SpanStyle { + fg: Some(color.clone()), + ..Default::default() + }, + }); + line.push(StyledSpan { + text: format!(" {:.1}%", pct), + style: SpanStyle { + fg: Some(color.clone()), + ..Default::default() + }, + }); + lines.push(line); + } + + lines.push(make_footer(border_color, margin, width)); + lines.push(StyledLine::empty()); + + lines +} + +// --- Helpers --- + +/// Create a diagram header line: ╭─ title ─────────────╮ +fn make_header( + title: &str, + border_color: &str, + title_color: &str, + margin: &str, + width: usize, +) -> StyledLine { + 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: format!("{title} "), + style: SpanStyle { + fg: Some(title_color.to_string()), + bold: true, + ..Default::default() + }, + }); + // Display columns, not bytes: a CJK or emoji title occupies more columns + // than `len()` reports characters (and multibyte text fewer), which used + // to skew the top border. + let used = 3 + title.width() + 2; // "╭─ " + title + " ╮" + let remaining = width.saturating_sub(used); + line.push(StyledSpan { + text: format!("{}╮", "─".repeat(remaining)), + style: SpanStyle { + fg: Some(border_color.to_string()), + ..Default::default() + }, + }); + line +} + +/// Create a diagram footer line: ╰─────────────────────╯ +fn make_footer(border_color: &str, margin: &str, width: usize) -> StyledLine { + let mut line = StyledLine::new(); + push_margin(&mut line, margin); + line.push(StyledSpan { + text: format!("╰{}╯", "─".repeat(width.saturating_sub(2))), + style: SpanStyle { + fg: Some(border_color.to_string()), + ..Default::default() + }, + }); + line +} + +fn push_margin(line: &mut StyledLine, margin: &str) { + if !margin.is_empty() { + line.push(StyledSpan { + text: margin.to_string(), + style: SpanStyle::default(), + }); + } +} + +fn parse_sequence_line(line: &str) -> (String, String, String, String) { + let arrows = ["-->>", "->>", "-->", "->"]; + for arrow in &arrows { + if let Some(pos) = line.find(arrow) { + let from = line[..pos].trim().to_string(); + let after = &line[pos + arrow.len()..]; + let (to, msg) = if let Some(colon_pos) = after.find(':') { + ( + after[..colon_pos].trim().to_string(), + after[colon_pos + 1..].trim().to_string(), + ) + } else { + (after.trim().to_string(), String::new()) + }; + return (arrow.to_string(), from, to, msg); + } + } + ( + String::new(), + line.to_string(), + String::new(), + String::new(), + ) +} + +#[cfg(test)] +mod header_tests { + use super::*; + + fn line_width(line: &StyledLine) -> usize { + line.spans.iter().map(|s| s.text.as_str().width()).sum() + } + + // The header border must be sized by the title's display columns, not its + // byte length: a CJK title (3 bytes but 2 columns per char) skewed the top + // border 10 columns off the footer. + #[test] + fn header_width_matches_footer_regardless_of_title_script() { + for title in ["flowchart", "日本語のパイ図", "⚠️ warning ⚠️", "Grüße"] { + let header = make_header(title, "#888888", "#ffffff", "", 56); + let footer = make_footer("#888888", "", 56); + assert_eq!( + line_width(&header), + 56, + "header for {title:?} is {} cols", + line_width(&header) + ); + assert_eq!(line_width(&header), line_width(&footer)); + } + } +} 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 new file mode 100644 index 0000000..ec51dc3 --- /dev/null +++ b/src/layout/mermaid/tests.rs @@ -0,0 +1,638 @@ +//! 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}" + ); + } +} + +// ── 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 │ │ +│ └───┬────┘ │ +│ │ │ +│ ▼ │ +│ ◉ │ +╰───────────────────╯", + ); +} + +// ── Class and ER 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 │ +╰───────────────────────────────────────────────╯", + ); +} + +#[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 │ │ +│ └───────┘ │ +╰────────────────────╯", + ); +} + +// ── 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 ◆ │ +╰────────────────────────────────────────────────╯", + ); +} + +// ── 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]; + +#[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/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 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 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/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/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 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 --> [*] 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..e8d703b 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 │ │ + │ └───────────────────┘ └─────────────┘ │ + ╰────────────────────────────────────────╯ @@ -147,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  ░░ │ + ╰────────────────────────────────────────────────────────────────────────────╯ 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