@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +879 -0
- package/dist/index.d.ts +879 -0
- package/dist/index.js +994 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/box-color.test.ts +80 -0
- package/src/__tests__/sequence-activation-fix.test.ts +91 -0
- package/src/__tests__/xychart-colors.test.ts +149 -0
- package/src/class/format.ts +15 -0
- package/src/class/parser.ts +593 -0
- package/src/class/types.ts +166 -0
- package/src/er/parser.ts +286 -0
- package/src/er/types.ts +99 -0
- package/src/expanded-shapes.ts +376 -0
- package/src/index.ts +48 -0
- package/src/sequence/activation-check.ts +149 -0
- package/src/sequence/activation-fix.ts +112 -0
- package/src/sequence/box-color.ts +85 -0
- package/src/sequence/parser.ts +613 -0
- package/src/sequence/types.ts +242 -0
- package/src/xychart/colors.ts +177 -0
- package/src/xychart/parser.ts +246 -0
- package/src/xychart/types.ts +150 -0
|
@@ -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
|
+
}
|