parseSequenceDiagram function

SequenceDiagram? parseSequenceDiagram(
  1. String content
)

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,
  );
}