From cfa72d42d7c7a06d9ad62ee8fdcea16ba634db7d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:28:32 +0000 Subject: [PATCH] grok-mermaid: Mermaid to Unicode box art playground via WASM Extracts the Mermaid-to-Unicode-box-drawing renderer from xai-org/grok-build (crates/codegen/xai-grok-markdown/src/mermaid.rs, Apache-2.0) and compiles it to WebAssembly for a browser playground. - grok-mermaid/: standalone Rust crate. src/mermaid.rs is the upstream file unmodified except two import lines, which now point at src/shim.rs, a small stand-in for the four ratatui types it uses (Style carries a CSS class name instead of terminal colors). src/lib.rs adds render_plain/render_html and a wasm-bindgen-free FFI. Includes the upstream Apache-2.0 LICENSE; all 148 upstream unit tests pass unchanged via cargo test. - grok-mermaid/build_wasm.sh: cargo build --target wasm32-unknown-unknown (plus optional wasm-opt) producing grok-mermaid.wasm (~190 KB). - grok-mermaid.html: playground with editable source, seven example diagrams (flowchart, subgraphs, sequence, state, class, ER, unsupported fallback), max-width control, copy-as-text and shareable URL fragment. - tests/test_grok_mermaid.py: Playwright coverage for initial render, example buttons, live editing and URL-fragment loading. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NHbXcjun9Rg1yn55N4P497 --- .gitignore | 3 + grok-mermaid.html | 310 +++ grok-mermaid.wasm | Bin 0 -> 190383 bytes grok-mermaid/Cargo.lock | 16 + grok-mermaid/Cargo.toml | 21 + grok-mermaid/LICENSE | 204 ++ grok-mermaid/README.md | 59 + grok-mermaid/build_wasm.sh | 30 + grok-mermaid/src/lib.rs | 170 ++ grok-mermaid/src/mermaid.rs | 5244 +++++++++++++++++++++++++++++++++++ grok-mermaid/src/shim.rs | 70 + tests/test_grok_mermaid.py | 58 + 12 files changed, 6185 insertions(+) create mode 100644 grok-mermaid.html create mode 100755 grok-mermaid.wasm create mode 100644 grok-mermaid/Cargo.lock create mode 100644 grok-mermaid/Cargo.toml create mode 100644 grok-mermaid/LICENSE create mode 100644 grok-mermaid/README.md create mode 100755 grok-mermaid/build_wasm.sh create mode 100644 grok-mermaid/src/lib.rs create mode 100644 grok-mermaid/src/mermaid.rs create mode 100644 grok-mermaid/src/shim.rs create mode 100644 tests/test_grok_mermaid.py diff --git a/.gitignore b/.gitignore index 07730e20..c8c567d1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ node_modules/ # Playwright test output test-results/ + +# Rust build output +grok-mermaid/target/ diff --git a/grok-mermaid.html b/grok-mermaid.html new file mode 100644 index 00000000..ebcdb9a8 --- /dev/null +++ b/grok-mermaid.html @@ -0,0 +1,310 @@ + + + + + +Mermaid to Unicode box art (grok-mermaid) + + + +

Mermaid to Unicode box art

+

+Type Mermaid diagram source below and it +is rendered as terminal-style Unicode box-drawing art — entirely in your +browser, by the actual Rust renderer from +xai-org/grok-build +(the Grok CLI) compiled to WebAssembly. Flowcharts, sequence, state, class and +ER diagrams are supported; other diagram types fall back to a framed source +listing. Rust source, license and build script: +grok-mermaid/. +

+ +
+ + + +
+ + + + +
+ +
+ +
+The renderer is mermaid.rs from +xai-org/grok-build +(crates/codegen/xai-grok-markdown/src/mermaid.rs), +copyright 2023-2026 SpaceXAI, used under the +Apache License 2.0, +compiled unmodified (apart from two import lines) to a 190 KB WebAssembly +module with no JavaScript reimplementation of any layout logic. +
+ + + + diff --git a/grok-mermaid.wasm b/grok-mermaid.wasm new file mode 100755 index 0000000000000000000000000000000000000000..7f9c8264e5a6501c434d6a3d41bee41707405cab GIT binary patch literal 190383 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: + `b` border, `n` node text, `e` edge, `el` edge label, `t` title, plus + `i` for italic. diff --git a/grok-mermaid/build_wasm.sh b/grok-mermaid/build_wasm.sh new file mode 100755 index 00000000..6bd96e8c --- /dev/null +++ b/grok-mermaid/build_wasm.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Rebuild ../grok-mermaid.wasm, the WebAssembly module behind +# ../grok-mermaid.html. +# +# The renderer itself is src/mermaid.rs, copied unmodified (except for two +# import lines — see the file header) from +# https://github.com/xai-org/grok-build +# crates/codegen/xai-grok-markdown/src/mermaid.rs (Apache-2.0). +# +# Requires a Rust toolchain with the wasm32-unknown-unknown target: +# rustup target add wasm32-unknown-unknown +# +# Usage: ./build_wasm.sh + +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) + +cargo build --manifest-path "$HERE/Cargo.toml" \ + --release --target wasm32-unknown-unknown + +WASM="$HERE/target/wasm32-unknown-unknown/release/grok_mermaid.wasm" + +# Optional: shrink further if wasm-opt (binaryen) is installed. +if command -v wasm-opt >/dev/null; then + wasm-opt -Oz "$WASM" -o "$WASM.opt" && mv "$WASM.opt" "$WASM" +fi + +cp "$WASM" "$HERE/../grok-mermaid.wasm" +echo "Wrote $HERE/../grok-mermaid.wasm ($(wc -c < "$HERE/../grok-mermaid.wasm") bytes)" diff --git a/grok-mermaid/src/lib.rs b/grok-mermaid/src/lib.rs new file mode 100644 index 00000000..a7f50b10 --- /dev/null +++ b/grok-mermaid/src/lib.rs @@ -0,0 +1,170 @@ +//! grok-mermaid: render Mermaid diagram source as Unicode box-drawing art. +//! +//! The actual renderer lives in `mermaid.rs`, copied (Apache-2.0) from +//! +//! (`crates/codegen/xai-grok-markdown/src/mermaid.rs`) — see LICENSE and +//! README.md in this directory. This file adds: +//! +//! - `render_plain` / `render_html`: safe Rust entry points. The HTML flavor +//! wraps each styled run in `` using the class names +//! `b` (border), `n` (node text), `e` (edge), `el` (edge label), +//! `t` (title), plus `i` for italic, so a page can color the art the way +//! the original TUI does. +//! - `wasm_alloc` / `wasm_render_html` / `wasm_result_ptr`: a tiny +//! wasm-bindgen-free FFI surface for the browser playground +//! (`../grok-mermaid.html`), built by `build_wasm.sh`. + +mod mermaid; +mod shim; + +use std::alloc::Layout; +use std::cell::RefCell; + +use mermaid::MermaidStyles; +use shim::Style; + +fn styles() -> MermaidStyles { + MermaidStyles { + border: Style::class("b"), + node_text: Style::class("n"), + edge: Style::class("e"), + edge_label: Style::class("el"), + title: Style::class("t"), + } +} + +/// Render mermaid source to plain (uncolored) box-drawing text, or `None` +/// for blank input. `max_width` bounds the diagram width in display columns; +/// diagrams that cannot fit fall back to the framed raw source. +pub fn render_plain(src: &str, max_width: Option) -> Option { + mermaid::render(src, &styles(), max_width).map(|art| art.plain_lines.join("\n")) +} + +/// Render mermaid source to HTML: box-drawing text with each styled run +/// wrapped in a classed `` (see module docs for the class names). +pub fn render_html(src: &str, max_width: Option) -> Option { + let art = mermaid::render(src, &styles(), max_width)?; + let mut out = String::new(); + for line in &art.styled_lines { + for span in &line.spans { + if span.content.is_empty() { + continue; + } + let text = escape_html(&span.content); + match span.style.class { + None => out.push_str(&text), + Some(class) => { + out.push_str(""); + out.push_str(&text); + out.push_str(""); + } + } + } + out.push('\n'); + } + Some(out) +} + +fn escape_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } + out +} + +// --------------------------------------------------------------------------- +// WASM FFI. Deliberately wasm-bindgen-free so the build needs nothing beyond +// `cargo build --target wasm32-unknown-unknown`. Protocol: +// +// 1. JS calls `wasm_alloc(len)` and writes UTF-8 mermaid source at the +// returned pointer. +// 2. JS calls `wasm_render_html(ptr, len, max_width)` (which frees that +// input buffer); it returns the byte length of the rendered HTML. +// 3. JS reads that many bytes from `wasm_result_ptr()` and UTF-8 decodes. +// +// `max_width <= 0` means unlimited. Blank input renders to an empty string. +// --------------------------------------------------------------------------- + +thread_local! { + static RESULT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn buf_layout(len: usize) -> Layout { + Layout::from_size_align(len.max(1), 1).unwrap() +} + +/// Allocate `len` bytes for passing input in. Ownership passes to the caller; +/// `wasm_render_html` takes it back and frees it. +#[unsafe(no_mangle)] +pub extern "C" fn wasm_alloc(len: usize) -> *mut u8 { + // SAFETY: buf_layout never has zero size. + unsafe { std::alloc::alloc(buf_layout(len)) } +} + +/// Render the UTF-8 mermaid source at `ptr..ptr+len` (a buffer obtained from +/// `wasm_alloc(len)`, freed by this call) and return the byte length of the +/// resulting HTML, readable at `wasm_result_ptr()`. +/// +/// # Safety +/// `ptr` must come from `wasm_alloc(len)` with this exact `len`, fully +/// initialized, and must not be used again after this call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wasm_render_html(ptr: *mut u8, len: usize, max_width: i32) -> usize { + // SAFETY: caller contract — ptr came from wasm_alloc(len), is initialized, + // and is not reused after this call. + let src = unsafe { + let src = String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).into_owned(); + std::alloc::dealloc(ptr, buf_layout(len)); + src + }; + let max_width = usize::try_from(max_width).ok().filter(|&w| w > 0); + let html = render_html(&src, max_width).unwrap_or_default(); + RESULT.with(|r| { + let mut r = r.borrow_mut(); + *r = html.into_bytes(); + r.len() + }) +} + +/// Pointer to the result of the most recent `wasm_render_html` call. Only +/// valid until the next call. +#[unsafe(no_mangle)] +pub extern "C" fn wasm_result_ptr() -> *const u8 { + RESULT.with(|r| r.borrow().as_ptr()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_plain_flowchart() { + let out = render_plain("graph TD\n A[Start] --> B[End]", Some(80)).unwrap(); + assert!(out.contains("Start"), "missing node label:\n{out}"); + assert!(out.contains('▼'), "missing arrowhead:\n{out}"); + } + + #[test] + fn renders_classed_html() { + let out = render_html("graph LR\n A[a & b] -->|go| C{c}", Some(120)).unwrap(); + assert!(out.contains(""), "missing border span:\n{out}"); + assert!(out.contains(""), "missing edge label span:\n{out}"); + assert!(out.contains("a & b"), "unescaped ampersand:\n{out}"); + } + + #[test] + fn blank_input_is_none() { + assert!(render_html(" \n ", None).is_none()); + } +} diff --git a/grok-mermaid/src/mermaid.rs b/grok-mermaid/src/mermaid.rs new file mode 100644 index 00000000..d0ffbb88 --- /dev/null +++ b/grok-mermaid/src/mermaid.rs @@ -0,0 +1,5244 @@ +//! Self-contained terminal renderer for Mermaid diagrams. +//! +//! Renders `graph`/`flowchart`, `sequenceDiagram`, and `stateDiagram` blocks +//! as Unicode box-drawing art; unsupported diagram types fall back to the raw +//! source in a framed box. +//! +//! Copied from +//! (`crates/codegen/xai-grok-markdown/src/mermaid.rs`), Apache-2.0 licensed, +//! Copyright 2023-2026 SpaceXAI. See the LICENSE file in this directory. +//! The only modification: the two `use ratatui::...` imports below now point +//! at `crate::shim`, a minimal local stand-in for the handful of `ratatui` +//! style/text types this module uses, so it compiles without ratatui. + +use std::collections::HashMap; + +use crate::shim::{Modifier, Style}; +use crate::shim::{Line, Span}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Theme-derived styles used when painting a diagram. +#[derive(Clone, Copy)] +pub(crate) struct MermaidStyles { + pub border: Style, + pub node_text: Style, + pub edge: Style, + pub edge_label: Style, + pub title: Style, +} + +/// Rendered diagram: styled lines for the TUI and plain lines for ANSI output. +pub(crate) struct MermaidArt { + pub styled_lines: Vec>, + pub plain_lines: Vec, +} + +const MAX_LABEL: usize = 28; +const PAD: usize = 1; +const GAP_X: usize = 3; +const GAP_Y: usize = 2; +/// Node labels wrap to at most this many display columns per line, and at most +/// this many lines (overflow is truncated with an ellipsis). +const WRAP_WIDTH: usize = 24; +const MAX_LINES: usize = 4; +/// Identifier-boundary characters preferred as break points when a single word +/// is too wide to fit, so it is not sliced mid-segment. +/// Mirrors `TOKEN_BREAK_CHARS` in `third_party/mermaid-to-svg/src/text_wrap.rs`; +/// the two renderers are deliberately independent, so keep these two in sync. +const LABEL_BREAK_CHARS: [char; 4] = ['_', '-', '.', '/']; +/// Sentinel marking the trailing column of a wide glyph (never emitted). +const CONT: char = '\u{0}'; +const MAX_NODES: usize = 128; +const MAX_EDGES: usize = 512; +const MAX_GROUPS: usize = 24; +const MAX_GROUP_DEPTH: usize = 6; +const MAX_CANVAS_CELLS: usize = 1 << 21; + +fn char_width(c: char) -> usize { + UnicodeWidthChar::width(c).unwrap_or(0) +} + +#[derive(Clone, Copy)] +enum Oversize { + Width, + Cells, +} + +/// Render a mermaid source block, or `None` for blank input. +pub(crate) fn render( + src: &str, + styles: &MermaidStyles, + max_width: Option, +) -> Option { + if src.trim().is_empty() { + return None; + } + + let outcome: Option> = parse_graph(src) + .map(|graph| { + if graph.groups.is_empty() { + layout_flowchart(&graph, styles, max_width) + } else { + render_grouped(&graph, styles, max_width) + } + }) + .or_else(|| parse_state(src).map(|graph| layout_flowchart(&graph, styles, max_width))) + .or_else(|| { + parse_class(src).map(|(graph, infos)| render_class(&graph, &infos, styles, max_width)) + }) + .or_else(|| { + parse_er(src).map(|(graph, infos)| render_class(&graph, &infos, styles, max_width)) + }) + .or_else(|| parse_sequence(src).map(|seq| layout_sequence(&seq, styles, max_width))); + + let too_wide = match outcome { + Some(Ok(art)) => return Some(art), + Some(Err(Oversize::Width)) => true, + Some(Err(Oversize::Cells)) | None => false, + }; + Some(fallback(src, styles, max_width, too_wide)) +} + +#[derive(Clone, Copy, PartialEq)] +enum Shape { + Rect, + Round, + Diamond, +} + +struct Node { + label: String, + shape: Shape, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Head { + None, + Arrow, + Circle, + Cross, + Triangle, + DiamondFill, + DiamondOpen, +} + +#[derive(Clone, Copy, PartialEq)] +enum LineKind { + Solid, + Dotted, + Thick, +} + +struct Edge { + from: usize, + to: usize, + label: Option, + head_to: Head, + head_from: Head, + line: LineKind, +} + +#[derive(Clone, Copy, PartialEq)] +enum Dir { + Down, + Up, + Right, + Left, +} + +struct Group { + id: String, + label: String, + parent: Option, +} + +struct Graph { + nodes: Vec, + edges: Vec, + index: HashMap, + groups: Vec, + node_group: Vec>, + cur_group: Option, + over_cap: bool, + dir: Dir, +} + +impl Graph { + fn node_index(&mut self, id: &str, label: Option<&str>, shape: Shape) -> Option { + if let Some(&i) = self.index.get(id) { + if let Some(label) = label { + self.nodes[i].label = label.to_string(); + self.nodes[i].shape = shape; + } + return Some(i); + } + if self.nodes.len() >= MAX_NODES { + self.over_cap = true; + return None; + } + let label = label.unwrap_or(id).to_string(); + self.index.insert(id.to_string(), self.nodes.len()); + self.nodes.push(Node { label, shape }); + self.node_group.push(self.cur_group); + Some(self.nodes.len() - 1) + } + + fn node_label(&mut self, id: &str, label: &str) -> Option { + if let Some(&i) = self.index.get(id) { + self.nodes[i].label = label.to_string(); + return Some(i); + } + self.node_index(id, Some(label), Shape::Round) + } +} + +fn parse_graph(src: &str) -> Option { + let mut statements: Vec = Vec::new(); + for raw_line in src.lines() { + split_statements(raw_line, &mut statements); + } + + let header = statements.first()?; + let mut header_tokens = header.split_whitespace(); + let kind = header_tokens.next()?.to_ascii_lowercase(); + if kind != "graph" && kind != "flowchart" { + return None; + } + let dir = match header_tokens + .next() + .unwrap_or("TB") + .to_ascii_uppercase() + .as_str() + { + "LR" => Dir::Right, + "RL" => Dir::Left, + "BT" => Dir::Up, + _ => Dir::Down, + }; + + let mut graph = Graph { + nodes: Vec::new(), + edges: Vec::new(), + index: HashMap::new(), + groups: Vec::new(), + node_group: Vec::new(), + cur_group: None, + over_cap: false, + dir, + }; + + let mut stack: Vec = Vec::new(); + for st in &statements[1..] { + let first_word = st.split_whitespace().next().unwrap_or(""); + match first_word.to_ascii_lowercase().as_str() { + "subgraph" => { + if graph.groups.len() >= MAX_GROUPS || stack.len() >= MAX_GROUP_DEPTH { + return None; + } + let (id, label) = parse_subgraph_decl(st["subgraph".len()..].trim()); + graph.groups.push(Group { + id, + label, + parent: stack.last().copied(), + }); + stack.push(graph.groups.len() - 1); + graph.cur_group = stack.last().copied(); + continue; + } + "end" => { + stack.pop(); + graph.cur_group = stack.last().copied(); + continue; + } + "classdef" | "class" | "style" | "linkstyle" | "click" | "direction" => continue, + _ => {} + } + parse_statement(st, &mut graph); + if graph.over_cap { + return None; + } + } + + if graph.nodes.is_empty() { + return None; + } + Some(graph) +} + +fn parse_subgraph_decl(rest: &str) -> (String, String) { + if let Some(q) = rest.strip_prefix('"') + && let Some((label, _)) = q.split_once('"') + { + return (label.to_string(), decode_html_entities(label)); + } + if let Some(open) = rest.find('[') { + let id = rest[..open].trim(); + let label = rest[open + 1..].trim_end_matches(']').trim(); + let label = clean_label(label); + if !id.is_empty() && !label.is_empty() { + return (id.to_string(), label); + } + } + (rest.to_string(), rest.to_string()) +} + +fn split_statements(line: &str, out: &mut Vec) { + let mut cur = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if in_quotes { + if c == '"' { + in_quotes = false; + } + cur.push(c); + } else { + match c { + '"' => { + in_quotes = true; + cur.push(c); + } + '%' if chars.peek() == Some(&'%') => break, + ';' => flush_statement(&mut cur, out), + _ => cur.push(c), + } + } + } + flush_statement(&mut cur, out); +} + +fn flush_statement(cur: &mut String, out: &mut Vec) { + let trimmed = cur.trim(); + if !trimmed.is_empty() { + out.push(trimmed.to_string()); + } + cur.clear(); +} + +fn parse_statement(st: &str, graph: &mut Graph) { + let chars: Vec = st.chars().collect(); + let mut i = 0; + + let Some((mut prev, ni)) = parse_node_group(&chars, i, graph) else { + return; + }; + i = ni; + + loop { + i = skip_spaces(&chars, i); + if i >= chars.len() { + break; + } + let Some((left, right, line, label, ni)) = parse_link(&chars, i) else { + break; + }; + i = skip_spaces(&chars, ni); + let Some((next, ni)) = parse_node_group(&chars, i, graph) else { + break; + }; + i = ni; + for &f in &prev { + for &t in &next { + if graph.edges.len() >= MAX_EDGES { + graph.over_cap = true; + return; + } + let (from, to, head_to, head_from) = if left == Head::Arrow && right != Head::Arrow + { + (t, f, Head::Arrow, right) + } else { + (f, t, right, left) + }; + graph.edges.push(Edge { + from, + to, + label: label.clone(), + head_to, + head_from, + line, + }); + } + } + prev = next; + } +} + +fn parse_node_group( + chars: &[char], + start: usize, + graph: &mut Graph, +) -> Option<(Vec, usize)> { + let (first, mut i) = parse_node(chars, start, graph)?; + let mut group = vec![first]; + loop { + let j = skip_spaces(chars, i); + if chars.get(j) != Some(&'&') { + break; + } + let (next, k) = parse_node(chars, j + 1, graph)?; + group.push(next); + i = k; + } + Some((group, i)) +} + +fn skip_spaces(chars: &[char], mut i: usize) -> usize { + while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') { + i += 1; + } + i +} + +fn is_id_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn parse_node(chars: &[char], start: usize, graph: &mut Graph) -> Option<(usize, usize)> { + let mut i = skip_spaces(chars, start); + let id_start = i; + while i < chars.len() && is_id_char(chars[i]) { + i += 1; + } + if i == id_start { + return None; + } + let id: String = chars[id_start..i].iter().collect(); + + let (shape, label, after) = match chars.get(i) { + Some('[') => { + if chars.get(i + 1) == Some(&'[') { + read_shape(chars, i + 2, "]]", Shape::Rect) + } else if chars.get(i + 1) == Some(&'(') { + read_shape(chars, i + 2, ")]", Shape::Round) + } else { + read_shape(chars, i + 1, "]", Shape::Rect) + } + } + Some('(') => { + if chars.get(i + 1) == Some(&'(') { + read_shape(chars, i + 2, "))", Shape::Round) + } else if chars.get(i + 1) == Some(&'[') { + read_shape(chars, i + 2, "])", Shape::Round) + } else { + read_shape(chars, i + 1, ")", Shape::Round) + } + } + Some('{') => { + if chars.get(i + 1) == Some(&'{') { + read_shape(chars, i + 2, "}}", Shape::Diamond) + } else { + read_shape(chars, i + 1, "}", Shape::Diamond) + } + } + Some('>') => read_shape(chars, i + 1, "]", Shape::Rect), + _ => (None, None, i), + }; + + let shape = shape.unwrap_or(Shape::Rect); + let label = label.as_deref(); + let idx = graph.node_index(&id, label, shape)?; + Some((idx, after)) +} + +fn read_shape( + chars: &[char], + start: usize, + closer: &str, + shape: Shape, +) -> (Option, Option, usize) { + let closer: Vec = closer.chars().collect(); + let mut i = start; + let mut text = String::new(); + let quoted = { + let mut j = start; + while matches!(chars.get(j), Some(' ') | Some('\t')) { + j += 1; + } + chars.get(j) == Some(&'"') + }; + let mut in_quotes = false; + while i < chars.len() { + let c = chars[i]; + if quoted && c == '"' { + in_quotes = !in_quotes; + text.push(c); + i += 1; + continue; + } + if !in_quotes && chars[i..].starts_with(closer.as_slice()) { + let label = clean_label(&text); + return (Some(shape), Some(label), i + closer.len()); + } + text.push(c); + i += 1; + } + (Some(shape), Some(clean_label(&text)), chars.len()) +} + +fn clean_label(raw: &str) -> String { + let stripped = strip_html_tags(raw.trim()); + let trimmed = stripped.trim(); + let unquoted = trimmed + .strip_prefix('"') + .and_then(|t| t.strip_suffix('"')) + .or_else(|| { + trimmed + .strip_prefix('\'') + .and_then(|t| t.strip_suffix('\'')) + }) + .unwrap_or(trimmed) + .trim(); + let text = if let Some(md) = unquoted.strip_prefix('`').and_then(|t| t.strip_suffix('`')) { + strip_markdown(md.trim()) + } else { + unquoted.to_string() + }; + // Decode after tag-stripping so `` is removed as markup while `<b>` + // survives as a literal ``; one decode at the single return covers both paths. + decode_html_entities(&text) +} + +const ENTITY_LOOKAHEAD: usize = 10; + +// Label text decodes HTML entities once: via clean_label for bracketed labels, or explicitly at each direct-push sink. +fn decode_html_entities(s: &str) -> String { + if !s.contains('&') { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < chars.len() { + if chars[i] != '&' { + out.push(chars[i]); + i += 1; + continue; + } + // Scan window (includes the terminating `;`) so a stray `&` or over-long run stays literal. + let hi = (i + 1 + ENTITY_LOOKAHEAD).min(chars.len()); + let semi = (i + 1..hi).find(|&j| chars[j] == ';'); + let decoded = semi.and_then(|j| { + let body: String = chars[i + 1..j].iter().collect(); + decode_entity_body(&body).map(|c| (c, j)) + }); + match decoded { + // Resume past the `;`; the single pass never re-scans emitted text, so + // `&lt;` decodes to the literal `<` rather than to `<`. + Some((c, j)) => { + out.push(c); + i = j + 1; + } + None => { + out.push('&'); + i += 1; + } + } + } + out +} + +fn decode_entity_body(body: &str) -> Option { + match body { + "lt" => Some('<'), + "gt" => Some('>'), + "amp" => Some('&'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => { + let num = body.strip_prefix('#')?; + let code = match num.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => num.parse::().ok()?, + }; + // Reject control chars: NUL collides with the CONT sentinel and ESC would inject ANSI into scrollback. + char::from_u32(code).filter(|c| !c.is_control()) + } + } +} + +fn strip_markdown(s: &str) -> String { + let no_code: String = s.chars().filter(|&c| c != '`').collect(); + let no_strong = no_code.replace("**", "").replace("__", ""); + let chars: Vec = no_strong.chars().collect(); + let mut out = String::with_capacity(no_strong.len()); + for (i, &c) in chars.iter().enumerate() { + if (c == '*' || c == '_') + && !(i > 0 + && chars[i - 1].is_alphanumeric() + && chars.get(i + 1).is_some_and(|n| n.is_alphanumeric())) + { + continue; + } + out.push(c); + } + out.trim().to_string() +} + +const HTML_FORMAT_TAGS: &[&str] = &[ + "b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "mark", "small", "big", "sub", + "sup", "code", "kbd", "samp", "var", "tt", "span", "font", "q", "abbr", "cite", "pre", +]; + +fn strip_html_tags(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < chars.len() { + if chars[i] == '<' + && let Some((name, end)) = html_tag_at(&chars, i) + { + let lower = name.to_ascii_lowercase(); + if lower == "br" { + out.push(' '); + i = end; + continue; + } + if HTML_FORMAT_TAGS.contains(&lower.as_str()) { + i = end; + continue; + } + } + out.push(chars[i]); + i += 1; + } + out +} + +fn html_tag_at(chars: &[char], start: usize) -> Option<(String, usize)> { + let mut i = start + 1; + if chars.get(i) == Some(&'/') { + i += 1; + } + let name_start = i; + while i < chars.len() && chars[i].is_ascii_alphanumeric() { + i += 1; + } + if i == name_start { + return None; + } + let name: String = chars[name_start..i].iter().collect(); + while i < chars.len() && chars[i] != '>' { + if chars[i] == '<' { + return None; + } + i += 1; + } + if chars.get(i) == Some(&'>') { + Some((name, i + 1)) + } else { + None + } +} + +fn is_link_char(c: char) -> bool { + matches!(c, '-' | '.' | '=' | '<' | '>') +} + +fn parse_link( + chars: &[char], + start: usize, +) -> Option<(Head, Head, LineKind, Option, usize)> { + let mut i = skip_spaces(chars, start); + let mut left = Head::None; + if let Some(&c) = chars.get(i) + && matches!(c, 'o' | 'x') + && matches!(chars.get(i + 1), Some('-' | '.' | '=')) + { + left = if c == 'o' { Head::Circle } else { Head::Cross }; + i += 1; + } + let op_start = i; + while i < chars.len() && matches!(chars[i], '-' | '.' | '=' | '<' | '>') { + i += 1; + } + if i == op_start { + return None; + } + let op1: String = chars[op_start..i].iter().collect(); + if left == Head::None && op1.starts_with('<') { + left = Head::Arrow; + } + let mut line = line_kind(&op1); + let mut right = if op1.contains('>') { + Head::Arrow + } else { + Head::None + }; + if right == Head::None + && let Some((head, ni)) = trailing_head(chars, i) + { + right = head; + i = ni; + } + + if chars.get(i) == Some(&'|') { + i += 1; + let l_start = i; + while i < chars.len() && chars[i] != '|' { + i += 1; + } + let label = clean_label(&chars[l_start..i].iter().collect::()); + if chars.get(i) == Some(&'|') { + i += 1; + } + return Some((left, right, line, non_empty(label), i)); + } + + if right == Head::None { + let text_start = skip_spaces(chars, i); + let mut j = text_start; + while j < chars.len() && !is_link_char(chars[j]) { + j += 1; + } + if j < chars.len() && j > text_start && matches!(chars[j], '-' | '.' | '=' | '>') { + let text: String = chars[text_start..j].iter().collect(); + let op2_start = j; + while j < chars.len() && is_link_char(chars[j]) { + j += 1; + } + let op2: String = chars[op2_start..j].iter().collect(); + right = if op2.contains('>') { + Head::Arrow + } else if let Some((head, nj)) = trailing_head(chars, j) { + j = nj; + head + } else { + Head::None + }; + if line == LineKind::Solid { + line = line_kind(&op2); + } + return Some((left, right, line, non_empty(clean_label(&text)), j)); + } + } + + Some((left, right, line, None, i)) +} + +fn line_kind(op: &str) -> LineKind { + if op.contains('=') { + LineKind::Thick + } else if op.contains('.') { + LineKind::Dotted + } else { + LineKind::Solid + } +} + +fn trailing_head(chars: &[char], i: usize) -> Option<(Head, usize)> { + let head = match chars.get(i) { + Some('o') => Head::Circle, + Some('x') => Head::Cross, + _ => return None, + }; + match chars.get(i + 1) { + None | Some(' ') | Some('\t') | Some('|') | Some('&') | Some(';') => Some((head, i + 1)), + _ => None, + } +} + +fn non_empty(s: String) -> Option { + if s.is_empty() { None } else { Some(s) } +} + +fn parse_state(src: &str) -> Option { + let mut statements: Vec = Vec::new(); + for raw_line in src.lines() { + split_statements(raw_line, &mut statements); + } + let header = statements.first()?; + if !header + .split_whitespace() + .next()? + .to_ascii_lowercase() + .starts_with("statediagram") + { + return None; + } + + let mut graph = Graph { + nodes: Vec::new(), + edges: Vec::new(), + index: HashMap::new(), + groups: Vec::new(), + node_group: Vec::new(), + cur_group: None, + over_cap: false, + dir: Dir::Down, + }; + + let mut in_note = false; + for st in &statements[1..] { + if in_note { + if st.eq_ignore_ascii_case("end note") { + in_note = false; + } + continue; + } + let first = st.split_whitespace().next().unwrap_or(""); + match first.to_ascii_lowercase().as_str() { + "direction" => { + graph.dir = match st + .split_whitespace() + .nth(1) + .unwrap_or("") + .to_ascii_uppercase() + .as_str() + { + "LR" => Dir::Right, + "RL" => Dir::Left, + "BT" => Dir::Up, + _ => Dir::Down, + }; + } + "note" => { + if !st.contains(':') { + in_note = true; + } + } + "state" => parse_state_decl(st, &mut graph)?, + "classdef" | "class" | "hide" | "scale" | "}" | "--" => {} + _ => { + if st.contains("-->") { + parse_transition(st, &mut graph)?; + } else { + parse_state_desc(st, &mut graph)?; + } + } + } + if graph.over_cap { + return None; + } + } + + if graph.nodes.is_empty() { + return None; + } + Some(graph) +} + +fn parse_state_decl(st: &str, graph: &mut Graph) -> Option<()> { + let rest = st["state".len()..].trim().trim_end_matches('{').trim(); + if rest.is_empty() { + return Some(()); + } + if let Some(q) = rest.strip_prefix('"') { + let (label, after) = q.split_once('"')?; + let id = after + .trim() + .strip_prefix("as") + .map(str::trim) + .unwrap_or(label); + graph.node_label(id, &decode_html_entities(label))?; + return Some(()); + } + let mut shape = Shape::Round; + let mut id = rest; + let mut stereotyped = false; + if let Some(pos) = rest.find("<<") { + let stereo = rest[pos + 2..].trim_end_matches(">>").trim(); + if stereo == "choice" { + shape = Shape::Diamond; + } + id = rest[..pos].trim(); + stereotyped = true; + } + if id.is_empty() || id.contains(char::is_whitespace) { + return None; + } + let label = if stereotyped { Some(id) } else { None }; + graph.node_index(id, label, shape)?; + Some(()) +} + +fn parse_transition(st: &str, graph: &mut Graph) -> Option<()> { + let mut rest = st; + let mut prev: Option = None; + while let Some((lhs, rhs)) = rest.split_once("-->") { + let from_id = lhs.trim_end().trim_end_matches('-').trim(); + let from = match prev { + Some(p) => { + if !from_id.is_empty() { + return None; + } + p + } + None => { + if from_id.is_empty() { + return None; + } + state_endpoint(graph, from_id, true)? + } + }; + let (to_part, tail) = match rhs.split_once("-->") { + Some((t, _)) => (t, &rhs[t.len()..]), + None => (rhs, ""), + }; + let (to_part, label) = match to_part.split_once(':') { + Some((t, l)) => (t, non_empty(decode_html_entities(l.trim()))), + None => (to_part, None), + }; + let to_id = to_part + .trim_start() + .trim_start_matches('>') + .trim_end() + .trim_end_matches('-') + .trim(); + if to_id.is_empty() { + return None; + } + let to = state_endpoint(graph, to_id, false)?; + if graph.edges.len() >= MAX_EDGES { + graph.over_cap = true; + return Some(()); + } + graph.edges.push(Edge { + from, + to, + label, + head_to: Head::Arrow, + head_from: Head::None, + line: LineKind::Solid, + }); + prev = Some(to); + rest = tail; + } + Some(()) +} + +fn state_endpoint(graph: &mut Graph, id: &str, is_source: bool) -> Option { + if id == "[*]" { + let key = if is_source { "[*]start" } else { "[*]end" }; + return graph.node_index(key, Some("●"), Shape::Round); + } + graph.node_index(id, None, Shape::Round) +} + +fn parse_state_desc(st: &str, graph: &mut Graph) -> Option<()> { + if let Some((id, desc)) = st.split_once(':') { + let id = id.trim(); + let desc = desc.trim(); + if id.is_empty() || id.contains(char::is_whitespace) || desc.is_empty() { + return None; + } + graph.node_label(id, &decode_html_entities(desc))?; + } else if !st.contains(char::is_whitespace) { + graph.node_index(st, None, Shape::Round)?; + } else { + return None; + } + Some(()) +} + +const MAX_MEMBERS: usize = 8; +const CLASS_OPS: &[(&str, Head, Head, LineKind)] = &[ + ("<|--", Head::Triangle, Head::None, LineKind::Solid), + ("--|>", Head::None, Head::Triangle, LineKind::Solid), + ("<|..", Head::Triangle, Head::None, LineKind::Dotted), + ("..|>", Head::None, Head::Triangle, LineKind::Dotted), + ("*--", Head::DiamondFill, Head::None, LineKind::Solid), + ("--*", Head::None, Head::DiamondFill, LineKind::Solid), + ("o--", Head::DiamondOpen, Head::None, LineKind::Solid), + ("--o", Head::None, Head::DiamondOpen, LineKind::Solid), + ("<--", Head::Arrow, Head::None, LineKind::Solid), + ("-->", Head::None, Head::Arrow, LineKind::Solid), + ("<..", Head::Arrow, Head::None, LineKind::Dotted), + ("..>", Head::None, Head::Arrow, LineKind::Dotted), + ("--", Head::None, Head::None, LineKind::Solid), + ("..", Head::None, Head::None, LineKind::Dotted), +]; + +#[derive(Default, Clone)] +struct ClassInfo { + annotation: Option, + attrs: Vec, + methods: Vec, +} + +fn parse_class(src: &str) -> Option<(Graph, Vec)> { + let mut statements: Vec = Vec::new(); + for raw_line in src.lines() { + split_statements(raw_line, &mut statements); + } + let header = statements.first()?; + if !header + .split_whitespace() + .next()? + .to_ascii_lowercase() + .starts_with("classdiagram") + { + return None; + } + + let mut graph = Graph { + nodes: Vec::new(), + edges: Vec::new(), + index: HashMap::new(), + groups: Vec::new(), + node_group: Vec::new(), + cur_group: None, + over_cap: false, + dir: Dir::Down, + }; + let mut infos: Vec = Vec::new(); + let mut cur_class: Option = None; + + for st in &statements[1..] { + if let Some(ci) = cur_class { + if st == "}" { + cur_class = None; + } else { + push_member(&mut infos[ci], st); + } + continue; + } + let first = st.split_whitespace().next().unwrap_or(""); + match first.to_ascii_lowercase().as_str() { + "direction" => { + graph.dir = match st + .split_whitespace() + .nth(1) + .unwrap_or("") + .to_ascii_uppercase() + .as_str() + { + "LR" => Dir::Right, + "RL" => Dir::Left, + "BT" => Dir::Up, + _ => Dir::Down, + }; + continue; + } + "note" | "callback" | "click" | "link" | "style" | "cssclass" | "classdef" + | "namespace" | "}" => continue, + "class" => { + let rest = st["class".len()..].trim(); + let (name, open) = match rest.strip_suffix('{') { + Some(n) => (n.trim(), true), + None => (rest, false), + }; + if name.is_empty() || name.contains(char::is_whitespace) { + return None; + } + let idx = graph.node_index(name, None, Shape::Rect)?; + sync_infos(&graph, &mut infos); + if open { + cur_class = Some(idx); + } + continue; + } + _ => {} + } + if let Some(ann) = st.strip_prefix("<<") { + let (ann, rest) = ann.split_once(">>")?; + let name = rest.trim(); + if name.is_empty() || name.contains(char::is_whitespace) { + return None; + } + let idx = graph.node_index(name, None, Shape::Rect)?; + sync_infos(&graph, &mut infos); + infos[idx].annotation = Some(ann.trim().to_string()); + continue; + } + if let Some((from, to, head_from, head_to, line, label)) = parse_class_relation(st) { + let f = graph.node_index(&from, None, Shape::Rect)?; + sync_infos(&graph, &mut infos); + let t = graph.node_index(&to, None, Shape::Rect)?; + sync_infos(&graph, &mut infos); + if graph.edges.len() >= MAX_EDGES { + return None; + } + graph.edges.push(Edge { + from: f, + to: t, + label, + head_to, + head_from, + line, + }); + continue; + } + if let Some((id, member)) = st.split_once(':') { + let id = id.trim(); + let member = member.trim(); + if id.is_empty() || id.contains(char::is_whitespace) || member.is_empty() { + return None; + } + let idx = graph.node_index(id, None, Shape::Rect)?; + sync_infos(&graph, &mut infos); + push_member(&mut infos[idx], member); + continue; + } + return None; + } + + if graph.nodes.is_empty() { + return None; + } + sync_infos(&graph, &mut infos); + Some((graph, infos)) +} + +fn sync_infos(graph: &Graph, infos: &mut Vec) { + while infos.len() < graph.nodes.len() { + infos.push(ClassInfo::default()); + } +} + +fn push_member(info: &mut ClassInfo, raw: &str) { + if let Some(ann) = raw.strip_prefix("<<") { + if let Some((ann, _)) = ann.split_once(">>") { + info.annotation = Some(ann.trim().to_string()); + } + return; + } + let member = decode_html_entities(&display_generics(raw.trim())); + let list = if member.contains('(') { + &mut info.methods + } else { + &mut info.attrs + }; + if list.len() < MAX_MEMBERS { + list.push(member); + } else if list.len() == MAX_MEMBERS { + list.push("…".to_string()); + } +} + +fn parse_class_relation( + st: &str, +) -> Option<(String, String, Head, Head, LineKind, Option)> { + let chars: Vec = st.chars().collect(); + let mut found: Option<(usize, &str, Head, Head, LineKind)> = None; + 'outer: for pos in 0..chars.len() { + for &(op, hf, ht, line) in CLASS_OPS { + if st[char_byte(st, pos)..].starts_with(op) { + if op.starts_with('o') && pos > 0 && is_id_char(chars[pos - 1]) { + continue; + } + if op.ends_with('o') + && chars + .get(pos + op.chars().count()) + .is_some_and(|&c| is_id_char(c)) + { + continue; + } + found = Some((pos, op, hf, ht, line)); + break 'outer; + } + } + } + let (pos, op, head_from, head_to, line) = found?; + let lhs = st[..char_byte(st, pos)].trim(); + let rhs = st[char_byte(st, pos) + op.len()..].trim(); + + let (lhs, card_from) = strip_cardinality_suffix(lhs); + let (rhs, card_to) = strip_cardinality_prefix(rhs); + let (to_id, rel_label) = match rhs.split_once(':') { + Some((t, l)) => (t.trim(), non_empty(decode_html_entities(l.trim()))), + None => (rhs.trim(), None), + }; + if lhs.is_empty() + || to_id.is_empty() + || lhs.contains(char::is_whitespace) + || to_id.contains(char::is_whitespace) + { + return None; + } + let label = non_empty( + [card_from, rel_label.unwrap_or_default(), card_to] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(" "), + ); + Some(( + lhs.to_string(), + to_id.to_string(), + head_from, + head_to, + line, + label, + )) +} + +fn char_byte(s: &str, char_pos: usize) -> usize { + s.char_indices() + .nth(char_pos) + .map(|(b, _)| b) + .unwrap_or(s.len()) +} + +fn strip_cardinality_suffix(s: &str) -> (&str, String) { + let t = s.trim_end(); + if let Some(rest) = t.strip_suffix('"') + && let Some(q) = rest.rfind('"') + { + return (rest[..q].trim_end(), rest[q + 1..].to_string()); + } + (t, String::new()) +} + +fn strip_cardinality_prefix(s: &str) -> (&str, String) { + let t = s.trim_start(); + if let Some(rest) = t.strip_prefix('"') + && let Some(q) = rest.find('"') + { + return (rest[q + 1..].trim_start(), rest[..q].to_string()); + } + (t, String::new()) +} + +fn display_generics(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut open = false; + for c in s.chars() { + if c == '~' { + out.push(if open { '>' } else { '<' }); + open = !open; + } else { + out.push(c); + } + } + out +} + +fn parse_er(src: &str) -> Option<(Graph, Vec)> { + let mut statements: Vec = Vec::new(); + for raw_line in src.lines() { + split_statements(raw_line, &mut statements); + } + let header = statements.first()?; + if !header + .split_whitespace() + .next()? + .eq_ignore_ascii_case("erdiagram") + { + return None; + } + + let mut graph = Graph { + nodes: Vec::new(), + edges: Vec::new(), + index: HashMap::new(), + groups: Vec::new(), + node_group: Vec::new(), + cur_group: None, + over_cap: false, + dir: Dir::Down, + }; + let mut infos: Vec = Vec::new(); + let mut cur_entity: Option = None; + + for st in &statements[1..] { + if let Some(ei) = cur_entity { + if st == "}" { + cur_entity = None; + } else { + push_er_attribute(&mut infos[ei], st); + } + continue; + } + if let Some((rel, label_part)) = split_er_relationship(st) { + let tokens: Vec<&str> = rel.split_whitespace().collect(); + let [lhs, op, rhs] = tokens.as_slice() else { + return None; + }; + let (card_l, card_r, line) = parse_er_op(op)?; + let f = er_entity(&mut graph, &mut infos, lhs)?; + let t = er_entity(&mut graph, &mut infos, rhs)?; + if graph.edges.len() >= MAX_EDGES { + return None; + } + let rel_label = label_part.map(clean_label).unwrap_or_default(); + let label = non_empty( + [card_l.to_string(), rel_label, card_r.to_string()] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(" "), + ); + graph.edges.push(Edge { + from: f, + to: t, + label, + head_to: Head::None, + head_from: Head::None, + line, + }); + continue; + } + let (decl, open) = match st.strip_suffix('{') { + Some(d) => (d.trim(), true), + None => (st.as_str(), false), + }; + if decl.is_empty() || decl.split_whitespace().count() != 1 { + return None; + } + let idx = er_entity(&mut graph, &mut infos, decl)?; + if open { + cur_entity = Some(idx); + } + } + + if graph.nodes.is_empty() { + return None; + } + sync_infos(&graph, &mut infos); + Some((graph, infos)) +} + +fn er_entity(graph: &mut Graph, infos: &mut Vec, token: &str) -> Option { + let idx = if let Some(open) = token.find('[') { + let id = &token[..open]; + let label = clean_label(token[open + 1..].trim_end_matches(']')); + if id.is_empty() || label.is_empty() { + return None; + } + graph.node_label(id, &label)? + } else { + graph.node_index(token, None, Shape::Rect)? + }; + sync_infos(graph, infos); + Some(idx) +} + +fn split_er_relationship(st: &str) -> Option<(&str, Option<&str>)> { + let (rel, label) = match st.split_once(':') { + Some((r, l)) => (r, Some(l.trim())), + None => (st, None), + }; + let has_op = rel.split_whitespace().any(|t| parse_er_op(t).is_some()); + if has_op { Some((rel, label)) } else { None } +} + +fn parse_er_op(tok: &str) -> Option<(&'static str, &'static str, LineKind)> { + if !tok.is_ascii() || tok.len() != 6 { + return None; + } + let line = match &tok[2..4] { + "--" => LineKind::Solid, + ".." => LineKind::Dotted, + _ => return None, + }; + Some((er_card(&tok[..2])?, er_card(&tok[4..6])?, line)) +} + +fn er_card(tok: &str) -> Option<&'static str> { + match tok { + "|o" | "o|" => Some("0..1"), + "||" => Some("1"), + "}o" | "o{" => Some("0..*"), + "}|" | "|{" => Some("1..*"), + _ => None, + } +} + +fn push_er_attribute(info: &mut ClassInfo, raw: &str) { + let mut parts: Vec<&str> = Vec::new(); + for tok in raw.split_whitespace() { + if tok.starts_with('"') { + break; + } + parts.push(tok); + } + if parts.is_empty() { + return; + } + let line = decode_html_entities(&parts.join(" ")); + if info.attrs.len() < MAX_MEMBERS { + info.attrs.push(line); + } else if info.attrs.len() == MAX_MEMBERS { + info.attrs.push("…".to_string()); + } +} + +fn render_class( + graph: &Graph, + infos: &[ClassInfo], + styles: &MermaidStyles, + max_width: Option, +) -> Result { + let extras: Vec = graph + .nodes + .iter() + .zip(infos) + .map(|(node, info)| { + let mut title = Vec::new(); + if let Some(a) = &info.annotation { + title.push(format!("«{a}»")); + } + title.push(display_generics(&node.label)); + NodeExtra::Compartments(vec![title, info.attrs.clone(), info.methods.clone()]) + }) + .collect(); + let mut canvas = layout_canvas(graph, &extras, max_width)?; + match graph.dir { + Dir::Up => canvas.flip_vertical(), + Dir::Left => canvas.flip_horizontal(), + _ => {} + } + let (styled_lines, plain_lines) = canvas.to_lines(styles); + Ok(MermaidArt { + styled_lines, + plain_lines, + }) +} + +const U: u8 = 1; +const D: u8 = 2; +const L: u8 = 4; +const R: u8 = 8; + +#[derive(Clone, Copy, PartialEq)] +enum Cls { + Empty, + Border, + Text, + Edge, + EdgeLabel, +} + +const STY_DOT: u8 = 1; +const STY_THICK: u8 = 2; +const STY_SOLID: u8 = 4; + +struct Canvas { + w: usize, + h: usize, + ch: Vec, + cls: Vec, + mask: Vec, + style: Vec, + occupied: Vec, + cur_style: u8, +} + +impl Canvas { + fn new(w: usize, h: usize) -> Self { + let n = w * h; + Self { + w, + h, + ch: vec![' '; n], + cls: vec![Cls::Empty; n], + mask: vec![0; n], + style: vec![0; n], + occupied: vec![false; n], + cur_style: STY_SOLID, + } + } + + fn idx(&self, x: usize, y: usize) -> usize { + y * self.w + x + } + + fn set(&mut self, x: usize, y: usize, c: char, cls: Cls) { + if x >= self.w || y >= self.h { + return; + } + let i = self.idx(x, y); + self.ch[i] = c; + self.cls[i] = cls; + } + + fn add_bits(&mut self, x: usize, y: usize, bits: u8) { + if x >= self.w || y >= self.h { + return; + } + let i = self.idx(x, y); + if self.occupied[i] { + return; + } + self.mask[i] |= bits; + self.style[i] |= self.cur_style; + if self.cls[i] != Cls::Border { + self.cls[i] = Cls::Edge; + } + } + + fn blit(&mut self, sub: &Canvas, ox: usize, oy: usize) { + for sy in 0..sub.h { + for sx in 0..sub.w { + let (x, y) = (ox + sx, oy + sy); + if x >= self.w || y >= self.h { + continue; + } + let si = sub.idx(sx, sy); + let di = self.idx(x, y); + self.ch[di] = sub.ch[si]; + self.cls[di] = sub.cls[si]; + self.style[di] = sub.style[si]; + self.occupied[di] = true; + } + } + } + + fn junction(&mut self, x: usize, y: usize, bits: u8) { + if x >= self.w || y >= self.h { + return; + } + let i = self.idx(x, y); + self.mask[i] |= bits; + if self.cls[i] != Cls::Border { + self.cls[i] = Cls::Edge; + } + } + + fn seg_v(&mut self, x: usize, y0: usize, y1: usize) { + let (a, b) = (y0.min(y1), y0.max(y1)); + for y in a..=b { + let mut bits = 0; + if y > a { + bits |= U; + } + if y < b { + bits |= D; + } + self.add_bits(x, y, bits); + } + } + + fn seg_h(&mut self, y: usize, x0: usize, x1: usize) { + let (a, b) = (x0.min(x1), x0.max(x1)); + for x in a..=b { + let mut bits = 0; + if x > a { + bits |= L; + } + if x < b { + bits |= R; + } + self.add_bits(x, y, bits); + } + } + + fn finalize_mask(&mut self) { + for i in 0..self.ch.len() { + if self.mask[i] != 0 && self.ch[i] == ' ' { + let c = mask_char(self.mask[i]); + self.ch[i] = match self.style[i] { + STY_DOT => dotted_char(c), + STY_THICK => thick_char(c), + _ => c, + }; + } + } + } + + /// Mirror top-to-bottom for `BT` (rows reorder; within-row text is + /// unaffected, so labels stay readable). Box-drawing glyphs flip too. + fn flip_vertical(&mut self) { + for y in 0..self.h / 2 { + let y2 = self.h - 1 - y; + for x in 0..self.w { + let (i, j) = (self.idx(x, y), self.idx(x, y2)); + self.ch.swap(i, j); + self.cls.swap(i, j); + } + } + for c in self.ch.iter_mut() { + *c = flip_glyph_v(*c); + } + } + + /// Mirror left-to-right for `RL`. Mirroring reverses each row, so after + /// flipping glyphs we reverse each text/label run back to reading order. + fn flip_horizontal(&mut self) { + for y in 0..self.h { + for x in 0..self.w / 2 { + let x2 = self.w - 1 - x; + let (i, j) = (self.idx(x, y), self.idx(x2, y)); + self.ch.swap(i, j); + self.cls.swap(i, j); + } + } + for c in self.ch.iter_mut() { + *c = flip_glyph_h(*c); + } + for y in 0..self.h { + let mut x = 0; + while x < self.w { + let cls = self.cls[self.idx(x, y)]; + if cls == Cls::Text || cls == Cls::EdgeLabel { + let start = self.idx(x, y); + while x < self.w && self.cls[self.idx(x, y)] == cls { + x += 1; + } + let end = self.idx(x, y); + self.ch[start..end].reverse(); + } else { + x += 1; + } + } + } + } + + fn to_lines(&self, styles: &MermaidStyles) -> (Vec>, Vec) { + let mut styled = Vec::with_capacity(self.h); + let mut plain = Vec::with_capacity(self.h); + for y in 0..self.h { + let mut last = self.w; + for x in (0..self.w).rev() { + let c = self.ch[self.idx(x, y)]; + if c != ' ' && c != CONT { + last = x + 1; + break; + } + } + let mut spans: Vec> = Vec::new(); + let mut plain_row = String::new(); + let mut run = String::new(); + let mut run_cls = Cls::Empty; + for x in 0..last { + let i = self.idx(x, y); + let c = self.ch[i]; + if c == CONT { + continue; + } + let cls = self.cls[i]; + plain_row.push(c); + if cls != run_cls && !run.is_empty() { + spans.push(Span::styled( + std::mem::take(&mut run), + style_for(run_cls, styles), + )); + } + run_cls = cls; + run.push(c); + } + if !run.is_empty() { + spans.push(Span::styled(run, style_for(run_cls, styles))); + } + styled.push(Line::from(spans)); + plain.push(plain_row.trim_end().to_string()); + } + (styled, plain) + } +} + +fn style_for(cls: Cls, styles: &MermaidStyles) -> Style { + match cls { + Cls::Empty => Style::default(), + Cls::Border => styles.border, + Cls::Text => styles.node_text, + Cls::Edge => styles.edge, + Cls::EdgeLabel => styles.edge_label, + } +} + +fn mask_char(mask: u8) -> char { + match mask { + 0 => ' ', + m if m == U || m == D || m == U | D => '│', + m if m == L || m == R || m == L | R => '─', + m if m == D | R => '┌', + m if m == D | L => '┐', + m if m == U | R => '└', + m if m == U | L => '┘', + m if m == U | D | R => '├', + m if m == U | D | L => '┤', + m if m == D | L | R => '┬', + m if m == U | L | R => '┴', + _ => '┼', + } +} + +fn dotted_char(c: char) -> char { + match c { + '─' => '╌', + '│' => '╎', + other => other, + } +} + +fn thick_char(c: char) -> char { + match c { + '─' => '━', + '│' => '┃', + '┌' => '┏', + '┐' => '┓', + '└' => '┗', + '┘' => '┛', + '├' => '┣', + '┤' => '┫', + '┬' => '┳', + '┴' => '┻', + '┼' => '╋', + other => other, + } +} + +fn flip_glyph_v(c: char) -> char { + match c { + '┌' => '└', + '└' => '┌', + '┐' => '┘', + '┘' => '┐', + '┏' => '┗', + '┗' => '┏', + '┓' => '┛', + '┛' => '┓', + '╭' => '╰', + '╰' => '╭', + '╮' => '╯', + '╯' => '╮', + '┬' => '┴', + '┴' => '┬', + '┳' => '┻', + '┻' => '┳', + '▼' => '▲', + '▲' => '▼', + '▽' => '△', + '△' => '▽', + other => other, + } +} + +fn flip_glyph_h(c: char) -> char { + match c { + '┌' => '┐', + '┐' => '┌', + '└' => '┘', + '┘' => '└', + '┏' => '┓', + '┓' => '┏', + '┗' => '┛', + '┛' => '┗', + '╭' => '╮', + '╮' => '╭', + '╰' => '╯', + '╯' => '╰', + '├' => '┤', + '┤' => '├', + '┣' => '┫', + '┫' => '┣', + '▶' => '◄', + '◄' => '▶', + '▷' => '◁', + '◁' => '▷', + other => other, + } +} + +struct Placed { + x: usize, + y: usize, + w: usize, + h: usize, + cx: usize, + cy: usize, + rank: usize, +} + +struct NodeSizes { + box_w: Vec, + box_h: Vec, + lay_w: Vec, + lay_h: Vec, + extra_h: Vec, + self_label_w: Vec, +} + +fn layout_flowchart( + graph: &Graph, + styles: &MermaidStyles, + max_width: Option, +) -> Result { + let extras: Vec = (0..graph.nodes.len()).map(|_| NodeExtra::Plain).collect(); + let mut canvas = layout_canvas(graph, &extras, max_width)?; + match graph.dir { + Dir::Up => canvas.flip_vertical(), + Dir::Left => canvas.flip_horizontal(), + _ => {} + } + let (styled_lines, plain_lines) = canvas.to_lines(styles); + Ok(MermaidArt { + styled_lines, + plain_lines, + }) +} + +enum NodeExtra { + Plain, + Frame(Canvas), + Compartments(Vec>), +} + +fn layout_canvas( + graph: &Graph, + extras: &[NodeExtra], + max_width: Option, +) -> Result { + let n = graph.nodes.len(); + if n == 0 { + return Err(Oversize::Cells); + } + + let ranks = compute_ranks(graph); + let max_rank = *ranks.iter().max().unwrap_or(&0); + + let mut by_rank: Vec> = vec![Vec::new(); max_rank + 1]; + for (idx, &r) in ranks.iter().enumerate() { + by_rank[r].push(idx); + } + order_ranks(&mut by_rank, &graph.edges, &ranks); + + let wrapped: Vec> = graph + .nodes + .iter() + .map(|node| wrap_label(&node.label, WRAP_WIDTH, MAX_LINES)) + .collect(); + let mut box_w: Vec = (0..n) + .map(|i| match &extras[i] { + NodeExtra::Frame(sub) => { + let title_w = fit_label(&graph.nodes[i].label, WRAP_WIDTH).width(); + (sub.w + 2).max(title_w + 4) + } + NodeExtra::Compartments(sections) => { + sections + .iter() + .flatten() + .map(|l| l.width()) + .max() + .unwrap_or(1) + .max(1) + + 2 * PAD + + 2 + } + NodeExtra::Plain => { + wrapped[i] + .iter() + .map(|l| l.width()) + .max() + .unwrap_or(1) + .max(1) + + 2 * PAD + + 2 + } + }) + .collect(); + let box_h: Vec = (0..n) + .map(|i| match &extras[i] { + NodeExtra::Frame(sub) => sub.h + 2, + NodeExtra::Compartments(sections) => { + let filled = sections.iter().filter(|s| !s.is_empty()).count(); + sections.iter().map(|s| s.len()).sum::() + filled.saturating_sub(1) + 2 + } + NodeExtra::Plain => wrapped[i].len() + 2, + }) + .collect(); + + let mut extra_h = vec![0usize; n]; + let mut self_label_w = vec![0usize; n]; + for e in &graph.edges { + if e.from == e.to { + extra_h[e.from] = 2; + if let Some(l) = &e.label { + self_label_w[e.from] = self_label_w[e.from].max(l.width().min(MAX_LABEL)); + } + } + } + for i in 0..n { + if extra_h[i] > 0 { + box_w[i] = box_w[i].max(7); + } + } + let lay_w: Vec = (0..n) + .map(|i| { + box_w[i] + + if self_label_w[i] > 0 { + 2 * (self_label_w[i] + 3) + } else { + 0 + } + }) + .collect(); + let lay_h: Vec = (0..n).map(|i| box_h[i] + extra_h[i]).collect(); + let sizes = NodeSizes { + box_w, + box_h, + lay_w, + lay_h, + extra_h, + self_label_w, + }; + + let mut placed: Vec = (0..n) + .map(|_| Placed { + x: 0, + y: 0, + w: 0, + h: 0, + cx: 0, + cy: 0, + rank: 0, + }) + .collect(); + + // BT/RL reuse the TD/LR layout, then flip the finished canvas (so text + // stays readable) into the bottom-up / right-to-left orientation. + let vertical = matches!(graph.dir, Dir::Down | Dir::Up); + let plan = if vertical { + place_td(&ranks, max_rank, &by_rank, &sizes, graph, &mut placed) + } else { + place_lr(&ranks, max_rank, &by_rank, &sizes, graph, &mut placed) + }; + let (canvas_w, canvas_h) = plan.canvas; + + if let Some(mw) = max_width + && canvas_w > mw + { + return Err(Oversize::Width); + } + if canvas_w.saturating_mul(canvas_h) > MAX_CANVAS_CELLS { + return Err(Oversize::Cells); + } + + let mut canvas = Canvas::new(canvas_w, canvas_h); + for idx in 0..n { + match &extras[idx] { + NodeExtra::Frame(sub) => { + draw_frame(&mut canvas, &placed[idx], &graph.nodes[idx].label, sub) + } + NodeExtra::Compartments(sections) => { + draw_class_box(&mut canvas, &placed[idx], sections) + } + NodeExtra::Plain => draw_box( + &mut canvas, + &placed[idx], + &wrapped[idx], + graph.nodes[idx].shape, + ), + } + } + for (i, edge) in graph.edges.iter().enumerate() { + canvas.cur_style = match edge.line { + LineKind::Solid => STY_SOLID, + LineKind::Dotted => STY_DOT, + LineKind::Thick => STY_THICK, + }; + if edge.from == edge.to { + route_self(&mut canvas, &placed[edge.from], edge); + continue; + } + let (from, to) = (&placed[edge.from], &placed[edge.to]); + let adjacent = to.rank == from.rank + 1; + let bus = plan.band_end[from.rank] + plan.edge_bus[i]; + let lane = plan.lane_base + plan.edge_lane[i]; + match (vertical, adjacent) { + (true, true) => route_forward(&mut canvas, from, to, edge, bus), + (true, false) => route_back(&mut canvas, from, to, edge, lane), + (false, true) => route_forward_lr(&mut canvas, from, to, edge, bus), + (false, false) => route_back_lr(&mut canvas, from, to, edge, lane), + } + } + + canvas.finalize_mask(); + Ok(canvas) +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Item { + Node(usize), + Group(usize), +} + +fn render_grouped( + graph: &Graph, + styles: &MermaidStyles, + max_width: Option, +) -> Result { + let mut proxy: HashMap = HashMap::new(); + for (gi, g) in graph.groups.iter().enumerate() { + if let Some(&ni) = graph.index.get(&g.id) { + proxy.insert(ni, gi); + } + } + + let group_chain = |g: Option| -> Vec { + let mut chain = Vec::new(); + let mut cur = g; + while let Some(gi) = cur { + chain.push(gi); + cur = graph.groups[gi].parent; + } + chain.reverse(); + chain + }; + let endpoint = |n: usize| -> (Item, Vec) { + match proxy.get(&n) { + Some(&gi) => (Item::Group(gi), group_chain(graph.groups[gi].parent)), + None => (Item::Node(n), group_chain(graph.node_group[n])), + } + }; + + let mut scope_edges: HashMap, Vec<(Item, Item, usize)>> = HashMap::new(); + let mut referenced: Vec = vec![false; graph.groups.len()]; + for (ei, e) in graph.edges.iter().enumerate() { + let (item_f, chain_f) = endpoint(e.from); + let (item_t, chain_t) = endpoint(e.to); + let k = chain_f + .iter() + .zip(&chain_t) + .take_while(|(a, b)| a == b) + .count(); + let scope = if k == 0 { None } else { Some(chain_f[k - 1]) }; + let f = if chain_f.len() > k { + Item::Group(chain_f[k]) + } else { + item_f + }; + let t = if chain_t.len() > k { + Item::Group(chain_t[k]) + } else { + item_t + }; + if let Item::Group(gi) = f { + referenced[gi] = true; + } + if let Item::Group(gi) = t { + referenced[gi] = true; + } + scope_edges.entry(scope).or_default().push((f, t, ei)); + } + + let mut direct_nodes: HashMap, Vec> = HashMap::new(); + for (ni, g) in graph.node_group.iter().enumerate() { + if !proxy.contains_key(&ni) { + direct_nodes.entry(*g).or_default().push(ni); + } + } + let mut keep = vec![false; graph.groups.len()]; + for gi in (0..graph.groups.len()).rev() { + let has_nodes = direct_nodes.get(&Some(gi)).is_some_and(|v| !v.is_empty()); + let has_children = + (0..graph.groups.len()).any(|c| graph.groups[c].parent == Some(gi) && keep[c]); + keep[gi] = has_nodes || has_children || referenced[gi]; + } + + let mut canvas = build_scope(graph, None, &scope_edges, &direct_nodes, &keep, max_width)?; + match graph.dir { + Dir::Up => canvas.flip_vertical(), + Dir::Left => canvas.flip_horizontal(), + _ => {} + } + let (styled_lines, plain_lines) = canvas.to_lines(styles); + Ok(MermaidArt { + styled_lines, + plain_lines, + }) +} + +fn build_scope( + graph: &Graph, + scope: Option, + scope_edges: &HashMap, Vec<(Item, Item, usize)>>, + direct_nodes: &HashMap, Vec>, + keep: &[bool], + max_width: Option, +) -> Result { + let mut items: Vec = Vec::new(); + if let Some(nodes) = direct_nodes.get(&scope) { + items.extend(nodes.iter().map(|&n| Item::Node(n))); + } + let child_groups: Vec = (0..graph.groups.len()) + .filter(|&gi| graph.groups[gi].parent == scope && keep[gi]) + .collect(); + items.extend(child_groups.iter().map(|&gi| Item::Group(gi))); + + if items.is_empty() { + return Ok(Canvas::new(1, 1)); + } + + let mut index_of: HashMap = HashMap::new(); + let mut nodes: Vec = Vec::new(); + let mut extras: Vec = Vec::new(); + for item in &items { + index_of.insert(*item, nodes.len()); + match item { + Item::Node(ni) => { + nodes.push(Node { + label: graph.nodes[*ni].label.clone(), + shape: graph.nodes[*ni].shape, + }); + extras.push(NodeExtra::Plain); + } + Item::Group(gi) => { + let sub = build_scope(graph, Some(*gi), scope_edges, direct_nodes, keep, None)?; + nodes.push(Node { + label: graph.groups[*gi].label.clone(), + shape: Shape::Rect, + }); + extras.push(NodeExtra::Frame(sub)); + } + } + } + + let mut edges: Vec = Vec::new(); + if let Some(list) = scope_edges.get(&scope) { + for (f, t, ei) in list { + let (Some(&fi), Some(&ti)) = (index_of.get(f), index_of.get(t)) else { + continue; + }; + let e = &graph.edges[*ei]; + edges.push(Edge { + from: fi, + to: ti, + label: e.label.clone(), + head_to: e.head_to, + head_from: e.head_from, + line: e.line, + }); + } + } + + let synth = Graph { + nodes, + edges, + index: HashMap::new(), + groups: Vec::new(), + node_group: Vec::new(), + cur_group: None, + over_cap: false, + dir: graph.dir, + }; + layout_canvas(&synth, &extras, max_width) +} + +fn draw_class_box(canvas: &mut Canvas, p: &Placed, sections: &[Vec]) { + draw_box(canvas, p, &[], Shape::Rect); + let inner = p.w.saturating_sub(2 * PAD + 2).max(1); + let mut row = p.y + 1; + let mut first = true; + for (si, section) in sections.iter().enumerate() { + if section.is_empty() { + continue; + } + if !first { + canvas.set(p.x, row, '├', Cls::Border); + for x in (p.x + 1)..(p.x + p.w - 1) { + canvas.set(x, row, '─', Cls::Border); + } + canvas.set(p.x + p.w - 1, row, '┤', Cls::Border); + row += 1; + } + first = false; + for line in section { + let text = fit_label(line, inner); + let tx = if si == 0 { + p.x + 1 + PAD + inner.saturating_sub(text.width()) / 2 + } else { + p.x + 1 + PAD + }; + draw_seq_text(canvas, &text, tx, row, Cls::Text); + row += 1; + } + } +} + +fn draw_frame(canvas: &mut Canvas, p: &Placed, title: &str, sub: &Canvas) { + draw_box(canvas, p, &[], Shape::Rect); + let t = fit_label(title, p.w.saturating_sub(4)); + draw_seq_text(canvas, &format!(" {t} "), p.x + 1, p.y, Cls::Text); + let ox = p.x + 1 + (p.w - 2 - sub.w) / 2; + let oy = p.y + 1 + (p.h - 2 - sub.h) / 2; + canvas.blit(sub, ox, oy); +} + +fn bus_spans_td( + graph: &Graph, + ranks: &[usize], + centers: &[usize], + r: usize, + exact: bool, +) -> Vec<(usize, usize, usize, usize, usize)> { + graph + .edges + .iter() + .enumerate() + .filter(|(_, e)| { + let jogs = if exact { + centers[e.from] != centers[e.to] + } else { + centers[e.from].abs_diff(centers[e.to]) > 1 + }; + e.from != e.to && ranks[e.from] == r && ranks[e.to] == r + 1 && jogs + }) + .map(|(i, e)| { + let a = centers[e.from].min(centers[e.to]); + let b = centers[e.from].max(centers[e.to]); + (a, b, e.from, e.to, i) + }) + .collect() +} + +fn lane_spans( + graph: &Graph, + ranks: &[usize], + placed: &[Placed], + vertical: bool, +) -> Vec<(usize, usize, usize, usize, usize)> { + graph + .edges + .iter() + .enumerate() + .filter(|(_, e)| e.from != e.to && ranks[e.to] != ranks[e.from] + 1) + .map(|(i, e)| { + let (pf, pt) = (&placed[e.from], &placed[e.to]); + let (a, b) = if vertical { + (pf.cy.min(pt.cy), pf.cy.max(pt.cy)) + } else { + (pf.cx.min(pt.cx), pf.cx.max(pt.cx)) + }; + (a, b, e.from, e.to, i) + }) + .collect() +} + +fn place_td( + ranks: &[usize], + max_rank: usize, + by_rank: &[Vec], + sizes: &NodeSizes, + graph: &Graph, + placed: &mut [Placed], +) -> RoutePlan { + let centers = assign_positions(by_rank, &sizes.lay_w, GAP_X, &graph.edges, ranks); + + let mut edge_bus = vec![0usize; graph.edges.len()]; + let mut bus_tracks = vec![0usize; max_rank + 1]; + for (r, tracks) in bus_tracks.iter_mut().enumerate().take(max_rank) { + let spans = bus_spans_td(graph, ranks, ¢ers, r, false); + if spans.is_empty() { + continue; + } + let (assigned, count) = assign_tracks(&spans); + for (idx, slot) in assigned { + edge_bus[idx] = slot; + } + *tracks = count; + } + + let rank_h: Vec = by_rank + .iter() + .map(|row| { + row.iter() + .map(|&i| sizes.box_h[i] + sizes.extra_h[i]) + .max() + .unwrap_or(3) + }) + .collect(); + let mut rank_y = vec![0usize; max_rank + 1]; + for r in 1..=max_rank { + let gap = GAP_Y.max(bus_tracks[r - 1] + 1); + rank_y[r] = rank_y[r - 1] + rank_h[r - 1] + gap; + } + let canvas_h = rank_y[max_rank] + rank_h[max_rank]; + let band_end: Vec = (0..=max_rank).map(|r| rank_y[r] + rank_h[r]).collect(); + + let mut diagram_w = 1; + for (r, row) in by_rank.iter().enumerate() { + for &idx in row { + let w = sizes.box_w[idx]; + let h = sizes.box_h[idx]; + let cx = centers[idx]; + let x = cx.saturating_sub(w / 2); + let y = rank_y[r] + (rank_h[r] - h - sizes.extra_h[idx]) / 2; + placed[idx] = Placed { + x, + y, + w, + h, + cx, + cy: y + h / 2, + rank: r, + }; + diagram_w = diagram_w.max(x + w); + if sizes.extra_h[idx] > 0 && sizes.self_label_w[idx] > 0 { + diagram_w = diagram_w.max(x + w + 2 + sizes.self_label_w[idx]); + } + } + } + + let mut content_w = diagram_w; + for e in &graph.edges { + if e.from == e.to { + continue; + } + if let Some(label) = &e.label { + let lw = label.width().min(MAX_LABEL); + if ranks[e.to] == ranks[e.from] + 1 { + content_w = content_w.max(placed[e.to].cx + 2 + lw); + } else { + content_w = content_w.max(diagram_w + lw + 1); + } + } + } + + let mut edge_lane = vec![0usize; graph.edges.len()]; + let lanes = lane_spans(graph, ranks, placed, true); + let (canvas_w, lane_base) = if lanes.is_empty() { + (content_w, 0) + } else { + let (assigned, count) = assign_tracks(&lanes); + for (idx, slot) in assigned { + edge_lane[idx] = slot; + } + (content_w + 1 + count, content_w + 1) + }; + + RoutePlan { + canvas: (canvas_w, canvas_h), + band_end, + edge_bus, + lane_base, + edge_lane, + } +} + +fn place_lr( + ranks: &[usize], + max_rank: usize, + by_rank: &[Vec], + sizes: &NodeSizes, + graph: &Graph, + placed: &mut [Placed], +) -> RoutePlan { + let col_w: Vec = by_rank + .iter() + .map(|row| row.iter().map(|&i| sizes.box_w[i]).max().unwrap_or(0)) + .collect(); + + let max_label = graph + .edges + .iter() + .filter(|e| e.from == e.to || ranks[e.to] == ranks[e.from] + 1) + .filter_map(|e| e.label.as_ref().map(|l| l.width().min(MAX_LABEL))) + .max() + .unwrap_or(0); + let base_gap = (GAP_X + 1).max(max_label + 3); + + let centers = assign_positions(by_rank, &sizes.lay_h, 1, &graph.edges, ranks); + + let mut edge_bus = vec![0usize; graph.edges.len()]; + let mut bus_tracks = vec![0usize; max_rank + 1]; + for (r, tracks) in bus_tracks.iter_mut().enumerate().take(max_rank) { + let spans = bus_spans_td(graph, ranks, ¢ers, r, true); + if spans.is_empty() { + continue; + } + let (assigned, count) = assign_tracks(&spans); + for (idx, slot) in assigned { + edge_bus[idx] = slot; + } + *tracks = count; + } + + let mut rank_x = vec![0usize; max_rank + 1]; + for r in 1..=max_rank { + let gap = base_gap.max(bus_tracks[r - 1] + 1); + rank_x[r] = rank_x[r - 1] + col_w[r - 1] + gap; + } + let canvas_w = rank_x[max_rank] + + col_w[max_rank] + + by_rank[max_rank] + .iter() + .filter(|&&i| sizes.extra_h[i] > 0 && sizes.self_label_w[i] > 0) + .map(|&i| 2 + sizes.self_label_w[i]) + .max() + .unwrap_or(0); + let band_end: Vec = (0..=max_rank).map(|r| rank_x[r] + col_w[r]).collect(); + + let mut diagram_h = 1; + for (r, row) in by_rank.iter().enumerate() { + let x = rank_x[r]; + for &idx in row { + let w = sizes.box_w[idx]; + let h = sizes.box_h[idx]; + let cy = centers[idx]; + let y = cy.saturating_sub((h + sizes.extra_h[idx]) / 2); + placed[idx] = Placed { + x, + y, + w, + h, + cx: x + w / 2, + cy: y + h / 2, + rank: r, + }; + diagram_h = diagram_h.max(y + h + sizes.extra_h[idx]); + } + } + + let mut edge_lane = vec![0usize; graph.edges.len()]; + let lanes = lane_spans(graph, ranks, placed, false); + let (canvas_h, lane_base) = if lanes.is_empty() { + (diagram_h, 0) + } else { + let (assigned, count) = assign_tracks(&lanes); + for (idx, slot) in assigned { + edge_lane[idx] = slot; + } + (diagram_h + 1 + count, diagram_h + 1) + }; + + RoutePlan { + canvas: (canvas_w, canvas_h), + band_end, + edge_bus, + lane_base, + edge_lane, + } +} + +struct RoutePlan { + canvas: (usize, usize), + band_end: Vec, + edge_bus: Vec, + lane_base: usize, + edge_lane: Vec, +} + +fn assign_tracks(spans: &[(usize, usize, usize, usize, usize)]) -> (Vec<(usize, usize)>, usize) { + let mut sorted = spans.to_vec(); + sorted.sort_unstable(); + let mut tracks: Vec> = Vec::new(); + let mut out = Vec::with_capacity(sorted.len()); + for &(s, e, f, t, idx) in &sorted { + let compatible = |members: &Vec<(usize, usize, usize, usize)>| { + members + .iter() + .all(|&(s2, e2, f2, t2)| e2 + 2 <= s || e + 2 <= s2 || f2 == f || t2 == t) + }; + let slot = match tracks.iter().position(compatible) { + Some(x) => x, + None => { + tracks.push(Vec::new()); + tracks.len() - 1 + } + }; + tracks[slot].push((s, e, f, t)); + out.push((idx, slot)); + } + (out, tracks.len()) +} + +/// Reorder nodes within each rank to minimize edge crossings (Sugiyama-style +/// barycenter sweeps): alternate down/up passes sort each rank by the mean +/// position of its forward neighbours, keeping the ordering with the fewest +/// crossings between adjacent ranks. +fn order_ranks(by_rank: &mut [Vec], edges: &[Edge], ranks: &[usize]) { + let n = ranks.len(); + if by_rank.len() < 2 || n < 3 { + return; + } + let mut parents: Vec> = vec![Vec::new(); n]; + let mut children: Vec> = vec![Vec::new(); n]; + for e in edges { + if e.from != e.to && ranks[e.to] > ranks[e.from] { + parents[e.to].push(e.from); + children[e.from].push(e.to); + } + } + + let mut pos = vec![0usize; n]; + let set_pos = |by_rank: &[Vec], pos: &mut Vec| { + for row in by_rank { + for (i, &v) in row.iter().enumerate() { + pos[v] = i; + } + } + }; + set_pos(by_rank, &mut pos); + + let mut best: Vec> = by_rank.to_vec(); + let mut best_crossings = count_crossings(edges, ranks, &pos); + if best_crossings == 0 { + return; + } + + for it in 0..8 { + if it % 2 == 0 { + for row in by_rank.iter_mut().skip(1) { + sort_by_barycenter(row, &parents, &pos); + for (i, &v) in row.iter().enumerate() { + pos[v] = i; + } + } + } else { + let last = by_rank.len() - 1; + for row in by_rank[..last].iter_mut().rev() { + sort_by_barycenter(row, &children, &pos); + for (i, &v) in row.iter().enumerate() { + pos[v] = i; + } + } + } + let crossings = count_crossings(edges, ranks, &pos); + if crossings < best_crossings { + best_crossings = crossings; + best = by_rank.to_vec(); + } + if best_crossings == 0 { + break; + } + } + + for (row, b) in by_rank.iter_mut().zip(best) { + *row = b; + } +} + +fn sort_by_barycenter(row: &mut [usize], neigh: &[Vec], pos: &[usize]) { + let mut keyed: Vec<(f64, usize)> = row + .iter() + .map(|&v| { + let key = if neigh[v].is_empty() { + pos[v] as f64 + } else { + neigh[v].iter().map(|&u| pos[u] as f64).sum::() / neigh[v].len() as f64 + }; + (key, v) + }) + .collect(); + keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (slot, (_, v)) in row.iter_mut().zip(keyed) { + *slot = v; + } +} + +fn count_crossings(edges: &[Edge], ranks: &[usize], pos: &[usize]) -> usize { + let adjacent: Vec<(usize, usize, usize)> = edges + .iter() + .filter(|e| e.from != e.to && ranks[e.to] == ranks[e.from] + 1) + .map(|e| (ranks[e.from], pos[e.from], pos[e.to])) + .collect(); + let mut crossings = 0; + for (i, a) in adjacent.iter().enumerate() { + for b in &adjacent[i + 1..] { + if a.0 == b.0 && ((a.1 < b.1 && a.2 > b.2) || (a.1 > b.1 && a.2 < b.2)) { + crossings += 1; + } + } + } + crossings +} + +/// Assign a center coordinate (along the cross-axis) to every node so nodes line +/// up under their neighbours. Iterative barycenter relaxation: each node drifts +/// toward the average of its forward neighbours while ranks keep order and a +/// minimum `sep` between boxes, which straightens chains and centers branches. +fn assign_positions( + by_rank: &[Vec], + size: &[usize], + sep: usize, + edges: &[Edge], + ranks: &[usize], +) -> Vec { + let n = size.len(); + let mut parents: Vec> = vec![Vec::new(); n]; + let mut children: Vec> = vec![Vec::new(); n]; + for e in edges { + if e.from != e.to && ranks[e.to] > ranks[e.from] { + parents[e.to].push(e.from); + children[e.from].push(e.to); + } + } + + let mut pos = vec![0f64; n]; + for row in by_rank { + let mut x = 0f64; + for &v in row { + let half = size[v] as f64 / 2.0; + x += half; + pos[v] = x; + x += half + sep as f64; + } + } + + for it in 0..10 { + if it % 2 == 0 { + for row in by_rank.iter() { + relax_rank(row, &parents, &mut pos, size, sep); + } + } else { + for row in by_rank.iter().rev() { + relax_rank(row, &children, &mut pos, size, sep); + } + } + } + + let min_left = (0..n) + .map(|v| pos[v] - size[v] as f64 / 2.0) + .fold(f64::INFINITY, f64::min); + let min_left = if min_left.is_finite() { min_left } else { 0.0 }; + (0..n) + .map(|v| (pos[v] - min_left).round().max(0.0) as usize) + .collect() +} + +fn relax_rank(nodes: &[usize], neigh: &[Vec], pos: &mut [f64], size: &[usize], sep: usize) { + let n = nodes.len(); + if n == 0 { + return; + } + let desired: Vec = nodes + .iter() + .map(|&v| { + if neigh[v].is_empty() { + pos[v] + } else { + neigh[v].iter().map(|&u| pos[u]).sum::() / neigh[v].len() as f64 + } + }) + .collect(); + + let half = |i: usize| size[nodes[i]] as f64 / 2.0; + let mut left = vec![0f64; n]; + let mut right = vec![0f64; n]; + for i in 0..n { + left[i] = if i == 0 { + desired[i] + } else { + desired[i].max(left[i - 1] + half(i - 1) + sep as f64 + half(i)) + }; + } + for i in (0..n).rev() { + right[i] = if i == n - 1 { + desired[i] + } else { + desired[i].min(right[i + 1] - half(i + 1) - sep as f64 - half(i)) + }; + } + for i in 0..n { + pos[nodes[i]] = (left[i] + right[i]) / 2.0; + } + for i in 1..n { + let min_p = pos[nodes[i - 1]] + half(i - 1) + sep as f64 + half(i); + if pos[nodes[i]] < min_p { + pos[nodes[i]] = min_p; + } + } +} + +fn wrap_label(label: &str, width: usize, max_lines: usize) -> Vec { + let width = width.max(1); + let char_w = |c: char| char_width(c).max(1); + let mut lines: Vec = Vec::new(); + let mut cur = String::new(); + let mut cur_w = 0usize; + for word in label.split_whitespace() { + let ww = word.width(); + if ww > width { + if !cur.is_empty() { + lines.push(std::mem::take(&mut cur)); + } + let mut chunk = String::new(); + let mut chunk_w = 0usize; + for ch in word.chars() { + let cw = char_w(ch); + if chunk_w + cw > width && !chunk.is_empty() { + // Prefer breaking after the last identifier boundary so a long + // token is not sliced mid-segment; fall back to a per-char break. + let carry = match chunk.rfind(LABEL_BREAK_CHARS) { + Some(p) => chunk.split_off(p + 1), + None => String::new(), + }; + lines.push(std::mem::take(&mut chunk)); + chunk_w = carry.chars().map(char_w).sum(); + chunk = carry; + } + chunk.push(ch); + chunk_w += cw; + } + cur = chunk; + cur_w = chunk_w; + } else if cur.is_empty() { + cur.push_str(word); + cur_w = ww; + } else if cur_w + 1 + ww <= width { + cur.push(' '); + cur.push_str(word); + cur_w += 1 + ww; + } else { + lines.push(std::mem::take(&mut cur)); + cur.push_str(word); + cur_w = ww; + } + } + if !cur.is_empty() { + lines.push(cur); + } + if lines.is_empty() { + lines.push(String::new()); + } + if lines.len() > max_lines { + lines.truncate(max_lines); + if let Some(last) = lines.last_mut() { + let target = width.saturating_sub(1).max(1); + let mut s = String::new(); + let mut sw = 0usize; + for ch in last.chars() { + let cw = char_w(ch); + if sw + cw > target { + break; + } + s.push(ch); + sw += cw; + } + s.push('…'); + *last = s; + } + } + lines +} + +fn fit_label(label: &str, inner: usize) -> String { + if label.width() <= inner { + return label.to_string(); + } + let mut out = String::new(); + let mut used = 0usize; + for c in label.chars() { + let cw = char_width(c); + if used + cw + 1 > inner { + break; + } + out.push(c); + used += cw; + } + out.push('…'); + out +} + +fn draw_box(canvas: &mut Canvas, p: &Placed, lines: &[String], shape: Shape) { + let (x, y, w, h) = (p.x, p.y, p.w, p.h); + let right = x + w - 1; + let bottom = y + h - 1; + + let (tl, tr, bl, br) = match shape { + Shape::Round | Shape::Diamond => ('╭', '╮', '╰', '╯'), + Shape::Rect => ('┌', '┐', '└', '┘'), + }; + canvas.set(x, y, tl, Cls::Border); + canvas.set(right, y, tr, Cls::Border); + canvas.set(x, bottom, bl, Cls::Border); + canvas.set(right, bottom, br, Cls::Border); + + for cx in (x + 1)..right { + canvas.add_bits(cx, y, L | R); + canvas.add_bits(cx, bottom, L | R); + } + for cy in (y + 1)..bottom { + canvas.add_bits(x, cy, U | D); + canvas.add_bits(right, cy, U | D); + } + + for cy in y..=bottom { + for cx in x..=right { + let i = canvas.idx(cx, cy); + canvas.occupied[i] = true; + } + } + + let inner = w.saturating_sub(2 * PAD + 2).max(1); + for (li, line) in lines.iter().enumerate() { + let row = y + 1 + li; + let text = fit_label(line, inner); + let tw = text.width(); + let text_x = x + 1 + PAD + inner.saturating_sub(tw) / 2; + let mut cur = text_x; + for c in text.chars() { + let cw = char_width(c).max(1); + canvas.set(cur, row, c, Cls::Text); + // Wide glyphs (CJK, emoji) own a second column; mark it as a + // continuation so the line builder doesn't emit a stray space. + for k in 1..cw { + canvas.set(cur + k, row, CONT, Cls::Text); + } + cur += cw; + } + } +} + +fn route_forward(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, bus: usize) { + let tx = to.cx; + let bx = if from.cx.abs_diff(tx) <= 1 { + tx + } else { + from.cx + }; + let by = from.y + from.h - 1; + let head_row = to.y - 1; + + canvas.junction(bx, by, D); + canvas.seg_v(bx, by, bus); + if bx == tx { + canvas.seg_v(bx, bus, head_row); + } else { + canvas.seg_h(bus, bx, tx); + canvas.seg_v(tx, bus, head_row); + } + + if edge.head_to == Head::None { + canvas.add_bits(tx, head_row, U); + } else { + canvas.set(tx, head_row, head_glyph(edge.head_to, '▼'), Cls::Edge); + } + if edge.head_from != Head::None { + canvas.set(bx, by, head_glyph(edge.head_from, '▲'), Cls::Edge); + } + + if let Some(label) = &edge.label { + place_label(canvas, label, head_row, tx + 1); + } +} + +fn head_glyph(head: Head, arrow: char) -> char { + match head { + Head::Circle => 'o', + Head::Cross => '×', + Head::DiamondFill => '◆', + Head::DiamondOpen => '◇', + Head::Triangle => match arrow { + '▼' => '▽', + '▲' => '△', + '◄' => '◁', + '▶' => '▷', + other => other, + }, + _ => arrow, + } +} + +fn route_self(canvas: &mut Canvas, p: &Placed, edge: &Edge) { + let bottom = p.y + p.h - 1; + let exit_x = p.cx + 1; + let ret_x = p.x + p.w - 2; + if ret_x <= exit_x || bottom + 2 >= canvas.h { + return; + } + let (v, h, bl, br) = match edge.line { + LineKind::Dotted => ('╎', '╌', '╰', '╯'), + LineKind::Thick => ('┃', '━', '┗', '┛'), + LineKind::Solid => ('│', '─', '╰', '╯'), + }; + canvas.junction(exit_x, bottom, D); + canvas.set(exit_x, bottom + 1, v, Cls::Edge); + canvas.set(exit_x, bottom + 2, bl, Cls::Edge); + for x in (exit_x + 1)..ret_x { + canvas.set(x, bottom + 2, h, Cls::Edge); + } + canvas.set(ret_x, bottom + 2, br, Cls::Edge); + canvas.set(ret_x, bottom + 1, head_glyph(edge.head_to, '▲'), Cls::Edge); + if let Some(label) = &edge.label { + place_label(canvas, label, bottom + 1, p.x + p.w + 1); + } +} + +fn route_back(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, lane_x: usize) { + let sx = from.x + from.w - 1; + let sy = from.cy; + let tx = to.x + to.w - 1; + let tyc = to.cy; + + canvas.junction(sx, sy, R); + canvas.seg_h(sy, sx, lane_x); + canvas.seg_v(lane_x, sy, tyc); + canvas.seg_h(tyc, tx + 1, lane_x); + + if edge.head_to == Head::None { + canvas.add_bits(tx + 1, tyc, R); + } else { + canvas.set(tx + 1, tyc, head_glyph(edge.head_to, '◄'), Cls::Edge); + } + if edge.head_from != Head::None { + canvas.set(sx, sy, head_glyph(edge.head_from, '◄'), Cls::Edge); + } + + if let Some(label) = &edge.label { + place_label( + canvas, + label, + tyc.saturating_sub(1), + lane_x.saturating_sub(label.width() + 1), + ); + } +} + +fn route_forward_lr(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, bus: usize) { + let rx = from.x + from.w - 1; + let ry = from.cy; + let ly = to.cy; + let head_col = to.x - 1; + + canvas.junction(rx, ry, R); + canvas.seg_h(ry, rx, bus); + if ry == ly { + canvas.seg_h(ry, bus, head_col); + } else { + canvas.seg_v(bus, ry, ly); + canvas.seg_h(ly, bus, head_col); + } + + if edge.head_to == Head::None { + canvas.add_bits(head_col, ly, R); + } else { + canvas.set(head_col, ly, head_glyph(edge.head_to, '▶'), Cls::Edge); + } + if edge.head_from != Head::None { + canvas.set(rx, ry, head_glyph(edge.head_from, '◄'), Cls::Edge); + } + + if let Some(label) = &edge.label { + place_label(canvas, label, ly.saturating_sub(1), bus + 1); + } +} + +fn route_back_lr(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, lane_y: usize) { + let sx = from.cx; + let sy = from.y + from.h - 1; + let tx = to.cx; + let ty = to.y + to.h - 1; + + canvas.junction(sx, sy, D); + canvas.seg_v(sx, sy, lane_y); + canvas.seg_h(lane_y, sx, tx); + canvas.seg_v(tx, lane_y, ty + 1); + + if edge.head_to == Head::None { + canvas.add_bits(tx, ty + 1, D); + } else { + canvas.set(tx, ty + 1, head_glyph(edge.head_to, '▲'), Cls::Edge); + } + if edge.head_from != Head::None { + canvas.set(sx, sy, head_glyph(edge.head_from, '▲'), Cls::Edge); + } + + if let Some(label) = &edge.label { + place_label(canvas, label, lane_y.saturating_sub(1), (sx + tx) / 2); + } +} + +fn place_label(canvas: &mut Canvas, label: &str, row: usize, start_x: usize) { + if row >= canvas.h { + return; + } + let text = fit_label(label, MAX_LABEL); + let mut x = start_x; + for c in text.chars() { + let cw = char_width(c).max(1); + if x + cw > canvas.w { + break; + } + let blocked = (0..cw).any(|k| { + let i = canvas.idx(x + k, row); + canvas.ch[i] != ' ' || canvas.mask[i] != 0 || canvas.occupied[i] + }); + if blocked { + break; + } + canvas.set(x, row, c, Cls::EdgeLabel); + for k in 1..cw { + canvas.set(x + k, row, CONT, Cls::EdgeLabel); + } + x += cw; + } +} + +fn compute_ranks(graph: &Graph) -> Vec { + let n = graph.nodes.len(); + let mut children: Vec> = vec![Vec::new(); n]; + let mut indeg = vec![0usize; n]; + for e in &graph.edges { + if e.from != e.to { + children[e.from].push(e.to); + indeg[e.to] += 1; + } + } + + let mut color = vec![0u8; n]; + let mut dag: Vec> = vec![Vec::new(); n]; + let mut order: Vec = Vec::with_capacity(n); + + let roots: Vec = (0..n).filter(|&i| indeg[i] == 0).collect(); + for start in roots.iter().copied().chain(0..n) { + if color[start] == 0 { + dfs_dag(start, &children, &mut color, &mut dag, &mut order); + } + } + + let mut rank = vec![0usize; n]; + for &u in order.iter().rev() { + for &v in &dag[u] { + rank[v] = rank[v].max(rank[u] + 1); + } + } + rank +} + +fn dfs_dag( + start: usize, + children: &[Vec], + color: &mut [u8], + dag: &mut [Vec], + order: &mut Vec, +) { + let mut stack: Vec<(usize, usize)> = vec![(start, 0)]; + color[start] = 1; + while let Some(frame) = stack.last_mut() { + let u = frame.0; + if frame.1 < children[u].len() { + let v = children[u][frame.1]; + frame.1 += 1; + if color[v] == 1 { + continue; + } + dag[u].push(v); + if color[v] == 0 { + color[v] = 1; + stack.push((v, 0)); + } + } else { + color[u] = 2; + order.push(u); + stack.pop(); + } + } +} + +const SEQ_GAP: usize = 5; +const SEQ_OPS: &[(&str, bool, SeqHead)] = &[ + ("-->>", true, SeqHead::Arrow), + ("->>", false, SeqHead::Arrow), + ("--x", true, SeqHead::Cross), + ("-x", false, SeqHead::Cross), + ("--)", true, SeqHead::Arrow), + ("-)", false, SeqHead::Arrow), + ("-->", true, SeqHead::Arrow), + ("->", false, SeqHead::Arrow), +]; + +#[derive(Clone, Copy, PartialEq)] +enum SeqHead { + Arrow, + Cross, +} + +enum NoteAnchor { + Over(usize, usize), + Left(usize), + Right(usize), +} + +enum SeqItem { + Message { + from: usize, + to: usize, + text: Option, + dashed: bool, + head: SeqHead, + }, + Note { + anchor: NoteAnchor, + text: String, + }, + Divider { + text: String, + }, +} + +struct Sequence { + labels: Vec, + index: HashMap, + items: Vec, +} + +impl Sequence { + fn participant(&mut self, id: &str, label: Option<&str>) -> Option { + if let Some(&i) = self.index.get(id) { + if let Some(label) = label { + self.labels[i] = label.to_string(); + } + return Some(i); + } + if self.labels.len() >= MAX_NODES { + return None; + } + self.index.insert(id.to_string(), self.labels.len()); + self.labels.push(label.unwrap_or(id).to_string()); + Some(self.labels.len() - 1) + } +} + +fn parse_sequence(src: &str) -> Option { + let mut statements: Vec = Vec::new(); + for raw_line in src.lines() { + split_statements(raw_line, &mut statements); + } + let header = statements.first()?; + if !header + .split_whitespace() + .next()? + .eq_ignore_ascii_case("sequencediagram") + { + return None; + } + + let mut seq = Sequence { + labels: Vec::new(), + index: HashMap::new(), + items: Vec::new(), + }; + let mut autonumber = false; + let mut msg_count = 0usize; + let mut blocks: Vec = Vec::new(); + + for st in &statements[1..] { + let first = st.split_whitespace().next().unwrap_or(""); + match first.to_ascii_lowercase().as_str() { + "participant" | "actor" => { + let rest = st[first.len()..].trim(); + if rest.is_empty() { + return None; + } + let (id, label) = match rest.split_once(" as ") { + Some((id, label)) => (id.trim(), Some(clean_label(label))), + None => (rest, None), + }; + seq.participant(id, label.as_deref())?; + } + "autonumber" => autonumber = true, + "activate" | "deactivate" | "create" | "destroy" | "title" | "acctitle" + | "accdescr" | "links" | "link" | "properties" => {} + "note" => { + let rest = st[first.len()..].trim(); + let (text_part, anchor) = parse_note_anchor(rest, &mut seq)?; + if seq.items.len() >= MAX_EDGES { + return None; + } + seq.items.push(SeqItem::Note { + anchor, + text: text_part, + }); + } + "loop" | "alt" | "opt" | "par" | "critical" | "break" | "else" | "and" | "option" => { + if matches!( + first.to_ascii_lowercase().as_str(), + "else" | "and" | "option" + ) { + if blocks.last() != Some(&true) { + continue; + } + } else { + blocks.push(true); + } + if seq.items.len() >= MAX_EDGES { + return None; + } + seq.items.push(SeqItem::Divider { + text: decode_html_entities(st), + }); + } + "rect" | "box" => blocks.push(false), + "end" => { + if blocks.pop() == Some(true) { + if seq.items.len() >= MAX_EDGES { + return None; + } + seq.items.push(SeqItem::Divider { + text: "end".to_string(), + }); + } + } + _ => { + let (from, to, mut text, dashed, head) = parse_seq_message(st, &mut seq)?; + if autonumber { + msg_count += 1; + text = Some(match text { + Some(t) => format!("{msg_count}. {t}"), + None => format!("{msg_count}."), + }); + } + if seq.items.len() >= MAX_EDGES { + return None; + } + seq.items.push(SeqItem::Message { + from, + to, + text, + dashed, + head, + }); + } + } + } + + if seq.labels.is_empty() { + return None; + } + Some(seq) +} + +fn parse_note_anchor(rest: &str, seq: &mut Sequence) -> Option<(String, NoteAnchor)> { + let lower = rest.to_ascii_lowercase(); + let (ids_and_text, kind) = if let Some(r) = lower.strip_prefix("over ") { + (&rest[rest.len() - r.len()..], 0u8) + } else if let Some(r) = lower.strip_prefix("left of ") { + (&rest[rest.len() - r.len()..], 1) + } else if let Some(r) = lower.strip_prefix("right of ") { + (&rest[rest.len() - r.len()..], 2) + } else { + return None; + }; + let (ids, text) = ids_and_text.split_once(':')?; + let text = decode_html_entities(text.trim()); + let mut parts = ids.split(',').map(str::trim).filter(|s| !s.is_empty()); + let a = seq.participant(parts.next()?, None)?; + let anchor = match kind { + 0 => { + let b = match parts.next() { + Some(id) => seq.participant(id, None)?, + None => a, + }; + NoteAnchor::Over(a.min(b), a.max(b)) + } + 1 => NoteAnchor::Left(a), + _ => NoteAnchor::Right(a), + }; + Some((text, anchor)) +} + +fn parse_seq_message( + st: &str, + seq: &mut Sequence, +) -> Option<(usize, usize, Option, bool, SeqHead)> { + let mut found: Option<(usize, &str, bool, SeqHead)> = None; + for (pos, _) in st.char_indices() { + for &(op, dashed, head) in SEQ_OPS { + if st[pos..].starts_with(op) { + found = Some((pos, op, dashed, head)); + break; + } + } + if found.is_some() { + break; + } + } + let (pos, op, dashed, head) = found?; + let from_id = st[..pos].trim(); + if from_id.is_empty() { + return None; + } + let rest = st[pos + op.len()..] + .trim_start() + .trim_start_matches(['+', '-']); + let (to_id, text) = match rest.split_once(':') { + Some((to, text)) => (to.trim(), non_empty(decode_html_entities(text.trim()))), + None => (rest.trim(), None), + }; + if to_id.is_empty() { + return None; + } + let from = seq.participant(from_id, None)?; + let to = seq.participant(to_id, None)?; + Some((from, to, text, dashed, head)) +} + +fn note_geometry(xs: &[usize], anchor: &NoteAnchor, text_w: usize) -> (usize, usize) { + match *anchor { + NoteAnchor::Over(l, r) => { + let center = (xs[l] + xs[r]) / 2; + let w = (xs[r] - xs[l] + 5).max(text_w + 2 * PAD + 2); + (center.saturating_sub(w / 2), w) + } + NoteAnchor::Left(i) => { + let w = text_w + 2 * PAD + 2; + (xs[i].saturating_sub(2 + w - 1), w) + } + NoteAnchor::Right(i) => (xs[i] + 2, text_w + 2 * PAD + 2), + } +} + +fn layout_sequence( + seq: &Sequence, + styles: &MermaidStyles, + max_width: Option, +) -> Result { + let n = seq.labels.len(); + let labels: Vec = seq + .labels + .iter() + .map(|l| fit_label(l, WRAP_WIDTH)) + .collect(); + let box_w: Vec = labels + .iter() + .map(|l| l.width().max(1) + 2 * PAD + 2) + .collect(); + let box_h = 3usize; + + let item_text_w = |text: &Option| text.as_deref().map(|t| t.width()).unwrap_or(0); + + let mut gaps: Vec = (0..n.saturating_sub(1)) + .map(|i| SEQ_GAP.max(box_w[i].div_ceil(2) + box_w[i + 1].div_ceil(2) + 1)) + .collect(); + + let mut reqs: Vec<(usize, usize, usize)> = Vec::new(); + for item in &seq.items { + match item { + SeqItem::Message { from, to, text, .. } => { + let tw = item_text_w(text); + if from != to { + let (l, r) = (*from.min(to), *from.max(to)); + reqs.push((l, r, (tw + 2).max(4))); + } else if *from + 1 < n { + reqs.push((*from, *from + 1, 5 + tw + 2)); + } + } + SeqItem::Note { anchor, text } => { + let tw = text.width(); + match *anchor { + NoteAnchor::Over(l, r) if l < r => reqs.push((l, r, tw.saturating_sub(1))), + NoteAnchor::Over(i, _) => { + let half = (tw + 4).div_ceil(2) + 2; + if i > 0 { + reqs.push((i - 1, i, half)); + } + if i + 1 < n { + reqs.push((i, i + 1, half)); + } + } + NoteAnchor::Left(i) if i > 0 => reqs.push((i - 1, i, tw + 7)), + NoteAnchor::Right(i) if i + 1 < n => reqs.push((i, i + 1, tw + 7)), + _ => {} + } + } + SeqItem::Divider { .. } => {} + } + } + reqs.sort_by_key(|&(l, r, _)| r - l); + for (l, r, need) in reqs { + let cur: usize = gaps[l..r].iter().sum(); + if cur < need { + gaps[r - 1] += need - cur; + } + } + + let mut xs = vec![0usize; n]; + xs[0] = box_w[0] / 2; + for i in 1..n { + xs[i] = xs[i - 1] + gaps[i - 1]; + } + + let mut canvas_w = xs[n - 1] + box_w[n - 1].div_ceil(2) + 1; + for item in &seq.items { + match item { + SeqItem::Message { from, to, text, .. } if from == to => { + canvas_w = canvas_w.max(xs[*from] + 5 + item_text_w(text) + 1); + } + SeqItem::Note { anchor, text } => { + let (x, w) = note_geometry(&xs, anchor, text.width()); + canvas_w = canvas_w.max(x + w + 1); + } + SeqItem::Divider { text } => { + canvas_w = canvas_w.max(text.width() + 4); + } + _ => {} + } + } + + let mut rows: Vec = Vec::with_capacity(seq.items.len()); + let mut y = box_h + 1; + for item in &seq.items { + rows.push(y); + y += match item { + SeqItem::Message { from, to, text, .. } => { + if from == to { + 4 + } else if text.is_some() { + 3 + } else { + 2 + } + } + SeqItem::Note { .. } => 4, + SeqItem::Divider { .. } => 2, + }; + } + let bottom_top = y; + let canvas_h = bottom_top + box_h; + + if let Some(mw) = max_width + && canvas_w > mw + { + return Err(Oversize::Width); + } + if canvas_w.saturating_mul(canvas_h) > MAX_CANVAS_CELLS { + return Err(Oversize::Cells); + } + + let mut canvas = Canvas::new(canvas_w, canvas_h); + for i in 0..n { + for by in [0, bottom_top] { + let p = Placed { + x: xs[i].saturating_sub(box_w[i] / 2), + y: by, + w: box_w[i], + h: box_h, + cx: xs[i], + cy: by + 1, + rank: 0, + }; + draw_box( + &mut canvas, + &p, + std::slice::from_ref(&labels[i]), + Shape::Rect, + ); + } + } + for (item, &r) in seq.items.iter().zip(&rows) { + if let SeqItem::Note { anchor, text } = item { + let (x, w) = note_geometry(&xs, anchor, text.width()); + let p = Placed { + x, + y: r, + w, + h: 3, + cx: x + w / 2, + cy: r + 1, + rank: 0, + }; + draw_box(&mut canvas, &p, std::slice::from_ref(text), Shape::Rect); + } + } + for &x in &xs { + canvas.junction(x, box_h - 1, D); + canvas.seg_v(x, box_h, bottom_top - 1); + canvas.junction(x, bottom_top, U); + } + + for (item, &r) in seq.items.iter().zip(&rows) { + match item { + SeqItem::Message { + from, + to, + text, + dashed, + head, + } => { + let line_ch = if *dashed { '╌' } else { '─' }; + if from == to { + let x = xs[*from]; + canvas.junction(x, r, R); + canvas.set(x + 1, r, line_ch, Cls::Edge); + canvas.set(x + 2, r, line_ch, Cls::Edge); + canvas.set(x + 3, r, '╮', Cls::Edge); + canvas.set(x + 3, r + 1, '│', Cls::Edge); + canvas.set( + x + 1, + r + 2, + if *head == SeqHead::Cross { '×' } else { '◄' }, + Cls::Edge, + ); + canvas.set(x + 2, r + 2, line_ch, Cls::Edge); + canvas.set(x + 3, r + 2, '╯', Cls::Edge); + if let Some(t) = text { + draw_seq_text(&mut canvas, t, x + 5, r + 1, Cls::Text); + } + } else { + let (x0, x1) = (xs[*from], xs[*to]); + let rightward = x1 > x0; + let arrow_row = if text.is_some() { r + 1 } else { r }; + let (lo, hi) = (x0.min(x1), x0.max(x1)); + canvas.junction(x0, arrow_row, if rightward { R } else { L }); + for x in (lo + 1)..hi { + canvas.set(x, arrow_row, line_ch, Cls::Edge); + } + let head_ch = match (head, rightward) { + (SeqHead::Cross, _) => '×', + (SeqHead::Arrow, true) => '▶', + (SeqHead::Arrow, false) => '◄', + }; + let head_x = if rightward { x1 - 1 } else { x1 + 1 }; + canvas.set(head_x, arrow_row, head_ch, Cls::Edge); + if let Some(t) = text { + let span = hi - lo - 1; + let t = fit_label(t, span.max(1)); + let tx = lo + 1 + span.saturating_sub(t.width()) / 2; + draw_seq_text(&mut canvas, &t, tx, r, Cls::Text); + } + } + } + SeqItem::Note { .. } => {} + SeqItem::Divider { text } => { + for x in 0..canvas_w { + canvas.set(x, r, '─', Cls::Edge); + } + let t = fit_label(text, canvas_w.saturating_sub(4)); + draw_seq_text(&mut canvas, &format!(" {t} "), 2, r, Cls::EdgeLabel); + } + } + } + + canvas.finalize_mask(); + let (styled_lines, plain_lines) = canvas.to_lines(styles); + Ok(MermaidArt { + styled_lines, + plain_lines, + }) +} + +fn draw_seq_text(canvas: &mut Canvas, text: &str, x: usize, y: usize, cls: Cls) { + let mut cur = x; + for c in text.chars() { + let cw = char_width(c).max(1); + for k in 0..cw { + if cur + k < canvas.w && y < canvas.h { + let i = canvas.idx(cur + k, y); + canvas.mask[i] = 0; + } + canvas.set(cur + k, y, if k == 0 { c } else { CONT }, cls); + } + cur += cw; + } +} + +const TOO_WIDE_HINT: &str = + "This diagram is too wide to display here \u{2014} open the image to view it in full."; + +fn fallback( + src: &str, + styles: &MermaidStyles, + max_width: Option, + too_wide: bool, +) -> MermaidArt { + let header = first_word(src); + let title = format!(" mermaid: {header} "); + let limit = max_width.map(|m| m.saturating_sub(4).max(8)); + let body: Vec = src + .lines() + .map(|l| l.trim_end()) + .skip_while(|l| l.is_empty()) + .flat_map(|l| chunk_line(l, limit)) + .collect(); + let content_w = body + .iter() + .map(|l| l.width()) + .chain(std::iter::once(title.width())) + .max() + .unwrap_or(0); + let inner = content_w + 2; + + let mut styled = Vec::new(); + let mut plain = Vec::new(); + + let mut top = String::from("╭"); + top.push_str(&title); + for _ in 0..inner.saturating_sub(title.width()) { + top.push('─'); + } + top.push('╮'); + styled.push(Line::from(vec![ + Span::styled("╭".to_string(), styles.border), + Span::styled(title.clone(), styles.title), + Span::styled( + format!("{}╮", "─".repeat(inner.saturating_sub(title.width()))), + styles.border, + ), + ])); + plain.push(top); + + for line in &body { + let pad = content_w.saturating_sub(line.width()); + styled.push(Line::from(vec![ + Span::styled("│ ".to_string(), styles.border), + Span::styled(line.clone(), styles.node_text), + Span::styled(format!("{} │", " ".repeat(pad)), styles.border), + ])); + plain.push(format!("│ {}{} │", line, " ".repeat(pad))); + } + + let bottom = format!("╰{}╯", "─".repeat(inner)); + styled.push(Line::from(Span::styled(bottom.clone(), styles.border))); + plain.push(bottom); + + if too_wide { + let hint_style = styles.border.add_modifier(Modifier::ITALIC); + for chunk in wrap_words(TOO_WIDE_HINT, max_width) { + styled.push(Line::from(Span::styled(chunk.clone(), hint_style))); + plain.push(chunk); + } + } + + MermaidArt { + styled_lines: styled, + plain_lines: plain, + } +} + +fn chunk_line(line: &str, limit: Option) -> Vec { + let Some(limit) = limit else { + return vec![line.to_string()]; + }; + if line.width() <= limit { + return vec![line.to_string()]; + } + let mut out = Vec::new(); + let mut cur = String::new(); + let mut cur_w = 0usize; + for c in line.chars() { + let cw = char_width(c).max(1); + if cur_w + cw > limit && !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + cur_w = 0; + } + cur.push(c); + cur_w += cw; + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +fn wrap_words(text: &str, limit: Option) -> Vec { + let Some(limit) = limit else { + return vec![text.to_string()]; + }; + let mut lines: Vec = Vec::new(); + let mut cur = String::new(); + for word in text.split(' ').filter(|w| !w.is_empty()) { + if cur.is_empty() { + cur.push_str(word); + } else if cur.width() + 1 + word.width() <= limit { + cur.push(' '); + cur.push_str(word); + } else { + lines.push(std::mem::take(&mut cur)); + cur.push_str(word); + } + } + if !cur.is_empty() { + lines.push(cur); + } + lines + .into_iter() + .flat_map(|l| chunk_line(&l, Some(limit))) + .collect() +} + +fn first_word(src: &str) -> String { + src.split_whitespace() + .next() + .unwrap_or("diagram") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn styles() -> MermaidStyles { + let s = Style::default(); + MermaidStyles { + border: s, + node_text: s, + edge: s, + edge_label: s, + title: s, + } + } + + fn plain(src: &str) -> String { + render(src, &styles(), Some(120)) + .unwrap() + .plain_lines + .join("\n") + } + + #[test] + fn parses_nodes_edges_and_direction() { + let g = parse_graph("flowchart LR\n A[Start] --> B[End]").unwrap(); + assert_eq!(g.nodes.len(), 2); + assert_eq!(g.edges.len(), 1); + assert_eq!(g.nodes[0].label, "Start"); + assert_eq!(g.nodes[1].label, "End"); + assert!(g.dir == Dir::Right); + } + + #[test] + fn non_flowchart_returns_none_from_parse() { + assert!(parse_graph("sequenceDiagram\n A->>B: hi").is_none()); + } + + #[test] + fn html_tags_are_stripped_from_labels() { + let g = parse_graph("flowchart TD\n A[\"Bold and italic\"] --> B").unwrap(); + assert_eq!(g.nodes[0].label, "Bold and italic"); + } + + #[test] + fn br_tag_becomes_a_space() { + let g = parse_graph("flowchart TD\n A[\"Line1
Line2
Line3\"]").unwrap(); + assert_eq!(g.nodes[0].label, "Line1 Line2 Line3"); + } + + #[test] + fn markdown_string_strips_bold_italic_and_code() { + let g = parse_graph( + "flowchart TD\n A[\"`**Start** here`\"] --> B[\"`Save to **database**`\"]\n B --> C[\"`**Done!**`\"]", + ) + .unwrap(); + assert_eq!(g.nodes[0].label, "Start here"); + assert_eq!(g.nodes[1].label, "Save to database"); + assert_eq!(g.nodes[2].label, "Done!"); + } + + #[test] + fn markdown_string_preserves_snake_case_and_strips_inline_code() { + let g = parse_graph("flowchart TD\n A[\"`_italic_ uses `vocab_size` with __all__`\"]") + .unwrap(); + assert_eq!(g.nodes[0].label, "italic uses vocab_size with all"); + } + + #[test] + fn markdown_string_edge_label_is_stripped() { + let g = + parse_graph("flowchart TD\n A -->|\"`**yes**`\"| B\n A -->|\"`__no__`\"| C").unwrap(); + assert_eq!(g.edges[0].label.as_deref(), Some("yes")); + assert_eq!(g.edges[1].label.as_deref(), Some("no")); + } + + #[test] + fn plain_label_keeps_literal_text_and_underscores() { + // Not a markdown string (no backtick wrapper): Mermaid renders it + // literally, so brackets, snake_case, and any `*`/`_` must survive. + let g = parse_graph("flowchart TD\n A[\"[ 464, 3797 ] seq_len d_model\"]").unwrap(); + assert_eq!(g.nodes[0].label, "[ 464, 3797 ] seq_len d_model"); + } + + #[test] + fn code_and_span_tags_are_stripped() { + let g = parse_graph( + "flowchart TD\n A[\"vocab_size x\"]", + ) + .unwrap(); + assert_eq!(g.nodes[0].label, "vocab_size x"); + } + + #[test] + fn bare_angle_brackets_are_kept() { + let g = parse_graph("flowchart TD\n A[\"a < b and c > d\"]").unwrap(); + assert_eq!(g.nodes[0].label, "a < b and c > d"); + } + + #[test] + fn generic_types_are_not_stripped_as_html() { + // `` / `` / `` look like tags but are not HTML + // formatting tags, so they must survive (only b/i/code/span/… etc. and + //
are stripped). + let g = parse_graph( + "flowchart TD\n A[\"Returns Vec\"] --> B[\"Option for \"]", + ) + .unwrap(); + assert_eq!(g.nodes[0].label, "Returns Vec"); + assert_eq!(g.nodes[1].label, "Option for "); + } + + #[test] + fn decode_html_entities_covers_named_numeric_and_double_escape() { + assert_eq!( + decode_html_entities("<a> & "x" 'y'"), + "
& \"x\" 'y'" + ); + assert_eq!(decode_html_entities("it's <ok>"), "it's "); + assert_eq!( + decode_html_entities("<tag> 'q'"), + " 'q'" + ); + // `&lt;` must yield the literal `<`, never `<`. + assert_eq!(decode_html_entities("&lt;"), "<"); + assert_eq!(decode_html_entities("a &foo; b & c"), "a &foo; b & c"); + // Control chars (NUL collides with CONT, ESC injects ANSI) never decode. + assert_eq!(decode_html_entities("ab�c"), "ab�c"); + assert_eq!(decode_html_entities("xy"), "xy"); + } + + #[test] + fn entity_escaped_flowchart_label_decodes_in_box_art() { + let src = "flowchart LR\n YAML[\"models-config/<model>/<env>.yaml\\nenterprise_api_config:\"]\n PY[\"model_config_map.py\\nlanguage_model_dict_to_proto()\"]\n YAML --> PY"; + let g = parse_graph(src).unwrap(); + assert!( + g.nodes[0] + .label + .contains("models-config//.yaml"), + "{}", + g.nodes[0].label + ); + let art = plain(src); + assert!(art.contains("") && art.contains(""), "{art}"); + assert!(!art.contains("<") && !art.contains(">"), "{art}"); + } + + #[test] + fn direct_push_sinks_decode_entities() { + // Entities contain `;`, which split_statements treats as a separator, so + // they reach a sink intact only inside quotes; assert through the real + // parsers where such quoting works. + let g = parse_state( + "stateDiagram-v2\n state \"work <job>\" as J\n Idle --> Run: \"on <go>\"\n Run: \"d <e>\"", + ) + .unwrap(); + let node = |s: &str| g.nodes.iter().any(|n| n.label.contains(s)); + let edge = |s: &str| { + g.edges + .iter() + .any(|e| e.label.as_deref().is_some_and(|l| l.contains(s))) + }; + assert!(node("work ") && node("d ") && edge("on ")); + assert!(!node("<") && !edge("<")); + + let (cg, _) = parse_class("classDiagram\n A --> B : \"uses <X>\"").unwrap(); + assert!(cg.edges.iter().any(|e| { + e.label + .as_deref() + .is_some_and(|l| l.contains("uses ") && !l.contains("<")) + })); + + let s = parse_sequence( + "sequenceDiagram\n A->>B: \"call <svc>\"\n Note over A,B: \"memo <o>\"\n alt \"c <x>\"\n A->>B: ok\n end", + ) + .unwrap(); + assert!(s.items.iter().any(|it| matches!(it, + SeqItem::Message { text: Some(t), .. } if t.contains("call ") && !t.contains("<")))); + assert!(s.items.iter().any(|it| matches!(it, + SeqItem::Note { text, .. } if text.contains("memo ") && !text.contains("<")))); + assert!(s.items.iter().any(|it| matches!(it, + SeqItem::Divider { text } if text.contains("c ") && !text.contains("<")))); + + // Class members and ER attributes have no clean quoted form (splitter + // fragments unquoted `;`; ER drops quoted text as a comment), so exercise + // those decodes at the finalizer directly. + let mut member = ClassInfo::default(); + push_member(&mut member, "+run <R>"); + assert_eq!(member.attrs, vec!["+run ".to_string()]); + let mut attr = ClassInfo::default(); + push_er_attribute(&mut attr, "string <pk>"); + assert_eq!(attr.attrs, vec!["string ".to_string()]); + } + + #[test] + fn quoted_label_with_inner_brackets_is_one_node() { + let g = parse_graph( + "flowchart TD\n IDs[\"Token IDs
[ 464, 3797 ]
indices\"]", + ) + .unwrap(); + assert_eq!(g.nodes.len(), 1, "inner brackets must not split the node"); + assert_eq!(g.edges.len(), 0, "no phantom edges from
+ brackets"); + assert_eq!(g.nodes[0].label, "Token IDs [ 464, 3797 ] indices"); + } + + #[test] + fn unquoted_label_with_embedded_quote_closes_at_bracket() { + let g = parse_graph("flowchart TD\n A[5\" pipe] --> B[24\" display]").unwrap(); + assert_eq!(g.nodes.len(), 2); + assert_eq!(g.edges.len(), 1); + assert_eq!(g.nodes[0].label, "5\" pipe"); + assert_eq!(g.nodes[1].label, "24\" display"); + } + + #[test] + fn quoted_label_with_inner_parens_is_one_node() { + let g = + parse_graph("flowchart TD\n A[\"Tokenizer (BPE / WordPiece)\"] --> B[Done]").unwrap(); + assert_eq!(g.nodes.len(), 2); + assert_eq!(g.edges.len(), 1); + assert_eq!(g.nodes[0].label, "Tokenizer (BPE / WordPiece)"); + } + + #[test] + fn diagram_with_html_labels_renders_without_tag_artifacts() { + let src = "flowchart TD\n IDs[\"3. Token IDs
[ 464, 3797 ]
indices\"] --> Out[\"done\"]"; + let out = plain(src); + assert!(!out.contains(""), "raw HTML tag leaked:\n{out}"); + assert!(!out.contains("B\n B-->C\n C-->A").unwrap(); + let r = compute_ranks(&g); + let idx = |id: &str| g.index[id]; + assert_eq!(r[idx("A")], 0); + assert_eq!(r[idx("B")], 1); + assert_eq!(r[idx("C")], 2); + } + + #[test] + fn td_render_has_boxes_labels_and_arrow() { + let out = plain("graph TD\n A[Start] --> B[End]"); + assert!(out.contains("Start"), "{out}"); + assert!(out.contains("End"), "{out}"); + assert!(out.contains('┌') || out.contains('╭'), "{out}"); + assert!(out.contains('▼'), "{out}"); + } + + #[test] + fn edge_label_is_rendered() { + let out = plain("graph TD\n A-->|yes| B"); + assert!(out.contains("yes"), "{out}"); + } + + #[test] + fn lr_is_shorter_than_td_for_a_chain() { + let chain = "A --> B --> C --> D"; + let td = render(&format!("graph TD\n {chain}"), &styles(), Some(120)) + .unwrap() + .plain_lines + .len(); + let lr = render(&format!("flowchart LR\n {chain}"), &styles(), Some(120)) + .unwrap() + .plain_lines + .len(); + assert!(lr < td, "expected LR ({lr}) shorter than TD ({td})"); + } + + #[test] + fn unsupported_diagram_uses_fallback_box() { + let out = plain("gantt\n title Plan\n section A\n task :a1, 2024-01-01, 30d"); + assert!(out.contains("mermaid: gantt"), "{out}"); + assert!(out.contains("Plan"), "{out}"); + } + + #[test] + fn blank_source_returns_none() { + assert!(render(" \n ", &styles(), Some(80)).is_none()); + } + + #[test] + fn inline_label_with_x_or_o_letters() { + let g = parse_graph("graph TD\n A -- no exit --> B").unwrap(); + assert_eq!(g.nodes.len(), 2); + assert_eq!(g.edges.len(), 1); + assert_eq!(g.edges[0].label.as_deref(), Some("no exit")); + } + + #[test] + fn wide_glyph_box_stays_aligned() { + let lines = render("graph TD\n A[日本語ab]", &styles(), Some(120)) + .unwrap() + .plain_lines; + let widths: Vec = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.width()) + .collect(); + assert!( + widths.windows(2).all(|w| w[0] == w[1]), + "box rows must share one width: {widths:?}\n{lines:?}" + ); + assert!(!lines.iter().any(|l| l.contains(CONT)), "sentinel leaked"); + } + + #[test] + fn merge_has_single_arrowhead() { + let out = plain("graph TD\n A[aaa] --> D[ddddddd]\n B[bb] --> D\n C[ccccc] --> D"); + let arrows = out.chars().filter(|&c| c == '▼').count(); + assert_eq!(arrows, 1, "merge edges share one arrowhead:\n{out}"); + assert!(!out.contains("▼▼"), "must not stack arrowheads:\n{out}"); + } + + #[test] + fn long_label_wraps_without_truncation() { + let out = + plain("graph TD\n A[Check if the user has permission to access resource] --> B[Done]"); + assert!(out.contains("permission"), "{out}"); + assert!(out.contains("resource"), "{out}"); + assert!(!out.contains('…'), "should wrap, not truncate:\n{out}"); + } + + #[test] + fn very_long_label_truncates_after_max_lines() { + let long = "alpha ".repeat(40); + let out = plain(&format!("graph TD\n A[{}] --> B[x]", long.trim())); + assert!(out.contains('…'), "should truncate past max lines:\n{out}"); + } + + #[test] + fn wrap_label_breaks_long_identifier_on_boundary() { + let lines = wrap_label("mark_filter_restore_context", WRAP_WIDTH, MAX_LINES); + // The first line ends on an identifier boundary, not a mid-segment slice. + assert!( + lines[0].ends_with('_'), + "first line must end on a boundary: {lines:?}" + ); + // Every break (all but the last line) lands on a boundary char. + for line in &lines[..lines.len() - 1] { + assert!( + line.ends_with(LABEL_BREAK_CHARS), + "line must break on a boundary: {line:?}" + ); + } + // Nothing is lost: the wrapped lines reconstruct the original word. + assert_eq!(lines.concat(), "mark_filter_restore_context"); + } + + #[test] + fn wrap_label_token_without_break_char_falls_back_per_char() { + let token = "a".repeat(40); + let lines = wrap_label(&token, WRAP_WIDTH, MAX_LINES); + // No boundary char -> per-char hard break across multiple lines. + assert!(lines.len() >= 2, "must hard-break: {lines:?}"); + // 40 narrow chars fit in <= MAX_LINES, so nothing is truncated or lost. + assert_eq!(lines.concat(), token); + } + + #[test] + fn flowchart_long_identifier_breaks_on_boundary_not_mid_segment() { + let out = plain("graph TD\n A[mark_filter_restore_context] --> B[Done]"); + // The boundary-respecting pieces are present in the rendered art; the + // `wrap_label_breaks_long_identifier_on_boundary` unit test proves there + // is no mid-segment slice (losslessly), so no offset-coupled guard here. + assert!(out.contains("mark_filter_restore_"), "{out}"); + assert!(out.contains("context"), "{out}"); + } + + #[test] + fn wrap_label_mixed_boundary_then_no_boundary_tail() { + let token = String::from("ab_") + &"c".repeat(40); + let lines = wrap_label(&token, WRAP_WIDTH, MAX_LINES); + // The boundary is taken first ... + assert!( + lines[0].ends_with('_'), + "first break on boundary: {lines:?}" + ); + // ... then the long no-boundary tail falls back to a per-char break. + assert!( + lines[1..].iter().any(|l| !l.contains(LABEL_BREAK_CHARS)), + "a later line must be a per-char break: {lines:?}" + ); + // 43 cols < MAX_LINES*WRAP_WIDTH, so it must not truncate; fully lossless. + assert_eq!(lines.concat(), token); + } + + #[test] + fn wrap_label_boundary_breaking_still_truncates_at_max_lines() { + let id = ["segment"; 20].join("_"); + let lines = wrap_label(&id, WRAP_WIDTH, MAX_LINES); + // The identifier far exceeds MAX_LINES*WRAP_WIDTH, so it truncates ... + assert_eq!(lines.len(), MAX_LINES); + // ... with the ellipsis still on the final line. + assert!( + lines.last().unwrap().ends_with('…'), + "truncation must keep the ellipsis: {lines:?}" + ); + } + + #[test] + fn bt_flips_orientation() { + let out = plain("flowchart BT\n A[first] --> B[second] --> C[third]"); + let lines: Vec<&str> = out.lines().collect(); + let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap(); + assert!( + row("third") < row("first"), + "BT: 'third' should sit above 'first':\n{out}" + ); + } + + #[test] + fn rl_flips_orientation() { + let out = plain("flowchart RL\n A[first] --> B[second] --> C[third]"); + let line = out.lines().find(|l| l.contains("first")).unwrap(); + assert!( + line.find("third") < line.find("first"), + "RL: 'third' should sit left of 'first':\n{out}" + ); + } + + #[test] + fn undirected_piped_label_has_no_arrowhead() { + let out = plain("graph TD\n A ---|maybe| B"); + assert!(out.contains("maybe"), "{out}"); + assert!( + !out.contains('▼'), + "undirected link should not draw an arrow:\n{out}" + ); + } + + #[test] + fn chain_edges_are_straight() { + let out = plain("graph TD\n A[aaaa] --> B[b] --> C[cccccccc]"); + for line in out.lines() { + assert!( + !line.contains('└') || !line.contains('┐'), + "chain should not jog: {line:?}" + ); + } + } + + #[test] + fn adversarial_chain_falls_back() { + let mut src = String::from("graph TD\n"); + for i in 0..10_000 { + src.push_str(&format!(" N{i} --> N{}\n", i + 1)); + } + let out = plain(&src); + assert!(out.contains("mermaid: graph"), "expected fallback:\n{out}"); + } + + #[test] + fn single_statement_chain_over_cap_falls_back() { + let mut src = String::from("graph LR\n "); + for i in 0..10_000 { + src.push_str(&format!("N{i}-->")); + } + src.push_str("N10000"); + let out = plain(&src); + assert!(out.contains("mermaid: graph"), "expected fallback"); + } + + #[test] + fn deep_chain_within_caps_renders() { + let mut src = String::from("graph TD\n"); + for i in 0..100 { + src.push_str(&format!(" N{i} --> N{}\n", i + 1)); + } + let out = render(&src, &styles(), Some(200)).unwrap().plain_lines; + let joined = out.join("\n"); + assert!(joined.contains("N0"), "{joined}"); + assert!(joined.contains("N100"), "{joined}"); + assert!(joined.contains('▼'), "{joined}"); + } + + #[test] + fn fallback_styled_and_plain_widths_match() { + let art = render("gantt\n title Plan\n a\n", &styles(), Some(120)).unwrap(); + assert_eq!(art.styled_lines.len(), art.plain_lines.len()); + let frame_w = art.plain_lines[0].width(); + for (styled, plain) in art.styled_lines.iter().zip(&art.plain_lines) { + let styled_w: usize = styled + .spans + .iter() + .map(|s| s.content.as_ref().width()) + .sum(); + assert_eq!(styled_w, plain.width(), "styled/plain widths diverge"); + assert_eq!(plain.width(), frame_w, "fallback box must be rectangular"); + } + } + + #[test] + fn over_wide_diagram_falls_back() { + let src = "flowchart LR\n A[aaaaaaaaaaaaaaaaaaaa] --> B[bbbbbbbbbbbbbbbbbbbb] --> C[cccccccccccccccccccc]"; + let out = render(src, &styles(), Some(40)).unwrap().plain_lines; + let joined = out.join("\n"); + assert!( + joined.contains("mermaid: flowchart"), + "expected fallback for over-wide diagram:\n{joined}" + ); + let max_w = out.iter().map(|l| l.width()).max().unwrap_or(0); + let fits = render(src, &styles(), Some(120)).unwrap().plain_lines; + assert!( + fits.iter().any(|l| l.contains('▶')), + "same diagram should render when it fits" + ); + assert!(max_w <= src.len(), "fallback width bounded by source"); + } + + #[test] + fn too_wide_fallback_appends_hint_below_box() { + let src = "flowchart LR\n A[aaaaaaaaaaaaaaaaaaaa] --> B[bbbbbbbbbbbbbbbbbbbb] --> C[cccccccccccccccccccc]"; + let out = render(src, &styles(), Some(40)).unwrap().plain_lines; + let joined = out.join("\n"); + + assert!( + joined.contains("mermaid: flowchart"), + "plain header:\n{joined}" + ); + assert!( + !joined.contains("(too wide)"), + "header stays plain:\n{joined}" + ); + assert!( + joined.contains("flowchart LR"), + "raw source kept:\n{joined}" + ); + + let bottom = out + .iter() + .position(|l| l.contains('╰')) + .expect("box bottom"); + let note = out + .iter() + .position(|l| l.contains("too wide")) + .expect("note row"); + assert!(note > bottom, "note must be below the box:\n{joined}"); + assert!( + joined.contains("open the image"), + "note points at the image:\n{joined}" + ); + + assert!( + out.iter().all(|l| l.width() <= 40), + "fits 40 cols:\n{joined}" + ); + } + + #[test] + fn unsupported_diagram_fallback_not_flagged_too_wide() { + let out = plain("gantt\n title Plan\n section A\n task :a1, 2024-01-01, 30d"); + assert!(out.contains("mermaid: gantt"), "{out}"); + assert!( + !out.contains("too wide"), + "unsupported type is not a width problem:\n{out}" + ); + } + + #[test] + fn fitting_diagram_has_no_width_warning() { + let out = plain("flowchart LR\n A[Start] --> B[End]"); + assert!( + !out.contains("too wide"), + "fitting diagram must not warn:\n{out}" + ); + assert!( + !out.contains("mermaid: flowchart"), + "should draw art, not box:\n{out}" + ); + assert!(out.contains('▶'), "should draw edges:\n{out}"); + } + + #[test] + fn bidirectional_link_draws_both_arrowheads() { + let lr = plain("flowchart LR\n A <--> B"); + assert!(lr.contains('◄') && lr.contains('▶'), "{lr}"); + let td = plain("graph TD\n A <--> B"); + assert!(td.contains('▲') && td.contains('▼'), "{td}"); + } + + #[test] + fn reversed_arrow_swaps_edge_direction() { + let g = parse_graph("graph TD\n A <-- B").unwrap(); + let idx = |id: &str| g.index[id]; + assert_eq!(g.edges.len(), 1); + assert_eq!(g.edges[0].from, idx("B")); + assert_eq!(g.edges[0].to, idx("A")); + assert_eq!(g.edges[0].head_to, Head::Arrow); + assert_eq!(g.edges[0].head_from, Head::None); + let out = plain("graph TD\n A <-- B"); + let lines: Vec<&str> = out.lines().collect(); + let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap(); + assert!(row("B") < row("A"), "B should rank above A:\n{out}"); + } + + #[test] + fn semicolon_and_comment_survive_inside_quoted_label() { + let g = parse_graph("graph TD\n A[\"wait; 50%% done\"] --> B").unwrap(); + assert_eq!(g.nodes.len(), 2); + assert_eq!(g.nodes[0].label, "wait; 50%% done"); + } + + #[test] + fn comment_outside_quotes_is_stripped() { + let g = + parse_graph("graph TD %% main flow\n A --> B %% trailing\n %% full line\n").unwrap(); + assert_eq!(g.nodes.len(), 2); + assert_eq!(g.edges.len(), 1); + } + + #[test] + fn skip_edge_routes_around_intermediate_boxes() { + let out = plain("graph TD\n A --> B\n B --> C\n A --> C"); + assert!(!out.contains('┼'), "no border corruption:\n{out}"); + assert!( + out.contains('◄'), + "skip edge enters target from lane:\n{out}" + ); + } + + fn ordered_ranks(src: &str) -> (Graph, Vec, Vec>) { + let g = parse_graph(src).unwrap(); + let ranks = compute_ranks(&g); + let max_rank = *ranks.iter().max().unwrap(); + let mut by_rank: Vec> = vec![Vec::new(); max_rank + 1]; + for (idx, &r) in ranks.iter().enumerate() { + by_rank[r].push(idx); + } + order_ranks(&mut by_rank, &g.edges, &ranks); + (g, ranks, by_rank) + } + + #[test] + fn order_ranks_removes_avoidable_crossing() { + let (g, ranks, by_rank) = ordered_ranks("graph TD\n C[ccc]\n D[ddd]\n A --> D\n B --> C"); + let mut pos = vec![0usize; g.nodes.len()]; + for row in &by_rank { + for (i, &v) in row.iter().enumerate() { + pos[v] = i; + } + } + assert_eq!(count_crossings(&g.edges, &ranks, &pos), 0); + let idx = |id: &str| g.index[id]; + assert!(pos[idx("D")] < pos[idx("C")], "D follows parent A leftward"); + } + + #[test] + fn order_ranks_keeps_crossing_free_order() { + let (g, ranks, by_rank) = ordered_ranks("graph TD\n A --> C\n B --> D"); + let idx = |id: &str| g.index[id]; + assert_eq!(by_rank[0], vec![idx("A"), idx("B")]); + assert_eq!(by_rank[1], vec![idx("C"), idx("D")]); + let mut pos = vec![0usize; g.nodes.len()]; + for row in &by_rank { + for (i, &v) in row.iter().enumerate() { + pos[v] = i; + } + } + assert_eq!(count_crossings(&g.edges, &ranks, &pos), 0); + } + + #[test] + fn crossing_edges_render_untangled() { + let out = plain("graph TD\n C[ccc]\n D[ddd]\n A --> D\n B --> C"); + let row = out + .lines() + .find(|l| l.contains("ccc") && l.contains("ddd")) + .unwrap(); + assert!( + row.find("ddd") < row.find("ccc"), + "children reorder under their parents:\n{out}" + ); + assert!(!out.contains('┼'), "{out}"); + } + + #[test] + fn three_layer_weave_untangles() { + let (g, ranks, by_rank) = ordered_ranks( + "graph TD\n X[x]\n Y[y]\n A --> Y\n B --> X\n X --> Q\n Y --> P\n P[p]\n Q[q]", + ); + let mut pos = vec![0usize; g.nodes.len()]; + for row in &by_rank { + for (i, &v) in row.iter().enumerate() { + pos[v] = i; + } + } + assert_eq!( + count_crossings(&g.edges, &ranks, &pos), + 0, + "both layers untangle" + ); + } + + #[test] + fn unavoidable_crossing_gets_separate_bus_rows() { + let crossing = plain("graph TD\n A --> D[ddd]\n A --> C[ccc]\n B --> C\n B --> D"); + let parallel = plain("graph TD\n A --> C[ccc]\n B --> D[ddd]"); + assert!(crossing.contains('┼'), "wire crossing renders:\n{crossing}"); + assert_eq!( + crossing.lines().count(), + parallel.lines().count() + 1, + "crossing pair claims one extra bus row:\n{crossing}" + ); + assert_eq!( + crossing.chars().filter(|&c| c == '▼').count(), + 2, + "{crossing}" + ); + } + + #[test] + fn fan_out_keeps_single_bus_row() { + let out = plain("graph TD\n A --> C[ccc]\n A --> D[ddd]"); + let baseline = plain("graph TD\n A --> C[ccc]"); + assert_eq!( + out.lines().count(), + baseline.lines().count(), + "shared-source jogs share one bus row:\n{out}" + ); + assert!(!out.contains('┼'), "{out}"); + } + + #[test] + fn shared_target_back_edges_share_one_lane() { + let two = plain("graph TD\n A --> B\n B --> C\n B --> A\n C --> A"); + let one = plain("graph TD\n A --> B\n B --> C\n C --> A"); + assert_eq!( + two.lines().map(|l| l.width()).max(), + one.lines().map(|l| l.width()).max(), + "shared-target back edges merge into one lane:\n{two}" + ); + assert_eq!(two.matches('◄').count(), 1, "{two}"); + } + + #[test] + fn distinct_back_edges_get_separate_lanes() { + let split = plain("graph TD\n A --> B\n B --> C\n B --> A\n C --> B"); + let single = plain("graph TD\n A --> B\n B --> C\n C --> B"); + assert_eq!(split.matches('◄').count(), 2, "{split}"); + assert!( + split.lines().map(|l| l.width()).max() > single.lines().map(|l| l.width()).max(), + "overlapping unrelated back edges claim a second lane:\n{split}" + ); + } + + #[test] + fn fallback_wraps_long_lines_to_max_width() { + let out = render( + "gantt\n title a very long line that should wrap inside the fallback box nicely", + &styles(), + Some(40), + ) + .unwrap() + .plain_lines; + assert!(out.iter().all(|l| l.width() <= 40), "{}", out.join("\n")); + for line in &out[1..out.len() - 1] { + assert!( + line.starts_with('│') && line.ends_with('│'), + "body rows keep both borders: {line:?}" + ); + } + assert!(out.join("\n").contains("nicely"), "{}", out.join("\n")); + } + + #[test] + fn class_renders_compartments() { + let out = plain( + "classDiagram\n class Animal {\n +int age\n +isMammal() bool\n }\n Animal <|-- Duck", + ); + assert!(out.contains("Animal"), "{out}"); + assert!(out.contains("+int age"), "{out}"); + assert!(out.contains("+isMammal() bool"), "{out}"); + assert!( + out.contains('├') && out.contains('┤'), + "section rules:\n{out}" + ); + let lines: Vec<&str> = out.lines().collect(); + let name = lines.iter().position(|l| l.contains("Animal")).unwrap(); + let attr = lines.iter().position(|l| l.contains("+int age")).unwrap(); + let method = lines + .iter() + .position(|l| l.contains("+isMammal() bool")) + .unwrap(); + assert!(name < attr && attr < method, "{out}"); + } + + #[test] + fn class_inheritance_triangle_at_parent() { + let out = plain("classDiagram\n Animal <|-- Duck\n Animal <|-- Fish"); + assert!(out.contains('△'), "hollow triangle:\n{out}"); + let lines: Vec<&str> = out.lines().collect(); + let animal = lines.iter().position(|l| l.contains("Animal")).unwrap(); + let duck = lines.iter().position(|l| l.contains("Duck")).unwrap(); + assert!(animal < duck, "parent above child:\n{out}"); + let tri = lines.iter().position(|l| l.contains('△')).unwrap(); + assert!( + tri >= animal && tri < duck, + "triangle at parent end:\n{out}" + ); + } + + #[test] + fn class_realization_is_dotted_triangle() { + let g = parse_class("classDiagram\n IShape <|.. Circle").unwrap().0; + assert_eq!(g.edges[0].head_from, Head::Triangle); + assert!(g.edges[0].line == LineKind::Dotted); + let out = plain("classDiagram\n IShape <|.. Circle"); + assert!(out.contains('╎') || out.contains('╌'), "{out}"); + } + + #[test] + fn class_composition_and_aggregation_diamonds() { + let out = plain("classDiagram\n Car *-- Engine\n Pond o-- Duck"); + assert!(out.contains('◆'), "filled diamond:\n{out}"); + assert!(out.contains('◇'), "open diamond:\n{out}"); + } + + #[test] + fn class_dependency_dotted_arrow() { + let g = parse_class("classDiagram\n A ..> B").unwrap().0; + assert_eq!(g.edges[0].head_to, Head::Arrow); + assert!(g.edges[0].line == LineKind::Dotted); + } + + #[test] + fn class_colon_members_merge_with_block() { + let out = plain( + "classDiagram\n class Duck {\n +swim()\n }\n Duck : +String beakColor\n S --> Duck", + ); + assert!(out.contains("+swim()"), "{out}"); + assert!(out.contains("+String beakColor"), "{out}"); + } + + #[test] + fn class_annotation_renders_guillemets() { + let out = plain("classDiagram\n <> Shape\n Shape <|.. Circle"); + assert!(out.contains("«interface»"), "{out}"); + } + + #[test] + fn class_generics_display_as_angle_brackets() { + let out = plain("classDiagram\n Shape~T~ : +area() T\n S --> Shape~T~"); + assert!(out.contains("Shape"), "{out}"); + assert!(!out.contains('~'), "{out}"); + } + + #[test] + fn class_cardinalities_fold_into_label() { + let out = plain("classDiagram\n Student \"many\" --> \"1\" School : attends"); + assert!(out.contains("many attends 1"), "{out}"); + } + + #[test] + fn class_from_end_head_survives_fan_out_jog() { + let out = plain("classDiagram\n Animal <|-- Duck\n Animal <|-- Fish\n Animal <|-- Cow"); + assert_eq!( + out.matches('△').count() + out.matches('▽').count(), + 1, + "merged from-end glyph on the parent border:\n{out}" + ); + } + + #[test] + fn class_empty_class_is_plain_titled_box() { + let out = plain("classDiagram\n class Loner\n A --> Loner"); + assert!(out.contains("Loner"), "{out}"); + } + + #[test] + fn class_unknown_statement_falls_back() { + let out = plain("classDiagram\n A --> B\n total garbage here"); + assert!(out.contains("mermaid: classDiagram"), "{out}"); + } + + #[test] + fn class_member_cap_ellipsis() { + let mut src = String::from("classDiagram\n class Big {\n"); + for i in 0..12 { + src.push_str(&format!(" +field{i}\n")); + } + src.push_str(" }\n A --> Big"); + let out = plain(&src); + assert!(out.contains("+field7"), "{out}"); + assert!(!out.contains("+field9"), "{out}"); + assert!(out.contains('…'), "{out}"); + } + + #[test] + fn class_direction_lr() { + let out = plain("classDiagram\n direction LR\n A --> B"); + let line = out.lines().find(|l| l.contains('A')).unwrap(); + assert!(line.contains('B'), "{out}"); + } + + #[test] + fn er_renders_entities_and_relationship_labels() { + let out = plain( + "erDiagram\n CUSTOMER ||--o{ ORDER : places\n CUSTOMER {\n string name PK \"full name\"\n int custNumber\n }", + ); + assert!(out.contains("CUSTOMER"), "{out}"); + assert!(out.contains("ORDER"), "{out}"); + assert!(out.contains("string name PK"), "{out}"); + assert!( + !out.contains("full name"), + "attribute comments dropped:\n{out}" + ); + assert!(out.contains("1 places 0..*"), "{out}"); + assert!(out.contains('├'), "attribute compartment rule:\n{out}"); + } + + #[test] + fn er_cardinality_map() { + let cases = [ + ("||--||", "1", "1"), + ("|o--o|", "0..1", "0..1"), + ("}o--o{", "0..*", "0..*"), + ("}|--|{", "1..*", "1..*"), + ("||--o{", "1", "0..*"), + ]; + for (op, l, r) in cases { + let (cl, cr, line) = parse_er_op(op).unwrap(); + assert_eq!((cl, cr), (l, r), "{op}"); + assert!(line == LineKind::Solid); + } + assert!(parse_er_op("||..o{").unwrap().2 == LineKind::Dotted); + assert!(parse_er_op("||==o{").is_none()); + assert!(parse_er_op("garbage").is_none()); + } + + #[test] + fn er_non_identifying_renders_dotted() { + let out = plain("erDiagram\n A ||..o{ B : uses"); + assert!(out.contains('╎') || out.contains('╌'), "{out}"); + } + + #[test] + fn er_relationships_have_no_arrowheads() { + let out = plain("erDiagram\n A ||--o{ B : has"); + for head in ['▼', '▲', '◄', '▶', '△', '◆', '◇'] { + assert!(!out.contains(head), "{head} in:\n{out}"); + } + } + + #[test] + fn er_entity_alias_label() { + let out = plain("erDiagram\n p[Person] ||--o{ a[\"Bank Account\"] : owns"); + assert!(out.contains("Person"), "{out}"); + assert!(out.contains("Bank Account"), "{out}"); + } + + #[test] + fn er_unquoted_label_and_bare_entity_decl() { + let g = parse_er("erDiagram\n LONER\n A ||--|| B : linked") + .unwrap() + .0; + assert_eq!(g.nodes.len(), 3); + let out = plain("erDiagram\n LONER\n A ||--|| B : linked"); + assert!(out.contains("LONER"), "{out}"); + assert!(out.contains("1 linked 1"), "{out}"); + } + + #[test] + fn er_attribute_cap_ellipsis() { + let mut src = String::from("erDiagram\n BIG {\n"); + for i in 0..12 { + src.push_str(&format!(" int f{i}\n")); + } + src.push_str(" }\n BIG ||--|| OTHER : x"); + let out = plain(&src); + assert!(out.contains("int f7"), "{out}"); + assert!(!out.contains("int f9"), "{out}"); + assert!(out.contains('…'), "{out}"); + } + + #[test] + fn er_unknown_statement_falls_back() { + let out = plain("erDiagram\n A ||--|| B : ok\n utter nonsense statement"); + assert!(out.contains("mermaid: erDiagram"), "{out}"); + } + + #[test] + fn subgraph_renders_titled_frame() { + let out = plain( + "graph TD\n S[Start] --> one\n subgraph one [Group One]\n A --> B\n end\n one --> E[End]", + ); + assert!(out.contains(" Group One "), "{out}"); + let lines: Vec<&str> = out.lines().collect(); + let title = lines.iter().position(|l| l.contains("Group One")).unwrap(); + let a = lines.iter().position(|l| l.contains("│ A │")).unwrap(); + let b = lines.iter().position(|l| l.contains("│ B │")).unwrap(); + let frame_close = lines + .iter() + .rposition(|l| l.trim_start().starts_with('└')) + .unwrap(); + assert!(title < a && a < b && b <= frame_close, "{out}"); + assert!(out.contains("Start") && out.contains("End"), "{out}"); + assert_eq!(out.matches('▼').count(), 3, "{out}"); + } + + #[test] + fn subgraph_edge_between_groups() { + let out = plain( + "graph TD\n subgraph api [API]\n A1 --> A2\n end\n subgraph db [Storage]\n B1\n end\n api --> db", + ); + assert!(out.contains(" API "), "{out}"); + assert!(out.contains(" Storage "), "{out}"); + let lines: Vec<&str> = out.lines().collect(); + let api = lines.iter().position(|l| l.contains("API")).unwrap(); + let db = lines.iter().position(|l| l.contains("Storage")).unwrap(); + assert!(api < db, "API frame ranks above Storage:\n{out}"); + } + + #[test] + fn subgraph_nested_frames() { + let out = plain( + "graph TD\n subgraph outer [Outer]\n subgraph inner [Inner]\n X --> Y\n end\n W --> X\n end\n S --> outer", + ); + assert!(out.contains(" Outer "), "{out}"); + assert!(out.contains(" Inner "), "{out}"); + let lines: Vec<&str> = out.lines().collect(); + let outer = lines.iter().position(|l| l.contains("Outer")).unwrap(); + let inner = lines.iter().position(|l| l.contains("Inner")).unwrap(); + assert!(outer < inner, "{out}"); + } + + #[test] + fn subgraph_cross_member_edge_attaches_to_frame() { + let out = plain("graph LR\n S --> A\n subgraph g [Workers]\n A --> B\n end\n B --> T"); + assert!(out.contains(" Workers "), "{out}"); + assert!(out.contains('S') && out.contains('T'), "{out}"); + assert_eq!(out.matches('▶').count(), 3, "{out}"); + let row = out.lines().find(|l| l.contains("│ A ├")).unwrap(); + assert!( + row.find('S') < row.find('A'), + "A stays outside the group (first definition wins):\n{out}" + ); + } + + #[test] + fn subgraph_id_referenced_before_declaration() { + let g = parse_graph("graph TD\n X --> two\n subgraph two\n C --> D\n end").unwrap(); + assert_eq!(g.groups.len(), 1); + let out = plain("graph TD\n X --> two\n subgraph two\n C --> D\n end"); + assert!(out.contains(" two "), "frame titled by id:\n{out}"); + assert!(out.contains("│ C │"), "{out}"); + } + + #[test] + fn subgraph_quoted_and_plain_titles() { + let out = plain("graph TD\n subgraph \"My Stuff\"\n A\n end\n S --> A"); + assert!(out.contains(" My Stuff "), "{out}"); + let out2 = plain("graph TD\n subgraph batch jobs\n B\n end\n S --> B"); + assert!(out2.contains(" batch jobs "), "{out2}"); + let out3 = plain("graph TD\n subgraph \"a <b>\"\n C\n end\n S --> C"); + assert!(out3.contains("a ") && !out3.contains("<"), "{out3}"); + } + + #[test] + fn subgraph_empty_is_dropped() { + let out = plain("graph TD\n subgraph ghost\n end\n A --> B"); + assert!(!out.contains("ghost"), "{out}"); + assert!(out.contains('▼'), "{out}"); + } + + #[test] + fn subgraph_bt_flips_frame_and_contents() { + let out = plain("flowchart BT\n S --> one\n subgraph one [Up]\n A --> B\n end"); + assert!(out.contains(" Up "), "{out}"); + let lines: Vec<&str> = out.lines().collect(); + let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap(); + assert!(row("│ B │") < row("│ A │"), "contents flip with BT:\n{out}"); + assert!(row(" Up ") < row("S"), "frame above source in BT:\n{out}"); + assert!(out.contains('▲'), "{out}"); + } + + #[test] + fn subgraph_depth_over_cap_falls_back() { + let mut src = String::from("graph TD\n"); + for i in 0..8 { + src.push_str(&format!(" subgraph g{i}\n")); + } + src.push_str(" A --> B\n"); + for _ in 0..8 { + src.push_str(" end\n"); + } + let out = plain(&src); + assert!(out.contains("mermaid: graph"), "{out}"); + } + + #[test] + fn subgraph_groupless_path_unchanged() { + let g = parse_graph("graph TD\n A --> B").unwrap(); + assert!(g.groups.is_empty()); + } + + #[test] + fn fan_out_creates_cross_product_edges() { + let g = parse_graph("graph TD\n A & B --> C & D").unwrap(); + assert_eq!(g.nodes.len(), 4); + assert_eq!(g.edges.len(), 4); + let idx = |id: &str| g.index[id]; + let has = |f: &str, t: &str| g.edges.iter().any(|e| e.from == idx(f) && e.to == idx(t)); + assert!(has("A", "C") && has("A", "D") && has("B", "C") && has("B", "D")); + let out = plain("graph TD\n A & B --> C & D"); + assert_eq!(out.chars().filter(|&c| c == '▼').count(), 2, "{out}"); + } + + #[test] + fn fan_out_in_chain() { + let g = parse_graph("graph LR\n A & B --> C --> D").unwrap(); + assert_eq!(g.edges.len(), 3); + } + + #[test] + fn fan_out_with_reversed_arrow() { + let g = parse_graph("graph TD\n A & B <-- C").unwrap(); + let idx = |id: &str| g.index[id]; + assert_eq!(g.edges.len(), 2); + assert!(g.edges.iter().all(|e| e.from == idx("C"))); + assert!(g.edges.iter().all(|e| e.head_to == Head::Arrow)); + } + + #[test] + fn circle_and_cross_endings_create_no_phantom_nodes() { + let g = parse_graph("graph TD\n A --o B\n C --x D").unwrap(); + assert_eq!(g.nodes.len(), 4, "no phantom o/x nodes"); + assert!(!g.index.contains_key("o")); + assert!(!g.index.contains_key("x")); + assert_eq!(g.edges[0].head_to, Head::Circle); + assert_eq!(g.edges[1].head_to, Head::Cross); + let out = plain("graph TD\n A --o B"); + assert!(out.contains('o'), "circle head rendered:\n{out}"); + } + + #[test] + fn left_endings_decorate_without_reversing() { + let g = parse_graph("graph TD\n A o-- B\n C x-- D").unwrap(); + let idx = |id: &str| g.index[id]; + assert_eq!(g.edges[0].from, idx("A")); + assert_eq!(g.edges[0].to, idx("B")); + assert_eq!(g.edges[0].head_from, Head::Circle); + assert_eq!(g.edges[1].head_from, Head::Cross); + assert_eq!(g.edges[0].head_to, Head::None); + } + + #[test] + fn reversed_arrow_with_end_marker_swaps_direction() { + let g = parse_graph("graph TD\n A <--o B\n C <--x D").unwrap(); + let idx = |id: &str| g.index[id]; + assert_eq!(g.edges[0].from, idx("B")); + assert_eq!(g.edges[0].to, idx("A")); + assert_eq!(g.edges[0].head_to, Head::Arrow); + assert_eq!(g.edges[0].head_from, Head::Circle); + assert_eq!(g.edges[1].from, idx("D")); + assert_eq!(g.edges[1].to, idx("C")); + assert_eq!(g.edges[1].head_from, Head::Cross); + let plain_rev = plain("graph TD\n A <--o B"); + let lines: Vec<&str> = plain_rev.lines().collect(); + let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap(); + assert!( + row("B") < row("A"), + "ranks match plain <-- reversal:\n{plain_rev}" + ); + } + + #[test] + fn both_end_markers_parse() { + let g = parse_graph("graph TD\n A o--o B\n C x--x D").unwrap(); + assert_eq!(g.edges[0].head_from, Head::Circle); + assert_eq!(g.edges[0].head_to, Head::Circle); + assert_eq!(g.edges[1].head_from, Head::Cross); + assert_eq!(g.edges[1].head_to, Head::Cross); + assert_eq!(g.nodes.len(), 4); + } + + #[test] + fn dotted_and_thick_lines_render_distinctly() { + let dotted = plain("graph TD\n A -.-> B"); + assert!(dotted.contains('╎'), "dotted vertical:\n{dotted}"); + let thick = plain("graph TD\n A ==> B"); + assert!(thick.contains('┃'), "thick vertical:\n{thick}"); + let solid = plain("graph TD\n A --> B"); + assert!( + !solid.contains('╎') && !solid.contains('┃'), + "solid unchanged:\n{solid}" + ); + } + + #[test] + fn dotted_label_form_renders_dashed() { + let out = plain("graph LR\n A -. maybe .-> B"); + assert!(out.contains('╌'), "{out}"); + assert!(out.contains("maybe"), "{out}"); + } + + #[test] + fn thick_jog_uses_thick_corners() { + let out = plain("graph TD\n A[aaaaaaa] ==> B\n A ==> C[ccccccc]"); + assert!( + out.contains('┏') || out.contains('┓') || out.contains('┳'), + "thick corners on jog:\n{out}" + ); + } + + #[test] + fn state_diagram_renders_states_and_transitions() { + let out = + plain("stateDiagram-v2\n [*] --> Idle\n Idle --> Running: start\n Running --> [*]"); + assert!(out.contains("Idle"), "{out}"); + assert!(out.contains("Running"), "{out}"); + assert!(out.contains("start"), "{out}"); + assert!(out.contains('▼'), "{out}"); + assert_eq!( + out.matches('●').count(), + 2, + "distinct start and end markers:\n{out}" + ); + let lines: Vec<&str> = out.lines().collect(); + let first_dot = lines.iter().position(|l| l.contains('●')).unwrap(); + let last_dot = lines.iter().rposition(|l| l.contains('●')).unwrap(); + let idle = lines.iter().position(|l| l.contains("Idle")).unwrap(); + assert!(first_dot < idle && idle < last_dot, "{out}"); + } + + #[test] + fn state_v1_header_renders() { + let out = plain("stateDiagram\n A --> B"); + assert!(out.contains('▼'), "{out}"); + } + + #[test] + fn state_boxes_are_rounded() { + let out = plain("stateDiagram-v2\n A --> B"); + assert!(out.contains('╭'), "{out}"); + assert!(!out.contains('┌'), "states render rounded:\n{out}"); + } + + #[test] + fn state_alias_label_renders() { + let out = plain("stateDiagram-v2\n state \"Waiting for input\" as W\n W --> Done"); + assert!(out.contains("Waiting for input"), "{out}"); + } + + #[test] + fn state_choice_parses_as_diamond() { + let g = parse_state( + "stateDiagram-v2\n state c <>\n A --> c\n c --> B: yes\n c --> D: no", + ) + .unwrap(); + assert!(g.nodes[g.index["c"]].shape == Shape::Diamond); + assert_eq!(g.edges.len(), 3); + } + + #[test] + fn state_description_sets_label() { + let out = plain("stateDiagram-v2\n s2 : waits patiently\n A --> s2"); + assert!(out.contains("waits patiently"), "{out}"); + } + + #[test] + fn state_direction_lr() { + let out = plain("stateDiagram-v2\n direction LR\n A --> B --> C"); + let td = plain("stateDiagram-v2\n A --> B"); + assert!( + out.lines().count() <= td.lines().count() + 2, + "LR stays flat:\n{out}" + ); + let line = out.lines().find(|l| l.contains('A')).unwrap(); + assert!(line.contains('B'), "A and B share a row in LR:\n{out}"); + } + + #[test] + fn state_composite_contents_render_flat() { + let out = plain("stateDiagram-v2\n state Active {\n A --> B\n }\n Active --> Done"); + assert!(out.contains("Active"), "{out}"); + assert!(out.contains('A') && out.contains('B'), "{out}"); + assert!(out.contains("Done"), "{out}"); + } + + #[test] + fn state_notes_are_skipped() { + let out = plain( + "stateDiagram-v2\n A --> B\n note right of A: inline note\n note left of B\n block text\n end note", + ); + assert!(out.contains('▼'), "{out}"); + assert!(!out.contains("note"), "{out}"); + assert!(!out.contains("block text"), "{out}"); + } + + #[test] + fn state_back_transition_uses_lane() { + let out = plain("stateDiagram-v2\n A --> B\n B --> C\n C --> B: retry"); + assert!(out.contains('◄'), "{out}"); + assert!(out.contains("retry"), "{out}"); + } + + #[test] + fn state_unknown_statement_falls_back() { + let out = plain("stateDiagram-v2\n A --> B\n some garbage line"); + assert!(out.contains("mermaid: stateDiagram-v2"), "{out}"); + } + + #[test] + fn state_over_cap_falls_back() { + let mut src = String::from("stateDiagram-v2\n"); + for i in 0..600 { + src.push_str(&format!(" S{i} --> S{}\n", i + 1)); + } + let out = plain(&src); + assert!(out.contains("mermaid: stateDiagram-v2"), "{out}"); + } + + #[test] + fn state_extra_dash_arrow_tolerated() { + let g = parse_state("stateDiagram-v2\n A ---> B").unwrap(); + assert_eq!(g.edges.len(), 1); + assert_eq!(g.nodes.len(), 2); + } + + #[test] + fn state_description_preserves_choice_shape() { + let g = parse_state( + "stateDiagram-v2\n state c <>\n c : pick a path\n A --> c\n c --> B", + ) + .unwrap(); + assert!(g.nodes[g.index["c"]].shape == Shape::Diamond); + assert_eq!(g.nodes[g.index["c"]].label, "pick a path"); + let g2 = + parse_state("stateDiagram-v2\n state c <>\n state \"pick\" as c\n A --> c") + .unwrap(); + assert!(g2.nodes[g2.index["c"]].shape == Shape::Diamond); + assert_eq!(g2.nodes[g2.index["c"]].label, "pick"); + } + + #[test] + fn state_chained_transitions_parse_as_separate_edges() { + let g = parse_state("stateDiagram-v2\n A --> B --> C").unwrap(); + assert_eq!(g.nodes.len(), 3, "three distinct states"); + assert_eq!(g.edges.len(), 2, "two edges"); + assert!(g.index.contains_key("B")); + assert!(g.index.contains_key("C")); + assert!( + !g.nodes.iter().any(|n| n.label.contains("-->")), + "no node swallows the arrow" + ); + let idx = |id: &str| g.index[id]; + assert!( + g.edges + .iter() + .any(|e| e.from == idx("A") && e.to == idx("B")) + ); + assert!( + g.edges + .iter() + .any(|e| e.from == idx("B") && e.to == idx("C")) + ); + } + + #[test] + fn state_chain_with_markers_and_label() { + let g = parse_state("stateDiagram-v2\n [*] --> A --> B: done").unwrap(); + assert_eq!(g.edges.len(), 2); + assert!(g.edges.iter().any(|e| e.label.as_deref() == Some("done"))); + let out = plain("stateDiagram-v2\n [*] --> A --> B: done"); + assert!(out.contains('●'), "{out}"); + assert!(out.contains("done"), "{out}"); + } + + #[test] + fn state_dangling_chain_falls_back() { + let out = plain("stateDiagram-v2\n A --> B -->"); + assert!(out.contains("mermaid: stateDiagram-v2"), "{out}"); + } + + #[test] + fn sequence_renders_actors_and_messages() { + let out = plain("sequenceDiagram\n Alice->>Bob: Hello Bob\n Bob-->>Alice: Hi Alice"); + assert!(out.contains("Alice"), "{out}"); + assert!(out.contains("Bob"), "{out}"); + assert!(out.contains("Hello Bob"), "{out}"); + assert!(out.contains('▶'), "solid call arrow:\n{out}"); + assert!(out.contains('◄'), "reply arrow:\n{out}"); + assert!(out.contains('╌'), "reply line is dashed:\n{out}"); + assert_eq!( + out.matches("│ Alice │").count(), + 2, + "actor boxes repeat at bottom:\n{out}" + ); + } + + #[test] + fn sequence_participant_as_label() { + let out = plain( + "sequenceDiagram\n participant C as Client\n participant S as Server\n C->>S: GET /", + ); + assert!(out.contains("Client"), "{out}"); + assert!(out.contains("Server"), "{out}"); + } + + #[test] + fn sequence_declared_order_wins() { + let out = plain("sequenceDiagram\n participant B\n participant A\n A->>B: hi"); + let line = out.lines().nth(1).unwrap(); + assert!( + line.find('B') < line.find('A'), + "B declared first sits left:\n{out}" + ); + } + + #[test] + fn sequence_self_message_loops() { + let out = plain("sequenceDiagram\n A->>A: think"); + assert!(out.contains('╮'), "{out}"); + assert!(out.contains('╯'), "{out}"); + assert!(out.contains("think"), "{out}"); + } + + #[test] + fn sequence_cross_head() { + let out = plain("sequenceDiagram\n A-x B: lost"); + assert!(out.contains('×'), "{out}"); + } + + #[test] + fn sequence_note_over_renders_box() { + let out = plain("sequenceDiagram\n A->>B: hi\n Note over A,B: happy path"); + assert!(out.contains("happy path"), "{out}"); + } + + #[test] + fn sequence_autonumber_prefixes_messages() { + let out = plain("sequenceDiagram\n autonumber\n A->>B: one\n B->>A: two"); + assert!(out.contains("1. one"), "{out}"); + assert!(out.contains("2. two"), "{out}"); + } + + #[test] + fn sequence_loop_renders_divider_and_end() { + let out = plain("sequenceDiagram\n A->>B: hi\n loop retry x3\n A->>B: again\n end"); + assert!(out.contains("loop retry x3"), "{out}"); + assert!(out.contains(" end "), "{out}"); + } + + #[test] + fn sequence_rect_block_is_invisible() { + let out = plain("sequenceDiagram\n rect rgb(0,0,0)\n A->>B: hi\n end"); + assert!(!out.contains("rect"), "{out}"); + assert!(!out.contains(" end "), "rect end is silent:\n{out}"); + } + + #[test] + fn sequence_box_end_does_not_close_enclosing_block() { + let out = plain( + "sequenceDiagram\n loop l1\n box g\n participant A\n end\n A->>B: hi\n A->>B: bye\n end", + ); + assert_eq!( + out.matches(" end ").count(), + 1, + "box end is silent, loop end renders:\n{out}" + ); + let lines: Vec<&str> = out.lines().collect(); + let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap(); + assert!( + row("loop l1") < row("hi") && row("bye") < row(" end "), + "messages stay inside the loop:\n{out}" + ); + assert!(!out.contains("box"), "{out}"); + } + + #[test] + fn sequence_critical_option_renders_dividers() { + let out = plain( + "sequenceDiagram\n critical connect\n A->>B: try\n option timeout\n A->>A: log\n end", + ); + assert!( + out.contains("critical connect"), + "valid critical diagram renders:\n{out}" + ); + assert!(out.contains("option timeout"), "{out}"); + assert!(out.contains(" end "), "{out}"); + } + + #[test] + fn sequence_long_label_widens_gap() { + let out = plain( + "sequenceDiagram\n A->>B: a very long message label that needs room\n B-->>A: ok", + ); + assert!( + out.contains("a very long message label that needs room"), + "{out}" + ); + } + + #[test] + fn mixed_solid_and_dotted_bus_stays_light() { + let out = plain("graph TD\n A --> C\n B -.-> C"); + assert!(out.contains('╌'), "dotted branch survives:\n{out}"); + assert!(out.contains('─'), "solid branch survives:\n{out}"); + assert!(out.contains('┬'), "shared merge cell stays light:\n{out}"); + } + + #[test] + fn box_borders_stay_light_next_to_styled_edges() { + let out = plain("graph TD\n A ==> B"); + assert!(out.contains('┌') && out.contains('└'), "{out}"); + assert!(!out.contains('┏'), "borders not restyled:\n{out}"); + } + + #[test] + fn self_loop_renders_below_box() { + let out = plain("graph TD\n A --> A"); + assert!(out.contains('╰') && out.contains('╯'), "{out}"); + assert!(out.contains('▲'), "loop returns into the box:\n{out}"); + } + + #[test] + fn self_loop_label_renders() { + let out = plain("graph TD\n A -->|again| A"); + assert!(out.contains("again"), "{out}"); + } + + #[test] + fn self_loop_coexists_with_forward_edge() { + let out = plain("graph TD\n A --> A\n A --> B"); + assert!(out.contains('▲'), "{out}"); + assert!(out.contains('▼'), "{out}"); + assert!(out.contains('B'), "{out}"); + assert!(!out.contains('┼'), "{out}"); + } + + #[test] + fn self_loop_flips_with_bt() { + let out = plain("flowchart BT\n A --> A\n A --> B"); + assert!(out.contains('▼'), "flipped loop head points down:\n{out}"); + assert!(out.contains('╭') || out.contains('╮'), "{out}"); + } + + #[test] + fn self_loop_in_lr() { + let out = plain("flowchart LR\n A --> A\n A --> B"); + assert!(out.contains('▲'), "{out}"); + assert!(out.contains('▶'), "{out}"); + } + + #[test] + fn inline_o_word_label_still_parses_as_label() { + let g = parse_graph("graph TD\n A -- or else --> B").unwrap(); + assert_eq!(g.nodes.len(), 2); + assert_eq!(g.edges[0].label.as_deref(), Some("or else")); + } + + #[test] + fn sequence_unparseable_arrow_falls_back() { + let out = plain("sequenceDiagram\n ->>B: orphan"); + assert!(out.contains("mermaid: sequenceDiagram"), "{out}"); + } + + #[test] + fn sequence_unknown_statement_falls_back() { + let out = plain("sequenceDiagram\n A->>B: hi\n garbage statement here"); + assert!(out.contains("mermaid: sequenceDiagram"), "{out}"); + } + + #[test] + fn sequence_over_wide_falls_back() { + let out = render( + "sequenceDiagram\n A->>B: this label is far wider than the available pane width", + &styles(), + Some(30), + ) + .unwrap() + .plain_lines + .join("\n"); + assert!(out.contains("mermaid: sequenceDiagram"), "{out}"); + } + + #[test] + fn sequence_over_cap_falls_back() { + let mut src = String::from("sequenceDiagram\n"); + for i in 0..600 { + src.push_str(&format!(" A->>B: msg {i}\n")); + } + let out = plain(&src); + assert!(out.contains("mermaid: sequenceDiagram"), "{out}"); + } + + #[test] + fn sequence_activation_markers_are_stripped() { + let out = plain("sequenceDiagram\n A->>+B: call\n B-->>-A: return"); + assert!(out.contains("call"), "{out}"); + assert!(out.contains("return"), "{out}"); + assert!(!out.contains('+'), "{out}"); + } + + #[test] + fn sequence_rows_are_rectangular_and_sentinel_free() { + let out = plain("sequenceDiagram\n Alice->>Bob: hi\n Note over Alice: solo note"); + assert!(!out.contains(CONT), "sentinel leaked:\n{out}"); + assert!(out.contains("solo note"), "{out}"); + } +} diff --git a/grok-mermaid/src/shim.rs b/grok-mermaid/src/shim.rs new file mode 100644 index 00000000..a3ce076f --- /dev/null +++ b/grok-mermaid/src/shim.rs @@ -0,0 +1,70 @@ +//! Minimal stand-ins for the `ratatui` style/text types that `mermaid.rs` +//! uses (`Style`, `Modifier`, `Span`, `Line`), so the renderer compiles +//! without pulling in ratatui. Instead of terminal colors, a `Style` carries +//! an optional CSS class name; `lib.rs` turns styled lines into HTML spans. + +use std::borrow::Cow; + +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +pub struct Modifier(u8); + +impl Modifier { + pub const ITALIC: Modifier = Modifier(1); +} + +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +pub struct Style { + pub class: Option<&'static str>, + modifiers: u8, +} + +impl Style { + /// A style tagged with a CSS class name. + pub const fn class(name: &'static str) -> Self { + Style { + class: Some(name), + modifiers: 0, + } + } + + pub fn add_modifier(mut self, modifier: Modifier) -> Self { + self.modifiers |= modifier.0; + self + } + + pub fn is_italic(&self) -> bool { + self.modifiers & Modifier::ITALIC.0 != 0 + } +} + +#[derive(Clone, PartialEq, Debug)] +pub struct Span<'a> { + pub content: Cow<'a, str>, + pub style: Style, +} + +impl<'a> Span<'a> { + pub fn styled>>(content: T, style: Style) -> Self { + Span { + content: content.into(), + style, + } + } +} + +#[derive(Clone, PartialEq, Debug, Default)] +pub struct Line<'a> { + pub spans: Vec>, +} + +impl<'a> From>> for Line<'a> { + fn from(spans: Vec>) -> Self { + Line { spans } + } +} + +impl<'a> From> for Line<'a> { + fn from(span: Span<'a>) -> Self { + Line { spans: vec![span] } + } +} diff --git a/tests/test_grok_mermaid.py b/tests/test_grok_mermaid.py new file mode 100644 index 00000000..5f9ef621 --- /dev/null +++ b/tests/test_grok_mermaid.py @@ -0,0 +1,58 @@ +""" +Playwright tests for grok-mermaid.html + +Tests Mermaid-to-Unicode-box-art rendering via the grok-mermaid.wasm +WebAssembly module (built from grok-mermaid/, extracted from +xai-org/grok-build). +""" + +import pathlib +from playwright.sync_api import Page, expect + + +test_dir = pathlib.Path(__file__).parent.absolute() +root = test_dir.parent.absolute() + + +def test_initial_render(page: Page, unused_port_server): + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/grok-mermaid.html") + expect(page.locator("h1")).to_have_text("Mermaid to Unicode box art") + # Default example is the flowchart; wait for the wasm render. + expect(page.locator("#output")).to_contain_text("Request received") + expect(page.locator("#output")).to_contain_text("▼") + # Colored spans from the wasm module's classed HTML output. + assert page.locator("#output span.b").count() > 0 + + +def test_example_buttons(page: Page, unused_port_server): + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/grok-mermaid.html") + page.locator("#examples button", has_text="Sequence").click() + expect(page.locator("#output")).to_contain_text("wasm_render_html()") + page.locator("#examples button", has_text="State").click() + expect(page.locator("#output")).to_contain_text("Loading") + # Unsupported diagram types fall back to a framed source listing. + page.locator("#examples button", has_text="Unsupported").click() + expect(page.locator("#output")).to_contain_text("mermaid: pie") + + +def test_live_editing(page: Page, unused_port_server): + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/grok-mermaid.html") + expect(page.locator("#output")).to_contain_text("Request received") + page.locator("#input").fill("graph LR\n A[Hello] --> B[World]") + expect(page.locator("#output")).to_contain_text("Hello") + expect(page.locator("#output")).to_contain_text("World") + expect(page.locator("#output")).to_contain_text("─") + + +def test_source_in_url_fragment(page: Page, unused_port_server): + unused_port_server.start(root) + src = "graph TD\n X[From the URL] --> Y[Rendered]" + from urllib.parse import quote + + page.goto( + f"http://127.0.0.1:{unused_port_server.port}/grok-mermaid.html#{quote(src)}" + ) + expect(page.locator("#output")).to_contain_text("From the URL")