use crate::Error;
use crate::sequence::{Message, MessageStyle, Participant, SequenceDiagram};
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();
for raw in src.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if line.to_lowercase().starts_with("sequencediagram") {
continue;
}
if line.eq_ignore_ascii_case("autonumber") {
continue;
}
if line.to_lowercase().starts_with("note ") {
continue;
}
if line.to_lowercase().starts_with("activate ")
|| line.to_lowercase().starts_with("deactivate ")
{
continue;
}
let lower = line.to_lowercase();
if matches!(
lower.split_whitespace().next().unwrap_or(""),
"loop"
| "alt"
| "else"
| "opt"
| "par"
| "and"
| "critical"
| "option"
| "break"
| "rect"
| "end"
) {
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) = try_parse_message(line) {
diag.ensure_participant(&msg.from.clone());
diag.ensure_participant(&msg.to.clone());
diag.messages.push(msg);
continue;
}
return Err(Error::ParseError(format!(
"unrecognised sequence diagram line: {line:?}"
)));
}
Ok(diag)
}
fn strip_keyword_prefix<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let len = keyword.len();
if line.len() > len
&& line[..len].eq_ignore_ascii_case(keyword)
&& line.as_bytes()[len].is_ascii_whitespace()
{
Some(line[len..].trim())
} else {
None
}
}
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> {
for &(arrow, style) in ARROWS {
if let Some((from, rest)) = line.split_once(arrow) {
let from = from.trim().to_string();
let (to, 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())
};
if from.is_empty() || to.is_empty() {
continue;
}
return Some(Message {
from,
to,
text,
style,
});
}
}
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_block_statements_are_skipped() {
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("block statements should be skipped, not error");
assert_eq!(
diag.messages.len(),
7,
"expected 7 messages, got {}: {:?}",
diag.messages.len(),
diag.messages.iter().map(|m| &m.text).collect::<Vec<_>>()
);
}
}