parseSequenceDiagram function
Parses Mermaid sequence diagram content into a SequenceDiagram record.
Returns null if content does not start with sequenceDiagram.
Implementation
SequenceDiagram? parseSequenceDiagram(String content) {
if (!isSequenceDiagram(content)) return null;
final participants = <SequenceParticipant>[];
final messages = <SequenceMessage>[];
final steps = <SequenceStep>[];
final groups = <SequenceParticipantGroup>[];
final rects = <SequenceRect>[];
final actorStyles = <String, UvStyle>{};
final blockStack = <({String type, int stepIndex})>[];
final groupStack = <List<String>>[];
final rectStack = <({int stepIndex, UvColor? color})>[];
// Mermaid permits an activation to remain open until the implicit end of
// the sequence; only deactivations need a matching active bar.
final activationCounts = <String, int>{};
int? nextMessageNumber;
var messageNumberIncrement = 1;
for (final rawLine in content.split('\n')) {
final line = rawLine.trim();
if (line.isEmpty ||
line.startsWith('%%') ||
line.toLowerCase() == 'sequencediagram') {
continue;
}
// autonumber
var m = _autonumberRe.firstMatch(line);
if (m != null) {
nextMessageNumber = int.tryParse(m.group(1) ?? '1') ?? 1;
messageNumberIncrement = int.tryParse(m.group(2) ?? '1') ?? 1;
continue;
}
// style
m = _styleRe.firstMatch(line);
if (m != null) {
final actorId = _stripQuotes(m.group(1)!);
final styleText = m.group(2)!.trim();
final style = _parseStyleDeclaration(styleText);
if (style != null) actorStyles[actorId] = style;
continue;
}
// classDef (parse but ignore for now, just record)
m = _classDefRe.firstMatch(line);
if (m != null) {
final name = m.group(1)!;
final styleText = m.group(2)!.trim();
final style = _parseStyleDeclaration(styleText);
if (style != null) actorStyles['classDef:$name'] = style;
continue;
}
// box
m = _boxRe.firstMatch(line);
if (m != null) {
final (color, label) = _parseBoxContent(m.group(1));
final ids = <String>[];
groups.add(
SequenceParticipantGroup(
label: label,
ids: ids,
backgroundColor: color,
),
);
groupStack.add(ids);
blockStack.add((type: 'box', stepIndex: steps.length));
continue;
}
// rect
m = _rectRe.firstMatch(line);
if (m != null) {
final colorToken = m.group(1)!.trim();
final color = parseMermaidColor(colorToken);
if (color == null && colorToken.toLowerCase() != 'transparent') {
throw FormatException('Invalid rect color: $colorToken');
}
rectStack.add((stepIndex: steps.length, color: color));
blockStack.add((type: 'rect', stepIndex: steps.length));
continue;
}
// participant
m = _participantRe.firstMatch(line);
if (m != null) {
final id = _stripQuotes(m.group(1)!);
if (id.contains('@{')) {
throw const FormatException(
'Participant JSON configuration is not supported',
);
}
final label = _decodeMermaidText(_stripQuotes(m.group(2) ?? id));
final existingStyle = actorStyles[id];
_ensureParticipant(
participants,
id,
label: label,
style: existingStyle,
isActor: line.toLowerCase().startsWith('actor '),
);
if (groupStack.isNotEmpty && !groupStack.last.contains(id)) {
groupStack.last.add(id);
}
continue;
}
// note
m = _noteRe.firstMatch(line);
if (m != null) {
final side = m.group(1)!.toLowerCase();
final over = _parseNoteTarget(side, m.group(2)!);
final label = _decodeMermaidText(_stripQuotes(m.group(3)!));
for (final p in over) {
_ensureParticipant(participants, p);
}
steps.add(
SequenceStepNote(
SequenceNote(
over: over,
label: label,
position: switch (side) {
'left' => SequenceNotePosition.left,
'right' => SequenceNotePosition.right,
_ => SequenceNotePosition.over,
},
),
),
);
continue;
}
// activation
m = _activationRe.firstMatch(line);
if (m != null) {
final participant = _stripQuotes(m.group(2)!);
_ensureParticipant(participants, participant);
final active = m.group(1)!.toLowerCase() == 'activate';
final count = activationCounts[participant] ?? 0;
if (!active && count == 0) {
throw FormatException(
'Cannot deactivate inactive participant: $participant',
);
}
activationCounts[participant] = active ? count + 1 : count - 1;
steps.add(
SequenceStepActivation(
SequenceActivation(participant: participant, active: active),
),
);
continue;
}
// alt
m = _altRe.firstMatch(line);
if (m != null) {
blockStack.add((type: 'alt', stepIndex: steps.length));
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.alt,
label: _decodeMermaidText(_stripQuotes(m.group(1)!)),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// Optional block.
m = _optRe.firstMatch(line);
if (m != null) {
blockStack.add((type: 'opt', stepIndex: steps.length));
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.opt,
label: _decodeMermaidText(_stripQuotes(m.group(1)!)),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// loop
m = _loopRe.firstMatch(line);
if (m != null) {
blockStack.add((type: 'loop', stepIndex: steps.length));
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.loop,
label: _decodeMermaidText(_stripQuotes(m.group(1)!)),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// Critical block.
m = _criticalRe.firstMatch(line);
if (m != null) {
blockStack.add((type: 'critical', stepIndex: steps.length));
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.critical,
label: _decodeMermaidText(_stripQuotes(m.group(1)!)),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// Parallel block.
m = _parRe.firstMatch(line);
if (m != null) {
blockStack.add((type: 'par', stepIndex: steps.length));
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.par,
label: _decodeMermaidText(_stripQuotes(m.group(1)!)),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// Break branch.
m = _breakRe.firstMatch(line);
if (m != null) {
blockStack.add((type: 'break', stepIndex: steps.length));
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.breakPart,
label: _decodeMermaidText(_stripQuotes(m.group(1)!)),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// else
m = _elseRe.firstMatch(line);
if (m != null) {
if (_nearestFragment(blockStack) != 'alt') {
throw FormatException('else is only valid inside an alt block');
}
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.elsePart,
label: _decodeMermaidText(_stripQuotes(m.group(1) ?? '')),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// and (for par)
m = _andRe.firstMatch(line);
if (m != null) {
if (_nearestFragment(blockStack) != 'par') {
throw FormatException('and is only valid inside a par block');
}
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.andPart,
label: _decodeMermaidText(_stripQuotes(m.group(1) ?? '')),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// option (for critical)
m = _optionRe.firstMatch(line);
if (m != null) {
if (_nearestFragment(blockStack) != 'critical') {
throw FormatException('option is only valid inside a critical block');
}
steps.add(
SequenceStepFragment(
SequenceFragment(
kind: SequenceFragmentKind.optionPart,
label: _decodeMermaidText(_stripQuotes(m.group(1) ?? '')),
depth: _fragmentDepth(blockStack),
),
),
);
continue;
}
// end
if (_endRe.hasMatch(line)) {
final block = blockStack.isEmpty ? null : blockStack.removeLast();
if (block == null) {
throw FormatException('Unexpected end without an open block');
}
if (block.type == 'box') {
groupStack.removeLast();
continue;
}
if (block.type == 'rect') {
final rect = rectStack.isEmpty
? (stepIndex: 0, color: null)
: rectStack.removeLast();
rects.add(
SequenceRect(
backgroundColor: rect.color ?? const UvRgb(0, 0, 0, a: 0),
startIndex: rect.stepIndex,
endIndex: steps.length,
depth: rectStack.length,
),
);
continue;
}
final kind = switch (block.type) {
'alt' || 'opt' || 'critical' || 'par' => SequenceFragmentKind.end,
'loop' => SequenceFragmentKind.end,
_ => SequenceFragmentKind.end,
};
steps.add(
SequenceStepFragment(SequenceFragment(kind: kind, label: block.type)),
);
continue;
}
// message
m = _messageRe.firstMatch(line);
if (m != null) {
final from = _stripQuotes(m.group(1)!);
final arrow = m.group(2)!;
final activation = m.group(3)!;
final to = _stripQuotes(m.group(4)!);
if (from.endsWith('()') || to.startsWith('()')) {
throw const FormatException(
'Central lifeline connections are not supported',
);
}
final label = _decodeMermaidText(_stripQuotes(m.group(5)!));
_ensureParticipant(participants, from);
_ensureParticipant(participants, to);
if (groupStack.isNotEmpty) {
if (!groupStack.last.contains(from)) groupStack.last.add(from);
if (!groupStack.last.contains(to)) groupStack.last.add(to);
}
final style = arrow.contains('--')
? SequenceMessageStyle.dashed
: SequenceMessageStyle.solid;
final head = _arrowHeadForSyntax(arrow);
if (activation.contains('+')) {
activationCounts[to] = (activationCounts[to] ?? 0) + 1;
} else if (activation.contains('-')) {
final count = activationCounts[from] ?? 0;
if (count == 0) {
throw FormatException(
'Cannot deactivate inactive participant: $from',
);
}
activationCounts[from] = count - 1;
}
final msg = SequenceMessage(
from: from,
to: to,
label: label,
style: style,
head: head,
number: nextMessageNumber,
activate: activation.contains('+') ? to : null,
deactivate: activation.contains('-') ? from : null,
styleOverride: actorStyles[from] ?? actorStyles[to],
bidirectional: arrow.startsWith('<'),
reverse: arrow.startsWith('<') && !arrow.startsWith('<->'),
);
messages.add(msg);
steps.add(SequenceStepMessage(msg));
if (nextMessageNumber != null) {
nextMessageNumber = nextMessageNumber + messageNumberIncrement;
}
continue;
}
if (RegExp(r'^(?:create|destroy)\b', caseSensitive: false).hasMatch(line)) {
throw FormatException(
'create/destroy participant lifecycles are not supported yet',
);
}
// Do not silently discard syntax: a typo in a diagram must be visible to
// callers rather than producing a convincing but incomplete picture.
throw FormatException('Unsupported sequence diagram syntax: $line');
}
if (blockStack.isNotEmpty) {
throw FormatException(
'Unclosed sequence diagram block: ${blockStack.last.type}',
);
}
return (
participants: participants,
messages: messages,
steps: steps,
groups: groups,
rects: rects,
actorStyles: actorStyles,
);
}