@zombie-mermaid/mermaid-parser 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,613 @@
1
+ import type { SequenceDiagram, Message, Block, Actor } from './types.ts'
2
+ import { normalizeBrTags, type Statement } from '@zombie-mermaid/core'
3
+ import { parseBoxHeader } from './box-color.ts'
4
+
5
+ /**
6
+ * Which `box … end` group (index into `diagram.boxes`) is currently open,
7
+ * and which box each participant already belongs to. Threaded through the
8
+ * actor-creating helpers so a participant first mentioned inside a box —
9
+ * by a declaration, a `create`, or (leniently) a message — joins it, the
10
+ * way Mermaid's `addActor` assigns `currentBox`.
11
+ */
12
+ interface BoxContext {
13
+ open: number | undefined
14
+ membership: Map<string, number>
15
+ }
16
+
17
+ /**
18
+ * Narrow a regex-captured block keyword to `Block['type']`. The capturing
19
+ * regex at the call site uses the same `(loop|alt|opt|par|critical|break|
20
+ * rect)` alternation, so the input is always one of these seven values in
21
+ * practice — but the match itself is typed as `string`.
22
+ *
23
+ * Exported for direct unit testing (see
24
+ * src/__tests__/sequence-parser.test.ts) — not otherwise part of this
25
+ * module's public parsing API.
26
+ */
27
+ export function isBlockType(value: string): value is Block['type'] {
28
+ return (
29
+ value === 'loop' ||
30
+ value === 'alt' ||
31
+ value === 'opt' ||
32
+ value === 'par' ||
33
+ value === 'critical' ||
34
+ value === 'break' ||
35
+ value === 'rect'
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Narrow a regex-captured block keyword to `Block['type']`, throwing if it
41
+ * somehow isn't one of the seven recognized keywords (see `isBlockType`
42
+ * above — unreachable via the guarding regex in practice, but this keeps
43
+ * the failure explicit rather than silently mistyping the value).
44
+ *
45
+ * Exported for direct unit testing (see
46
+ * src/__tests__/sequence-parser.test.ts) — the throw branch is unreachable
47
+ * through the public `parseSequenceDiagram` API (the regex that captures
48
+ * the value already restricts it to the seven valid keywords), so it can
49
+ * only be exercised by calling this function directly.
50
+ */
51
+ export function toBlockType(value: string): Block['type'] {
52
+ if (!isBlockType(value)) {
53
+ throw new Error(`Invalid block type: "${value}"`)
54
+ }
55
+ return value
56
+ }
57
+
58
+ // ============================================================================
59
+ // Sequence diagram parser
60
+ //
61
+ // Parses Mermaid sequenceDiagram syntax into a SequenceDiagram structure.
62
+ //
63
+ // Supported syntax:
64
+ // participant A as Alice
65
+ // actor B as Bob
66
+ // A->>B: Solid arrow
67
+ // A-->>B: Dashed arrow
68
+ // A-)B: Open arrow
69
+ // A--)B: Dashed open arrow
70
+ // A->>+B: Activate target
71
+ // A-->>-B: Deactivate source
72
+ // activate A / deactivate A (standalone form of the +/- shorthand)
73
+ // create participant C [as Label] / create actor C [as Label]
74
+ // (on the line before C's first message)
75
+ // destroy C (on the line before C's last message)
76
+ // box <color?> <label?> ... end (participant grouping; see box-color.ts)
77
+ // A<<->>B: Bidirectional solid arrow
78
+ // A<<-->>B: Bidirectional dashed arrow
79
+ // autonumber / autonumber <start> <step> / autonumber off
80
+ // loop Label ... end
81
+ // alt Label ... else Label ... end
82
+ // opt Label ... end
83
+ // par Label ... and Label ... end
84
+ // Note left of A: Text
85
+ // Note right of A: Text
86
+ // Note over A,B: Text
87
+ // ============================================================================
88
+
89
+ // Message-line arrow regexes, shared by the two-pass match in the "Message"
90
+ // branch of the parsing loop below (see the comment there for why there are
91
+ // two, and issue #341 for the mis-split bug this two-pass approach fixes).
92
+ // `MESSAGE_LONG_ARROW_RE` only recognizes the "long" arrow forms — anything
93
+ // ending in `>` (`->`, `-->`, `->>`, `-->>`) plus the bidirectional tokens
94
+ // (`<<->>`, `<<-->>`) — which essentially never occur by accident inside an
95
+ // unquoted actor name. `MESSAGE_ANY_ARROW_RE` is the original, full
96
+ // alternation (also matching the short open/cross forms `-)`, `--)`, `-x`,
97
+ // `--x`), used as a fallback when a line has no long arrow at all.
98
+ const MESSAGE_LONG_ARROW_RE =
99
+ /^(.+?)\s*(<<->>|<<-->>|--?>?>)\s*([+-]?)(.+?)\s*:\s*(.+)$/
100
+ const MESSAGE_ANY_ARROW_RE =
101
+ /^(.+?)\s*(<<->>|<<-->>|--?>?>|--?[)x]|--?>>|--?>)\s*([+-]?)(.+?)\s*:\s*(.+)$/
102
+
103
+ /**
104
+ * Parse a Mermaid sequence diagram.
105
+ * Expects the first line to be "sequenceDiagram".
106
+ */
107
+ // Audited for issue #100 (non-null assertions): every `!` in this file is
108
+ // one of three idioms already accepted as justified elsewhere in this
109
+ // codebase — (1) a bounds-checked loop-index array access (`lines[i]!`
110
+ // inside a `for (let i = 1; i < lines.length; ...)` loop), (2) a
111
+ // regex-mandatory-capture-group access after `.match()` (a group not
112
+ // wrapped in an optional `(?:...)?`, so it always participates when the
113
+ // overall match succeeds), or (3) `blockStack[blockStack.length - 1]!` /
114
+ // `blockStack.pop()!`, both guarded immediately above by an explicit
115
+ // `blockStack.length > 0` check. See src/parser.ts (PR #158) and this
116
+ // subsystem's layout.ts audit (PR #149), which fixed the one genuinely
117
+ // risky assertion in the subsystem (a Map get/set race) but didn't reach
118
+ // this file — PR #146 separately reviewed this file's `as` casts, a
119
+ // different concern from these `!`s. `noUncheckedIndexedAccess` can't see
120
+ // any of these guarantees, but removing the `!` would only replace a
121
+ // proven-safe assertion with an unreachable guard. Left as-is; no
122
+ // behavior change.
123
+ export function parseSequenceDiagram(lines: Statement[]): SequenceDiagram {
124
+ const diagram: SequenceDiagram = {
125
+ actors: [],
126
+ messages: [],
127
+ blocks: [],
128
+ notes: [],
129
+ activations: [],
130
+ boxes: [],
131
+ }
132
+
133
+ const boxCtx: BoxContext = { open: undefined, membership: new Map() }
134
+ // Block-stack depth at the moment the open box began. Mermaid's grammar
135
+ // only allows participant declarations inside a box, so `end` there can
136
+ // only mean the box — but this parser is lenient about other statements,
137
+ // and a `loop` opened inside a box must still own the next `end`.
138
+ let boxOpenedAtDepth = 0
139
+ // Line the currently-open box started on, for the unclosed-box error at
140
+ // EOF below (issue #762) — `boxCtx.open` alone doesn't carry position.
141
+ let boxOpenLine: number | undefined
142
+
143
+ // Track actor IDs to auto-create actors referenced in messages
144
+ const actorIds = new Set<string>()
145
+ // Track block nesting with a stack
146
+ const blockStack: Array<{
147
+ type: Block['type']
148
+ label: string
149
+ startIndex: number
150
+ dividers: Block['dividers']
151
+ line: number
152
+ }> = []
153
+
154
+ // `autonumber` state — a bare `autonumber` turns numbering on starting at 1
155
+ // (step 1); `autonumber <start> <step>` sets both explicitly; `autonumber
156
+ // off` turns it back off. Only messages consume a number — notes and
157
+ // block/divider lines don't advance the counter.
158
+ const autonumber = { enabled: false, next: 1, step: 1 }
159
+
160
+ // `create participant X` / `destroy X` bind to the very next message —
161
+ // Mermaid's sequenceDb enforces that the next message's recipient is the
162
+ // created participant, and that the destroyed one is its sender or
163
+ // recipient, throwing otherwise. Mirrored here (same error text) rather
164
+ // than guessing which later message was meant. A directive with no
165
+ // message after it at all is left as a plain declaration.
166
+ let pendingCreate: string | undefined
167
+ let pendingDestroy: string | undefined
168
+
169
+ for (let i = 1; i < lines.length; i++) {
170
+ const stmt = lines[i]!
171
+ const line = stmt.text
172
+
173
+ // --- box <color?> <label?> ---
174
+ // Opens a participant group; closed by `end`. Boxes cannot nest
175
+ // (Mermaid's grammar rejects it), so a second `box` while one is open
176
+ // is an error rather than an implicit close.
177
+ const boxMatch = line.match(/^box(?:\s+(.*))?$/)
178
+ if (boxMatch) {
179
+ if (boxCtx.open !== undefined) {
180
+ throw new Error(
181
+ `Line ${stmt.line}: Sequence diagram: a box cannot be nested inside another box — close the open box with "end" first`,
182
+ )
183
+ }
184
+ const { color, label } = parseBoxHeader(boxMatch[1] ?? '')
185
+ const box: SequenceDiagram['boxes'][number] = {
186
+ label: normalizeBrTags(label),
187
+ actorIds: [],
188
+ }
189
+ if (color !== undefined) box.color = color
190
+ diagram.boxes.push(box)
191
+ boxCtx.open = diagram.boxes.length - 1
192
+ boxOpenedAtDepth = blockStack.length
193
+ boxOpenLine = stmt.line
194
+ continue
195
+ }
196
+
197
+ // --- create participant / create actor ---
198
+ // "create participant C" / "create actor C as Label" — the line before
199
+ // C's first message. Same shape as a plain declaration, so the alias and
200
+ // the participant/actor kind are kept, not dropped with the keyword.
201
+ const createMatch = line.match(
202
+ /^create\s+(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/,
203
+ )
204
+ if (createMatch) {
205
+ const type: 'participant' | 'actor' =
206
+ createMatch[1] === 'actor' ? 'actor' : 'participant'
207
+ const id = createMatch[2]!
208
+ if (actorIds.has(id)) {
209
+ // Mermaid's own wording (sequenceDb `createParticipant`).
210
+ throw new Error(
211
+ `Line ${stmt.line}: It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior`,
212
+ )
213
+ }
214
+ actorIds.add(id)
215
+ diagram.actors.push({
216
+ id,
217
+ label: normalizeBrTags(createMatch[3]?.trim() ?? id),
218
+ type,
219
+ })
220
+ joinOpenBox(diagram, boxCtx, id, stmt.line)
221
+ pendingCreate = id
222
+ continue
223
+ }
224
+
225
+ // --- destroy ---
226
+ // "destroy C" — the line before C's last message.
227
+ const destroyMatch = line.match(/^destroy\s+(.+)$/)
228
+ if (destroyMatch) {
229
+ const id = destroyMatch[1]!.trim()
230
+ ensureActor(diagram, actorIds, boxCtx, id, stmt.line)
231
+ pendingDestroy = id
232
+ continue
233
+ }
234
+
235
+ // --- autonumber directive ---
236
+ const autonumberMatch = line.match(
237
+ /^autonumber(?:\s+(off|\d+(?:\.\d{1,2})?)(?:\s+(\d+(?:\.\d{1,2})?))?)?$/,
238
+ )
239
+ if (autonumberMatch) {
240
+ const start = autonumberMatch[1]
241
+ const step = autonumberMatch[2]
242
+ if (start === 'off') {
243
+ autonumber.enabled = false
244
+ } else {
245
+ autonumber.enabled = true
246
+ autonumber.next = start !== undefined ? Number(start) : 1
247
+ autonumber.step = step !== undefined ? Number(step) : 1
248
+ }
249
+ continue
250
+ }
251
+
252
+ // --- Participant / Actor declaration ---
253
+ // "participant A as Alice" or "participant Alice"
254
+ // "actor B as Bob" or "actor Bob"
255
+ const actorMatch = line.match(
256
+ /^(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/,
257
+ )
258
+ if (actorMatch) {
259
+ // Group 1 is constrained by the regex alternation to 'participant' | 'actor'
260
+ const type: 'participant' | 'actor' =
261
+ actorMatch[1] === 'actor' ? 'actor' : 'participant'
262
+ const id = actorMatch[2]!
263
+ const rawLabel = actorMatch[3]?.trim() ?? id
264
+ const label = normalizeBrTags(rawLabel)
265
+ if (!actorIds.has(id)) {
266
+ actorIds.add(id)
267
+ diagram.actors.push({ id, label, type })
268
+ }
269
+ // A re-declaration inside a box joins it (or errors if it already
270
+ // belongs to a different one) — Mermaid's `addActor` rule.
271
+ joinOpenBox(diagram, boxCtx, id, stmt.line)
272
+ continue
273
+ }
274
+
275
+ // --- Note ---
276
+ // "Note left of A: text" / "Note right of A: text" / "Note over A,B: text"
277
+ const noteMatch = line.match(
278
+ /^Note\s+(left of|right of|over)\s+([^:]+):\s*(.+)$/i,
279
+ )
280
+ if (noteMatch) {
281
+ const posStr = noteMatch[1]!.toLowerCase()
282
+ const actorsStr = noteMatch[2]!.trim()
283
+ const text = normalizeBrTags(noteMatch[3]!.trim())
284
+ const noteActorIds = actorsStr.split(',').map((s) => s.trim())
285
+
286
+ // Ensure actors exist
287
+ for (const aid of noteActorIds) {
288
+ ensureActor(diagram, actorIds, boxCtx, aid, stmt.line)
289
+ }
290
+
291
+ let position: 'left' | 'right' | 'over' = 'over'
292
+ if (posStr === 'left of') position = 'left'
293
+ else if (posStr === 'right of') position = 'right'
294
+
295
+ diagram.notes.push({
296
+ actorIds: noteActorIds,
297
+ text,
298
+ position,
299
+ afterIndex: diagram.messages.length - 1,
300
+ })
301
+ continue
302
+ }
303
+
304
+ // --- Block start: loop, alt, opt, par, critical, break, rect ---
305
+ const blockMatch = line.match(
306
+ /^(loop|alt|opt|par|critical|break|rect)\s*(.*)$/,
307
+ )
308
+ if (blockMatch) {
309
+ const blockType = toBlockType(blockMatch[1]!)
310
+ const rawBlockLabel = blockMatch[2]?.trim() ?? ''
311
+ const label = normalizeBrTags(rawBlockLabel)
312
+ blockStack.push({
313
+ type: blockType,
314
+ label,
315
+ startIndex: diagram.messages.length,
316
+ dividers: [],
317
+ line: stmt.line,
318
+ })
319
+ continue
320
+ }
321
+
322
+ // --- Block divider: else, and ---
323
+ const dividerMatch = line.match(/^(else|and)\s*(.*)$/)
324
+ if (dividerMatch && blockStack.length > 0) {
325
+ const rawDividerLabel = dividerMatch[2]?.trim() ?? ''
326
+ const label = normalizeBrTags(rawDividerLabel)
327
+ blockStack[blockStack.length - 1]!.dividers.push({
328
+ index: diagram.messages.length,
329
+ label,
330
+ })
331
+ continue
332
+ }
333
+
334
+ // --- Box end ---
335
+ // `end` closes the open box unless a block was opened *inside* it,
336
+ // in which case the block (innermost) owns this `end`.
337
+ if (
338
+ line === 'end' &&
339
+ boxCtx.open !== undefined &&
340
+ blockStack.length === boxOpenedAtDepth
341
+ ) {
342
+ boxCtx.open = undefined
343
+ boxOpenLine = undefined
344
+ continue
345
+ }
346
+
347
+ // --- Block end ---
348
+ if (line === 'end' && blockStack.length > 0) {
349
+ const completed = blockStack.pop()!
350
+ diagram.blocks.push({
351
+ type: completed.type,
352
+ label: completed.label,
353
+ startIndex: completed.startIndex,
354
+ endIndex: Math.max(diagram.messages.length - 1, completed.startIndex),
355
+ dividers: completed.dividers,
356
+ })
357
+ continue
358
+ }
359
+
360
+ // --- Unmatched end ---
361
+ // Neither branch above claimed this "end" — no box and no block is
362
+ // currently open, so there's nothing for it to close. Previously
363
+ // silently ignored (#762); mirrors the "box cannot be nested" throw
364
+ // above in surfacing a structural mistake instead of dropping it.
365
+ if (line === 'end') {
366
+ throw new Error(
367
+ `Line ${stmt.line}: Sequence diagram: "end" does not match any open block ("loop"/"alt"/"opt"/"par"/"critical"/"break"/"rect") or "box" — nothing is currently open to close.`,
368
+ )
369
+ }
370
+
371
+ // --- Standalone activate / deactivate ---
372
+ // "activate A" / "deactivate A". Mermaid's own grammar expands the `+`/`-`
373
+ // arrow shorthand into exactly this (message, then an activeStart for the
374
+ // recipient / activeEnd for the sender), so record the same event the
375
+ // shorthand implies and let layout.ts feed both through one activation
376
+ // stack. Checked before the message regex: neither keyword contains an
377
+ // arrow token, but an actor literally named `activate` used as a message
378
+ // *source* (`activate->>B: x`) still reaches the message branch, since
379
+ // the `\s+` here requires whitespace after the keyword.
380
+ const activationMatch = line.match(/^(activate|deactivate)\s+(.+)$/)
381
+ if (activationMatch) {
382
+ const actorId = activationMatch[2]!.trim()
383
+ ensureActor(diagram, actorIds, boxCtx, actorId, stmt.line)
384
+ diagram.activations.push({
385
+ actorId,
386
+ kind: activationMatch[1] === 'activate' ? 'start' : 'end',
387
+ afterIndex: diagram.messages.length - 1,
388
+ })
389
+ continue
390
+ }
391
+
392
+ // --- Message ---
393
+ // Patterns: A->>B, A-->>B, A-)B, A--)B, A<<->>B, A<<-->>B, with optional
394
+ // +/- activation. Format: FROM ARROW TO: LABEL
395
+ //
396
+ // FROM/TO are matched with a lazy `.+?` (not `\S+?`) so an undeclared
397
+ // actor name can contain spaces or internal hyphens — e.g. `cron
398
+ // job->>customer-notifier: hi` — mirroring real Mermaid's own sequence
399
+ // grammar, whose unquoted ACTOR token excludes only the characters that
400
+ // start an arrow/label (`/ \ + ( ) < - > :`) rather than all whitespace.
401
+ // Because the quantifier is lazy and anchored by the arrow/colon tokens
402
+ // that follow, this still resolves to the same minimal split as before
403
+ // for plain single-word names.
404
+ //
405
+ // Two-pass match (issue #341): the lazy FROM capture stops as soon as
406
+ // *any* position looks like a valid arrow+TO+`:`+LABEL tail, which is a
407
+ // false positive when an unquoted actor name happens to contain a short
408
+ // open/cross arrow substring (`-)`, `--)`, `-x`, `--x` — matched by the
409
+ // `--?[)x]` alternative) ahead of the *real* arrow later in the line —
410
+ // e.g. `foo-x-bar->>baz: hi` mis-split at the embedded `-x` instead of
411
+ // the real `->>`. Those two-char forms are rare and highly ambiguous
412
+ // inside a bare identifier, whereas the "long" forms (anything ending
413
+ // in `>`, plus the bidirectional tokens) essentially never occur by
414
+ // accident, since they require a literal `>` character in an unquoted
415
+ // name. So: try the long forms only first, and only fall back to the
416
+ // full alternation (including the short forms) if the line has no long
417
+ // arrow at all — which is what keeps a genuinely short-arrow message
418
+ // like `A-)B: msg` working. This doesn't attempt to disambiguate every
419
+ // theoretically possible collision (e.g. a literal `->` substring
420
+ // embedded before a real `->>`) — see the issue's own "Scope" section,
421
+ // which limits the fix to the `-x`/`-)`/`--x`/`--)` substrings.
422
+ const msgMatch =
423
+ line.match(MESSAGE_LONG_ARROW_RE) ?? line.match(MESSAGE_ANY_ARROW_RE)
424
+ if (msgMatch) {
425
+ // Mermaid's own unquoted actor-name grammar excludes `>` and `)` from
426
+ // ever starting an identifier (see the FROM/TO comment above) — so a
427
+ // TO capture starting with either one means the real arrow in the
428
+ // source is longer than what the alternation above actually matched
429
+ // (e.g. "->>>" only matches as "->>", leaving a stray ">" to be
430
+ // absorbed into TO as part of a garbled actor name) rather than a
431
+ // genuinely valid, if unusual, message. Reject it with an actionable
432
+ // error instead of silently minting that garbled actor — see the
433
+ // `Alice->>>Bob: Hello` example in issue #762.
434
+ const arrowToken = msgMatch[2]!
435
+ const toFirstChar = msgMatch[4]![0]
436
+ if (toFirstChar === '>' || toFirstChar === ')') {
437
+ throw new Error(
438
+ `Line ${stmt.line}: Malformed sequence-diagram arrow in "${line}" — "${arrowToken}${toFirstChar}" is not a recognized arrow. Expected one of: ->, -->, ->>, -->>, -x, --x, -), --), <<->>, <<-->>.`,
439
+ )
440
+ }
441
+ pushMessage(
442
+ diagram,
443
+ actorIds,
444
+ boxCtx,
445
+ autonumber,
446
+ msgMatch[1]!,
447
+ msgMatch[2]!,
448
+ msgMatch[3],
449
+ msgMatch[4]!,
450
+ msgMatch[5]!,
451
+ stmt.line,
452
+ )
453
+ const msgIndex = diagram.messages.length - 1
454
+ const msg = diagram.messages[msgIndex]!
455
+ if (pendingCreate !== undefined) {
456
+ if (msg.to !== pendingCreate) {
457
+ throw new Error(
458
+ `Line ${stmt.line}: The created participant ${pendingCreate} does not have an associated creating message after its declaration. Please check the sequence diagram.`,
459
+ )
460
+ }
461
+ findActor(diagram, pendingCreate).createdAt = msgIndex
462
+ pendingCreate = undefined
463
+ }
464
+ if (pendingDestroy !== undefined) {
465
+ if (msg.from !== pendingDestroy && msg.to !== pendingDestroy) {
466
+ throw new Error(
467
+ `Line ${stmt.line}: The destroyed participant ${pendingDestroy} does not have an associated destroying message after its declaration. Please check the sequence diagram.`,
468
+ )
469
+ }
470
+ findActor(diagram, pendingDestroy).destroyedAt = msgIndex
471
+ pendingDestroy = undefined
472
+ }
473
+ continue
474
+ }
475
+ }
476
+
477
+ // A block/box left open at EOF is a structural mistake, not something to
478
+ // silently accept as "closed by end of input" — mirrors the class-diagram
479
+ // parser's unclosed-body check (#761) for the same reason: previously
480
+ // this was silently ignored (#762). Checked innermost-first (the block
481
+ // stack) since an unclosed block is the more specific, more actionable
482
+ // thing to report when both are open.
483
+ if (blockStack.length > 0) {
484
+ const unclosed = blockStack[blockStack.length - 1]!
485
+ throw new Error(
486
+ `Line ${unclosed.line}: Sequence diagram: unclosed "${unclosed.type}" block — expected a matching "end" before the diagram ends.`,
487
+ )
488
+ }
489
+ if (boxCtx.open !== undefined) {
490
+ const openBox = diagram.boxes[boxCtx.open]!
491
+ throw new Error(
492
+ `Line ${boxOpenLine}: Sequence diagram: unclosed "box${openBox.label ? ` ${openBox.label}` : ''}" — expected a matching "end" before the diagram ends.`,
493
+ )
494
+ }
495
+
496
+ return diagram
497
+ }
498
+
499
+ /**
500
+ * Look up an actor the parser itself already registered (every caller
501
+ * passes an id that went through `ensureActor` or the create branch first,
502
+ * so the miss branch is a parser-invariant violation, not user error).
503
+ */
504
+ function findActor(diagram: SequenceDiagram, id: string): Actor {
505
+ const actor = diagram.actors.find((a) => a.id === id)
506
+ if (actor === undefined) {
507
+ /* v8 ignore next */
508
+ throw new Error(`Sequence diagram: unknown actor "${id}"`)
509
+ }
510
+ return actor
511
+ }
512
+
513
+ /** Ensure an actor exists, creating a default participant if not */
514
+ function ensureActor(
515
+ diagram: SequenceDiagram,
516
+ actorIds: Set<string>,
517
+ boxCtx: BoxContext,
518
+ id: string,
519
+ lineNumber: number,
520
+ ): void {
521
+ if (!actorIds.has(id)) {
522
+ actorIds.add(id)
523
+ diagram.actors.push({ id, label: id, type: 'participant' })
524
+ }
525
+ joinOpenBox(diagram, boxCtx, id, lineNumber)
526
+ }
527
+
528
+ /**
529
+ * Put `id` into the currently open `box`, if any. A participant already in
530
+ * a *different* box is an error (Mermaid's own wording); one already in
531
+ * this box, or no box open, is a no-op.
532
+ */
533
+ function joinOpenBox(
534
+ diagram: SequenceDiagram,
535
+ boxCtx: BoxContext,
536
+ id: string,
537
+ lineNumber: number,
538
+ ): void {
539
+ const open = boxCtx.open
540
+ if (open === undefined) return
541
+ const existing = boxCtx.membership.get(id)
542
+ if (existing === open) return
543
+ if (existing !== undefined) {
544
+ const from = diagram.boxes[existing]!.label
545
+ const to = diagram.boxes[open]!.label
546
+ throw new Error(
547
+ `Line ${lineNumber}: A same participant should only be defined in one Box: ${id} can't be in '${from}' and in '${to}' at the same time.`,
548
+ )
549
+ }
550
+ boxCtx.membership.set(id, open)
551
+ diagram.boxes[open]!.actorIds.push(id)
552
+ }
553
+
554
+ /**
555
+ * Build a `Message` from a matched arrow-message line and push it onto the
556
+ * diagram. Shared by both message regexes in `parseSequenceDiagram` above so
557
+ * the arrow → line-style/arrow-head/bidirectional mapping and `autonumber`
558
+ * bookkeeping can't drift out of sync between them.
559
+ */
560
+ function pushMessage(
561
+ diagram: SequenceDiagram,
562
+ actorIds: Set<string>,
563
+ boxCtx: BoxContext,
564
+ autonumber: { enabled: boolean; next: number; step: number },
565
+ from: string,
566
+ arrow: string,
567
+ activationMark: string | undefined,
568
+ to: string,
569
+ rawLabel: string,
570
+ lineNumber: number,
571
+ ): void {
572
+ ensureActor(diagram, actorIds, boxCtx, from, lineNumber)
573
+ ensureActor(diagram, actorIds, boxCtx, to, lineNumber)
574
+
575
+ const bidirectional = arrow === '<<->>' || arrow === '<<-->>'
576
+ const lineStyle = bidirectional
577
+ ? arrow === '<<-->>'
578
+ ? 'dashed'
579
+ : 'solid'
580
+ : arrow.startsWith('--')
581
+ ? 'dashed'
582
+ : 'solid'
583
+ // ">>" = filled arrow, ")" or ">" alone = open arrow, "x" = cross (treat as
584
+ // filled). Both bidirectional tokens end in ">>", so they fall out as filled.
585
+ const arrowHead =
586
+ arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open'
587
+ // "x"/"--x" is Mermaid's "lost message" terminator — a cross, not a plain
588
+ // filled arrowhead. `arrow.includes('x')` is unambiguous here: the only
589
+ // arrow tokens containing "x" are "-x"/"--x" (see the message regex's
590
+ // `--?[)x]` alternative above), never "->>"/"-->>" or the bidirectional
591
+ // forms.
592
+ const isLost = arrow.includes('x')
593
+
594
+ const msg: Message = {
595
+ from,
596
+ to,
597
+ label: normalizeBrTags(rawLabel.trim()),
598
+ lineStyle,
599
+ arrowHead,
600
+ }
601
+ if (isLost) msg.isLost = true
602
+ if (bidirectional) msg.bidirectional = true
603
+ if (activationMark === '+') msg.activate = true
604
+ if (activationMark === '-') msg.deactivate = true
605
+
606
+ if (autonumber.enabled) {
607
+ msg.seqNumber = autonumber.next
608
+ autonumber.next =
609
+ Math.round((autonumber.next + autonumber.step) * 100) / 100
610
+ }
611
+
612
+ diagram.messages.push(msg)
613
+ }