use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use super::ident::validate_mermaid_ident;
pub use super::ident::MermaidIdentError;
use crate::model::ids::ObjectId;
use crate::model::seq_ast::{
SequenceAst, SequenceBlock, SequenceBlockKind, SequenceMessage, SequenceMessageKind,
SequenceParticipant, SequenceSection, SequenceSectionKind,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MermaidSequenceParseError {
MissingHeader,
UnsupportedSyntax { line_no: usize, line: String },
InvalidParticipantDecl { line_no: usize, line: String },
InvalidParticipantName { line_no: usize, name: String, reason: MermaidIdentError },
InvalidMessageLine { line_no: usize, line: String },
InvalidMessageParticipant { line_no: usize, name: String, reason: MermaidIdentError },
MissingMessageText { line_no: usize, line: String },
UnmatchedEnd { line_no: usize },
ElseOutsideAlt { line_no: usize, line: String },
AndOutsidePar { line_no: usize, line: String },
BlockNestingTooDeep { line_no: usize, max_depth: usize },
EmptyBlockSection { line_no: usize, section_id: ObjectId },
UnclosedBlock { opened_on_line_no: usize, block_id: ObjectId, kind: SequenceBlockKind },
}
impl fmt::Display for MermaidSequenceParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingHeader => {
f.write_str("expected 'sequenceDiagram' as the first non-empty line")
}
Self::UnsupportedSyntax { line_no, line } => {
write!(f, "unsupported Mermaid syntax on line {line_no}: {line}")
}
Self::InvalidParticipantDecl { line_no, line } => {
write!(
f,
"invalid participant declaration on line {line_no}: {line} (expected 'participant <name>' or '<role> <name>')"
)
}
Self::InvalidParticipantName {
line_no,
name,
reason,
} => write!(
f,
"invalid participant name on line {line_no}: {name} ({reason})"
),
Self::InvalidMessageLine { line_no, line } => write!(
f,
"invalid message syntax on line {line_no}: {line} (expected '<from><arrow><to>: <text>')"
),
Self::InvalidMessageParticipant {
line_no,
name,
reason,
} => write!(
f,
"invalid message participant on line {line_no}: {name} ({reason})"
),
Self::MissingMessageText { line_no, line } => {
write!(f, "missing message text on line {line_no}: {line}")
}
Self::UnmatchedEnd { line_no } => {
write!(
f,
"unmatched 'end' on line {line_no}: no block is currently open"
)
}
Self::ElseOutsideAlt { line_no, line } => write!(
f,
"invalid 'else' on line {line_no}: only valid inside an open 'alt' block: {line}"
),
Self::AndOutsidePar { line_no, line } => write!(
f,
"invalid 'and' on line {line_no}: only valid inside an open 'par' block: {line}"
),
Self::BlockNestingTooDeep { line_no, max_depth } => write!(
f,
"block nesting too deep on line {line_no}: max supported depth is {max_depth}"
),
Self::EmptyBlockSection {
line_no,
section_id,
} => write!(
f,
"empty block section on line {line_no}: {section_id} contains no messages"
),
Self::UnclosedBlock {
opened_on_line_no,
block_id,
kind,
} => write!(
f,
"unclosed '{}' block {block_id}: missing 'end' for block opened on line {opened_on_line_no}",
block_kind_keyword(*kind)
),
}
}
}
impl std::error::Error for MermaidSequenceParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MermaidSequenceExportError {
MissingParticipant { participant_id: ObjectId },
InvalidParticipantRole { participant_id: ObjectId, role: String },
InvalidMessageText { message_id: ObjectId, text: String },
InvalidBlockMembership { block_id: ObjectId, reason: String },
}
impl fmt::Display for MermaidSequenceExportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingParticipant { participant_id } => {
write!(f, "message references missing participant id: {participant_id}")
}
Self::InvalidParticipantRole {
participant_id,
role,
} => write!(
f,
"cannot export participant {participant_id} with unsupported role keyword: {role:?}"
),
Self::InvalidMessageText { message_id, text } => write!(
f,
"cannot export message text for {message_id}: contains unsupported characters: {text:?}"
),
Self::InvalidBlockMembership { block_id, reason } => {
write!(f, "cannot export block {block_id}: {reason}")
}
}
}
}
impl std::error::Error for MermaidSequenceExportError {}
fn participant_id_from_mermaid_name(name: &str) -> Result<ObjectId, MermaidIdentError> {
validate_mermaid_ident(name)?;
ObjectId::new(format!("p:{name}")).map_err(|_| MermaidIdentError::ContainsSlash)
}
fn message_id_from_index(index: usize) -> ObjectId {
ObjectId::new(format!("m:{index:04}")).expect("valid message id")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Arrow {
Sync,
Async,
Return,
}
impl Arrow {
fn from_token(token: &str) -> Option<Self> {
match token {
"-)" | "--)" => Some(Self::Async),
"-->>" | "<<-->>" => Some(Self::Return),
"->>" | "->" | "-->" | "<<->>" | "-x" | "--x" => Some(Self::Sync),
_ => None,
}
}
fn as_token(self) -> &'static str {
match self {
Self::Sync => "->>",
Self::Async => "-)",
Self::Return => "-->>",
}
}
fn kind(self) -> SequenceMessageKind {
match self {
Self::Sync => SequenceMessageKind::Sync,
Self::Async => SequenceMessageKind::Async,
Self::Return => SequenceMessageKind::Return,
}
}
fn from_kind(kind: SequenceMessageKind) -> Self {
match kind {
SequenceMessageKind::Sync => Self::Sync,
SequenceMessageKind::Async => Self::Async,
SequenceMessageKind::Return => Self::Return,
}
}
}
fn split_once_any<'a>(
haystack: &'a str,
needles: &[&'static str],
) -> Option<(&'a str, &'static str, &'a str)> {
let mut best: Option<(usize, &'static str)> = None;
for &needle in needles {
if let Some(idx) = haystack.find(needle) {
let take = match best {
None => true,
Some((best_idx, best_needle)) => {
idx < best_idx || (idx == best_idx && needle.len() > best_needle.len())
}
};
if take {
best = Some((idx, needle));
}
}
}
let (idx, needle) = best?;
let left = &haystack[..idx];
let right = &haystack[idx + needle.len()..];
Some((left, needle, right))
}
fn is_comment_line(trimmed: &str) -> bool {
trimmed.starts_with("%%")
}
fn is_reserved_sequence_keyword(keyword: &str) -> bool {
matches!(
keyword,
"alt"
| "and"
| "activate"
| "autonumber"
| "break"
| "box"
| "critical"
| "create"
| "deactivate"
| "destroy"
| "else"
| "end"
| "loop"
| "note"
| "opt"
| "option"
| "par"
| "rect"
)
}
fn participant_declaration(
trimmed: &str,
line_no: usize,
) -> Result<Option<(Option<&str>, &str)>, MermaidSequenceParseError> {
let mut parts = trimmed.split_whitespace();
let Some(keyword) = parts.next() else {
return Ok(None);
};
if is_reserved_sequence_keyword(keyword) {
return Ok(None);
}
let Some(name) = parts.next() else {
if keyword == "participant" || keyword == "actor" {
return Err(MermaidSequenceParseError::InvalidParticipantDecl {
line_no,
line: trimmed.to_owned(),
});
}
return Ok(None);
};
if parts.next().is_some() {
if keyword == "participant" || keyword == "actor" {
return Err(MermaidSequenceParseError::InvalidParticipantDecl {
line_no,
line: trimmed.to_owned(),
});
}
return Ok(None);
}
if keyword == "participant" {
return Ok(Some((None, name)));
}
if validate_mermaid_ident(keyword).is_err() {
return Ok(None);
}
Ok(Some((Some(keyword), name)))
}
fn ensure_participant(
participants: &mut BTreeMap<ObjectId, SequenceParticipant>,
name: &str,
line_no: usize,
) -> Result<ObjectId, MermaidSequenceParseError> {
let participant_id = participant_id_from_mermaid_name(name).map_err(|reason| {
MermaidSequenceParseError::InvalidMessageParticipant {
line_no,
name: name.to_owned(),
reason,
}
})?;
participants
.entry(participant_id.clone())
.or_insert_with(|| SequenceParticipant::new(name.to_owned()));
Ok(participant_id)
}
const MAX_BLOCK_NEST_DEPTH: usize = 8;
#[derive(Debug, Clone)]
struct OpenBlock {
block_index: usize,
block_id: ObjectId,
kind: SequenceBlockKind,
header: Option<String>,
sections: Vec<SequenceSection>,
blocks: Vec<SequenceBlock>,
current_section_index: usize,
opened_on_line_no: usize,
}
impl OpenBlock {
fn new(
block_index: usize,
kind: SequenceBlockKind,
header: Option<String>,
opened_on_line_no: usize,
) -> Self {
let block_id = SequenceBlock::make_block_id(block_index);
let section_id = SequenceSection::make_section_id(block_index, 0);
Self {
block_index,
block_id,
kind,
header,
sections: vec![SequenceSection::new(
section_id,
SequenceSectionKind::Main,
None,
Vec::new(),
)],
blocks: Vec::new(),
current_section_index: 0,
opened_on_line_no,
}
}
fn current_section(&self) -> &SequenceSection {
self.sections.get(self.current_section_index).expect("current section in range")
}
fn current_section_mut(&mut self) -> &mut SequenceSection {
self.sections.get_mut(self.current_section_index).expect("current section in range")
}
fn push_message_id(&mut self, message_id: ObjectId) {
self.current_section_mut().message_ids_mut().push(message_id);
}
fn start_section(&mut self, kind: SequenceSectionKind, header: Option<String>) {
let section_index = self.sections.len();
let section_id = SequenceSection::make_section_id(self.block_index, section_index);
self.sections.push(SequenceSection::new(section_id, kind, header, Vec::new()));
self.current_section_index = section_index;
}
fn into_block(self) -> SequenceBlock {
SequenceBlock::new(self.block_id, self.kind, self.header, self.sections, self.blocks)
}
}
fn block_kind_keyword(kind: SequenceBlockKind) -> &'static str {
match kind {
SequenceBlockKind::Alt => "alt",
SequenceBlockKind::Opt => "opt",
SequenceBlockKind::Loop => "loop",
SequenceBlockKind::Par => "par",
}
}
fn section_kind_keyword(kind: SequenceSectionKind) -> &'static str {
match kind {
SequenceSectionKind::Main => "",
SequenceSectionKind::Else => "else",
SequenceSectionKind::And => "and",
}
}
fn keyword_header(trimmed: &str, keyword: &str) -> Option<String> {
let rest = trimmed.get(keyword.len()..).unwrap_or_default().trim();
(!rest.is_empty()).then(|| rest.to_owned())
}
pub fn parse_sequence_diagram(input: &str) -> Result<SequenceAst, MermaidSequenceParseError> {
let mut ast = SequenceAst::default();
let mut saw_header = false;
let mut used_message_ids = BTreeSet::<ObjectId>::new();
let mut open_blocks = Vec::<OpenBlock>::new();
let mut next_block_index = 0usize;
for (idx, raw_line) in input.lines().enumerate() {
let line_no = idx + 1;
let trimmed = raw_line.trim();
if trimmed.is_empty() || is_comment_line(trimmed) {
continue;
}
if !saw_header {
if trimmed == "sequenceDiagram" {
saw_header = true;
continue;
}
return Err(MermaidSequenceParseError::MissingHeader);
}
if let Some(keyword) = trimmed.split_whitespace().next() {
match keyword {
"alt" | "opt" | "loop" | "par" => {
if open_blocks.len() >= MAX_BLOCK_NEST_DEPTH {
return Err(MermaidSequenceParseError::BlockNestingTooDeep {
line_no,
max_depth: MAX_BLOCK_NEST_DEPTH,
});
}
next_block_index += 1;
let kind = match keyword {
"alt" => SequenceBlockKind::Alt,
"opt" => SequenceBlockKind::Opt,
"loop" => SequenceBlockKind::Loop,
"par" => SequenceBlockKind::Par,
_ => unreachable!("matched keyword"),
};
let header = keyword_header(trimmed, keyword);
open_blocks.push(OpenBlock::new(next_block_index, kind, header, line_no));
continue;
}
"else" => {
let Some(top) = open_blocks.last_mut() else {
return Err(MermaidSequenceParseError::ElseOutsideAlt {
line_no,
line: trimmed.to_owned(),
});
};
if top.kind != SequenceBlockKind::Alt {
return Err(MermaidSequenceParseError::ElseOutsideAlt {
line_no,
line: trimmed.to_owned(),
});
}
if top.current_section().message_ids().is_empty() {
return Err(MermaidSequenceParseError::EmptyBlockSection {
line_no,
section_id: top.current_section().section_id().clone(),
});
}
let header = keyword_header(trimmed, keyword);
top.start_section(SequenceSectionKind::Else, header);
continue;
}
"and" => {
let Some(top) = open_blocks.last_mut() else {
return Err(MermaidSequenceParseError::AndOutsidePar {
line_no,
line: trimmed.to_owned(),
});
};
if top.kind != SequenceBlockKind::Par {
return Err(MermaidSequenceParseError::AndOutsidePar {
line_no,
line: trimmed.to_owned(),
});
}
if top.current_section().message_ids().is_empty() {
return Err(MermaidSequenceParseError::EmptyBlockSection {
line_no,
section_id: top.current_section().section_id().clone(),
});
}
let header = keyword_header(trimmed, keyword);
top.start_section(SequenceSectionKind::And, header);
continue;
}
"end" => {
if trimmed != "end" {
return Err(MermaidSequenceParseError::UnsupportedSyntax {
line_no,
line: trimmed.to_owned(),
});
}
let Some(top) = open_blocks.last() else {
return Err(MermaidSequenceParseError::UnmatchedEnd { line_no });
};
if top.current_section().message_ids().is_empty() {
return Err(MermaidSequenceParseError::EmptyBlockSection {
line_no,
section_id: top.current_section().section_id().clone(),
});
}
let finished = open_blocks.pop().expect("present");
let block = finished.into_block();
if let Some(parent) = open_blocks.last_mut() {
parent.blocks.push(block);
} else {
ast.blocks_mut().push(block);
}
continue;
}
_ => {}
}
if let Some((role, name)) = participant_declaration(trimmed, line_no)? {
validate_mermaid_ident(name).map_err(|reason| {
MermaidSequenceParseError::InvalidParticipantName {
line_no,
name: name.to_owned(),
reason,
}
})?;
let participant_id = participant_id_from_mermaid_name(name).map_err(|reason| {
MermaidSequenceParseError::InvalidParticipantName {
line_no,
name: name.to_owned(),
reason,
}
})?;
let participant = ast
.participants_mut()
.entry(participant_id)
.or_insert_with(|| SequenceParticipant::new(name.to_owned()));
participant.set_role(role);
continue;
}
}
let (from_raw, arrow_token, rest) = split_once_any(
trimmed,
&["<<-->>", "<<->>", "-->>", "->>", "--)", "-)", "--x", "-x", "-->", "->"],
)
.ok_or_else(|| MermaidSequenceParseError::UnsupportedSyntax {
line_no,
line: trimmed.to_owned(),
})?;
let arrow = Arrow::from_token(arrow_token).ok_or_else(|| {
MermaidSequenceParseError::InvalidMessageLine { line_no, line: trimmed.to_owned() }
})?;
let rest = rest.trim_start();
let (raw_arrow, rest) = match rest.chars().next() {
Some('+' | '-') => {
let suffix = rest.chars().next().expect("present");
let mut raw_arrow = arrow_token.to_owned();
raw_arrow.push(suffix);
(raw_arrow, &rest[suffix.len_utf8()..])
}
_ => (arrow_token.to_owned(), rest),
};
let (to_raw, text_raw) = rest.split_once(':').ok_or_else(|| {
MermaidSequenceParseError::InvalidMessageLine { line_no, line: trimmed.to_owned() }
})?;
let from_name = from_raw.trim();
let to_name = to_raw.trim();
validate_mermaid_ident(from_name).map_err(|reason| {
MermaidSequenceParseError::InvalidMessageParticipant {
line_no,
name: from_name.to_owned(),
reason,
}
})?;
validate_mermaid_ident(to_name).map_err(|reason| {
MermaidSequenceParseError::InvalidMessageParticipant {
line_no,
name: to_name.to_owned(),
reason,
}
})?;
let text = text_raw.trim();
if text.is_empty() {
return Err(MermaidSequenceParseError::MissingMessageText {
line_no,
line: trimmed.to_owned(),
});
}
let from_participant_id = ensure_participant(ast.participants_mut(), from_name, line_no)?;
let to_participant_id = ensure_participant(ast.participants_mut(), to_name, line_no)?;
let message_index = ast.messages().len() + 1;
let mut message_id = message_id_from_index(message_index);
let mut bump = 0usize;
while used_message_ids.contains(&message_id) {
bump += 1;
message_id = message_id_from_index(message_index + bump);
}
used_message_ids.insert(message_id.clone());
let message_id_for_membership = message_id.clone();
let order_key = (message_index as i64) * 1000;
let mut message = SequenceMessage::new(
message_id,
from_participant_id,
to_participant_id,
arrow.kind(),
text.to_owned(),
order_key,
);
let canonical = Arrow::from_kind(arrow.kind()).as_token();
message.set_raw_arrow((raw_arrow != canonical).then_some(raw_arrow));
for open_block in &mut open_blocks {
open_block.push_message_id(message_id_for_membership.clone());
}
ast.messages_mut().push(message);
}
if !saw_header {
return Err(MermaidSequenceParseError::MissingHeader);
}
if let Some(unclosed) = open_blocks.last() {
return Err(MermaidSequenceParseError::UnclosedBlock {
opened_on_line_no: unclosed.opened_on_line_no,
block_id: unclosed.block_id.clone(),
kind: unclosed.kind,
});
}
Ok(ast)
}
fn validate_export_message_text(text: &str) -> bool {
!text.contains('\n') && !text.contains('\r')
}
fn validate_export_participant_role(
participant_id: &ObjectId,
role: &str,
) -> Result<(), MermaidSequenceExportError> {
if role == "participant"
|| is_reserved_sequence_keyword(role)
|| validate_mermaid_ident(role).is_err()
{
return Err(MermaidSequenceExportError::InvalidParticipantRole {
participant_id: participant_id.clone(),
role: role.to_owned(),
});
}
Ok(())
}
fn validate_export_arrow_token(raw: &str, expected_kind: SequenceMessageKind) -> Option<&str> {
let raw = raw.trim();
if raw.is_empty()
|| raw.contains('\n')
|| raw.contains('\r')
|| raw.chars().any(|ch| ch.is_whitespace())
{
return None;
}
let (base, _suffix) = match raw.strip_suffix('+') {
Some(base) => (base, Some('+')),
None => match raw.strip_suffix('-') {
Some(base) => (base, Some('-')),
None => (raw, None),
},
};
let arrow = Arrow::from_token(base)?;
if arrow.kind() != expected_kind {
return None;
}
Some(raw)
}
#[derive(Debug, Clone, Copy)]
struct ExportBlockRange {
start: usize,
end: usize,
}
#[derive(Debug, Clone, Copy)]
struct ExportSectionRange<'a> {
section: &'a SequenceSection,
start: usize,
end: usize,
}
#[derive(Debug, Clone, Copy)]
enum ExportEvent<'a> {
BlockOpen { block: &'a SequenceBlock, depth: usize },
SectionSplit { block: &'a SequenceBlock, section: &'a SequenceSection, depth: usize },
BlockClose { block: &'a SequenceBlock, depth: usize },
}
fn export_event_sort_key_before<'a>(
event: &ExportEvent<'a>,
) -> (u8, usize, &'a ObjectId, Option<&'a ObjectId>) {
match *event {
ExportEvent::SectionSplit { block, section, depth } => {
(0, depth, block.block_id(), Some(section.section_id()))
}
ExportEvent::BlockOpen { block, depth } => (1, depth, block.block_id(), None),
ExportEvent::BlockClose { block, depth } => (2, depth, block.block_id(), None),
}
}
fn export_event_sort_key_after<'a>(event: &ExportEvent<'a>) -> (usize, &'a ObjectId) {
match *event {
ExportEvent::BlockClose { block, depth } => (usize::MAX - depth, block.block_id()),
ExportEvent::BlockOpen { block, depth } => (usize::MAX - depth, block.block_id()),
ExportEvent::SectionSplit { block, depth, section: _ } => {
(usize::MAX - depth, block.block_id())
}
}
}
fn export_section_ranges<'a>(
block: &'a SequenceBlock,
message_index_by_id: &BTreeMap<ObjectId, usize>,
) -> Result<Vec<ExportSectionRange<'a>>, MermaidSequenceExportError> {
let mut ranges = Vec::<ExportSectionRange<'a>>::new();
if block.sections().is_empty() {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: "has no sections".to_owned(),
});
}
for section in block.sections() {
if section.message_ids().is_empty() {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!("section {} is empty", section.section_id()),
});
}
let mut indices = Vec::<usize>::with_capacity(section.message_ids().len());
for message_id in section.message_ids() {
let Some(&idx) = message_index_by_id.get(message_id) else {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!(
"section {} references missing message id {}",
section.section_id(),
message_id
),
});
};
indices.push(idx);
}
indices.sort_unstable();
indices.dedup();
if indices.is_empty() {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!("section {} is empty after dedup", section.section_id()),
});
}
for window in indices.windows(2) {
if window[1] != window[0] + 1 {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!(
"section {} message membership is not contiguous",
section.section_id()
),
});
}
}
ranges.push(ExportSectionRange {
section,
start: indices[0],
end: indices[indices.len() - 1],
});
}
for (idx, range) in ranges.iter().enumerate() {
if idx == 0 {
continue;
}
let prev = &ranges[idx - 1];
if range.start != prev.end + 1 {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!(
"section {} does not start immediately after previous section {}",
range.section.section_id(),
prev.section.section_id()
),
});
}
}
Ok(ranges)
}
fn export_schedule_block<'a>(
block: &'a SequenceBlock,
depth: usize,
message_index_by_id: &BTreeMap<ObjectId, usize>,
before: &mut [Vec<ExportEvent<'a>>],
after: &mut [Vec<ExportEvent<'a>>],
) -> Result<ExportBlockRange, MermaidSequenceExportError> {
let section_ranges = export_section_ranges(block, message_index_by_id)?;
let start = section_ranges.first().expect("non-empty").start;
let end = section_ranges.last().expect("non-empty").end;
if start >= before.len() || end >= after.len() {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: "block message range is out of bounds".to_owned(),
});
}
before[start].push(ExportEvent::BlockOpen { block, depth });
after[end].push(ExportEvent::BlockClose { block, depth });
for range in section_ranges.iter().skip(1) {
before[range.start].push(ExportEvent::SectionSplit {
block,
section: range.section,
depth,
});
}
let mut child_ranges = Vec::<(usize, usize, &ObjectId)>::new();
for child in block.blocks() {
let child_range =
export_schedule_block(child, depth + 1, message_index_by_id, before, after)?;
if child_range.start < start || child_range.end > end {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!(
"nested block {} is outside parent message range",
child.block_id()
),
});
}
let mut containing_sections = section_ranges
.iter()
.filter(|section| child_range.start >= section.start && child_range.end <= section.end);
let Some(_section) = containing_sections.next() else {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!(
"nested block {} is not contained within a single parent section",
child.block_id()
),
});
};
if containing_sections.next().is_some() {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!(
"nested block {} is ambiguously contained in multiple parent sections",
child.block_id()
),
});
}
child_ranges.push((child_range.start, child_range.end, child.block_id()));
}
child_ranges.sort_by_key(|(start, _end, block_id)| (*start, (*block_id).clone()));
for window in child_ranges.windows(2) {
let (a_start, a_end, a_id) = window[0];
let (b_start, b_end, b_id) = window[1];
if a_end >= b_start {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: block.block_id().clone(),
reason: format!(
"nested blocks overlap: {a_id} [{a_start}..{a_end}] and {b_id} [{b_start}..{b_end}]"
),
});
}
}
Ok(ExportBlockRange { start, end })
}
pub fn export_sequence_diagram(ast: &SequenceAst) -> Result<String, MermaidSequenceExportError> {
let mut out = String::new();
out.push_str("sequenceDiagram\n");
for (participant_id, participant) in ast.participants() {
if let Some(role) = participant.role() {
validate_export_participant_role(participant_id, role)?;
out.push_str(role);
out.push(' ');
out.push_str(participant.mermaid_name());
} else {
out.push_str("participant ");
out.push_str(participant.mermaid_name());
}
out.push('\n');
}
let mut messages = ast.messages().iter().collect::<Vec<_>>();
messages.sort_by(|a, b| SequenceMessage::cmp_in_order(a, b));
let mut before = vec![Vec::<ExportEvent<'_>>::new(); messages.len()];
let mut after = vec![Vec::<ExportEvent<'_>>::new(); messages.len()];
if !ast.blocks().is_empty() {
if messages.is_empty() {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: ast
.blocks()
.first()
.map(|block| block.block_id().clone())
.unwrap_or_else(|| ObjectId::new("b:0000").expect("valid id")),
reason: "diagram contains blocks but no messages".to_owned(),
});
}
let message_index_by_id = messages
.iter()
.enumerate()
.map(|(idx, msg)| (msg.message_id().clone(), idx))
.collect::<BTreeMap<_, _>>();
let mut root_ranges = Vec::<(usize, usize, &ObjectId)>::new();
for block in ast.blocks() {
let range =
export_schedule_block(block, 0, &message_index_by_id, &mut before, &mut after)?;
root_ranges.push((range.start, range.end, block.block_id()));
}
root_ranges.sort_by_key(|(start, _end, block_id)| (*start, (*block_id).clone()));
for window in root_ranges.windows(2) {
let (a_start, a_end, a_id) = window[0];
let (b_start, b_end, b_id) = window[1];
if a_end >= b_start {
return Err(MermaidSequenceExportError::InvalidBlockMembership {
block_id: a_id.clone(),
reason: format!(
"root blocks overlap: {a_id} [{a_start}..{a_end}] and {b_id} [{b_start}..{b_end}]"
),
});
}
}
}
for (idx, msg) in messages.into_iter().enumerate() {
let mut before_events = std::mem::take(&mut before[idx]);
before_events
.sort_by(|a, b| export_event_sort_key_before(a).cmp(&export_event_sort_key_before(b)));
for event in before_events {
match event {
ExportEvent::BlockOpen { block, depth: _ } => {
out.push_str(block_kind_keyword(block.kind()));
if let Some(header) = block.header() {
if !header.is_empty() {
out.push(' ');
out.push_str(header);
}
}
out.push('\n');
}
ExportEvent::SectionSplit { block: _, section, depth: _ } => {
let keyword = section_kind_keyword(section.kind());
if keyword.is_empty() {
continue;
}
out.push_str(keyword);
if let Some(header) = section.header() {
if !header.is_empty() {
out.push(' ');
out.push_str(header);
}
}
out.push('\n');
}
ExportEvent::BlockClose { .. } => {
}
}
}
let from_name = ast
.participants()
.get(msg.from_participant_id())
.map(|p| p.mermaid_name())
.ok_or_else(|| MermaidSequenceExportError::MissingParticipant {
participant_id: msg.from_participant_id().clone(),
})?;
let to_name =
ast.participants().get(msg.to_participant_id()).map(|p| p.mermaid_name()).ok_or_else(
|| MermaidSequenceExportError::MissingParticipant {
participant_id: msg.to_participant_id().clone(),
},
)?;
out.push_str(from_name);
let arrow = msg
.raw_arrow()
.and_then(|raw| validate_export_arrow_token(raw, msg.kind()))
.unwrap_or_else(|| Arrow::from_kind(msg.kind()).as_token());
out.push_str(arrow);
out.push_str(to_name);
out.push_str(": ");
let text = msg.text();
if !validate_export_message_text(text) {
return Err(MermaidSequenceExportError::InvalidMessageText {
message_id: msg.message_id().clone(),
text: text.to_owned(),
});
}
out.push_str(text);
out.push('\n');
let mut after_events = std::mem::take(&mut after[idx]);
after_events
.sort_by(|a, b| export_event_sort_key_after(a).cmp(&export_event_sort_key_after(b)));
for event in after_events {
if let ExportEvent::BlockClose { .. } = event {
out.push_str("end\n");
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{
export_sequence_diagram, parse_sequence_diagram, participant_id_from_mermaid_name,
MermaidSequenceExportError, MermaidSequenceParseError,
};
use crate::model::seq_ast::{SequenceAst, SequenceMessageKind, SequenceParticipant};
use crate::model::seq_ast::{SequenceBlockKind, SequenceMessage, SequenceSectionKind};
use std::collections::BTreeSet;
type SequenceParticipantSemanticView = BTreeSet<String>;
type SequenceMessageSemanticView = Vec<(String, String, SequenceMessageKind, String)>;
fn assert_canonical_roundtrip(input: &str, expected: &str) {
let ast1 = parse_sequence_diagram(input).expect("parse 1");
let out1 = export_sequence_diagram(&ast1).expect("export 1");
assert_eq!(out1, expected);
let ast2 = parse_sequence_diagram(&out1).expect("parse 2");
let out2 = export_sequence_diagram(&ast2).expect("export 2");
assert_eq!(out2, expected);
}
#[test]
fn actor_role_round_trips_through_export_and_parse() {
let role_of = |ast: &SequenceAst, name: &str| -> Option<String> {
ast.participants()
.values()
.find(|participant| participant.mermaid_name() == name)
.and_then(|participant| participant.role().map(ToOwned::to_owned))
};
let input = "sequenceDiagram\nactor Alice\nparticipant Bob\nAlice->>Bob: hi\n";
let ast1 = parse_sequence_diagram(input).expect("parse 1");
assert_eq!(role_of(&ast1, "Alice").as_deref(), Some("actor"));
assert_eq!(role_of(&ast1, "Bob"), None);
let out = export_sequence_diagram(&ast1).expect("export");
assert!(out.contains("actor Alice"), "export must emit the actor keyword:\n{out}");
assert!(out.contains("participant Bob"), "export:\n{out}");
let ast2 = parse_sequence_diagram(&out).expect("parse 2");
assert_eq!(
role_of(&ast2, "Alice").as_deref(),
Some("actor"),
"actor role must survive the round-trip; export was:\n{out}",
);
assert_eq!(role_of(&ast2, "Bob"), None);
}
#[test]
fn participant_redeclaration_clears_previous_role() {
let role_of = |ast: &SequenceAst, name: &str| -> Option<String> {
ast.participants()
.values()
.find(|participant| participant.mermaid_name() == name)
.and_then(|participant| participant.role().map(ToOwned::to_owned))
};
let input = "sequenceDiagram\nactor Alice\nparticipant Alice\nAlice->>Alice: hi\n";
let ast = parse_sequence_diagram(input).expect("parse");
assert_eq!(role_of(&ast, "Alice"), None);
let out = export_sequence_diagram(&ast).expect("export");
assert!(out.contains("participant Alice"), "export must clear actor role:\n{out}");
assert!(!out.contains("actor Alice"), "export must not preserve stale actor role:\n{out}");
}
#[test]
fn custom_participant_role_round_trips_through_export_and_parse() {
let role_of = |ast: &SequenceAst, name: &str| -> Option<String> {
ast.participants()
.values()
.find(|participant| participant.mermaid_name() == name)
.and_then(|participant| participant.role().map(ToOwned::to_owned))
};
let alice_id = participant_id_from_mermaid_name("Alice").unwrap();
let mut alice = SequenceParticipant::new("Alice");
alice.set_role(Some("boundary"));
let mut ast = SequenceAst::default();
ast.participants_mut().insert(alice_id, alice);
let out = export_sequence_diagram(&ast).expect("export");
assert!(out.contains("boundary Alice"), "export must emit the custom role:\n{out}");
let parsed = parse_sequence_diagram(&out).expect("parse exported role");
assert_eq!(role_of(&parsed, "Alice").as_deref(), Some("boundary"));
}
#[test]
fn unsupported_directive_is_not_parsed_as_custom_role() {
let err = parse_sequence_diagram("sequenceDiagram\nactivate Alice\n").unwrap_err();
assert!(matches!(err, MermaidSequenceParseError::UnsupportedSyntax { .. }));
}
#[test]
fn export_rejects_reserved_participant_role_keyword() {
let alice_id = participant_id_from_mermaid_name("Alice").unwrap();
let mut alice = SequenceParticipant::new("Alice");
alice.set_role(Some("activate"));
let mut ast = SequenceAst::default();
ast.participants_mut().insert(alice_id.clone(), alice);
let err = export_sequence_diagram(&ast).unwrap_err();
assert!(matches!(
err,
MermaidSequenceExportError::InvalidParticipantRole { participant_id, role }
if participant_id == alice_id && role == "activate"
));
}
fn semantic_view(
ast: &SequenceAst,
) -> (SequenceParticipantSemanticView, SequenceMessageSemanticView) {
let participants = ast
.participants()
.values()
.map(|p| p.mermaid_name().to_owned())
.collect::<BTreeSet<_>>();
let mut messages = Vec::new();
for msg in ast.messages() {
let from = ast
.participants()
.get(msg.from_participant_id())
.expect("from participant")
.mermaid_name()
.to_owned();
let to = ast
.participants()
.get(msg.to_participant_id())
.expect("to participant")
.mermaid_name()
.to_owned();
messages.push((from, to, msg.kind(), msg.text().to_owned()));
}
(participants, messages)
}
#[test]
fn parses_participants_and_messages() {
let input = r#"
%% comment
sequenceDiagram
participant Alice
participant Bob
Alice->>Bob: Hello
Bob-->>Alice: Great!
Alice-)Bob: See you later
"#;
let ast = parse_sequence_diagram(input).expect("parse");
let (participants, messages) = semantic_view(&ast);
assert_eq!(participants, ["Alice".to_owned(), "Bob".to_owned()].into_iter().collect());
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].0, "Alice");
assert_eq!(messages[0].1, "Bob");
assert_eq!(messages[0].2, SequenceMessageKind::Sync);
assert_eq!(messages[0].3, "Hello");
assert_eq!(messages[1].2, SequenceMessageKind::Return);
assert_eq!(messages[2].2, SequenceMessageKind::Async);
}
#[test]
fn accepts_additional_mermaid_arrow_variants_and_activation_suffixes() {
let input = r#"
sequenceDiagram
participant Alice
participant Bob
participant Carol
Alice->Bob: no-head
Alice-->Bob: dotted no-head
Alice->>Bob: head
Alice-->>Bob: dotted head
Alice<<->>Bob: bidi head
Alice<<-->>Bob: dotted bidi
Alice-xBob: cross
Alice--xBob: dotted cross
Alice-)Bob: async
Alice--)Bob: dotted async
Alice->>+Carol: activated
Bob-->>-Alice: deactivated
"#;
let ast = parse_sequence_diagram(input).expect("parse");
let (_, messages) = semantic_view(&ast);
assert_eq!(messages.len(), 12);
assert_eq!(messages[0].2, SequenceMessageKind::Sync);
assert_eq!(messages[1].2, SequenceMessageKind::Sync);
assert_eq!(messages[2].2, SequenceMessageKind::Sync);
assert_eq!(messages[3].2, SequenceMessageKind::Return);
assert_eq!(messages[4].2, SequenceMessageKind::Sync);
assert_eq!(messages[5].2, SequenceMessageKind::Return);
assert_eq!(messages[6].2, SequenceMessageKind::Sync);
assert_eq!(messages[7].2, SequenceMessageKind::Sync);
assert_eq!(messages[8].2, SequenceMessageKind::Async);
assert_eq!(messages[9].2, SequenceMessageKind::Async);
assert_eq!(messages[10].2, SequenceMessageKind::Sync);
assert_eq!(messages[11].2, SequenceMessageKind::Return);
}
#[test]
fn creates_implicit_participants_from_messages() {
let input = "sequenceDiagram\nAlice->>Bob: Hi\n";
let ast = parse_sequence_diagram(input).expect("parse");
let (participants, messages) = semantic_view(&ast);
assert_eq!(participants, ["Alice".to_owned(), "Bob".to_owned()].into_iter().collect());
assert_eq!(messages.len(), 1);
}
#[test]
fn semantic_roundtrip_parse_export_parse() {
let input = r#"
sequenceDiagram
%% order of declarations doesn't matter for semantics
Bob-->>Alice: Pong
Alice->>Bob: Ping
"#;
let ast1 = parse_sequence_diagram(input).expect("parse 1");
let out = export_sequence_diagram(&ast1).expect("export");
let ast2 = parse_sequence_diagram(&out).expect("parse 2");
assert_eq!(semantic_view(&ast1), semantic_view(&ast2));
}
#[test]
fn preserves_non_canonical_arrow_tokens_and_activation_suffixes_on_export() {
let input = r#"
sequenceDiagram
Alice->>Bob: Canonical
Alice-->Bob: Dotted no arrowhead
Bob<<-->>Alice: Two-way return
Alice->>+Bob: Activate
Bob--)Alice: Async dotted open
"#;
let ast1 = parse_sequence_diagram(input).expect("parse 1");
let arrows1 = ast1
.messages_in_order()
.into_iter()
.map(|msg| (msg.kind(), msg.raw_arrow().map(ToOwned::to_owned), msg.text().to_owned()))
.collect::<Vec<_>>();
assert_eq!(
arrows1,
vec![
(SequenceMessageKind::Sync, None, "Canonical".to_owned()),
(
SequenceMessageKind::Sync,
Some("-->".to_owned()),
"Dotted no arrowhead".to_owned()
),
(
SequenceMessageKind::Return,
Some("<<-->>".to_owned()),
"Two-way return".to_owned()
),
(SequenceMessageKind::Sync, Some("->>+".to_owned()), "Activate".to_owned()),
(
SequenceMessageKind::Async,
Some("--)".to_owned()),
"Async dotted open".to_owned()
),
]
);
let out = export_sequence_diagram(&ast1).expect("export");
let ast2 = parse_sequence_diagram(&out).expect("parse 2");
let arrows2 = ast2
.messages_in_order()
.into_iter()
.map(|msg| (msg.kind(), msg.raw_arrow().map(ToOwned::to_owned), msg.text().to_owned()))
.collect::<Vec<_>>();
assert_eq!(arrows2, arrows1);
}
#[test]
fn rejects_missing_header() {
let err = parse_sequence_diagram("participant Alice\n").unwrap_err();
assert_eq!(err, MermaidSequenceParseError::MissingHeader);
}
#[test]
fn export_rejects_newlines_and_cr_in_message_text() {
let mut ast =
parse_sequence_diagram("sequenceDiagram\nAlice->>Bob: Hello\n").expect("parse");
let original = ast.messages()[0].clone();
for text in ["Hello\nWorld", "Hello\rWorld"] {
*ast.messages_mut() = vec![SequenceMessage::new(
original.message_id().clone(),
original.from_participant_id().clone(),
original.to_participant_id().clone(),
original.kind(),
text,
original.order_key(),
)];
let err = export_sequence_diagram(&ast).unwrap_err();
assert_eq!(
err,
MermaidSequenceExportError::InvalidMessageText {
message_id: original.message_id().clone(),
text: text.to_owned(),
}
);
}
}
#[test]
fn exports_alt_else_block_canonically() {
let input = r#"
sequenceDiagram
Alice->>Bob: Start
alt success
Bob->>Alice: OK
else failure
Bob->>Alice: Nope
end
Alice->>Bob: Done
"#;
let expected = "\
sequenceDiagram
participant Alice
participant Bob
Alice->>Bob: Start
alt success
Bob->>Alice: OK
else failure
Bob->>Alice: Nope
end
Alice->>Bob: Done
";
assert_canonical_roundtrip(input, expected);
}
#[test]
fn exports_opt_block_canonically() {
let input = r#"
sequenceDiagram
Alice->>Bob: Pre
opt Maybe
Bob->>Alice: Inside
end
Alice->>Bob: Post
"#;
let expected = "\
sequenceDiagram
participant Alice
participant Bob
Alice->>Bob: Pre
opt Maybe
Bob->>Alice: Inside
end
Alice->>Bob: Post
";
assert_canonical_roundtrip(input, expected);
}
#[test]
fn exports_loop_block_canonically() {
let input = r#"
sequenceDiagram
Alice->>Bob: Pre
loop Retry
Bob->>Alice: Attempt
end
Alice->>Bob: Post
"#;
let expected = "\
sequenceDiagram
participant Alice
participant Bob
Alice->>Bob: Pre
loop Retry
Bob->>Alice: Attempt
end
Alice->>Bob: Post
";
assert_canonical_roundtrip(input, expected);
}
#[test]
fn exports_par_and_block_canonically() {
let input = r#"
sequenceDiagram
Alice->>Bob: Pre
par First
Alice->>Bob: Left
and Second
Bob->>Alice: Right
end
Alice->>Bob: Post
"#;
let expected = "\
sequenceDiagram
participant Alice
participant Bob
Alice->>Bob: Pre
par First
Alice->>Bob: Left
and Second
Bob->>Alice: Right
end
Alice->>Bob: Post
";
assert_canonical_roundtrip(input, expected);
}
#[test]
fn exports_nested_blocks_canonically() {
let input = r#"
sequenceDiagram
Alice->>Bob: Start
alt Outer
opt Inner
Bob->>Alice: Inside
end
Bob->>Alice: After
else Other
Bob->>Alice: ElseMsg
end
Alice->>Bob: Done
"#;
let expected = "\
sequenceDiagram
participant Alice
participant Bob
Alice->>Bob: Start
alt Outer
opt Inner
Bob->>Alice: Inside
end
Bob->>Alice: After
else Other
Bob->>Alice: ElseMsg
end
Alice->>Bob: Done
";
assert_canonical_roundtrip(input, expected);
let ast = parse_sequence_diagram(input).expect("parse");
assert_eq!(ast.blocks().len(), 1);
let outer = &ast.blocks()[0];
assert_eq!(outer.kind(), SequenceBlockKind::Alt);
assert_eq!(outer.sections().len(), 2);
assert_eq!(outer.sections()[0].kind(), SequenceSectionKind::Main);
assert_eq!(outer.sections()[1].kind(), SequenceSectionKind::Else);
assert_eq!(outer.blocks().len(), 1);
assert_eq!(outer.blocks()[0].kind(), SequenceBlockKind::Opt);
}
#[test]
fn rejects_unmatched_end() {
let err = parse_sequence_diagram("sequenceDiagram\nAlice->>Bob: Hi\nend\n").unwrap_err();
assert_eq!(err, MermaidSequenceParseError::UnmatchedEnd { line_no: 3 });
}
#[test]
fn rejects_else_outside_alt() {
let err =
parse_sequence_diagram("sequenceDiagram\nAlice->>Bob: Hi\nelse oops\n").unwrap_err();
assert_eq!(
err,
MermaidSequenceParseError::ElseOutsideAlt { line_no: 3, line: "else oops".to_owned() }
);
}
#[test]
fn rejects_and_outside_par() {
let err =
parse_sequence_diagram("sequenceDiagram\nAlice->>Bob: Hi\nand oops\n").unwrap_err();
assert_eq!(
err,
MermaidSequenceParseError::AndOutsidePar { line_no: 3, line: "and oops".to_owned() }
);
}
#[test]
fn rejects_empty_section_before_else() {
let err = parse_sequence_diagram("sequenceDiagram\nalt A\nelse B\nAlice->>Bob: Hi\nend\n")
.unwrap_err();
assert_eq!(
err,
MermaidSequenceParseError::EmptyBlockSection {
line_no: 3,
section_id: crate::model::seq_ast::SequenceSection::make_section_id(1, 0),
}
);
}
#[test]
fn rejects_block_nesting_too_deep() {
let input = "\
sequenceDiagram
opt a1
opt a2
opt a3
opt a4
opt a5
opt a6
opt a7
opt a8
opt a9
Alice->>Bob: Hi
";
let err = parse_sequence_diagram(input).unwrap_err();
assert_eq!(
err,
MermaidSequenceParseError::BlockNestingTooDeep {
line_no: 10,
max_depth: super::MAX_BLOCK_NEST_DEPTH,
}
);
}
}