From c80c6cad4c684311fb20096fa21f3201a25c427d Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 13 Oct 2026 19:45:22 -0300 Subject: [PATCH] feat(diagrams): wave 3 Co-Authored-By: Claude Sonnet 5.5 --- crates/diagrams/src/geom.rs | 2 + crates/diagrams/src/layout.rs | 3 +- crates/diagrams/src/lib.rs | 15 +- crates/diagrams/src/model.rs | 185 ++++++++++ crates/diagrams/src/parse.rs | 11 +- crates/diagrams/src/seq_layout.rs | 339 ++++++++++++++++++ crates/diagrams/src/sequence.rs | 325 +++++++++++++++++ crates/diagrams/src/svg.rs | 16 +- .../tests/fixtures/sequence_basics.mmd | 14 + .../tests/fixtures/sequence_blocks.mmd | 25 ++ .../tests/fixtures/sequence_malformed.mmd | 11 + .../tests/fixtures/sequence_notes.mmd | 10 + crates/diagrams/tests/flowchart.rs | 4 +- crates/diagrams/tests/sequence.rs | 250 +++++++++++++ 14 files changed, 1188 insertions(+), 22 deletions(-) create mode 100644 crates/diagrams/src/seq_layout.rs create mode 100644 crates/diagrams/src/sequence.rs create mode 100644 crates/diagrams/tests/fixtures/sequence_basics.mmd create mode 100644 crates/diagrams/tests/fixtures/sequence_blocks.mmd create mode 100644 crates/diagrams/tests/fixtures/sequence_malformed.mmd create mode 100644 crates/diagrams/tests/fixtures/sequence_notes.mmd create mode 100644 crates/diagrams/tests/sequence.rs diff --git a/crates/diagrams/src/geom.rs b/crates/diagrams/src/geom.rs index 33de65c..b7e0ed2 100644 --- a/crates/diagrams/src/geom.rs +++ b/crates/diagrams/src/geom.rs @@ -91,6 +91,8 @@ pub enum EdgeStyle { Dotted, /// Thick line with an arrow head. Thick, + /// Thin dashed line without an arrow head (sequence lifelines). + Plain, } /// One drawn primitive. Rendering is a pure function of these values. diff --git a/crates/diagrams/src/layout.rs b/crates/diagrams/src/layout.rs index cfd875f..4e09899 100644 --- a/crates/diagrams/src/layout.rs +++ b/crates/diagrams/src/layout.rs @@ -3,7 +3,7 @@ use crate::error::{Error, Result}; use crate::geom::{Align, Frame, Layout, Point, Shape, Text}; use crate::model::{Diagram, Direction, Flowchart, NodeShape}; -use crate::rank; +use crate::{rank, seq_layout}; const MARGIN: f64 = 24.0; const NODE_HEIGHT: f64 = 40.0; @@ -25,6 +25,7 @@ const LOOP_SIZE: f64 = 24.0; pub fn layout(diagram: &Diagram) -> Result { match diagram { Diagram::Flowchart(f) => Ok(flowchart(f)), + Diagram::Sequence(s) => Ok(seq_layout::sequence(s)), Diagram::Unsupported { kind, .. } => Err(Error::NotLayoutable { kind: kind.clone() }), } } diff --git a/crates/diagrams/src/lib.rs b/crates/diagrams/src/lib.rs index 57b2072..38e6c07 100644 --- a/crates/diagrams/src/lib.rs +++ b/crates/diagrams/src/lib.rs @@ -1,8 +1,9 @@ //! Diagrams: Mermaid-subset parser, layout and SVG output. //! -//! 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`]). +//! This part supports flowcharts (`flowchart`/`graph`) and sequence diagrams (`sequenceDiagram`); +//! 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)] @@ -14,11 +15,17 @@ mod lex; mod model; mod parse; mod rank; +mod seq_layout; +mod sequence; mod svg; 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 model::{ + ArrowKind, Block, BlockKind, Diagnostic, Diagram, Direction, Edge, Event, Flowchart, Group, Message, Node, NodeShape, Note, NotePosition, Participant, + Section, SequenceDiagram, +}; pub use parse::{MAX_EDGES, MAX_INPUT_BYTES, MAX_NODES, MAX_SUBGRAPH_DEPTH, parse}; +pub use sequence::{MAX_BLOCK_DEPTH, MAX_EVENTS, MAX_PARTICIPANTS}; pub use svg::{Palette, escape, to_svg, to_svg_with}; diff --git a/crates/diagrams/src/model.rs b/crates/diagrams/src/model.rs index ea178f6..c23f206 100644 --- a/crates/diagrams/src/model.rs +++ b/crates/diagrams/src/model.rs @@ -24,6 +24,8 @@ impl Diagnostic { pub enum Diagram { /// A supported flowchart. Flowchart(Flowchart), + /// A supported sequence diagram. + Sequence(SequenceDiagram), /// A recognised Mermaid kind that this build cannot render yet. Unsupported { /// Mermaid diagram kind as written in the header (for example `gantt`). @@ -39,6 +41,7 @@ impl Diagram { pub fn diagnostics(&self) -> &[Diagnostic] { match self { Self::Flowchart(f) => &f.diagnostics, + Self::Sequence(s) => &s.diagnostics, Self::Unsupported { .. } => &[], } } @@ -48,6 +51,7 @@ impl Diagram { pub fn source(&self) -> &str { match self { Self::Flowchart(f) => f.source(), + Self::Sequence(s) => s.source(), Self::Unsupported { source, .. } => source, } } @@ -57,6 +61,7 @@ impl Diagram { pub fn kind(&self) -> &str { match self { Self::Flowchart(_) => "flowchart", + Self::Sequence(_) => "sequenceDiagram", Self::Unsupported { kind, .. } => kind, } } @@ -154,3 +159,183 @@ impl Flowchart { &self.source } } + +// ---- Sequence diagram model ---- + +/// A participant as declared by `participant`/`actor` (or implied by a message). +#[derive(Debug, Clone, PartialEq)] +pub struct Participant { + /// Identifier used to address the participant in the source. + pub id: String, + /// Text drawn in the participant box (an alias, else the id). + pub display: String, + /// True when declared with `actor`. + pub actor: bool, +} + +/// Arrow operator written between two participants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArrowKind { + /// `->`. + Solid, + /// `->>`. + SolidFilled, + /// `-->`. + Dashed, + /// `-->>`. + DashedFilled, + /// `-x`. + Cross, + /// `--x`. + DashedCross, + /// `-)`. + Open, + /// `--)`. + DashedOpen, +} + +impl ArrowKind { + /// True when the operator is written with dashes (`--` prefix variants). + #[must_use] + pub fn is_dashed(self) -> bool { + matches!(self, Self::Dashed | Self::DashedFilled | Self::DashedCross | Self::DashedOpen) + } + + /// Keyword list ordered longest-first: the first match on a message head wins. + pub(crate) const OPERATORS: [(&'static str, Self); 8] = [ + ("-->>", Self::DashedFilled), + ("-->", Self::Dashed), + ("--x", Self::DashedCross), + ("--)", Self::DashedOpen), + ("->>", Self::SolidFilled), + ("->", Self::Solid), + ("-x", Self::Cross), + ("-)", Self::Open), + ]; +} + +/// One message between two participants. +#[derive(Debug, Clone, PartialEq)] +pub struct Message { + /// Index of the sender in [`SequenceDiagram::participants`]. + pub from: usize, + /// Index of the receiver in [`SequenceDiagram::participants`]. + pub to: usize, + /// Text after the `:` (may be empty). + pub text: String, + /// Arrow operator as written. + pub arrow: ArrowKind, + /// Automatic message number when `autonumber` is active. + pub number: Option, +} + +/// Placement of a note relative to its participants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotePosition { + /// `Note over A,B`. + Over, + /// `Note left of A`. + Left, + /// `Note right of A`. + Right, +} + +/// A note box. +#[derive(Debug, Clone, PartialEq)] +pub struct Note { + /// Where to draw the note. + pub position: NotePosition, + /// Participant indices (all of them for `Over`, one for `Left`/`Right`). + pub targets: Vec, + /// Text after the `:`. + pub text: String, +} + +/// Keyword that opens a framed block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockKind { + /// `loop`. + Loop, + /// `alt`. + Alt, + /// `opt`. + Opt, + /// `par`. + Par, + /// `critical`. + Critical, + /// `break`. + Break, +} + +impl BlockKind { + /// The keyword as written in the source. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Loop => "loop", + Self::Alt => "alt", + Self::Opt => "opt", + Self::Par => "par", + Self::Critical => "critical", + Self::Break => "break", + } + } +} + +/// One branch or alternative inside a block (`else ...`, `and ...`). +#[derive(Debug, Clone, PartialEq)] +pub struct Section { + /// Keyword (`else` or `and`). + pub keyword: &'static str, + /// Text after the keyword. + pub label: String, +} + +/// A framed block that contains nested events until the matching `end`. +#[derive(Debug, Clone, PartialEq)] +pub struct Block { + /// Kind as written in the source keyword. + pub kind: BlockKind, + /// Text after the keyword (may be empty). + pub label: String, + /// Events of the block, in source order. + pub children: Vec, +} + +/// One statement of a sequence diagram, in source order. +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + /// A message between two participants. + Message(Message), + /// A note box. + Note(Note), + /// A framed block; its rows continue until the matching `end`. + Block(Block), + /// A branch divider (`else`/`and`) inside a block. + Section(Section), + /// `activate P` (or a `+` suffix on a message target). + Activate { participant: usize }, + /// `deactivate P` (or a `-` suffix on a message target). + Deactivate { participant: usize }, +} + +/// A parsed sequence diagram. +#[derive(Debug, Clone, PartialEq)] +pub struct SequenceDiagram { + /// Participants in first-appearance order. + pub participants: Vec, + /// Events in source order; blocks nest through [`Event::Block`]. + pub events: Vec, + /// Non-fatal parse problems. + pub diagnostics: Vec, + pub(crate) source: String, +} + +impl SequenceDiagram { + /// 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 index d7aa4a6..76a73db 100644 --- a/crates/diagrams/src/parse.rs +++ b/crates/diagrams/src/parse.rs @@ -36,13 +36,14 @@ pub fn parse(src: &str) -> Result { 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()) { + return if lower == "sequencediagram" { + crate::sequence::parse(src.to_owned(), header_no).map(Diagram::Sequence) + } else 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) { @@ -100,9 +101,9 @@ mod tests { #[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"); + let d = parse("classDiagram\n class A").unwrap_or_else(|_| panic!("expected Unsupported")); + assert!(matches!(d, Diagram::Unsupported { ref kind, ref source } if kind == "classDiagram" && source.contains("class A"))); + assert_eq!(d.source(), "classDiagram\n class A"); assert_eq!(d.diagnostics(), []); } diff --git a/crates/diagrams/src/seq_layout.rs b/crates/diagrams/src/seq_layout.rs new file mode 100644 index 0000000..bf23aed --- /dev/null +++ b/crates/diagrams/src/seq_layout.rs @@ -0,0 +1,339 @@ +//! Sequence diagram placement: participant columns, lifelines, event rows and blocks. + +use std::fmt::Write as _; + +use crate::geom::{Align, EdgeStyle, Frame, Layout, Point, Shape, Text}; +use crate::model::{ArrowKind, Event, Note, NotePosition, SequenceDiagram}; + +const MARGIN: f64 = 24.0; +const CHAR_WIDTH: f64 = 7.5; +const BOX_HEIGHT: f64 = 40.0; +const BOX_PAD: f64 = 24.0; +const MIN_BOX: f64 = 90.0; +const COL_GAP: f64 = 60.0; +const ROW_HEIGHT: f64 = 52.0; +const ROW_TOP_PAD: f64 = 16.0; +const NOTE_HEIGHT: f64 = 36.0; +const NOTE_PAD: f64 = 24.0; +const MIN_NOTE: f64 = 60.0; +const NOTE_GAP: f64 = 30.0; +const BAR_WIDTH: f64 = 12.0; +const SELF_LOOP: f64 = 30.0; +const BLOCK_PAD: f64 = 10.0; +const LIFELINE_PAD: f64 = 28.0; + +/// One laid-out event row. +enum RowItem { + /// Horizontal arrow, self-loop when `from == to`. + Message { from: usize, to: usize, arrow: ArrowKind, label: String }, + /// Note box. + Note { position: NotePosition, targets: Vec, text: String }, + /// Branch divider text. + Section { text: String }, +} + +/// A block frame spanning rows `start..end`. +struct BlockSpan { + title: String, + start: usize, + end: usize, +} + +/// A vertical activation bar from its activation row to its deactivation row. +struct Bar { + participant: usize, + start: usize, + end: usize, +} + +/// Lays a parsed sequence diagram out; never fails. +pub(crate) fn sequence(sd: &SequenceDiagram) -> Layout { + if sd.participants.is_empty() { + return Layout { shapes: Vec::new(), width: 2.0 * MARGIN, height: 2.0 * MARGIN }; + } + let widths: Vec = sd.participants.iter().map(|p| (text_width(&p.display) + BOX_PAD).max(MIN_BOX)).collect(); + let centers = centers_for(sd, &widths); + let total_rows = count_rows(&sd.events); + let mut rows = Vec::new(); + let mut blocks = Vec::new(); + let mut bars = Vec::new(); + let mut stacks = vec![Vec::new(); sd.participants.len()]; + walk(&sd.events, &mut rows, &mut blocks, &mut bars, &mut stacks); + for (p, stack) in stacks.iter().enumerate() { + for start in stack { + bars.push(Bar { participant: p, start: *start, end: total_rows.saturating_sub(1) }); + } + } + blocks.sort_by_key(|b| (b.start, b.end)); + + let box_top = MARGIN; + let box_bottom = box_top + BOX_HEIGHT; + let row_top = |n: usize| box_bottom + ROW_TOP_PAD + f64::from(u32::try_from(n).unwrap_or(u32::MAX)) * ROW_HEIGHT; + let row_bottom = |n: usize| row_top(n) + ROW_HEIGHT; + + let mut shapes = lifelines(sd, &widths, ¢ers, box_bottom, total_rows); + for b in &blocks { + shapes.push(block_frame(b, sd, &widths, ¢ers, box_bottom)); + } + for bar in &bars { + let (y0, y1) = (row_top(bar.start), row_bottom(bar.end)); + if let Some(c) = centers.get(bar.participant) { + let frame = Frame::new(c - BAR_WIDTH / 2.0, y0, BAR_WIDTH, (y1 - y0).max(ROW_HEIGHT / 2.0)); + shapes.push(Shape::Box { + frame, + radius: 3.0, + text: Text { at: Point::new(frame.center_x(), frame.center_y()), content: String::new(), align: Align::Middle }, + }); + } + } + for (n, item) in rows.iter().enumerate() { + row_shape(item, n, sd, &widths, ¢ers, &mut shapes, box_bottom); + } + for ((part, c), w) in sd.participants.iter().zip(¢ers).zip(&widths) { + let frame = Frame::new(c - w / 2.0, box_top, *w, BOX_HEIGHT); + shapes.push(Shape::Box { + frame, + radius: 0.0, + text: Text { at: Point::new(frame.center_x(), frame.center_y()), content: part.display.clone(), align: Align::Middle }, + }); + } + + let mut height = 2.0 * MARGIN; + for s in &shapes { + if let Some(frame) = frame_of(s) { + height = height.max(frame.bottom() + MARGIN); + } + } + height = height.max(row_bottom(total_rows.saturating_sub(1)) + LIFELINE_PAD + MARGIN); + let width = canvas_width(&shapes); + Layout { shapes, width, height } +} + +fn frame_of(shape: &Shape) -> Option { + match shape { + Shape::Box { frame, .. } | Shape::Diamond { frame, .. } | Shape::Circle { frame, .. } | Shape::Group { frame, .. } => Some(*frame), + _ => None, + } +} + +fn canvas_width(shapes: &[Shape]) -> f64 { + let mut width = 2.0 * MARGIN; + for s in shapes { + if let Some(frame) = frame_of(s) { + width = width.max(frame.right() + MARGIN); + } + } + width +} + +fn text_width(s: &str) -> f64 { + f64::from(u32::try_from(s.chars().count().min(200)).unwrap_or(200)) * CHAR_WIDTH +} + +/// Column centres: participant widths plus gaps widened for note boxes. +fn centers_for(sd: &SequenceDiagram, widths: &[f64]) -> Vec { + let n = widths.len(); + let mut gaps = vec![COL_GAP; n.saturating_sub(1)]; + let boxed = |i: usize, widths: &[f64]| widths.get(i).copied().unwrap_or(MIN_BOX); + for note in flat_notes(&sd.events) { + let want = text_width(¬e.text) + 2.0 * NOTE_PAD; + match note.position { + NotePosition::Over if note.targets.len() > 1 => { + let first = *note.targets.first().unwrap_or(&0); + let last = *note.targets.last().unwrap_or(&0).min(&(n.saturating_sub(1))); + if last > first { + let span: f64 = + (first..last).map(|i| f64::midpoint(boxed(i, widths), boxed(i + 1, widths)) + gaps.get(i).copied().unwrap_or(COL_GAP)).sum::(); + let missing = want + 2.0 * NOTE_GAP - span; + if missing > 0.0 { + for gap in gaps.iter_mut().take(last).skip(first) { + *gap += missing / f64::from(u32::try_from(last - first).unwrap_or(u32::MAX)); + } + } + } + } + NotePosition::Left | NotePosition::Right => { + let t = *note.targets.first().unwrap_or(&0); + let side = if note.position == NotePosition::Left { t.checked_sub(1) } else { Some(t + 1).filter(|&j| j < n) }; + if let Some(i) = side { + let next = boxed((i + 1).min(n.saturating_sub(1)), widths); + let want_gap = (want + 2.0 * NOTE_GAP - f64::midpoint(boxed(i, widths), next)).max(0.0); + if let Some(gap) = gaps.get_mut(i) { + *gap = gap.max(want_gap); + } + } + } + NotePosition::Over => {} + } + } + let mut out = Vec::with_capacity(n); + let mut cursor = MARGIN; + for (i, w) in widths.iter().enumerate() { + let half = w / 2.0; + if i > 0 { + cursor += gaps.get(i - 1).copied().unwrap_or(COL_GAP); + } + out.push(cursor + half); + cursor += w; + } + out +} + +/// Every note in the diagram, flattened in source order. +fn flat_notes(events: &[Event]) -> Vec<&Note> { + let mut out = Vec::new(); + for e in events { + match e { + Event::Note(note) => out.push(note), + Event::Block(b) => out.extend(flat_notes(&b.children)), + _ => {} + } + } + out +} + +/// Total event rows across all nesting levels. +fn count_rows(events: &[Event]) -> usize { + events + .iter() + .map(|e| match e { + Event::Block(b) => 1 + count_rows(&b.children), + _ => 1, + }) + .sum() +} + +/// Walks the event tree into row items, block spans and matched activation bars. +fn walk(events: &[Event], rows: &mut Vec, blocks: &mut Vec, bars: &mut Vec, stacks: &mut Vec>) { + for event in events { + match event { + Event::Message(m) => { + let mut label = String::new(); + if let Some(n) = m.number { + let _ = write!(label, "{n}. "); + } + label.push_str(&m.text); + rows.push(RowItem::Message { from: m.from, to: m.to, arrow: m.arrow, label }); + } + Event::Note(note) => rows.push(RowItem::Note { position: note.position, targets: note.targets.clone(), text: note.text.clone() }), + Event::Section(s) => { + let mut text = String::from(s.keyword); + if !s.label.is_empty() { + text.push(' '); + text.push_str(&s.label); + } + rows.push(RowItem::Section { text }); + } + Event::Activate { participant } => { + if let Some(stack) = stacks.get_mut(*participant) { + stack.push(rows.len()); + } + rows.push(RowItem::Section { text: String::new() }); + } + Event::Deactivate { participant } => { + if let Some(stack) = stacks.get_mut(*participant) + && let Some(start) = stack.pop() + { + bars.push(Bar { participant: *participant, start, end: rows.len() }); + } + rows.push(RowItem::Section { text: String::new() }); + } + Event::Block(b) => { + let start = rows.len(); + walk(&b.children, rows, blocks, bars, stacks); + let end = rows.len().max(start + 1); + let mut title = String::from(b.kind.name()); + if !b.label.is_empty() { + title.push(' '); + title.push_str(&b.label); + } + blocks.push(BlockSpan { title, start, end }); + rows.push(RowItem::Section { text: String::new() }); + } + } + } +} +/// Leftmost and rightmost box edges across all participants. +fn box_span(sd: &SequenceDiagram, widths: &[f64], centers: &[f64]) -> (f64, f64) { + let mut left = f64::INFINITY; + let mut right = f64::NEG_INFINITY; + for (i, c) in centers.iter().enumerate().take(sd.participants.len()) { + let half = widths.get(i).copied().unwrap_or(MIN_BOX) / 2.0; + left = left.min(c - half); + right = right.max(c + half); + } + (left, right) +} + +/// Dotted vertical lifelines under every participant box. +fn lifelines(sd: &SequenceDiagram, widths: &[f64], centers: &[f64], box_bottom: f64, total_rows: usize) -> Vec { + let bottom = box_bottom + ROW_TOP_PAD + f64::from(u32::try_from(total_rows).unwrap_or(u32::MAX)) * ROW_HEIGHT + LIFELINE_PAD; + centers + .iter() + .enumerate() + .take(sd.participants.len()) + .map(|(i, c)| { + let half = widths.get(i).copied().unwrap_or(MIN_BOX) / 2.0; + Shape::Edge { points: vec![Point::new(c + half / 2.0, box_bottom), Point::new(c + half / 2.0, bottom)], label: None, style: EdgeStyle::Plain } + }) + .collect() +} + +/// Bounding frame of a block span drawn as a group. +fn block_frame(b: &BlockSpan, sd: &SequenceDiagram, widths: &[f64], centers: &[f64], box_bottom: f64) -> Shape { + let (left, right) = box_span(sd, widths, centers); + let row_y = |n: usize| box_bottom + ROW_TOP_PAD + f64::from(u32::try_from(n).unwrap_or(u32::MAX)) * ROW_HEIGHT; + let y0 = row_y(b.start) - BLOCK_PAD; + let y1 = row_y(b.end.saturating_sub(1)) + ROW_HEIGHT + BLOCK_PAD; + let frame = Frame::new(left - BLOCK_PAD, y0, (right - left) + 2.0 * BLOCK_PAD, y1 - y0); + Shape::Group { frame, title: Text { at: Point::new(frame.x + 8.0, frame.y + 12.0), content: b.title.clone(), align: Align::Start } } +} + +/// Draws one event row at index `n`. +fn row_shape(item: &RowItem, n: usize, sd: &SequenceDiagram, widths: &[f64], centers: &[f64], shapes: &mut Vec, box_bottom: f64) { + let y = box_bottom + ROW_TOP_PAD + f64::from(u32::try_from(n).unwrap_or(u32::MAX)) * ROW_HEIGHT + ROW_HEIGHT / 2.0; + match item { + RowItem::Message { from, to, arrow, label } => { + let (Some(ca), Some(cb)) = (centers.get(*from).copied(), centers.get(*to).copied()) else { return }; + let (points, label_at) = if from == to { + ( + vec![Point::new(ca, y - 8.0), Point::new(ca + SELF_LOOP, y - 8.0), Point::new(ca + SELF_LOOP, y + 8.0), Point::new(ca, y + 8.0)], + Point::new(ca + SELF_LOOP + 10.0, y), + ) + } else { + (vec![Point::new(ca, y), Point::new(cb, y)], Point::new(f64::midpoint(ca, cb), y - 8.0)) + }; + let text = (!label.is_empty()).then(|| Text { at: label_at, content: label.clone(), align: Align::Middle }); + shapes.push(Shape::Edge { points, label: text, style: if arrow.is_dashed() { EdgeStyle::Dotted } else { EdgeStyle::Solid } }); + } + RowItem::Note { position, targets, text } => { + let w = (text_width(text) + 2.0 * NOTE_PAD).max(MIN_NOTE); + let y_top = y - ROW_HEIGHT / 2.0 + (ROW_HEIGHT - NOTE_HEIGHT) / 2.0; + let first = targets.first().copied().unwrap_or(0); + let last = targets.last().copied().unwrap_or(0); + let (ca, cb) = (centers.get(first).copied().unwrap_or(MARGIN), centers.get(last).copied().unwrap_or(MARGIN)); + let cx = match position { + NotePosition::Over => f64::midpoint(ca, cb), + NotePosition::Left => (MARGIN + w / 2.0).max(f64::midpoint(ca - MIN_BOX, ca)), + NotePosition::Right => { + let neighbor = centers.get(last + 1).copied().map_or(ca + MIN_BOX, |next| f64::midpoint(ca, next)); + neighbor.max(ca) + } + }; + let width = if *position == NotePosition::Over && last > first { ((cb - ca) + 8.0).max(w) } else { w }; + let frame = Frame::new(cx - width / 2.0, y_top, width, NOTE_HEIGHT); + shapes.push(Shape::Box { + frame, + radius: 4.0, + text: Text { at: Point::new(frame.center_x(), frame.center_y()), content: text.clone(), align: Align::Middle }, + }); + } + RowItem::Section { text } => { + if text.is_empty() { + return; + } + let (left, right) = box_span(sd, widths, centers); + shapes.push(Shape::Text { text: Text { at: Point::new(f64::midpoint(left, right), y), content: text.clone(), align: Align::Middle } }); + } + } +} diff --git a/crates/diagrams/src/sequence.rs b/crates/diagrams/src/sequence.rs new file mode 100644 index 0000000..86c8b23 --- /dev/null +++ b/crates/diagrams/src/sequence.rs @@ -0,0 +1,325 @@ +//! Sequence diagram model and parser (`sequenceDiagram`). + +use std::collections::HashMap; + +use crate::error::{Error, Result}; +use crate::model::{ArrowKind, Block, BlockKind, Diagnostic, Event, Message, Note, NotePosition, Participant, Section, SequenceDiagram}; + +/// Largest participant count a sequence diagram may hold. +pub const MAX_PARTICIPANTS: usize = 200; + +/// Largest total event count (at every nesting level) a sequence diagram may hold. +pub const MAX_EVENTS: usize = 5_000; + +/// Largest `loop`/`alt`/... nesting depth (8 levels). +pub const MAX_BLOCK_DEPTH: usize = 8; +/// Accumulates sequence statements into a [`SequenceDiagram`]. +struct Parser { + diagram: SequenceDiagram, + index: HashMap, + /// Events accumulated at the innermost level. + current: Vec, + /// One frame per open block, innermost last. + frames: Vec, + event_count: usize, + autonumber: Option, + /// Open activations per participant index. + activations: Vec, +} + +/// Saved state of an open block: keyword, label, saved parent events, depth-cap marker. +struct Frame { + kind: BlockKind, + label: String, + skipped: bool, + outer: Vec, +} + +/// Parses the body of a `sequenceDiagram`, skipping the `header_no` header line. +/// +/// # Errors +/// [`Error::LimitExceeded`] past [`MAX_PARTICIPANTS`] or [`MAX_EVENTS`]. +pub(crate) fn parse(source: String, header_no: usize) -> Result { + let lines: Vec = source.lines().map(String::from).collect(); + let mut parser = Parser { + diagram: SequenceDiagram { participants: Vec::new(), events: Vec::new(), diagnostics: Vec::new(), source }, + index: HashMap::new(), + current: Vec::new(), + frames: Vec::new(), + event_count: 0, + autonumber: None, + activations: Vec::new(), + }; + for (i, line) in lines.iter().enumerate().skip(header_no.saturating_add(1)) { + parser.line(i.saturating_add(1), line.as_str())?; + } + parser.close_open(); + parser.diagram.events = std::mem::take(&mut parser.current); + Ok(parser.diagram) +} + +impl Parser { + fn line(&mut self, line_no: usize, text: &str) -> Result<()> { + let trimmed = text.trim().trim_end_matches(';').trim(); + if trimmed.is_empty() || trimmed.starts_with("%%") { + return Ok(()); + } + let (word, rest) = trimmed.split_once(char::is_whitespace).map_or((trimmed, ""), |(w, r)| (w, r.trim())); + match word.to_ascii_lowercase().as_str() { + "participant" | "actor" => return self.declare(rest, line_no, word.eq_ignore_ascii_case("actor")), + "note" => return self.note(line_no, rest), + "loop" | "alt" | "opt" | "par" | "critical" | "break" => { + self.open_block(kind_of(word), rest, line_no); + return Ok(()); + } + "end" => { + self.close_block(line_no); + return Ok(()); + } + "else" | "and" => return self.section(word, rest, line_no), + "activate" | "deactivate" => return self.activation(word.eq_ignore_ascii_case("activate"), rest, line_no), + "autonumber" => { + self.autonumber(rest, line_no); + return Ok(()); + } + _ => {} + } + self.message(line_no, trimmed) + } + + fn declare(&mut self, rest: &str, line_no: usize, actor: bool) -> Result<()> { + let mut words = rest.split_whitespace(); + let Some(id) = words.next() else { + self.diagnostic(line_no, "expected a participant name"); + return Ok(()); + }; + let display = match words.next() { + None => id.to_owned(), + Some(second) if second.eq_ignore_ascii_case("as") => { + let alias = words.collect::>().join(" "); + if alias.is_empty() { + self.diagnostic(line_no, "`as` without an alias text"); + id.to_owned() + } else { + alias + } + } + Some(_) => { + self.diagnostic(line_no, "expected `participant NAME as ALIAS`"); + id.to_owned() + } + }; + let idx = self.participant(id)?; + if let Some(p) = self.diagram.participants.get_mut(idx) { + p.display = display; + p.actor = actor; + } + Ok(()) + } + + /// Registers (or finds) a participant; implicit declarations share the caps. + fn participant(&mut self, id: &str) -> Result { + if let Some(&idx) = self.index.get(id) { + return Ok(idx); + } + if self.diagram.participants.len() >= MAX_PARTICIPANTS { + return Err(Error::LimitExceeded { what: "participants", max: MAX_PARTICIPANTS }); + } + self.index.insert(id.to_owned(), self.diagram.participants.len()); + self.diagram.participants.push(Participant { id: id.to_owned(), display: id.to_owned(), actor: false }); + self.activations.push(0); + Ok(self.diagram.participants.len().saturating_sub(1)) + } + + fn push(&mut self, event: Event) -> Result<()> { + if self.event_count >= MAX_EVENTS { + return Err(Error::LimitExceeded { what: "sequence events", max: MAX_EVENTS }); + } + self.event_count += 1; + if let Event::Activate { participant } | Event::Deactivate { participant } = event + && let Some(open) = self.activations.get_mut(participant) + { + *open = match event { + Event::Activate { .. } => open.saturating_add(1), + _ => open.saturating_sub(1), + }; + } + self.current.push(event); + Ok(()) + } + + fn note(&mut self, line_no: usize, rest: &str) -> Result<()> { + let Some((body, text)) = rest.split_once(':') else { + self.diagnostic(line_no, "notes need `Note left|right|over ...: text`"); + return Ok(()); + }; + let body = body.trim(); + let lower = body.to_ascii_lowercase(); + let (position, names) = if lower.starts_with("over ") { + (NotePosition::Over, body.get(5..).unwrap_or("")) + } else if lower.starts_with("left of ") { + (NotePosition::Left, body.get(8..).unwrap_or("")) + } else if lower.starts_with("right of ") { + (NotePosition::Right, body.get(9..).unwrap_or("")) + } else { + self.diagnostic(line_no, "notes start with `Note over`, `Note left of` or `Note right of`"); + return Ok(()); + }; + let mut targets = Vec::new(); + for name in names.split(',') { + match name.trim() { + "" => { + self.diagnostic(line_no, "note has an empty participant name"); + } + trimmed => targets.push(self.participant(trimmed)?), + } + } + if targets.is_empty() || (position != NotePosition::Over && targets.len() > 1) { + self.diagnostic(line_no, "`Note left/right of` takes exactly one participant"); + return Ok(()); + } + self.push(Event::Note(Note { position, targets, text: text.trim().to_owned() })) + } + + fn open_block(&mut self, kind: BlockKind, label: &str, line_no: usize) { + let skipped = self.frames.len() >= MAX_BLOCK_DEPTH; + if skipped { + self.diagnostic(line_no, format!("blocks nest at most {MAX_BLOCK_DEPTH} deep")); + } + let outer = std::mem::take(&mut self.current); + self.frames.push(Frame { kind, label: label.trim().to_owned(), skipped, outer }); + } + + fn close_block(&mut self, line_no: usize) { + match self.frames.pop() { + None => self.diagnostic(line_no, "`end` without an open block"), + Some(frame) => self.fold_frame(frame), + } + } + + /// Folds a block frame back into its parent level, keeping skipped contents inline. + fn fold_frame(&mut self, frame: Frame) { + let children = std::mem::take(&mut self.current); + self.current = frame.outer; + if frame.skipped { + self.current.extend(children); + } else { + self.current.push(Event::Block(Block { kind: frame.kind, label: frame.label, children })); + } + } + + fn section(&mut self, keyword: &str, label: &str, line_no: usize) -> Result<()> { + if self.frames.is_empty() { + self.diagnostic(line_no, format!("`{keyword}` outside any block")); + return Ok(()); + } + let keyword = if keyword.eq_ignore_ascii_case("else") { "else" } else { "and" }; + self.push(Event::Section(Section { keyword, label: label.to_owned() })) + } + + fn activation(&mut self, activate: bool, rest: &str, line_no: usize) -> Result<()> { + let id = rest.trim(); + if id.is_empty() { + self.diagnostic(line_no, "expected a participant after activate/deactivate"); + return Ok(()); + } + let idx = self.participant(id)?; + self.push(if activate { Event::Activate { participant: idx } } else { Event::Deactivate { participant: idx } }) + } + + fn autonumber(&mut self, rest: &str, line_no: usize) { + self.autonumber = match rest.trim().parse::() { + Ok(start) => Some(start.saturating_sub(1)), + Err(_) if rest.trim().is_empty() => Some(0), + Err(_) => { + self.diagnostic(line_no, "`autonumber` takes an optional start number"); + self.autonumber + } + }; + } + + fn message(&mut self, line_no: usize, trimmed: &str) -> Result<()> { + let Some((head, text)) = trimmed.split_once(':') else { + self.diagnostic(line_no, "cannot parse line (messages need `A -> B: text`)"); + return Ok(()); + }; + let Some((pos, (op, kind))) = ArrowKind::OPERATORS + .iter() + .filter_map(|(op, kind)| head.find(op).map(|p| (p, (*op, *kind)))) + .min_by_key(|(p, (op, _))| (*p, std::cmp::Reverse(op.len()))) + else { + self.diagnostic(line_no, "unknown statement; messages use `->`, `->>`, `-->`, `-->>`, `-x`, `--x`, `-)` or `--)`"); + return Ok(()); + }; + let from = self.resolve(head.get(..pos).unwrap_or("").trim(), line_no)?; + let to = head.get(pos + op.len()..).unwrap_or("").trim(); + let (to, follow) = self.target(to, line_no)?; + let number = self.autonumber.as_mut().map(|n| { + *n = n.saturating_add(1); + *n + }); + self.push(Event::Message(Message { from, to, text: text.trim().to_owned(), arrow: kind, number }))?; + if let Some(activate) = follow { + self.push(if activate { Event::Activate { participant: to } } else { Event::Deactivate { participant: to } })?; + } + Ok(()) + } + + /// Resolves a participant; empty names become diagnostics at participant 0. + fn resolve(&mut self, id: &str, line_no: usize) -> Result { + if id.is_empty() { + self.diagnostic(line_no, "message is missing a participant name"); + return Ok(0); + } + self.participant(id) + } + + /// Resolves the message receiver, stripping a `+`/`-` activation marker written + /// either before (Mermaid style) or after the name. + fn target(&mut self, id: &str, line_no: usize) -> Result<(usize, Option)> { + let marker = [ + id.strip_prefix('+').map(|rest| (rest, true)), + id.strip_prefix('-').map(|rest| (rest, false)), + id.strip_suffix('+').map(|rest| (rest, true)), + id.strip_suffix('-').map(|rest| (rest, false)), + ] + .into_iter() + .flatten() + .next(); + let (body, follow) = marker.map_or((id, None), |(rest, activate)| (rest, Some(activate))); + let idx = self.resolve(body.trim(), line_no)?; + Ok((idx, follow)) + } + + /// Folds every still-open block back; reports unmatched blocks and activations. + fn close_open(&mut self) { + let open = self.frames.len(); + while let Some(frame) = self.frames.pop() { + self.fold_frame(frame); + } + if open > 0 { + self.diagnostic(self.diagram.source.lines().count(), format!("{open} block(s) were never closed with `end`")); + } + let dangling: Vec = self.activations.iter().enumerate().filter_map(|(i, c)| (*c > 0).then_some(i)).collect(); + for idx in dangling { + let id = self.diagram.participants.get(idx).map_or("?", |p| p.id.as_str()); + self.diagnostic(self.diagram.source.lines().count(), format!("`activate {id}` was never followed by `deactivate {id}`")); + } + } + + fn diagnostic(&mut self, line_no: usize, message: impl Into) { + self.diagram.diagnostics.push(Diagnostic::new(line_no, message)); + } +} + +/// Maps a block keyword to its kind. +fn kind_of(word: &str) -> BlockKind { + match word.to_ascii_lowercase().as_str() { + "alt" => BlockKind::Alt, + "opt" => BlockKind::Opt, + "par" => BlockKind::Par, + "critical" => BlockKind::Critical, + "break" => BlockKind::Break, + _ => BlockKind::Loop, + } +} diff --git a/crates/diagrams/src/svg.rs b/crates/diagrams/src/svg.rs index 9e8eaad..3303808 100644 --- a/crates/diagrams/src/svg.rs +++ b/crates/diagrams/src/svg.rs @@ -121,17 +121,13 @@ fn shape_svg(s: &mut String, shape: &Shape, p: &Palette) { } 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 (width, dash, marker) = match style { + EdgeStyle::Solid => (1.5, "", " marker-end=\"url(#arrow)\""), + EdgeStyle::Dotted => (1.5, " stroke-dasharray=\"4 4\"", " marker-end=\"url(#arrow)\""), + EdgeStyle::Thick => (3.0, "", " marker-end=\"url(#arrow)\""), + EdgeStyle::Plain => (1.5, " stroke-dasharray=\"2 4\"", ""), }; - let _ = write!( - s, - "", - pts.join(" "), - escape(&p.edge) - ); + let _ = write!(s, "", pts.join(" "), escape(&p.edge)); if let Some(label) = label { text_svg(s, label, p); } diff --git a/crates/diagrams/tests/fixtures/sequence_basics.mmd b/crates/diagrams/tests/fixtures/sequence_basics.mmd new file mode 100644 index 0000000..1189464 --- /dev/null +++ b/crates/diagrams/tests/fixtures/sequence_basics.mmd @@ -0,0 +1,14 @@ +sequenceDiagram + participant A as Alice + actor B as Bob + participant Carol + A->>B: Hello + B-->A: Pong + A->B: sync + A-->>B: async reply + B-->>A: dashed filled + A-xB: cross + A--xB: dashed cross + A-)B: open + A--)B: dashed open + A->A: self note \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/sequence_blocks.mmd b/crates/diagrams/tests/fixtures/sequence_blocks.mmd new file mode 100644 index 0000000..bbd7895 --- /dev/null +++ b/crates/diagrams/tests/fixtures/sequence_blocks.mmd @@ -0,0 +1,25 @@ +sequenceDiagram + participant A + participant B + autonumber + loop every second + A->>B: tick + alt ok + B-->>A: ack + else bad + B-->>A: nak + end + end + opt maybe + par left side + A->>B: p1 + and right side + B->>A: p2 + end + end + critical must + A->>B: do + end + break on error + A->>A: stop + end \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/sequence_malformed.mmd b/crates/diagrams/tests/fixtures/sequence_malformed.mmd new file mode 100644 index 0000000..5d62f90 --- /dev/null +++ b/crates/diagrams/tests/fixtures/sequence_malformed.mmd @@ -0,0 +1,11 @@ +sequenceDiagram + A->>B: fine + oops no arrow + A -> B no colon + Note somewhere X: bad + end + else lonely + participant + autonumber five + %% a comment + A->>B: after junk \ No newline at end of file diff --git a/crates/diagrams/tests/fixtures/sequence_notes.mmd b/crates/diagrams/tests/fixtures/sequence_notes.mmd new file mode 100644 index 0000000..82e8b34 --- /dev/null +++ b/crates/diagrams/tests/fixtures/sequence_notes.mmd @@ -0,0 +1,10 @@ +sequenceDiagram + participant A + participant B + Note left of A: single + Note over A,B: shared + Note right of B: after + activate A + deactivate A + A->>+B: wake + A->>-B: done \ No newline at end of file diff --git a/crates/diagrams/tests/flowchart.rs b/crates/diagrams/tests/flowchart.rs index 56c0562..2b42547 100644 --- a/crates/diagrams/tests/flowchart.rs +++ b/crates/diagrams/tests/flowchart.rs @@ -47,9 +47,9 @@ fn headers_and_source() { 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!(matches!(d, Diagram::Sequence(_))); assert_eq!(d.source(), seq); - assert!(matches!(layout(&d), Err(Error::NotLayoutable { .. }))); + assert!(layout(&d).is_ok(), "sequence diagrams lay out"); let src = fixture("chains.mmd"); assert_eq!(parse(&src).unwrap_or_else(|e| panic!("{e}")).source(), src); } diff --git a/crates/diagrams/tests/sequence.rs b/crates/diagrams/tests/sequence.rs new file mode 100644 index 0000000..eacb4df --- /dev/null +++ b/crates/diagrams/tests/sequence.rs @@ -0,0 +1,250 @@ +//! Parse, layout and SVG behaviour of sequence diagrams, driven by the fixtures in `tests/fixtures`. + +use std::fmt::Write as _; + +use notescraft_diagrams::{ArrowKind, BlockKind, Diagram, Error, Event, NotePosition, Shape, 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 seq(src: &str) -> notescraft_diagrams::SequenceDiagram { + match parse(src) { + Ok(Diagram::Sequence(s)) => s, + other => panic!("expected a sequence diagram, got {other:?}"), + } +} + +fn messages(s: ¬escraft_diagrams::SequenceDiagram) -> Vec<¬escraft_diagrams::Message> { + collect(&s.events).into_iter().flatten().collect() +} + +fn collect(events: &[Event]) -> Vec> { + events + .iter() + .map(|e| match e { + Event::Message(m) => vec![m], + Event::Block(b) => collect(&b.children).into_iter().flatten().collect(), + _ => Vec::new(), + }) + .collect() +} + +#[test] +fn participants_aliases_and_actors() { + let s = seq(&fixture("sequence_basics.mmd")); + assert_eq!(s.participants.len(), 3); + let names: Vec<&str> = s.participants.iter().map(|p| p.display.as_str()).collect(); + assert_eq!(names, ["Alice", "Bob", "Carol"]); + assert!(s.participants.get(1).is_some_and(|p| p.actor)); +} + +#[test] +fn every_arrow_type_is_recognised() { + let s = seq(&fixture("sequence_basics.mmd")); + let arrows: Vec = messages(&s).iter().map(|m| m.arrow).collect(); + for want in [ + ArrowKind::SolidFilled, + ArrowKind::Dashed, + ArrowKind::Solid, + ArrowKind::DashedFilled, + ArrowKind::Cross, + ArrowKind::DashedCross, + ArrowKind::Open, + ArrowKind::DashedOpen, + ] { + assert!(arrows.contains(&want), "missing {want:?} in {arrows:?}"); + } + assert!(messages(&s).iter().any(|m| m.from == m.to), "self message is kept"); + assert!(s.diagnostics.is_empty(), "{:?}", s.diagnostics); +} + +#[test] +fn notes_and_activations() { + let s = seq(&fixture("sequence_notes.mmd")); + let notes: Vec<(&NotePosition, &str)> = s + .events + .iter() + .filter_map(|e| match e { + Event::Note(n) => Some((&n.position, n.text.as_str())), + _ => None, + }) + .collect(); + assert_eq!(notes, [(&NotePosition::Left, "single"), (&NotePosition::Over, "shared"), (&NotePosition::Right, "after")]); + if let Some(Event::Note(n)) = s.events.get(1) { + assert_eq!(n.targets, [0, 1]); + } + assert!(s.events.iter().any(|e| matches!(e, Event::Activate { participant: 0 }))); + assert!(s.events.iter().any(|e| matches!(e, Event::Deactivate { participant: 0 }))); + // The `+`/`-` markers on the message target activate then deactivate B. + for window in s.events.windows(2) { + if matches!(window[0], Event::Message(ref m) if m.text == "wake") { + assert!(matches!(window[1], Event::Activate { participant: 1 })); + } + if matches!(window[0], Event::Message(ref m) if m.text == "done") { + assert!(matches!(window[1], Event::Deactivate { participant: 1 })); + } + } + assert!(s.diagnostics.is_empty(), "{:?}", s.diagnostics); +} + +#[test] +fn nested_blocks_and_autonumber() { + let s = seq(&fixture("sequence_blocks.mmd")); + let tops: Vec<&BlockKind> = s + .events + .iter() + .filter_map(|e| match e { + Event::Block(b) => Some(&b.kind), + _ => None, + }) + .collect(); + assert_eq!(tops.len(), 4); + let msg = messages(&s); + let numbers: Vec> = msg.iter().map(|m| m.number).collect(); + assert_eq!(numbers, [Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7)]); + let kinds = s + .events + .iter() + .filter_map(|e| match e { + Event::Block(b) => Some((b.kind.name(), b.label.as_str())), + _ => None, + }) + .collect::>(); + assert_eq!(kinds, [("loop", "every second"), ("opt", "maybe"), ("critical", "must"), ("break", "on error")]); +} +#[test] +fn else_and_sections_nest_inside_blocks() { + let s = seq(&fixture("sequence_blocks.mmd")); + if let Some(Event::Block(loop_block)) = s.events.first() { + assert_eq!(loop_block.kind, BlockKind::Loop); + assert!(matches!(loop_block.children.get(1), Some(Event::Block(alt)) if alt.kind.name() == "alt")); + } + assert!(s.events.iter().any(|e| match e { + Event::Block(b) => b.children.iter().any(|c| match c { + Event::Block(inner) => inner.children.iter().any(|c2| matches!(c2, Event::Section(_))), + _ => false, + }), + _ => false, + })); +} + +#[test] +fn autonumber_start_number() { + let s = seq("sequenceDiagram\nautonumber 10\nA->>B: x\nA->>B: y"); + assert_eq!(messages(&s).iter().map(|m| m.number).collect::>(), [Some(10), Some(11)]); +} + +#[test] +fn malformed_lines_become_diagnostics_and_parsing_continues() { + let s = seq(&fixture("sequence_malformed.mmd")); + assert_ne!(s.diagnostics, []); + assert!(s.diagnostics.len() >= 6, "{:?}", s.diagnostics); + assert!(messages(&s).iter().any(|m| m.text == "fine")); + assert!(messages(&s).iter().any(|m| m.text == "after junk"), "lines after bad ones still parse"); + assert!(s.diagnostics.iter().any(|d| d.message.contains("`end` without an open block"))); +} + +#[test] +fn too_many_participants_is_an_error() { + let mut src = String::from("sequenceDiagram\n"); + for i in 0..=notescraft_diagrams::MAX_PARTICIPANTS { + let _ = writeln!(src, "participant P{i}"); + } + assert!(matches!(parse(&src), Err(Error::LimitExceeded { what: "participants", .. }))); +} + +#[test] +fn too_many_events_is_an_error() { + let mut src = String::from("sequenceDiagram\nparticipant A\nparticipant B\n"); + for i in 0..=notescraft_diagrams::MAX_EVENTS { + let _ = writeln!(src, "A->>B: m{i}"); + } + assert!(matches!(parse(&src), Err(Error::LimitExceeded { what: "sequence events", .. }))); +} + +#[test] +fn nesting_beyond_the_cap_keeps_contents_and_warns() { + let mut src = String::from("sequenceDiagram\nA->>B: outside\n"); + for _ in 0..=notescraft_diagrams::MAX_BLOCK_DEPTH { + src.push_str("loop deep\n"); + } + src.push_str("A->>B: inside\n"); + for _ in 0..=notescraft_diagrams::MAX_BLOCK_DEPTH { + src.push_str("end\n"); + } + let s = seq(&src); + assert!(s.diagnostics.iter().any(|d| d.message.contains("nest at most"))); + assert!(messages(&s).iter().any(|m| m.text == "outside"), "prefix lines stay where they were"); + assert!(messages(&s).iter().any(|m| m.text == "inside"), "contents past the cap are kept"); +} + +#[test] +fn unbalanced_activation_is_diagnosed() { + let s = seq("sequenceDiagram\nloop forever\nA->>B: hi\nactivate A"); + assert!(s.diagnostics.iter().any(|d| d.message.contains("never followed by"))); + assert!(s.diagnostics.iter().any(|d| d.message.contains("never closed with `end`"))); +} +#[test] +fn layout_is_deterministic_and_structured() { + let d = parse(&fixture("sequence_blocks.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); + assert!(a.shapes.iter().any(|s| matches!(s, Shape::Edge { style: notescraft_diagrams::EdgeStyle::Plain, .. })), "dotted lifelines"); + assert!(a.shapes.iter().any(|s| matches!(s, Shape::Group { title, .. } if title.content.contains("loop every second")))); + assert!(a.shapes.iter().any(|s| matches!(s, Shape::Edge { style: notescraft_diagrams::EdgeStyle::Dotted, label: Some(t), .. } if t.content == "2. ack"))); + let last_node = a.shapes.iter().rposition(|s| matches!(s, Shape::Box { .. })); + let first_group = a.shapes.iter().position(|s| matches!(s, Shape::Group { .. })); + assert!(first_group.is_some_and(|g| last_node.is_some_and(|n| g < n)), "blocks draw before the participant boxes"); + assert!(a.shapes.iter().any(|s| matches!(s, Shape::Box { text, .. } if text.content == "A")), "participant box keeps its id"); + assert!(last_node.is_some_and(|n| n + 1 == a.shapes.len()), "participant boxes draw last"); + assert!(a.width > 0.0 && a.height > 0.0); +} + +fn frame_width(shape: &Shape) -> f64 { + match shape { + Shape::Box { frame, .. } => frame.width, + _ => f64::INFINITY, + } +} + +#[test] +fn svg_escapes_script_tags_in_messages() { + let d = parse("sequenceDiagram\nA->>B: ").unwrap_or_else(|e| panic!("{e}")); + let svg = to_svg(&layout(&d).unwrap_or_else(|e| panic!("{e}"))); + assert!(!svg.contains("")); +} + +#[test] +fn notes_lay_out_as_boxes() { + let d = parse(&fixture("sequence_notes.mmd")).unwrap_or_else(|e| panic!("{e}")); + let l = layout(&d).unwrap_or_else(|e| panic!("{e}")); + let note_boxes = l.shapes.iter().filter(|s| matches!(s, Shape::Box { text, .. } if text.content == "shared")).count(); + assert_eq!(note_boxes, 1); + assert!(l.shapes.iter().any(|s| matches!(s, Shape::Box { text, .. } if text.content == "single"))); + assert!(l.shapes.iter().any(|s| matches!(s, Shape::Box { text, .. } if text.content.is_empty() && frame_width(s) < 20.0)), "activation bars"); +} + +#[test] +fn self_message_lays_out_as_a_loop() { + let d = parse(&fixture("sequence_basics.mmd")).unwrap_or_else(|e| panic!("{e}")); + let l = layout(&d).unwrap_or_else(|e| panic!("{e}")); + assert!(l.shapes.iter().any(|s| matches!(s, Shape::Edge { points, label: Some(t), .. } if points.len() > 2 && t.content == "self note"))); +} + +#[test] +#[ignore = "perf: run with --release -- --ignored"] +fn five_hundred_messages_lay_out_under_a_second() { + let mut src = String::from("sequenceDiagram\nparticipant A\nparticipant B\nparticipant C\n"); + for i in 0..500 { + let _ = writeln!(src, "A->>B: m{i}\nB-->>C: r{i}"); + } + let d = parse(&src).unwrap_or_else(|e| panic!("{e}")); + let start = std::time::Instant::now(); + let l = layout(&d).unwrap_or_else(|e| panic!("{e}")); + let took = start.elapsed(); + eprintln!("1000-event sequence: {took:?}, {} shapes, {}x{}", l.shapes.len(), l.width, l.height); + assert!(took.as_secs_f64() < 1.0, "{took:?}"); +}