Merge branch 'feat/diagrams-b' into develop
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(), []);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¢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<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(¬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::<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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Vec<¬escraft_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("<script>"), "{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:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user