use crate::Error;
use crate::parser::common::{
block_kind_from_keyword, continuation_keyword_for, parse_sequence_note_anchor,
strip_activation_marker, strip_inline_comment, strip_keyword_prefix,
};
use crate::sequence::{
Activation, AutonumberChange, AutonumberState, Block, BlockBranch, BlockKind, Message,
MessageStyle, NoteEvent, Participant, SequenceDiagram,
};
use std::collections::HashMap;
enum ActEvent {
Open { participant: String, at: usize },
Close { participant: String, at: usize },
}
struct OpenBlock {
kind: BlockKind,
start_message: usize,
branches: Vec<BlockBranch>,
}
const ARROWS: &[(&str, MessageStyle)] = &[
("-->>", MessageStyle::DashedArrow),
("-->", MessageStyle::DashedLine),
("->>", MessageStyle::SolidArrow),
("->", MessageStyle::SolidLine),
];
pub fn parse(src: &str) -> Result<SequenceDiagram, Error> {
let mut diag = SequenceDiagram::default();
let mut act_events: Vec<ActEvent> = Vec::new();
let mut block_stack: Vec<OpenBlock> = Vec::new();
for raw in src.lines() {
let line = strip_inline_comment(raw).trim();
if line.is_empty() {
continue;
}
if line.to_lowercase().starts_with("sequencediagram") {
continue;
}
if line.eq_ignore_ascii_case("autonumber") {
diag.autonumber_changes.push(AutonumberChange {
at_message: diag.messages.len(),
state: AutonumberState::On { next_value: 1 },
});
continue;
}
if let Some(rest) = strip_keyword_prefix(line, "autonumber") {
let state = if rest.eq_ignore_ascii_case("off") {
AutonumberState::Off
} else {
let start: u32 = rest
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
AutonumberState::On { next_value: start }
};
diag.autonumber_changes.push(AutonumberChange {
at_message: diag.messages.len(),
state,
});
continue;
}
if line.eq_ignore_ascii_case("end note") {
return Err(Error::ParseError(
"sequence diagrams use `<br>` for multi-line notes, \
not `end note` (which is a state-diagram form)"
.to_string(),
));
}
if let Some(rest) = strip_keyword_prefix(line, "note") {
if let Some(colon_pos) = rest.find(':') {
let anchor_part = rest[..colon_pos].trim();
let text_part = rest[colon_pos + 1..].trim();
if let Some(anchor) = parse_sequence_note_anchor(anchor_part) {
let text = text_part.replace("<br/>", "\n").replace("<br>", "\n");
diag.notes.push(NoteEvent {
anchor,
text,
after_message: diag.messages.len(),
});
continue;
}
}
continue;
}
if let Some(rest) = strip_keyword_prefix(line, "activate") {
let participant = rest.trim();
if participant.is_empty() {
return Err(Error::ParseError(
"`activate` directive missing participant".to_string(),
));
}
act_events.push(ActEvent::Open {
participant: participant.to_string(),
at: diag.messages.len(),
});
continue;
}
if let Some(rest) = strip_keyword_prefix(line, "deactivate") {
let participant = rest.trim();
if participant.is_empty() {
return Err(Error::ParseError(
"`deactivate` directive missing participant".to_string(),
));
}
let at = diag.messages.len().saturating_sub(1);
act_events.push(ActEvent::Close {
participant: participant.to_string(),
at,
});
continue;
}
let lower = line.to_lowercase();
let head = lower.split_whitespace().next().unwrap_or("");
if let Some(kind) = block_kind_from_keyword(head) {
let label = strip_keyword_prefix(line, head)
.unwrap_or("")
.trim()
.to_string();
let at = diag.messages.len();
block_stack.push(OpenBlock {
kind,
start_message: at,
branches: vec![BlockBranch {
label,
start_message: at,
end_message: 0, }],
});
continue;
}
if matches!(head, "else" | "and" | "option") {
let top = block_stack.last_mut().ok_or_else(|| {
Error::ParseError(format!("`{head}` continuation keyword outside any block"))
})?;
let expected = continuation_keyword_for(top.kind);
if expected != Some(head) {
return Err(Error::ParseError(format!(
"`{head}` not valid inside `{:?}` block (expected `{}`)",
top.kind,
expected.unwrap_or("end"),
)));
}
let last = top.branches.last_mut().expect("frame has 1+ branches");
last.end_message = diag.messages.len().saturating_sub(1);
let label = strip_keyword_prefix(line, head)
.unwrap_or("")
.trim()
.to_string();
top.branches.push(BlockBranch {
label,
start_message: diag.messages.len(),
end_message: 0,
});
continue;
}
if head == "end" {
let mut frame = block_stack.pop().ok_or_else(|| {
Error::ParseError("`end` with no matching block opener".to_string())
})?;
let last_msg = diag.messages.len().saturating_sub(1);
frame
.branches
.last_mut()
.expect("frame has 1+ branches")
.end_message = last_msg;
diag.blocks.push(Block {
kind: frame.kind,
branches: frame.branches,
start_message: frame.start_message,
end_message: last_msg,
});
continue;
}
if head == "rect" {
continue;
}
if let Some(rest) = strip_keyword_prefix(line, "participant")
.or_else(|| strip_keyword_prefix(line, "actor"))
{
let p = parse_participant_decl(rest)?;
if let Some(idx) = diag.participant_index(&p.id) {
diag.participants[idx].label = p.label;
} else {
diag.participants.push(p);
}
continue;
}
if let Some((msg, marker)) = try_parse_message(line) {
let from = msg.from.clone();
let to = msg.to.clone();
let msg_idx = diag.messages.len();
diag.ensure_participant(&from);
diag.ensure_participant(&to);
diag.messages.push(msg);
match marker {
Some(true) => act_events.push(ActEvent::Open {
participant: to,
at: msg_idx,
}),
Some(false) => act_events.push(ActEvent::Close {
participant: from,
at: msg_idx,
}),
None => {}
}
continue;
}
return Err(Error::ParseError(format!(
"unrecognised sequence diagram line: {line:?}"
)));
}
if !block_stack.is_empty() {
let kinds: Vec<String> = block_stack
.iter()
.map(|b| format!("{:?}", b.kind).to_lowercase())
.collect();
return Err(Error::ParseError(format!(
"unclosed block(s) at end of input: {} (missing `end`)",
kinds.join(", "),
)));
}
finalize_activations(&act_events, &mut diag)?;
Ok(diag)
}
fn finalize_activations(events: &[ActEvent], diag: &mut SequenceDiagram) -> Result<(), Error> {
let mut stacks: HashMap<String, Vec<usize>> = HashMap::new();
for ev in events {
match ev {
ActEvent::Open { participant, at } => {
stacks.entry(participant.clone()).or_default().push(*at);
}
ActEvent::Close { participant, at } => {
let start = stacks
.get_mut(participant)
.and_then(|s| s.pop())
.ok_or_else(|| {
Error::ParseError(format!(
"deactivate `{participant}` with no matching activate"
))
})?;
diag.activations.push(Activation {
participant: participant.clone(),
start_message: start,
end_message: *at,
});
}
}
}
let last = diag.messages.len().saturating_sub(1);
for (participant, mut stack) in stacks {
while let Some(start) = stack.pop() {
diag.activations.push(Activation {
participant: participant.clone(),
start_message: start,
end_message: last,
});
}
}
Ok(())
}
fn parse_participant_decl(rest: &str) -> Result<Participant, Error> {
let lower = rest.to_lowercase();
if let Some(as_idx) = lower.find(" as ") {
let id = rest[..as_idx].trim().to_string();
let label = rest[as_idx + 4..].trim().to_string();
if id.is_empty() {
return Err(Error::ParseError(
"participant declaration has an empty ID".to_string(),
));
}
Ok(Participant::with_label(id, label))
} else {
let id = rest.trim().to_string();
if id.is_empty() {
return Err(Error::ParseError(
"participant declaration has an empty ID".to_string(),
));
}
Ok(Participant::new(id))
}
}
fn try_parse_message(line: &str) -> Option<(Message, Option<bool>)> {
for &(arrow, style) in ARROWS {
if let Some((from, rest)) = line.split_once(arrow) {
let from = from.trim().to_string();
let (to_token, text) = if let Some((to_part, msg_part)) = rest.split_once(':') {
(to_part.trim().to_string(), msg_part.trim().to_string())
} else {
(rest.trim().to_string(), String::new())
};
let (to, marker) = strip_activation_marker(&to_token);
if from.is_empty() || to.is_empty() {
continue;
}
return Some((
Message {
from,
to,
text,
style,
},
marker,
));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sequence::MessageStyle;
#[test]
fn parse_minimal_sequence() {
let src = "sequenceDiagram\nA->>B: hi";
let diag = parse(src).unwrap();
assert_eq!(diag.participants.len(), 2, "expected 2 participants");
assert_eq!(diag.messages.len(), 1, "expected 1 message");
assert_eq!(diag.messages[0].from, "A");
assert_eq!(diag.messages[0].to, "B");
assert_eq!(diag.messages[0].text, "hi");
assert_eq!(diag.messages[0].style, MessageStyle::SolidArrow);
}
#[test]
fn parse_explicit_participants_with_aliases() {
let src = "sequenceDiagram\nparticipant W as Worker\nparticipant S as Server";
let diag = parse(src).unwrap();
assert_eq!(diag.participants[0].id, "W");
assert_eq!(diag.participants[0].label, "Worker");
assert_eq!(diag.participants[1].id, "S");
assert_eq!(diag.participants[1].label, "Server");
}
#[test]
fn parse_actor_treated_like_participant() {
let src = "sequenceDiagram\nactor U as User\nU->>S: hello\nS-->>U: world";
let diag = parse(src).unwrap();
assert_eq!(diag.participants[0].label, "User");
assert_eq!(diag.messages[1].style, MessageStyle::DashedArrow);
}
#[test]
fn parse_all_arrow_styles() {
let src = "sequenceDiagram\nA->>B: solid arrow\nA-->>B: dashed arrow\nA->B: solid line\nA-->B: dashed line";
let diag = parse(src).unwrap();
assert_eq!(diag.messages[0].style, MessageStyle::SolidArrow);
assert_eq!(diag.messages[1].style, MessageStyle::DashedArrow);
assert_eq!(diag.messages[2].style, MessageStyle::SolidLine);
assert_eq!(diag.messages[3].style, MessageStyle::DashedLine);
}
#[test]
fn parse_comment_and_blank_lines_ignored() {
let src = "sequenceDiagram\n%% This is a comment\n\nA->>B: ok";
let diag = parse(src).unwrap();
assert_eq!(diag.messages.len(), 1);
}
#[test]
fn parse_participant_auto_created_from_message() {
let src = "sequenceDiagram\nAlice->>Bob: hello";
let diag = parse(src).unwrap();
assert_eq!(diag.participants.len(), 2);
assert_eq!(diag.participants[0].id, "Alice");
assert_eq!(diag.participants[1].id, "Bob");
}
#[test]
fn parse_self_message() {
let src = "sequenceDiagram\nA->>A: self";
let diag = parse(src).unwrap();
assert_eq!(diag.participants.len(), 1);
assert_eq!(diag.messages[0].from, "A");
assert_eq!(diag.messages[0].to, "A");
}
#[test]
fn parse_complex_nested_blocks_records_full_tree() {
let src = r#"sequenceDiagram
participant W
participant CP
W->>CP: read
alt Batch is empty
W->>W: beat heartbeat
else Batch has events
alt Success
W->>CP: save checkpoint
else Retry exhausted
W->>W: back off
end
end
loop Every second
W->>W: tick
end
par A to B
W->>CP: write
and C to D
W->>CP: read
end"#;
let diag = parse(src).expect("must parse cleanly");
assert_eq!(diag.messages.len(), 7);
assert_eq!(diag.blocks.len(), 4);
assert_eq!(diag.blocks[0].kind, BlockKind::Alt);
assert_eq!(diag.blocks[0].branches.len(), 2);
assert_eq!(diag.blocks[0].branches[0].label, "Success");
assert_eq!(diag.blocks[0].branches[1].label, "Retry exhausted");
assert_eq!(diag.blocks[1].kind, BlockKind::Alt);
assert_eq!(diag.blocks[1].branches[0].label, "Batch is empty");
assert_eq!(diag.blocks[2].kind, BlockKind::Loop);
assert_eq!(diag.blocks[2].branches[0].label, "Every second");
assert_eq!(diag.blocks[3].kind, BlockKind::Par);
assert_eq!(diag.blocks[3].branches.len(), 2);
assert_eq!(diag.blocks[3].branches[0].label, "A to B");
assert_eq!(diag.blocks[3].branches[1].label, "C to D");
}
#[test]
fn parse_autonumber_bare_enables_at_start_one() {
let diag = parse("sequenceDiagram\nautonumber\nA->>B: hi").unwrap();
assert_eq!(diag.autonumber_changes.len(), 1);
assert_eq!(diag.autonumber_changes[0].at_message, 0);
assert_eq!(
diag.autonumber_changes[0].state,
AutonumberState::On { next_value: 1 }
);
}
#[test]
fn parse_autonumber_with_start_value() {
let diag = parse("sequenceDiagram\nautonumber 5\nA->>B: hi").unwrap();
assert_eq!(
diag.autonumber_changes[0].state,
AutonumberState::On { next_value: 5 }
);
}
#[test]
fn parse_autonumber_off() {
let diag =
parse("sequenceDiagram\nautonumber\nA->>B: hi\nautonumber off\nB->>A: bye").unwrap();
assert_eq!(diag.autonumber_changes.len(), 2);
assert_eq!(diag.autonumber_changes[1].at_message, 1);
assert_eq!(diag.autonumber_changes[1].state, AutonumberState::Off);
}
#[test]
fn parse_autonumber_mid_diagram_rebase() {
let diag = parse("sequenceDiagram\nA->>B: a\nautonumber 100\nB->>A: b").unwrap();
assert_eq!(diag.autonumber_changes[0].at_message, 1);
assert_eq!(
diag.autonumber_changes[0].state,
AutonumberState::On { next_value: 100 }
);
}
#[test]
fn parse_note_left_of_records_left_anchor() {
let diag = parse("sequenceDiagram\nA->>B: hi\nnote left of A : context").unwrap();
assert_eq!(diag.notes.len(), 1);
assert_eq!(
diag.notes[0].anchor,
crate::sequence::NoteAnchor::LeftOf("A".to_string())
);
assert_eq!(diag.notes[0].text, "context");
assert_eq!(diag.notes[0].after_message, 1, "after the only message");
}
#[test]
fn parse_note_right_of_records_right_anchor() {
let diag = parse("sequenceDiagram\nnote right of B : tip\nA->>B: hi").unwrap();
assert_eq!(
diag.notes[0].anchor,
crate::sequence::NoteAnchor::RightOf("B".to_string())
);
assert_eq!(diag.notes[0].after_message, 0);
}
#[test]
fn parse_note_over_single_anchor() {
let diag = parse("sequenceDiagram\nA->>B: hi\nnote over A : single").unwrap();
assert_eq!(
diag.notes[0].anchor,
crate::sequence::NoteAnchor::Over("A".to_string())
);
}
#[test]
fn parse_note_over_pair_anchor() {
let diag = parse("sequenceDiagram\nA->>B: hi\nnote over A,B : shared").unwrap();
assert_eq!(
diag.notes[0].anchor,
crate::sequence::NoteAnchor::OverPair("A".to_string(), "B".to_string())
);
}
#[test]
fn parse_note_br_tags_become_newlines() {
let diag =
parse("sequenceDiagram\nA->>B: hi\nnote over A : line1<br>line2<br/>line3").unwrap();
assert_eq!(diag.notes[0].text, "line1\nline2\nline3");
}
#[test]
fn parse_end_note_returns_helpful_error() {
let err = parse("sequenceDiagram\nA->>B: hi\nend note")
.expect_err("end note must be rejected with a helpful error");
let msg = format!("{err}");
assert!(
msg.contains("<br>") || msg.contains("not `end note`"),
"error must mention `<br>` or `not end note`, got: {msg}"
);
}
#[test]
fn parse_floating_note_silently_skipped() {
let diag = parse("sequenceDiagram\nA->>B: hi\nnote \"floating\" as N1").unwrap();
assert!(diag.notes.is_empty());
}
#[test]
fn parse_multiple_notes_track_message_position() {
let diag = parse(
"sequenceDiagram\n\
A->>B: first\n\
note right of B : after first\n\
B->>A: second\n\
note left of A : after second",
)
.unwrap();
assert_eq!(diag.notes.len(), 2);
assert_eq!(diag.notes[0].after_message, 1);
assert_eq!(diag.notes[1].after_message, 2);
}
#[test]
fn parse_explicit_activate_deactivate_pair() {
let diag = parse(
"sequenceDiagram\n\
A->>B: hi\n\
activate B\n\
B->>A: ok\n\
deactivate B",
)
.unwrap();
assert_eq!(diag.activations.len(), 1);
assert_eq!(diag.activations[0].participant, "B");
assert_eq!(diag.activations[0].start_message, 1);
assert_eq!(diag.activations[0].end_message, 1);
}
#[test]
fn parse_inline_plus_activates_target() {
let diag = parse("sequenceDiagram\nA->>+B: hi").unwrap();
assert_eq!(diag.activations.len(), 1);
assert_eq!(diag.activations[0].participant, "B");
assert_eq!(diag.activations[0].start_message, 0);
assert_eq!(diag.activations[0].end_message, 0);
assert_eq!(diag.messages[0].to, "B");
}
#[test]
fn parse_inline_minus_deactivates_source() {
let diag = parse(
"sequenceDiagram\n\
A->>+B: call\n\
B-->>-A: reply",
)
.unwrap();
assert_eq!(diag.activations.len(), 1);
assert_eq!(diag.activations[0].participant, "B");
assert_eq!(diag.activations[0].start_message, 0);
assert_eq!(diag.activations[0].end_message, 1);
assert_eq!(diag.messages[1].to, "A");
}
#[test]
fn parse_nested_activations_same_participant() {
let diag = parse(
"sequenceDiagram\n\
A->>B: outer\n\
activate B\n\
A->>B: inner\n\
activate B\n\
B->>A: inner reply\n\
deactivate B\n\
B->>A: outer reply\n\
deactivate B",
)
.unwrap();
assert_eq!(diag.activations.len(), 2);
assert_eq!(diag.activations[0].participant, "B");
assert_eq!(diag.activations[0].start_message, 2);
assert_eq!(diag.activations[1].participant, "B");
assert_eq!(diag.activations[1].start_message, 1);
}
#[test]
fn parse_orphan_deactivate_errors() {
let err = parse("sequenceDiagram\nA->>B: hi\ndeactivate B")
.expect_err("orphan deactivate must error");
let msg = err.to_string();
assert!(
msg.contains("deactivate") && msg.contains('B'),
"error mentions deactivate and the participant: {msg}"
);
}
#[test]
fn parse_unclosed_activate_extends_to_last_message() {
let diag = parse(
"sequenceDiagram\n\
activate B\n\
A->>B: one\n\
B->>A: two",
)
.unwrap();
assert_eq!(diag.activations.len(), 1);
assert_eq!(diag.activations[0].start_message, 0);
assert_eq!(diag.activations[0].end_message, 1, "extends to last msg");
}
#[test]
fn parse_activate_missing_participant_errors() {
let err = parse("sequenceDiagram\nactivate").expect_err("bare `activate` is malformed");
assert!(err.to_string().contains("activate"));
}
#[test]
fn parse_loop_records_single_branch_block() {
let diag = parse(
"sequenceDiagram\n\
loop Every second\n\
A->>B: tick\n\
end",
)
.unwrap();
assert_eq!(diag.blocks.len(), 1);
let b = &diag.blocks[0];
assert_eq!(b.kind, BlockKind::Loop);
assert_eq!(b.branches.len(), 1);
assert_eq!(b.branches[0].label, "Every second");
assert_eq!(b.start_message, 0);
assert_eq!(b.end_message, 0);
}
#[test]
fn parse_alt_else_records_two_branches_with_labels() {
let diag = parse(
"sequenceDiagram\n\
alt success\n\
A->>B: ok\n\
else failure\n\
A->>B: fail\n\
end",
)
.unwrap();
assert_eq!(diag.blocks.len(), 1);
let b = &diag.blocks[0];
assert_eq!(b.kind, BlockKind::Alt);
assert_eq!(b.branches.len(), 2);
assert_eq!(b.branches[0].label, "success");
assert_eq!(b.branches[0].start_message, 0);
assert_eq!(b.branches[0].end_message, 0);
assert_eq!(b.branches[1].label, "failure");
assert_eq!(b.branches[1].start_message, 1);
assert_eq!(b.branches[1].end_message, 1);
}
#[test]
fn parse_opt_block() {
let diag = parse("sequenceDiagram\nopt cache hit\nA->>B: get\nend").unwrap();
assert_eq!(diag.blocks[0].kind, BlockKind::Opt);
assert_eq!(diag.blocks[0].branches[0].label, "cache hit");
}
#[test]
fn parse_par_with_multiple_and_branches() {
let diag = parse(
"sequenceDiagram\n\
par phase1\n\
A->>B: a\n\
and phase2\n\
A->>B: b\n\
and phase3\n\
A->>B: c\n\
end",
)
.unwrap();
assert_eq!(diag.blocks[0].kind, BlockKind::Par);
assert_eq!(diag.blocks[0].branches.len(), 3);
assert_eq!(diag.blocks[0].branches[2].label, "phase3");
}
#[test]
fn parse_critical_with_option() {
let diag = parse(
"sequenceDiagram\n\
critical primary\n\
A->>B: try\n\
option network down\n\
A->>B: retry\n\
end",
)
.unwrap();
assert_eq!(diag.blocks[0].kind, BlockKind::Critical);
assert_eq!(diag.blocks[0].branches.len(), 2);
}
#[test]
fn parse_break_block() {
let diag = parse("sequenceDiagram\nbreak quota exceeded\nA->>B: 429\nend").unwrap();
assert_eq!(diag.blocks[0].kind, BlockKind::Break);
}
#[test]
fn parse_nested_loop_inside_alt() {
let diag = parse(
"sequenceDiagram\n\
alt outer\n\
loop inner\n\
A->>B: tick\n\
end\n\
else fallback\n\
A->>B: skip\n\
end",
)
.unwrap();
assert_eq!(diag.blocks.len(), 2);
assert_eq!(diag.blocks[0].kind, BlockKind::Loop);
assert_eq!(diag.blocks[1].kind, BlockKind::Alt);
assert_eq!(diag.blocks[1].start_message, 0);
assert_eq!(diag.blocks[1].end_message, 1);
}
#[test]
fn parse_orphan_end_errors() {
let err = parse("sequenceDiagram\nA->>B: hi\nend").expect_err("orphan end");
assert!(err.to_string().contains("end"));
}
#[test]
fn parse_else_outside_alt_errors() {
let err = parse("sequenceDiagram\nA->>B: hi\nelse foo\nA->>B: x").expect_err("orphan else");
assert!(err.to_string().contains("else"));
}
#[test]
fn parse_and_inside_alt_errors_with_kind_hint() {
let err = parse(
"sequenceDiagram\n\
alt foo\n\
A->>B: x\n\
and bar\n\
A->>B: y\n\
end",
)
.expect_err("`and` not valid inside alt");
let m = err.to_string();
assert!(m.contains("and") && m.contains("else"));
}
#[test]
fn parse_unclosed_block_at_eof_errors() {
let err = parse("sequenceDiagram\nloop forever\nA->>B: hi").expect_err("unclosed loop");
assert!(err.to_string().contains("unclosed"));
}
#[test]
fn parse_rect_block_silently_skipped() {
let diag = parse("sequenceDiagram\nrect rgb(200,150,255)\nA->>B: hi\nend");
assert!(diag.is_err()); }
}