diff --git a/Cargo.lock b/Cargo.lock index ab2f636..1d0ba3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,6 +162,9 @@ version = "0.1.0" [[package]] name = "notescraft-diagrams" version = "0.1.0" +dependencies = [ + "thiserror", +] [[package]] name = "notescraft-editor" diff --git a/crates/diagrams/Cargo.toml b/crates/diagrams/Cargo.toml index 62fa5f3..758165e 100644 --- a/crates/diagrams/Cargo.toml +++ b/crates/diagrams/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true rust-version.workspace = true publish = false +[dependencies] +thiserror = { workspace = true } + [lib] path = "src/lib.rs" diff --git a/crates/diagrams/src/error.rs b/crates/diagrams/src/error.rs new file mode 100644 index 0000000..5e5059c --- /dev/null +++ b/crates/diagrams/src/error.rs @@ -0,0 +1,39 @@ +//! Error type for the diagrams crate: one enum, one alias, no `anyhow`. + +use thiserror::Error as ThisError; + +/// Everything that can go wrong turning Mermaid source into a diagram or a layout. +/// +/// Recoverable syntax problems never reach this type: they become +/// [`Diagnostic`](crate::Diagnostic) values and parsing continues. +#[derive(Debug, ThisError)] +pub enum Error { + /// The source is larger than the cap. + #[error("diagram source is {len} bytes, the cap is {max} bytes")] + TooLarge { + /// Actual byte length of the input. + len: usize, + /// The cap that was exceeded. + max: usize, + }, + /// No Mermaid diagram header could be found on the first meaningful line. + #[error("no Mermaid diagram header found (expected flowchart, graph or a known diagram kind)")] + NoHeader, + /// A structural cap (nodes, edges, subgraph depth) was exceeded. + #[error("too many {what}: the cap is {max}")] + LimitExceeded { + /// What hit the cap (`"nodes"`, `"edges"`, ...). + what: &'static str, + /// The cap that was exceeded. + max: usize, + }, + /// Layout was asked for a diagram kind that has no layout yet. + #[error("no layout is implemented for diagram kind `{kind}`")] + NotLayoutable { + /// Mermaid diagram kind that was recognised but cannot be laid out yet. + kind: String, + }, +} + +/// Result alias used throughout the crate. +pub type Result = std::result::Result; diff --git a/crates/diagrams/src/flowchart.rs b/crates/diagrams/src/flowchart.rs new file mode 100644 index 0000000..8970f81 --- /dev/null +++ b/crates/diagrams/src/flowchart.rs @@ -0,0 +1,279 @@ +//! Flowchart statements: nodes, edges, subgraphs and bookkeeping. + +use std::collections::HashMap; + +use crate::error::{Error, Result}; +use crate::geom::EdgeStyle; +use crate::lex::{Item, lex}; +use crate::model::{Diagnostic, Direction, Edge, Flowchart, Group, Node, NodeShape}; +use crate::parse::{MAX_EDGES, MAX_NODES, MAX_SUBGRAPH_DEPTH}; + +/// Accumulates flowchart statements into a [`Flowchart`]. +pub(crate) struct Builder { + direction: Direction, + nodes: Vec, + index: HashMap, + edges: Vec, + groups: Vec, + stack: Vec, + diagnostics: Vec, + source: String, + last_line: usize, +} + +impl Builder { + /// A builder holding `source`, returned unchanged in the finished flowchart. + pub fn new(source: String) -> Self { + Self { + direction: Direction::default(), + nodes: Vec::new(), + index: HashMap::new(), + edges: Vec::new(), + groups: Vec::new(), + stack: Vec::new(), + diagnostics: Vec::new(), + source, + last_line: 1, + } + } + + /// Sets the direction declared in the header. + pub fn set_direction(&mut self, direction: Direction) { + self.direction = direction; + } + + /// Records a non-fatal problem. + pub fn diagnostic(&mut self, line: usize, message: impl Into) { + self.diagnostics.push(Diagnostic::new(line, message)); + } + + /// Consumes one source line. + pub fn line(&mut self, line_no: usize, text: &str) -> Result<()> { + self.last_line = line_no; + let trimmed = text.trim().trim_end_matches(';').trim(); + if trimmed.is_empty() || trimmed.starts_with("%%") { + return Ok(()); + } + if let Some(rest) = trimmed.strip_prefix("subgraph") + && (rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + return self.subgraph(rest); + } + if trimmed == "end" { + self.end(line_no); + return Ok(()); + } + if trimmed.starts_with("direction ") { + self.diagnostic(line_no, "per-subgraph direction is ignored; the flowchart direction is used"); + return Ok(()); + } + let items = match lex(trimmed) { + Ok(items) => items, + Err(message) => { + self.diagnostic(line_no, message); + return Ok(()); + } + }; + self.items(line_no, items) + } + + /// Reports subgraphs still open when the source ends. + pub fn close_open_subgraphs(&mut self) { + let open = self.stack.len(); + if open > 0 { + let last = self.last_line; + self.diagnostic(last, format!("{open} subgraph(s) were never closed with `end`")); + self.stack.clear(); + } + } + + /// Consumes the finished flowchart. + pub fn finish(self) -> Flowchart { + Flowchart { direction: self.direction, nodes: self.nodes, edges: self.edges, groups: self.groups, diagnostics: self.diagnostics, source: self.source } + } + + fn items(&mut self, line_no: usize, items: Vec) -> Result<()> { + let mut chain: Vec = Vec::new(); + let mut prev: Option<(Vec, EdgeStyle, Option)> = None; + for item in items { + match item { + Item::Node { id, label, shape } => chain.push(self.node(&id, &label, shape)?), + Item::And => { + if chain.is_empty() { + self.diagnostic(line_no, "`&` without a node on its left"); + } + } + Item::Edge { style, label } => { + if let Some((sources, pstyle, plabel)) = prev.take() { + if chain.is_empty() { + self.diagnostic(line_no, "edge without a target node"); + } else { + self.connect(&sources, &chain, pstyle, plabel.as_deref())?; + } + } else if chain.is_empty() { + self.diagnostic(line_no, "edge without a source node"); + } + prev = Some((std::mem::take(&mut chain), style, label)); + } + } + } + if let Some((sources, style, label)) = prev { + if chain.is_empty() { + self.diagnostic(line_no, "edge without a target node"); + } else { + self.connect(&sources, &chain, style, label.as_deref())?; + } + } + Ok(()) + } + + fn connect(&mut self, sources: &[usize], targets: &[usize], style: EdgeStyle, label: Option<&str>) -> Result<()> { + for from in sources.iter().copied() { + for to in targets.iter().copied() { + if self.edges.len() >= MAX_EDGES { + return Err(Error::LimitExceeded { what: "edges", max: MAX_EDGES }); + } + self.edges.push(Edge { from, to, label: label.map(str::to_owned), style }); + } + } + Ok(()) + } + + fn node(&mut self, id: &str, label: &str, shape: NodeShape) -> Result { + if let Some(&idx) = self.index.get(id) { + if !label.is_empty() + && label != id + && let Some(node) = self.nodes.get_mut(idx) + { + label.clone_into(&mut node.label); + node.shape = shape; + } + return Ok(idx); + } + if self.nodes.len() >= MAX_NODES { + return Err(Error::LimitExceeded { what: "nodes", max: MAX_NODES }); + } + let group = self.stack.last().and_then(|i| self.groups.get(*i)).map(|g| g.id.clone()); + let idx = self.nodes.len(); + let label = if label.is_empty() { id.to_owned() } else { label.to_owned() }; + self.nodes.push(Node { id: id.to_owned(), label, shape, group }); + self.index.insert(id.to_owned(), idx); + Ok(idx) + } + + fn subgraph(&mut self, rest: &str) -> Result<()> { + if self.stack.len() >= MAX_SUBGRAPH_DEPTH { + return Err(Error::LimitExceeded { what: "subgraph nesting levels", max: MAX_SUBGRAPH_DEPTH }); + } + let text = rest.trim().trim_matches('"'); + let (written, title) = match text.find('[') { + Some(open) => { + let close = text.rfind(']').unwrap_or(text.len()); + (text.get(..open).unwrap_or_default().trim().to_owned(), text.get(open.saturating_add(1)..close).unwrap_or_default().trim().to_owned()) + } + // `subgraph name` is both id and title; a multi-word title gets a generated id. + None if !text.is_empty() && !text.contains(char::is_whitespace) => (text.to_owned(), text.to_owned()), + None => (String::new(), text.to_owned()), + }; + let id = if written.is_empty() { self.unique_id() } else { written }; + let parent = self.stack.last().copied(); + let depth = self.stack.len(); + self.groups.push(Group { id, title, depth, parent }); + let last = self.groups.len().saturating_sub(1); + self.stack.push(last); + Ok(()) + } + + fn end(&mut self, line_no: usize) { + if self.stack.pop().is_none() { + self.diagnostic(line_no, "`end` without a matching `subgraph`"); + } + } + + fn unique_id(&self) -> String { + let mut id = format!("sub{}", self.groups.len()); + while self.groups.iter().any(|g| g.id == id) { + id.push('_'); + } + id + } +} + +#[cfg(test)] +mod tests { + use super::Builder; + use crate::model::{Diagram, Direction, NodeShape}; + use crate::parse::parse; + + fn build(src: &str) -> Builder { + Builder::new(src.to_owned()) + } + + #[test] + fn nodes_and_edges() { + let mut b = build(""); + b.line(1, "A[Start] --> B(End) --> C{Done}").unwrap_or(()); + let f = b.finish(); + assert_eq!(f.nodes.len(), 3); + assert_eq!(f.edges.len(), 2); + assert_eq!(f.nodes.get(1).map(|n| n.shape), Some(NodeShape::Round)); + assert_eq!(f.nodes.get(2).map(|n| n.shape), Some(NodeShape::Rhombus)); + } + + #[test] + fn ampersand_expands_to_a_product() { + let mut b = build(""); + b.line(1, "A & B --> C").unwrap_or(()); + let f = b.finish(); + assert_eq!(f.edges.len(), 2); + assert_eq!(f.edges.first().map(|e| (e.from, e.to)), Some((0, 2))); + } + + #[test] + fn subgraphs_track_membership_and_nesting() { + let mut b = build(""); + b.line(1, "subgraph outer [Outer]").unwrap_or(()); + b.line(2, "subgraph inner").unwrap_or(()); + b.line(3, "A").unwrap_or(()); + b.line(4, "end").unwrap_or(()); + b.line(5, "B").unwrap_or(()); + b.line(6, "end").unwrap_or(()); + let f = b.finish(); + assert_eq!(f.groups.len(), 2); + assert_eq!(f.groups.first().map(|g| g.id.as_str()), Some("outer")); + assert_eq!(f.groups.first().map(|g| g.depth), Some(0)); + assert_eq!(f.groups.get(1).map(|g| g.parent), Some(Some(0))); + assert_eq!(f.groups.get(1).map(|g| g.id.as_str()), Some("inner")); + assert_eq!(f.nodes.first().and_then(|n| n.group.as_deref()), Some("inner")); + assert_eq!(f.nodes.get(1).and_then(|n| n.group.as_deref()), Some("outer")); + } + + #[test] + fn dangling_end_and_unclosed_subgraph_are_diagnostics() { + let mut b = build(""); + b.line(1, "end").unwrap_or(()); + b.line(2, "subgraph x").unwrap_or(()); + b.close_open_subgraphs(); + let f = b.finish(); + assert_eq!(f.diagnostics.len(), 2); + } + + #[test] + fn direction_line_inside_subgraph_is_a_diagnostic() { + let mut b = build(""); + b.line(1, "direction LR").unwrap_or(()); + let f = b.finish(); + assert_eq!(f.nodes, []); + assert_eq!(f.diagnostics.len(), 1); + } + + #[test] + fn full_flowchart_parses() { + let d = parse("flowchart LR\n A --> B\n B -->|yes| C\n").unwrap_or_else(|_| panic!("parse failed")); + let Diagram::Flowchart(f) = d else { panic!("expected flowchart") }; + assert_eq!(f.direction, Direction::LeftRight); + assert_eq!(f.nodes.len(), 3); + assert_eq!(f.edges.get(1).and_then(|e| e.label.as_deref()), Some("yes")); + assert_eq!(f.diagnostics, []); + } +} diff --git a/crates/diagrams/src/geom.rs b/crates/diagrams/src/geom.rs new file mode 100644 index 0000000..33de65c --- /dev/null +++ b/crates/diagrams/src/geom.rs @@ -0,0 +1,173 @@ +//! Geometry primitives shared by the layout and the SVG renderer. + +/// A point in diagram space (origin at the top-left of the drawing). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + /// Horizontal coordinate. + pub x: f64, + /// Vertical coordinate. + pub y: f64, +} + +impl Point { + /// Builds a point. + #[must_use] + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +/// An axis-aligned rectangle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Frame { + /// Left edge. + pub x: f64, + /// Top edge. + pub y: f64, + /// Width of the rectangle. + pub width: f64, + /// Height of the rectangle. + pub height: f64, +} + +impl Frame { + /// Builds a frame. + #[must_use] + pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height } + } + + /// Horizontal centre. + #[must_use] + pub fn center_x(self) -> f64 { + self.x + self.width / 2.0 + } + + /// Vertical centre. + #[must_use] + pub fn center_y(self) -> f64 { + self.y + self.height / 2.0 + } + + /// Right edge. + #[must_use] + pub fn right(self) -> f64 { + self.x + self.width + } + + /// Bottom edge. + #[must_use] + pub fn bottom(self) -> f64 { + self.y + self.height + } +} + +/// Horizontal placement of a [`Text`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + /// Centred on `x`. + Middle, + /// Left edge at `x`. + Start, +} + +/// A run of text drawn at a point. +#[derive(Debug, Clone, PartialEq)] +pub struct Text { + /// Anchor point. + pub at: Point, + /// Unstyled text content; escaping happens in the renderer. + pub content: String, + /// Horizontal placement relative to [`Text::at`]. + pub align: Align, +} + +/// Stroke style of an [`Shape::Edge`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeStyle { + /// Plain line with an arrow head. + Solid, + /// Dashed line with an arrow head. + Dotted, + /// Thick line with an arrow head. + Thick, +} + +/// One drawn primitive. Rendering is a pure function of these values. +#[derive(Debug, Clone, PartialEq)] +pub enum Shape { + /// Rounded or square box (rectangle, rounded rectangle, cylinder). + Box { + /// Outer rectangle of the box. + frame: Frame, + /// Corner radius; `0.0` for a square rectangle. + radius: f64, + /// Label drawn inside the box. + text: Text, + }, + /// Rhombus / decision node. + Diamond { + /// Bounding rectangle of the rhombus. + frame: Frame, + /// Label drawn inside the rhombus. + text: Text, + }, + /// Ellipse node. + Circle { + /// Bounding rectangle of the ellipse. + frame: Frame, + /// Label drawn inside the ellipse. + text: Text, + }, + /// A polyline edge with an optional label. + Edge { + /// Polyline vertices, in order. + points: Vec, + /// Label drawn near the middle of the polyline. + label: Option, + /// Stroke style. + style: EdgeStyle, + }, + /// Free-standing text (currently only used as an edge label). + Text { + /// Anchor point. + text: Text, + }, + /// Bounding box of a subgraph, drawn behind its members. + Group { + /// Rectangle enclosing every member node. + frame: Frame, + /// Title drawn at the top-left inside the rectangle. + title: Text, + }, +} + +/// A laid-out diagram: drawable primitives plus the canvas size. +#[derive(Debug, Clone, PartialEq)] +pub struct Layout { + /// Shapes in draw order (groups and edges first, node shapes last). + pub shapes: Vec, + /// Canvas width in user units. + pub width: f64, + /// Canvas height in user units. + pub height: f64, +} + +#[cfg(test)] +mod tests { + use super::{Frame, Point}; + + #[test] + fn frame_accessors() { + let f = Frame::new(10.0, 20.0, 30.0, 40.0); + assert!((f.center_x() - 25.0).abs() < f64::EPSILON); + assert!((f.center_y() - 40.0).abs() < f64::EPSILON); + assert!((f.right() - 40.0).abs() < f64::EPSILON); + assert!((f.bottom() - 60.0).abs() < f64::EPSILON); + } + + #[test] + fn point_is_a_pair() { + assert_eq!(Point::new(1.0, 2.0), Point { x: 1.0, y: 2.0 }); + } +} diff --git a/crates/diagrams/src/layout.rs b/crates/diagrams/src/layout.rs new file mode 100644 index 0000000..cfd875f --- /dev/null +++ b/crates/diagrams/src/layout.rs @@ -0,0 +1,192 @@ +//! Flowchart placement: turns ranks and layer order into drawable [`Shape`]s. + +use crate::error::{Error, Result}; +use crate::geom::{Align, Frame, Layout, Point, Shape, Text}; +use crate::model::{Diagram, Direction, Flowchart, NodeShape}; +use crate::rank; + +const MARGIN: f64 = 24.0; +const NODE_HEIGHT: f64 = 40.0; +const MIN_NODE_WIDTH: f64 = 64.0; +const CHAR_WIDTH: f64 = 7.5; +const NODE_PADDING: f64 = 32.0; +const NODE_GAP: f64 = 40.0; +const RANK_GAP: f64 = 64.0; +const GROUP_PAD: f64 = 16.0; +const GROUP_TITLE: f64 = 24.0; +const RADIUS_ROUND: f64 = 8.0; +const RADIUS_DB: f64 = 4.0; +const LOOP_SIZE: f64 = 24.0; + +/// Lays a diagram out. Only flowcharts have a layout today. +/// +/// # Errors +/// [`Error::NotLayoutable`] for diagram kinds that are parsed but not laid out yet. +pub fn layout(diagram: &Diagram) -> Result { + match diagram { + Diagram::Flowchart(f) => Ok(flowchart(f)), + Diagram::Unsupported { kind, .. } => Err(Error::NotLayoutable { kind: kind.clone() }), + } +} + +fn node_size(label: &str, shape: NodeShape) -> (f64, f64) { + let chars = label.chars().count().min(200); + let w = (f64::from(u32::try_from(chars).unwrap_or(200)) * CHAR_WIDTH + NODE_PADDING).max(MIN_NODE_WIDTH); + match shape { + NodeShape::Rhombus => (w * 1.3, NODE_HEIGHT * 1.4), + NodeShape::Circle => (w, NODE_HEIGHT * 1.2), + _ => (w, NODE_HEIGHT), + } +} + +fn flowchart(chart: &Flowchart) -> Layout { + let f = chart; + let count = f.nodes.len(); + let horizontal = matches!(f.direction, Direction::LeftRight | Direction::RightLeft); + let reversed = matches!(f.direction, Direction::BottomTop | Direction::RightLeft); + let edges: Vec<(usize, usize)> = f.edges.iter().map(|e| (e.from, e.to)).collect(); + let dag = rank::acyclic(count, &edges); + let ranks = rank::ranks(count, &dag); + let layers = rank::layers(count, &ranks, &dag); + + let sizes: Vec<(f64, f64)> = f.nodes.iter().map(|node| node_size(&node.label, node.shape)).collect(); + let size_of = |i: usize| sizes.get(i).copied().unwrap_or((MIN_NODE_WIDTH, NODE_HEIGHT)); + // Abstract axes: `along` runs inside a layer, `across` runs from rank to rank. + let along = |i: usize| if horizontal { size_of(i).1 } else { size_of(i).0 }; + let across = |i: usize| if horizontal { size_of(i).0 } else { size_of(i).1 }; + + let layer_extent: Vec = layers + .iter() + .map(|l| l.iter().map(|&i| along(i)).sum::() + NODE_GAP * f64::from(u32::try_from(l.len().saturating_sub(1)).unwrap_or(u32::MAX))) + .collect(); + let widest = layer_extent.iter().copied().fold(0.0, f64::max); + + let mut centers = vec![Point::new(0.0, 0.0); count]; + let mut offset = 0.0; + for (layer, extent) in layers.iter().zip(&layer_extent) { + let thickness = layer.iter().map(|&i| across(i)).fold(0.0, f64::max); + let mut cursor = (widest - extent) / 2.0; + for &i in layer { + let a = cursor + along(i) / 2.0; + let b = offset + thickness / 2.0; + cursor += along(i) + NODE_GAP; + if let Some(slot) = centers.get_mut(i) { + *slot = if horizontal { Point::new(b, a) } else { Point::new(a, b) }; + } + } + offset += thickness + RANK_GAP; + } + let total = (offset - RANK_GAP).max(0.0); + for c in &mut centers { + if reversed { + if horizontal { + c.x = total - c.x; + } else { + c.y = total - c.y; + } + } + c.x += MARGIN; + c.y += MARGIN; + } + let frame_of = |i: usize| { + let (w, h) = size_of(i); + let c = centers.get(i).copied().unwrap_or(Point::new(0.0, 0.0)); + Frame::new(c.x - w / 2.0, c.y - h / 2.0, w, h) + }; + + let mut shapes = group_shapes(f, &frame_of); + for e in &f.edges { + shapes.push(edge_shape(e.from, e.to, e.label.as_deref(), e.style, &frame_of)); + } + for (i, node) in f.nodes.iter().enumerate() { + let frame = frame_of(i); + let text = Text { at: Point::new(frame.center_x(), frame.center_y()), content: node.label.clone(), align: Align::Middle }; + shapes.push(match node.shape { + NodeShape::Rect => Shape::Box { frame, radius: 0.0, text }, + NodeShape::Round => Shape::Box { frame, radius: RADIUS_ROUND, text }, + NodeShape::Database => Shape::Box { frame, radius: RADIUS_DB, text }, + NodeShape::Rhombus => Shape::Diamond { frame, text }, + NodeShape::Circle => Shape::Circle { frame, text }, + }); + } + let (mut width, mut height) = (2.0 * MARGIN, 2.0 * MARGIN); + for s in &shapes { + let f = match s { + Shape::Box { frame, .. } | Shape::Diamond { frame, .. } | Shape::Circle { frame, .. } | Shape::Group { frame, .. } => *frame, + _ => continue, + }; + width = width.max(f.right() + MARGIN); + height = height.max(f.bottom() + MARGIN); + } + Layout { shapes, width, height } +} + +fn group_shapes(f: &Flowchart, frame_of: &dyn Fn(usize) -> Frame) -> Vec { + let mut order: Vec = (0..f.groups.len()).collect(); + order.sort_by_key(|&g| f.groups.get(g).map_or(0, |group| group.depth)); + let mut out = Vec::new(); + for g in order { + let Some(group) = f.groups.get(g) else { continue }; + let mut bounds: Option<(f64, f64, f64, f64)> = None; + for (i, node) in f.nodes.iter().enumerate() { + if !in_group(f, node.group.as_deref(), &group.id) { + continue; + } + let fr = frame_of(i); + bounds = Some(match bounds { + None => (fr.x, fr.y, fr.right(), fr.bottom()), + Some((x0, y0, x1, y1)) => (x0.min(fr.x), y0.min(fr.y), x1.max(fr.right()), y1.max(fr.bottom())), + }); + } + let Some((x0, y0, x1, y1)) = bounds else { continue }; + let pad = GROUP_PAD * (1.0 + 0.5 * f64::from(u32::try_from(f.groups.len().saturating_sub(g).min(8)).unwrap_or(0)) / 8.0); + let frame = Frame::new(x0 - pad, y0 - pad - GROUP_TITLE, x1 - x0 + 2.0 * pad, y1 - y0 + 2.0 * pad + GROUP_TITLE); + let title = Text { at: Point::new(frame.x + 8.0, frame.y + GROUP_TITLE / 2.0), content: group.title.clone(), align: Align::Start }; + out.push(Shape::Group { frame, title }); + } + out +} + +/// True when `start` is `target` or one of its descendants (walks the parent chain, depth-capped). +fn in_group(f: &Flowchart, start: Option<&str>, target: &str) -> bool { + let mut current = start; + for _ in 0..=crate::parse::MAX_SUBGRAPH_DEPTH { + let Some(id) = current else { return false }; + if id == target { + return true; + } + current = f.groups.iter().find(|g| g.id == id).and_then(|g| g.parent).and_then(|p| f.groups.get(p)).map(|g| g.id.as_str()); + } + false +} + +fn edge_shape(from: usize, to: usize, label: Option<&str>, style: crate::geom::EdgeStyle, frame_of: &dyn Fn(usize) -> Frame) -> Shape { + let (a, b) = (frame_of(from), frame_of(to)); + let points = if from == to { + let (x, y) = (a.right(), a.y); + vec![ + Point::new(x, a.center_y() - 8.0), + Point::new(x + LOOP_SIZE, a.center_y() - 8.0), + Point::new(x + LOOP_SIZE, y - LOOP_SIZE / 2.0), + Point::new(a.center_x() + 8.0, y - LOOP_SIZE / 2.0), + Point::new(a.center_x() + 8.0, y), + ] + } else { + vec![clip(a, b), clip(b, a)] + }; + let label = label.map(|l| { + let (first, last) = (points.first().copied().unwrap_or(Point::new(0.0, 0.0)), points.last().copied().unwrap_or(Point::new(0.0, 0.0))); + Text { at: Point::new(f64::midpoint(first.x, last.x), f64::midpoint(first.y, last.y) - 6.0), content: l.to_owned(), align: Align::Middle } + }); + Shape::Edge { points, label, style } +} + +/// Where the segment from the centre of `a` towards the centre of `b` leaves `a`. +fn clip(a: Frame, b: Frame) -> Point { + let (dx, dy) = (b.center_x() - a.center_x(), b.center_y() - a.center_y()); + let (hw, hh) = (a.width / 2.0, a.height / 2.0); + let tx = if dx.abs() > f64::EPSILON { hw / dx.abs() } else { f64::INFINITY }; + let ty = if dy.abs() > f64::EPSILON { hh / dy.abs() } else { f64::INFINITY }; + let t = tx.min(ty); + if t.is_finite() { Point::new(a.center_x() + dx * t, a.center_y() + dy * t) } else { Point::new(a.center_x(), a.center_y()) } +} diff --git a/crates/diagrams/src/lex.rs b/crates/diagrams/src/lex.rs new file mode 100644 index 0000000..4907059 --- /dev/null +++ b/crates/diagrams/src/lex.rs @@ -0,0 +1,273 @@ +//! Line lexer for the flowchart subset: nodes, edges and `&` separators. +//! +//! Pure byte scanning: every Mermaid delimiter is ASCII, so scanning for ASCII +//! delimiters is safe on UTF-8 input. + +use crate::geom::EdgeStyle; +use crate::model::NodeShape; + +/// One lexical element of a flowchart line. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Item { + /// A node, either newly declared or a reference to an existing one. + Node { + /// Identifier as written. + id: String, + /// Label text (may equal the id). + label: String, + /// Outline shape. + shape: NodeShape, + }, + /// An edge operator, possibly carrying a label. + Edge { + /// Stroke style implied by the operator. + style: EdgeStyle, + /// Label written as `|text|` or `-- text -->`. + label: Option, + }, + /// The `&` separator between alternatives in `A & B --> C`. + And, +} + +/// Lexes one flowchart line. `Err` carries the diagnostic message. +pub(crate) fn lex(line: &str) -> std::result::Result, String> { + let mut c = Cursor { bytes: line.as_bytes(), pos: 0 }; + let mut items = Vec::new(); + loop { + c.skip_ws(); + let Some(b) = c.peek() else { return Ok(items) }; + if b == b'&' { + c.advance(); + items.push(Item::And); + } else if let Some(edge) = c.edge() { + items.push(Item::Edge { style: edge.style, label: edge.label }); + } else if matches!(b, b'-' | b'=') { + return Err("edge operator is missing its terminator".to_owned()); + } else { + items.push(c.node()?); + } + } +} + +/// Bytes between two positions, decoded lossily (never fails). +fn text(bytes: &[u8], from: usize, to: usize) -> String { + bytes.get(from..to).map_or_else(String::new, |b| String::from_utf8_lossy(b).into_owned()) +} + +struct Cursor<'a> { + bytes: &'a [u8], + pos: usize, +} + +struct EdgeToken { + style: EdgeStyle, + label: Option, +} + +impl Cursor<'_> { + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn advance(&mut self) { + self.pos = self.pos.saturating_add(1); + } + + fn matches(&self, pat: &str) -> bool { + self.bytes.get(self.pos..).is_some_and(|b| b.starts_with(pat.as_bytes())) + } + + fn skip_ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t')) { + self.advance(); + } + } + + /// Reads an edge operator at the cursor, if one is there. + fn edge(&mut self) -> Option { + let plain: [(&str, EdgeStyle); 4] = [("-.->", EdgeStyle::Dotted), ("==>", EdgeStyle::Thick), ("===", EdgeStyle::Thick), ("---", EdgeStyle::Solid)]; + for (pat, style) in plain { + if self.matches(pat) { + self.pos = self.pos.checked_add(pat.len())?; + return Some(self.finish_edge(EdgeToken { style, label: None })); + } + } + if self.matches("-->") { + self.advance(); + self.advance(); + self.advance(); + return Some(self.finish_edge(EdgeToken { style: EdgeStyle::Solid, label: None })); + } + if self.matches("--") { + return self.labelled_edge(); + } + None + } + + /// Reads `-- text -->`: scans forward for the terminating operator. + fn labelled_edge(&mut self) -> Option { + let start = self.pos.checked_add(2)?; + let terminators: [(&str, EdgeStyle); 5] = + [("-->", EdgeStyle::Solid), ("---", EdgeStyle::Solid), ("==>", EdgeStyle::Thick), ("===", EdgeStyle::Thick), ("-.->", EdgeStyle::Dotted)]; + let mut best: Option<(usize, usize, EdgeStyle)> = None; + for (pat, style) in terminators { + if let Some(found) = find(&self.bytes[start..], pat) { + let at = start.checked_add(found)?; + if best.is_none_or(|(b, _, _)| at < b) { + best = Some((at, pat.len(), style)); + } + } + } + let (at, len, style) = best?; + let label = text(self.bytes, start, at).trim().to_owned(); + self.pos = at.checked_add(len)?; + Some(self.finish_edge(EdgeToken { style, label: non_empty(label) })) + } + + /// Consumes an optional `|label|` following an edge operator. + fn finish_edge(&mut self, mut edge: EdgeToken) -> EdgeToken { + self.skip_ws(); + if self.peek() == Some(b'|') { + self.advance(); + let start = self.pos; + while let Some(b) = self.peek() { + self.advance(); + if b == b'|' { + break; + } + } + let label = text(self.bytes, start, self.pos.saturating_sub(1)).trim().to_owned(); + edge.label = non_empty(label); + } + edge + } + + /// Reads an identifier, then an optional shape block. + fn node(&mut self) -> std::result::Result { + let id = self.ident(); + if id.is_empty() { + return Err(format!("unexpected character `{}`", self.peek().map_or(' ', char::from))); + } + let (label, shape) = self.block()?; + Ok(Item::Node { label, id, shape }) + } + + fn ident(&mut self) -> String { + let start = self.pos; + while let Some(b) = self.peek() { + let dot_ok = b == b'.' && !matches!(self.bytes.get(self.pos.saturating_add(1)).copied(), Some(b'-' | b'=')); + if b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'/' | b'+') || dot_ok { + self.advance(); + } else { + break; + } + } + text(self.bytes, start, self.pos) + } + + /// Reads `[]`, `()`, `{}`, `(())` or `([])` after an identifier. + fn block(&mut self) -> std::result::Result<(String, NodeShape), String> { + self.skip_ws(); + let Some(outer) = self.peek().filter(|b| matches!(b, b'[' | b'(' | b'{')) else { + return Ok((String::new(), NodeShape::Rect)); + }; + let mut opens = vec![self.pos]; + self.advance(); + let mut depth = 1usize; + while let Some(b) = self.peek() { + self.advance(); + match b { + b'[' | b'(' | b'{' => { + depth = depth.saturating_add(1); + opens.push(self.pos.saturating_sub(1)); + } + b']' | b')' | b'}' => { + depth -= 1; + if depth == 0 { + let close = self.pos.saturating_sub(1); + let inner = opens.get(1).and_then(|i| self.bytes.get(*i).copied()); + let start = opens.last().and_then(|p| p.checked_add(1)).ok_or_else(|| "empty node label".to_owned())?; + return Ok((unquote(&text(self.bytes, start, close)), node_shape(outer, inner))); + } + } + _ => {} + } + } + Err("unterminated node shape block".to_owned()) + } +} + +fn non_empty(label: String) -> Option { + (!label.is_empty()).then_some(label) +} + +fn unquote(label: &str) -> String { + let trimmed = label.trim(); + trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')).map_or_else(|| trimmed.to_owned(), str::to_owned) +} + +fn node_shape(outer: u8, inner: Option) -> NodeShape { + match (outer, inner) { + (b'(', Some(b'(')) => NodeShape::Circle, + (b'(', Some(b'[')) => NodeShape::Database, + (b'(', None) => NodeShape::Round, + (b'[', None | Some(b'[')) => NodeShape::Rect, + _ => NodeShape::Rhombus, + } +} + +/// Byte offset of `pat` inside `haystack`, if present. +fn find(haystack: &[u8], pat: &str) -> Option { + let pat = pat.as_bytes(); + if pat.is_empty() || haystack.len() < pat.len() { + return None; + } + haystack.windows(pat.len()).position(|w| w == pat) +} + +#[cfg(test)] +mod tests { + use super::{Item, NodeShape, lex}; + + #[test] + fn lexes_nodes_and_edges() { + let items = lex("A[Start] --> B").unwrap_or_default(); + assert_eq!(items.len(), 3); + assert!(matches!(items.first(), Some(Item::Node { id, shape: NodeShape::Rect, .. }) if id == "A")); + assert!(matches!(items.get(1), Some(Item::Edge { style: crate::geom::EdgeStyle::Solid, label: None }))); + } + + #[test] + fn lexes_all_shapes() { + for (src, shape) in [ + ("A[x]", NodeShape::Rect), + ("A(x)", NodeShape::Round), + ("A{x}", NodeShape::Rhombus), + ("A((x))", NodeShape::Circle), + ("A([x])", NodeShape::Database), + ] { + let items = lex(src).unwrap_or_default(); + assert!(matches!(items.first(), Some(Item::Node { shape: s, .. }) if *s == shape), "{src} -> {items:?}"); + } + } + + #[test] + fn lexes_both_label_syntaxes() { + let pipe = lex("A -->|go| B").unwrap_or_default(); + assert!(matches!(pipe.get(1), Some(Item::Edge { label: Some(l), .. }) if l == "go")); + let text = lex("A -- go --> B").unwrap_or_default(); + assert!(matches!(text.get(1), Some(Item::Edge { label: Some(l), .. }) if l == "go")); + } + + #[test] + fn lexes_ampersand_chains() { + let items = lex("A & B --> C").unwrap_or_default(); + assert_eq!(items.len(), 5); + assert!(matches!(items.get(1), Some(Item::And))); + } + + #[test] + fn reports_bad_lines() { + assert!(lex("A[x").is_err()); + } +} diff --git a/crates/diagrams/src/lib.rs b/crates/diagrams/src/lib.rs index 80be530..57b2072 100644 --- a/crates/diagrams/src/lib.rs +++ b/crates/diagrams/src/lib.rs @@ -1,16 +1,24 @@ -//! Diagrams: Mermaid-subset parser, layout and SVG/egui shapes. +//! Diagrams: Mermaid-subset parser, layout and SVG output. //! -//! Supports flowchart/graph, sequence, class/state UML, ER, gantt, pie. -//! Unsupported syntax yields a diagnostic; the source is never dropped. +//! This part supports flowcharts (`flowchart`/`graph`); other Mermaid kinds are recognised and +//! returned as [`Diagram::Unsupported`]. Unsupported or malformed syntax yields a +//! [`Diagnostic`] and parsing continues; the source is never dropped ([`Diagram::source`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -// TODO(phase 2): Mermaid-subset parser, layout and SVG/egui shapes (flowchart, sequence, class/state UML, ER, gantt, pie). +mod error; +mod flowchart; +mod geom; +mod layout; +mod lex; +mod model; +mod parse; +mod rank; +mod svg; -#[cfg(test)] -mod tests { - #[test] - fn package_name_is_correct() { - assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-diagrams"); - } -} +pub use error::{Error, Result}; +pub use geom::{Align, EdgeStyle, Frame, Layout, Point, Shape, Text}; +pub use layout::layout; +pub use model::{Diagnostic, Diagram, Direction, Edge, Flowchart, Group, Node, NodeShape}; +pub use parse::{MAX_EDGES, MAX_INPUT_BYTES, MAX_NODES, MAX_SUBGRAPH_DEPTH, parse}; +pub use svg::{Palette, escape, to_svg, to_svg_with}; diff --git a/crates/diagrams/src/model.rs b/crates/diagrams/src/model.rs new file mode 100644 index 0000000..ea178f6 --- /dev/null +++ b/crates/diagrams/src/model.rs @@ -0,0 +1,156 @@ +//! Parsed-diagram model: diagnostics, diagrams and flowchart elements. + +use crate::geom::EdgeStyle; + +/// A non-fatal problem found while parsing. Parsing always continues after one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + /// One-based line number in the input. + pub line: usize, + /// Human-readable description of the problem. + pub message: String, +} + +impl Diagnostic { + /// Builds a diagnostic. + #[must_use] + pub fn new(line: usize, message: impl Into) -> Self { + Self { line, message: message.into() } + } +} + +/// A parsed diagram. Unimplemented kinds are kept as source plus a kind name. +#[derive(Debug, Clone, PartialEq)] +pub enum Diagram { + /// A supported flowchart. + Flowchart(Flowchart), + /// A recognised Mermaid kind that this build cannot render yet. + Unsupported { + /// Mermaid diagram kind as written in the header (for example `gantt`). + kind: String, + /// The original input, unchanged. + source: String, + }, +} + +impl Diagram { + /// Diagnostics collected while parsing. Always empty for [`Diagram::Unsupported`]. + #[must_use] + pub fn diagnostics(&self) -> &[Diagnostic] { + match self { + Self::Flowchart(f) => &f.diagnostics, + Self::Unsupported { .. } => &[], + } + } + + /// The input, unchanged, for every diagram kind. + #[must_use] + pub fn source(&self) -> &str { + match self { + Self::Flowchart(f) => f.source(), + Self::Unsupported { source, .. } => source, + } + } + + /// The Mermaid diagram kind, used for diagnostics and MCP tool results. + #[must_use] + pub fn kind(&self) -> &str { + match self { + Self::Flowchart(_) => "flowchart", + Self::Unsupported { kind, .. } => kind, + } + } +} + +/// Flow direction declared by the header. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Direction { + /// `TD` / `TB`: top to bottom. + #[default] + TopDown, + /// `BT`: bottom to top. + BottomTop, + /// `LR`: left to right. + LeftRight, + /// `RL`: right to left. + RightLeft, +} + +/// Shape drawn for a node, as written between brackets in the source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NodeShape { + /// `id[Label]`: square rectangle. + #[default] + Rect, + /// `id(Label)`: rounded rectangle. + Round, + /// `id{Label}`: rhombus. + Rhombus, + /// `id((Label))`: ellipse. + Circle, + /// `id([Label])`: cylinder drawn as a box with double side strokes. + Database, +} + +/// A node of a flowchart, addressed by index in [`Flowchart::nodes`]. +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + /// Identifier as written in the source. + pub id: String, + /// Display text (defaults to the id when no label was written). + pub label: String, + /// Node outline. + pub shape: NodeShape, + /// Id of the innermost subgraph containing this node, if any. + pub group: Option, +} + +/// An edge between two node indices. +#[derive(Debug, Clone, PartialEq)] +pub struct Edge { + /// Index of the source node in [`Flowchart::nodes`]. + pub from: usize, + /// Index of the target node in [`Flowchart::nodes`]. + pub to: usize, + /// Optional edge label. + pub label: Option, + /// Stroke style implied by the edge operator. + pub style: EdgeStyle, +} + +/// A `subgraph` block. Members are listed through [`Node::group`]. +#[derive(Debug, Clone, PartialEq)] +pub struct Group { + /// Stable id (`sub0`, `sub1`, ... for title-only subgraphs, else the written id). + pub id: String, + /// Title drawn at the top of the box. + pub title: String, + /// Nesting depth, `0` for a top-level subgraph. + pub depth: usize, + /// Index of the enclosing subgraph in [`Flowchart::groups`], if any. + pub parent: Option, +} + +/// A parsed flowchart. +#[derive(Debug, Clone, PartialEq)] +pub struct Flowchart { + /// Declared direction (`TopDown` when the header omits it). + pub direction: Direction, + /// Nodes in first-appearance order. + pub nodes: Vec, + /// Edges in source order. + pub edges: Vec, + /// Subgraphs in opening order, outermost first. + pub groups: Vec, + /// Non-fatal parse problems. + pub diagnostics: Vec, + pub(crate) source: String, +} + +impl Flowchart { + /// The input, unchanged. + #[must_use] + pub fn source(&self) -> &str { + &self.source + } +} diff --git a/crates/diagrams/src/parse.rs b/crates/diagrams/src/parse.rs new file mode 100644 index 0000000..d7aa4a6 --- /dev/null +++ b/crates/diagrams/src/parse.rs @@ -0,0 +1,122 @@ +//! Entry point: header dispatch, hard caps and line iteration. + +use crate::error::{Error, Result}; +use crate::flowchart::Builder; +use crate::model::{Diagram, Direction}; + +/// Largest input [`parse`] accepts (1 MiB). +pub const MAX_INPUT_BYTES: usize = 1024 * 1024; + +/// Largest node count a flowchart may hold. +pub const MAX_NODES: usize = 5_000; + +/// Largest edge count a flowchart may hold. +pub const MAX_EDGES: usize = 20_000; + +/// Largest `subgraph` nesting depth (8 levels). +pub const MAX_SUBGRAPH_DEPTH: usize = 8; + +/// Mermaid kinds recognised by v1 that later parts of this crate will implement. +const RECOGNISED_OTHER_HEADERS: [&str; 7] = ["sequencediagram", "classdiagram", "statediagram-v2", "statediagram", "erdiagram", "gantt", "pie"]; + +/// Parses Mermaid source into a [`Diagram`]. +/// +/// A flowchart is fully parsed. Other recognised Mermaid kinds come back as +/// [`Diagram::Unsupported`] with the source intact, never as an error. Text that +/// cannot be understood becomes a diagnostic on the flowchart it belongs to. +/// +/// # Errors +/// [`Error::TooLarge`] past [`MAX_INPUT_BYTES`], [`Error::NoHeader`] without a known header, and +/// [`Error::LimitExceeded`] when a structural cap is hit. +pub fn parse(src: &str) -> Result { + if src.len() > MAX_INPUT_BYTES { + return Err(Error::TooLarge { len: src.len(), max: MAX_INPUT_BYTES }); + } + let Some((header_no, header)) = find_header(src) else { return Err(Error::NoHeader) }; + let (kind, rest) = split_header(&header); + let lower = kind.to_ascii_lowercase(); + if lower != "flowchart" && lower != "graph" { + return if RECOGNISED_OTHER_HEADERS.contains(&lower.as_str()) { + Ok(Diagram::Unsupported { kind, source: src.to_owned() }) + } else { + Err(Error::NoHeader) + }; + } + + let mut builder = Builder::new(src.to_owned()); + let rest = rest.trim().trim_end_matches(';').trim(); + match direction(rest) { + Some(dir) => builder.set_direction(dir), + None if rest.is_empty() => {} + None => builder.diagnostic(header_no + 1, format!("unknown flowchart direction `{rest}`")), + } + for (i, line) in src.lines().enumerate().skip(header_no.saturating_add(1)) { + builder.line(i.saturating_add(1), line)?; + } + builder.close_open_subgraphs(); + Ok(Diagram::Flowchart(builder.finish())) +} + +/// Parses `TD`, `TB`, `BT`, `LR` or `RL` (case-insensitive). +fn direction(token: &str) -> Option { + match token.to_ascii_uppercase().as_str() { + "TD" | "TB" => Some(Direction::TopDown), + "BT" => Some(Direction::BottomTop), + "LR" => Some(Direction::LeftRight), + "RL" => Some(Direction::RightLeft), + _ => None, + } +} + +/// First non-blank, non-comment line as `(zero-based index, trimmed text)`. +fn find_header(src: &str) -> Option<(usize, String)> { + src.lines().enumerate().find_map(|(i, line)| { + let trimmed = line.trim(); + (!trimmed.is_empty() && !trimmed.starts_with("%%")).then(|| (i, trimmed.to_owned())) + }) +} + +/// Splits `flowchart TD` into its kind and the remainder. +fn split_header(header: &str) -> (String, &str) { + let trimmed = header.trim_start_matches('%'); + match trimmed.split_once(char::is_whitespace) { + Some((kind, rest)) => (kind.trim().to_owned(), rest), + None => (trimmed.to_owned(), ""), + } +} + +#[cfg(test)] +mod tests { + use super::{direction, find_header, parse, split_header}; + use crate::error::Error; + use crate::model::{Diagram, Direction}; + + #[test] + fn empty_and_garbage_have_no_header() { + assert!(matches!(parse(""), Err(Error::NoHeader))); + assert!(matches!(parse("banana\n A --> B"), Err(Error::NoHeader))); + assert!(find_header("%% just a comment").is_none()); + } + + #[test] + fn recognised_but_unimplemented_kinds_are_unsupported() { + let d = parse("sequenceDiagram\n A->>B: hi").unwrap_or_else(|_| panic!("expected Unsupported")); + assert!(matches!(d, Diagram::Unsupported { ref kind, ref source } if kind == "sequenceDiagram" && source.contains("A->>B"))); + assert_eq!(d.source(), "sequenceDiagram\n A->>B: hi"); + assert_eq!(d.diagnostics(), []); + } + + #[test] + fn too_large_input_is_rejected() { + let big = "a".repeat(super::MAX_INPUT_BYTES + 1); + assert!(matches!(parse(&big), Err(Error::TooLarge { .. }))); + } + + #[test] + fn header_helpers() { + assert_eq!(direction("lr"), Some(Direction::LeftRight)); + assert_eq!(direction("nope"), None); + assert_eq!(split_header("flowchart TD"), ("flowchart".to_owned(), "TD")); + assert_eq!(split_header("gantt").0, "gantt"); + } +} diff --git a/crates/diagrams/src/rank.rs b/crates/diagrams/src/rank.rs new file mode 100644 index 0000000..b24d8f9 --- /dev/null +++ b/crates/diagrams/src/rank.rs @@ -0,0 +1,187 @@ +//! Layer assignment and in-layer ordering for flowcharts (Sugiyama-lite). +//! +//! Everything here is iterative and deterministic: no recursion on input depth, no hash +//! iteration order, and no indexing with computed values. + +/// Number of barycenter sweeps over the layers. +const SWEEPS: usize = 4; + +fn at(values: &[usize], i: usize) -> usize { + values.get(i).copied().unwrap_or(0) +} + +fn set(values: &mut [usize], i: usize, v: usize) { + if let Some(slot) = values.get_mut(i) { + *slot = v; + } +} + +/// Edges oriented so that the graph is acyclic: back edges found by an iterative DFS are reversed. +/// Self loops are dropped. Order of the result follows the input order. +pub(crate) fn acyclic(n: usize, edges: &[(usize, usize)]) -> Vec<(usize, usize)> { + let mut adj: Vec> = vec![Vec::new(); n]; + for (i, &(from, _)) in edges.iter().enumerate() { + if let Some(list) = adj.get_mut(from) { + list.push(i); + } + } + // 0 = unvisited, 1 = on the DFS stack, 2 = done. + let mut state = vec![0u8; n]; + let mut reversed = vec![false; edges.len()]; + for root in 0..n { + if state.get(root).copied() != Some(0) { + continue; + } + let mut stack: Vec<(usize, usize)> = vec![(root, 0)]; + if let Some(s) = state.get_mut(root) { + *s = 1; + } + while let Some(&(node, next)) = stack.last() { + let edge_ids = adj.get(node).map_or(&[][..], Vec::as_slice); + let Some(&edge_id) = edge_ids.get(next) else { + if let Some(s) = state.get_mut(node) { + *s = 2; + } + stack.pop(); + continue; + }; + if let Some(top) = stack.last_mut() { + top.1 += 1; + } + let Some(&(_, to)) = edges.get(edge_id) else { continue }; + match state.get(to).copied() { + Some(0) => { + if let Some(s) = state.get_mut(to) { + *s = 1; + } + stack.push((to, 0)); + } + Some(1) => { + if let Some(r) = reversed.get_mut(edge_id) { + *r = true; + } + } + _ => {} + } + } + } + edges.iter().zip(&reversed).filter(|((from, to), _)| from != to).map(|(&(from, to), &rev)| if rev { (to, from) } else { (from, to) }).collect() +} + +/// Longest-path ranks of an acyclic edge list (Kahn order, so no recursion). +pub(crate) fn ranks(n: usize, dag: &[(usize, usize)]) -> Vec { + let mut indegree = vec![0usize; n]; + let mut out: Vec> = vec![Vec::new(); n]; + for &(from, to) in dag { + if let Some(d) = indegree.get_mut(to) { + *d += 1; + } + if let Some(list) = out.get_mut(from) { + list.push(to); + } + } + let mut rank = vec![0usize; n]; + let mut queue: Vec = (0..n).filter(|&i| at(&indegree, i) == 0).collect(); + let mut head = 0; + while let Some(&node) = queue.get(head) { + head += 1; + let r = at(&rank, node); + for &to in out.get(node).map_or(&[][..], Vec::as_slice) { + if at(&rank, to) < r + 1 { + set(&mut rank, to, r + 1); + } + if let Some(d) = indegree.get_mut(to) { + *d = d.saturating_sub(1); + if *d == 0 { + queue.push(to); + } + } + } + } + rank +} + +/// Nodes grouped per rank and ordered by repeated barycenter sweeps. +pub(crate) fn layers(n: usize, rank: &[usize], dag: &[(usize, usize)]) -> Vec> { + let depth = rank.iter().copied().max().map_or(0, |m| m + 1); + let mut layers: Vec> = vec![Vec::new(); depth]; + for node in 0..n { + if let Some(layer) = layers.get_mut(at(rank, node)) { + layer.push(node); + } + } + let mut preds: Vec> = vec![Vec::new(); n]; + let mut succs: Vec> = vec![Vec::new(); n]; + for &(from, to) in dag { + if let Some(p) = preds.get_mut(to) { + p.push(from); + } + if let Some(s) = succs.get_mut(from) { + s.push(to); + } + } + let mut pos = vec![0usize; n]; + refresh(&layers, &mut pos); + for sweep in 0..SWEEPS { + let downward = sweep % 2 == 0; + let order: Vec = if downward { (0..depth).collect() } else { (0..depth).rev().collect() }; + for r in order { + let neighbours = if downward { &preds } else { &succs }; + let Some(layer) = layers.get_mut(r) else { continue }; + let mut keyed: Vec<(usize, usize, usize)> = layer + .iter() + .enumerate() + .map(|(i, &node)| { + let adj = neighbours.get(node).map_or(&[][..], Vec::as_slice); + let sum: usize = adj.iter().map(|&m| at(&pos, m) * 2 + 1).sum(); + // Scaled barycenter; nodes without neighbours keep their slot. + let key = if adj.is_empty() { i * 2 + 1 } else { sum / adj.len() }; + (key, node, i) + }) + .collect(); + keyed.sort_unstable(); + *layer = keyed.into_iter().map(|(_, node, _)| node).collect(); + refresh_layer(layer, &mut pos); + } + } + layers +} + +fn refresh(layers: &[Vec], pos: &mut [usize]) { + for layer in layers { + refresh_layer(layer, pos); + } +} + +fn refresh_layer(layer: &[usize], pos: &mut [usize]) { + for (i, &node) in layer.iter().enumerate() { + set(pos, node, i); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cycle_is_broken_and_ranked() { + let dag = acyclic(3, &[(0, 1), (1, 2), (2, 0)]); + assert_eq!(dag.len(), 3); + let r = ranks(3, &dag); + assert_eq!(r.iter().copied().max(), Some(2), "a 3-cycle becomes a chain: {r:?}"); + } + + #[test] + fn self_loops_are_dropped() { + assert_eq!(acyclic(1, &[(0, 0)]), Vec::<(usize, usize)>::new()); + } + + #[test] + fn diamond_has_three_ranks() { + let dag = [(0, 1), (0, 2), (1, 3), (2, 3)]; + assert_eq!(ranks(4, &dag), vec![0, 1, 1, 2]); + let layers = layers(4, &ranks(4, &dag), &dag); + assert_eq!(layers.len(), 3); + assert_eq!(layers.get(1).map(Vec::len), Some(2)); + } +} diff --git a/crates/diagrams/src/svg.rs b/crates/diagrams/src/svg.rs new file mode 100644 index 0000000..9e8eaad --- /dev/null +++ b/crates/diagrams/src/svg.rs @@ -0,0 +1,169 @@ +//! SVG output for a [`Layout`]: plain escaped markup, no scripts, no external references. + +use std::fmt::Write as _; + +use crate::geom::{Align, EdgeStyle, Frame, Layout, Point, Shape, Text}; + +/// Colours used by [`to_svg`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Palette { + /// Page background. + pub background: String, + /// Node fill. + pub node_fill: String, + /// Node outline. + pub node_stroke: String, + /// Text colour. + pub text: String, + /// Edge stroke and arrow head. + pub edge: String, + /// Subgraph fill. + pub group_fill: String, + /// Subgraph outline. + pub group_stroke: String, +} + +impl Default for Palette { + fn default() -> Self { + Self { + background: "#ffffff".into(), + node_fill: "#eef1ff".into(), + node_stroke: "#5b47d6".into(), + text: "#1b1b21".into(), + edge: "#5f5f6e".into(), + group_fill: "#f7f7f9".into(), + group_stroke: "#bcc0cc".into(), + } + } +} + +/// Escapes text for use in SVG text nodes and attribute values. +#[must_use] +pub fn escape(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_str("""), + '\'' => out.push_str("'"), + c if c.is_control() && c != '\n' => {} + c => out.push(c), + } + } + out +} + +/// Renders a layout as a standalone SVG document with the default palette. +#[must_use] +pub fn to_svg(layout: &Layout) -> String { + to_svg_with(layout, &Palette::default()) +} + +/// Renders a layout as a standalone SVG document. +#[must_use] +pub fn to_svg_with(layout: &Layout, p: &Palette) -> String { + let mut s = String::new(); + let (w, h) = (layout.width.ceil(), layout.height.ceil()); + let _ = write!( + s, + "\ +\ +", + edge = escape(&p.edge), + bg = escape(&p.background), + ); + for shape in &layout.shapes { + shape_svg(&mut s, shape, p); + } + s.push_str(""); + s +} + +fn shape_svg(s: &mut String, shape: &Shape, p: &Palette) { + let (fill, stroke) = (escape(&p.node_fill), escape(&p.node_stroke)); + match shape { + Shape::Box { frame, radius, text } => { + let _ = write!( + s, + "", + frame.x, frame.y, frame.width, frame.height + ); + text_svg(s, text, p); + } + Shape::Diamond { frame, text } => { + let f: &Frame = frame; + let _ = write!( + s, + "", + f.center_x(), + f.y, + f.right(), + f.center_y(), + f.center_x(), + f.bottom(), + f.x, + f.center_y() + ); + text_svg(s, text, p); + } + Shape::Circle { frame, text } => { + let _ = write!( + s, + "", + frame.center_x(), + frame.center_y(), + frame.width / 2.0, + frame.height / 2.0 + ); + text_svg(s, text, p); + } + Shape::Edge { points, label, style } => { + let pts: Vec = points.iter().map(|Point { x, y }| format!("{x},{y}")).collect(); + let (width, dash) = match style { + EdgeStyle::Solid => (1.5, ""), + EdgeStyle::Dotted => (1.5, " stroke-dasharray=\"4 4\""), + EdgeStyle::Thick => (3.0, ""), + }; + let _ = write!( + s, + "", + pts.join(" "), + escape(&p.edge) + ); + if let Some(label) = label { + text_svg(s, label, p); + } + } + Shape::Text { text } => text_svg(s, text, p), + Shape::Group { frame, title } => { + let _ = write!( + s, + "", + frame.x, + frame.y, + frame.width, + frame.height, + escape(&p.group_fill), + escape(&p.group_stroke) + ); + text_svg(s, title, p); + } + } +} + +fn text_svg(s: &mut String, t: &Text, p: &Palette) { + let anchor = match t.align { + Align::Middle => "middle", + Align::Start => "start", + }; + let _ = write!( + s, + "{}", + t.at.x, + t.at.y, + escape(&p.text), + escape(&t.content) + ); +} diff --git a/crates/diagrams/tests/fixtures/chains.mmd b/crates/diagrams/tests/fixtures/chains.mmd new file mode 100644 index 0000000..f89fbfb --- /dev/null +++ b/crates/diagrams/tests/fixtures/chains.mmd @@ -0,0 +1,18 @@ +%% chains, `&`, both label syntaxes and nested subgraphs +graph LR + A --> B --> C + D & E --> F + G -->|pipe label| H + I -- inline label --> J + K -.-> L + M ==> N + O --- P + subgraph outer [Outer box] + direction TB + subgraph inner + Q --> R + end + S --> T + end + C --> Q + T --> Z \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/cyclic.mmd b/crates/diagrams/tests/fixtures/cyclic.mmd new file mode 100644 index 0000000..34785d3 --- /dev/null +++ b/crates/diagrams/tests/fixtures/cyclic.mmd @@ -0,0 +1,6 @@ +flowchart TD + A --> B + B --> C + C --> A + A --> D + D --> B \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/escaping.mmd b/crates/diagrams/tests/fixtures/escaping.mmd new file mode 100644 index 0000000..e64b055 --- /dev/null +++ b/crates/diagrams/tests/fixtures/escaping.mmd @@ -0,0 +1,3 @@ +flowchart TD + A[""] --> B["a & b < c > d \"quoted\""] + B --> C[plain] \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/malformed.mmd b/crates/diagrams/tests/fixtures/malformed.mmd new file mode 100644 index 0000000..f390e38 --- /dev/null +++ b/crates/diagrams/tests/fixtures/malformed.mmd @@ -0,0 +1,9 @@ +flowchart TD + A[ok] --> ][ + ~~~ not an edge ~~~ + B --> C + subgraph + C --> D + ?? + D --> E( ) + E --> F \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/sequence.mmd b/crates/diagrams/tests/fixtures/sequence.mmd new file mode 100644 index 0000000..c030194 --- /dev/null +++ b/crates/diagrams/tests/fixtures/sequence.mmd @@ -0,0 +1,3 @@ +sequenceDiagram + Alice->>Bob: Hello Bob, how are you? + Bob-->>Alice: Fine! \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/shapes.mmd b/crates/diagrams/tests/fixtures/shapes.mmd new file mode 100644 index 0000000..5ebe5b3 --- /dev/null +++ b/crates/diagrams/tests/fixtures/shapes.mmd @@ -0,0 +1,7 @@ +%% every node shape of the subset +flowchart TD + A[Start] --> B(Round) + B --> C{Diamond} + C --> D((Circle)) + D --> E([Stadium]) + E --> F[End] \ No newline at end of file diff --git a/crates/diagrams/tests/flowchart.rs b/crates/diagrams/tests/flowchart.rs new file mode 100644 index 0000000..56c0562 --- /dev/null +++ b/crates/diagrams/tests/flowchart.rs @@ -0,0 +1,116 @@ +//! Parse, layout and SVG behaviour of flowcharts, driven by the fixtures in `tests/fixtures`. + +use std::fmt::Write as _; + +use notescraft_diagrams::{Diagram, Error, NodeShape, Shape, escape, layout, parse, to_svg}; + +fn fixture(name: &str) -> String { + std::fs::read_to_string(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap_or_default() +} + +fn flow(src: &str) -> notescraft_diagrams::Flowchart { + match parse(src) { + Ok(Diagram::Flowchart(f)) => f, + other => panic!("expected a flowchart, got {other:?}"), + } +} + +#[test] +fn shapes_are_recognised() { + let f = flow(&fixture("shapes.mmd")); + let shapes: Vec = f.nodes.iter().map(|n| n.shape).collect(); + for want in [NodeShape::Rect, NodeShape::Round, NodeShape::Rhombus, NodeShape::Circle, NodeShape::Database] { + assert!(shapes.contains(&want), "missing {want:?} in {shapes:?}"); + } +} + +#[test] +fn chains_ampersands_labels_and_subgraphs() { + let f = flow(&fixture("chains.mmd")); + assert!(f.edges.len() >= 12, "edges: {}", f.edges.len()); + assert!(f.edges.iter().any(|e| e.label.as_deref() == Some("pipe label"))); + assert!(f.edges.iter().any(|e| e.label.as_deref() == Some("inline label"))); + assert_eq!(f.groups.len(), 2); + assert_eq!(f.groups.iter().map(|g| g.depth).max(), Some(1)); +} + +#[test] +fn malformed_lines_become_diagnostics() { + let d = parse(&fixture("malformed.mmd")).unwrap_or_else(|e| panic!("{e}")); + assert_ne!(d.diagnostics(), []); + assert!(!flow(&fixture("malformed.mmd")).edges.is_empty(), "good lines after bad ones still parse"); +} + +#[test] +fn headers_and_source() { + assert!(matches!(parse("no header here"), Err(Error::NoHeader))); + assert!(matches!(parse(""), Err(Error::NoHeader))); + let seq = fixture("sequence.mmd"); + let d = parse(&seq).unwrap_or_else(|e| panic!("{e}")); + assert!(matches!(d, Diagram::Unsupported { .. })); + assert_eq!(d.source(), seq); + assert!(matches!(layout(&d), Err(Error::NotLayoutable { .. }))); + let src = fixture("chains.mmd"); + assert_eq!(parse(&src).unwrap_or_else(|e| panic!("{e}")).source(), src); +} + +#[test] +fn oversized_input_is_rejected() { + let big = format!("flowchart TD\n{}", "A --> B\n".repeat(200_000)); + assert!(matches!(parse(&big), Err(Error::TooLarge { .. }))); +} + +#[test] +fn cyclic_graph_lays_out() { + let d = parse(&fixture("cyclic.mmd")).unwrap_or_else(|e| panic!("{e}")); + let l = layout(&d).unwrap_or_else(|e| panic!("{e}")); + assert!(l.width > 0.0 && l.height > 0.0); +} + +#[test] +fn layout_is_deterministic_and_ordered_back_to_front() { + let d = parse(&fixture("chains.mmd")).unwrap_or_else(|e| panic!("{e}")); + let (a, b) = (layout(&d).unwrap_or_else(|e| panic!("{e}")), layout(&d).unwrap_or_else(|e| panic!("{e}"))); + assert_eq!(a, b); + let first_node = a.shapes.iter().position(|s| matches!(s, Shape::Box { .. } | Shape::Diamond { .. } | Shape::Circle { .. })); + let last_edge = a.shapes.iter().rposition(|s| matches!(s, Shape::Edge { .. })); + assert!(last_edge < first_node, "edges are drawn before nodes"); + assert!(a.shapes.iter().any(|s| matches!(s, Shape::Group { .. }))); +} + +#[test] +fn directions_change_the_orientation() { + let td = layout(&parse("flowchart TD\nA --> B --> C").unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}")); + let lr = layout(&parse("flowchart LR\nA --> B --> C").unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}")); + assert!(td.height > td.width, "TD grows downwards: {}x{}", td.width, td.height); + assert!(lr.width > lr.height, "LR grows rightwards: {}x{}", lr.width, lr.height); +} + +#[test] +fn svg_is_escaped() { + let d = parse(&fixture("escaping.mmd")).unwrap_or_else(|e| panic!("{e}")); + let svg = to_svg(&layout(&d).unwrap_or_else(|e| panic!("{e}"))); + assert!(!svg.contains("")); + assert_eq!(escape("&'"), "<a href="x">&'"); +} + +#[test] +fn self_loop_lays_out() { + let l = layout(&parse("flowchart TD\nA --> A").unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}")); + assert!(l.shapes.iter().any(|s| matches!(s, Shape::Edge { points, .. } if points.len() > 2))); +} + +#[test] +#[ignore = "perf: run with --release -- --ignored"] +fn thousand_node_chain_lays_out_under_a_second() { + let mut src = String::from("flowchart TD\n"); + for i in 0..1000 { + let _ = writeln!(src, "N{i} --> N{}", i + 1); + } + let start = std::time::Instant::now(); + let l = layout(&parse(&src).unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}")); + let took = start.elapsed(); + eprintln!("1000-node chain: {took:?}, {} shapes", l.shapes.len()); + assert!(took.as_secs_f64() < 1.0, "{took:?}"); +}