Merge branch 'feat/diagrams-b' into develop

This commit is contained in:
Marcos Paulo
2026-10-13 19:45:22 -03:00
14 changed files with 1188 additions and 22 deletions
+2
View File
@@ -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.
+2 -1
View File
@@ -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<Layout> {
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() }),
}
}
+11 -4
View File
@@ -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};
+185
View File
@@ -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<usize>,
}
/// 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<usize>,
/// 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<Event>,
}
/// 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<Participant>,
/// Events in source order; blocks nest through [`Event::Block`].
pub events: Vec<Event>,
/// Non-fatal parse problems.
pub diagnostics: Vec<Diagnostic>,
pub(crate) source: String,
}
impl SequenceDiagram {
/// The input, unchanged.
#[must_use]
pub fn source(&self) -> &str {
&self.source
}
}
+6 -5
View File
@@ -36,13 +36,14 @@ pub fn parse(src: &str) -> Result<Diagram> {
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(), []);
}
+339
View File
@@ -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<usize>, 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<f64> = 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, &centers, box_bottom, total_rows);
for b in &blocks {
shapes.push(block_frame(b, sd, &widths, &centers, 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, &centers, &mut shapes, box_bottom);
}
for ((part, c), w) in sd.participants.iter().zip(&centers).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<Frame> {
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<f64> {
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(&note.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::<f64>();
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<RowItem>, blocks: &mut Vec<BlockSpan>, bars: &mut Vec<Bar>, stacks: &mut Vec<Vec<usize>>) {
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<Shape> {
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<Shape>, 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 } });
}
}
}
+325
View File
@@ -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<String, usize>,
/// Events accumulated at the innermost level.
current: Vec<Event>,
/// One frame per open block, innermost last.
frames: Vec<Frame>,
event_count: usize,
autonumber: Option<usize>,
/// Open activations per participant index.
activations: Vec<usize>,
}
/// Saved state of an open block: keyword, label, saved parent events, depth-cap marker.
struct Frame {
kind: BlockKind,
label: String,
skipped: bool,
outer: Vec<Event>,
}
/// 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<SequenceDiagram> {
let lines: Vec<String> = 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::<Vec<_>>().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<usize> {
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::<usize>() {
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<usize> {
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<bool>)> {
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<usize> = 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<String>) {
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,
}
}
+6 -10
View File
@@ -121,17 +121,13 @@ fn shape_svg(s: &mut String, shape: &Shape, p: &Palette) {
}
Shape::Edge { points, label, style } => {
let pts: Vec<String> = 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,
"<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width}\"{dash} marker-end=\"url(#arrow)\"/>",
pts.join(" "),
escape(&p.edge)
);
let _ = write!(s, "<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width}\"{dash}{marker}/>", pts.join(" "), escape(&p.edge));
if let Some(label) = label {
text_svg(s, label, p);
}
+14
View File
@@ -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
+25
View File
@@ -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
+11
View File
@@ -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
+10
View File
@@ -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
+2 -2
View File
@@ -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);
}
+250
View File
@@ -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: &notescraft_diagrams::SequenceDiagram) -> Vec<&notescraft_diagrams::Message> {
collect(&s.events).into_iter().flatten().collect()
}
fn collect(events: &[Event]) -> Vec<Vec<&notescraft_diagrams::Message>> {
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<ArrowKind> = 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<Option<usize>> = 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::<Vec<_>>();
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::<Vec<_>>(), [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: <script>alert(1)</script>").unwrap_or_else(|e| panic!("{e}"));
let svg = to_svg(&layout(&d).unwrap_or_else(|e| panic!("{e}")));
assert!(!svg.contains("<script"), "{svg}");
assert!(svg.contains("&lt;script&gt;"), "{svg}");
assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
}
#[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:?}");
}