@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,593 @@
1
+ import type {
2
+ ClassDiagram,
3
+ ClassNode,
4
+ ClassRelationship,
5
+ ClassMember,
6
+ RelationshipType,
7
+ ClassNamespace,
8
+ } from './types.ts'
9
+ import {
10
+ normalizeBrTags,
11
+ applyClickStatement,
12
+ splitClassShorthand,
13
+ tryApplyClassAssignment,
14
+ tryApplyClassDef,
15
+ tryApplyCssClass,
16
+ tryApplyStyleStatement,
17
+ type Statement,
18
+ } from '@zombie-mermaid/core'
19
+
20
+ // ============================================================================
21
+ // Class diagram parser
22
+ //
23
+ // Parses Mermaid classDiagram syntax into a ClassDiagram structure.
24
+ //
25
+ // Supported syntax:
26
+ // class Animal { +String name; +eat() void }
27
+ // class Shape { <<abstract>> }
28
+ // Animal <|-- Dog (inheritance)
29
+ // Car *-- Engine (composition)
30
+ // Car o-- Wheel (aggregation)
31
+ // A --> B (association)
32
+ // A ..> B (dependency)
33
+ // A ..|> B (realization)
34
+ // A "1" --> "*" B : label (with cardinality + label)
35
+ // Animal : +String name (inline attribute)
36
+ // namespace MyNamespace { class A { } }
37
+ // click Animal "https://example.com" "Tooltip" _blank (see docs/decisions/no-script-interactivity.md)
38
+ // classDef name fill:#f96 / style Animal fill:#f96 / cssClass "A,B" name
39
+ // class Animal:::name (style-class shorthand, also on relationship ends)
40
+ // note "text" / note for Animal "line1\nline2"
41
+ // ============================================================================
42
+
43
+ /**
44
+ * The set of relationship-arrow tokens `parseRelationship` recognizes,
45
+ * shared with the "does this line even look like a relationship attempt"
46
+ * check below so the two can't drift apart. Order matters for regex
47
+ * alternation (longer/more-specific tokens like `--|>` must be tried before
48
+ * the bare `--` fallback).
49
+ */
50
+ const RELATIONSHIP_ARROW_SOURCE =
51
+ '<\\|--|<\\|\\.\\.|\\*--|o--|-->|--\\*|--o|--\\|>|\\.\\.>|\\.\\.\\|>|<--|<\\.\\.?|--'
52
+
53
+ /**
54
+ * Matches a line that *contains* a relationship-arrow token somewhere, even
55
+ * if the overall shape (FROM/TO, cardinalities) doesn't parse. Used to
56
+ * scope the "malformed relationship" error (issue #761) to lines that
57
+ * already look like an attempt at this construct — mirroring the same
58
+ * "attempt, then validate" pattern #722 used for the ER and xychart-beta
59
+ * parsers, so a genuinely unrelated line (no arrow at all) still falls
60
+ * through silently rather than throwing on totally unrecognized syntax.
61
+ */
62
+ const RELATIONSHIP_ARROW_MARKER = new RegExp(RELATIONSHIP_ARROW_SOURCE)
63
+
64
+ /**
65
+ * Parse a Mermaid class diagram.
66
+ * Expects the first line to be "classDiagram".
67
+ */
68
+ // Audited for issue #100 (non-null assertions): every `!` in this file is
69
+ // either a bounds-checked loop-index array access (`lines[i]!` inside a
70
+ // `for (let i = 1; i < lines.length; ...)` loop) or a regex-mandatory-
71
+ // capture-group access after `.match()` (a group that isn't wrapped in an
72
+ // optional `(?:...)?`, so it always participates when the overall match
73
+ // succeeds). Both are the same idioms already accepted as justified
74
+ // elsewhere in this codebase (see src/parser.ts, PR #158, and this
75
+ // subsystem's earlier layout.ts/renderer.ts audit, PR #147, which fixed
76
+ // the genuinely risky assertions but didn't reach this file) —
77
+ // `noUncheckedIndexedAccess` can't see either guarantee, but removing the
78
+ // `!` would only replace a proven-safe assertion with an unreachable guard.
79
+ // Left as-is; no behavior change.
80
+ export function parseClassDiagram(lines: Statement[]): ClassDiagram {
81
+ const diagram: ClassDiagram = {
82
+ classes: [],
83
+ relationships: [],
84
+ namespaces: [],
85
+ interactions: new Map(),
86
+ classDefs: new Map(),
87
+ classAssignments: new Map(),
88
+ nodeStyles: new Map(),
89
+ notes: [],
90
+ }
91
+
92
+ // Track classes by ID for deduplication
93
+ const classMap = new Map<string, ClassNode>()
94
+ // Track namespace nesting
95
+ let currentNamespace: ClassNamespace | null = null
96
+ // Track class body parsing
97
+ let currentClass: ClassNode | null = null
98
+ let braceDepth = 0
99
+ // Line the currently-open class body started on, for the unclosed-body
100
+ // error below (issue #761) — `currentClass`/`braceDepth` alone don't
101
+ // carry position.
102
+ let openClassLine: number | undefined
103
+
104
+ for (let i = 1; i < lines.length; i++) {
105
+ const stmt = lines[i]!
106
+ const line = stmt.text
107
+
108
+ // --- Inside a class body block ---
109
+ if (currentClass && braceDepth > 0) {
110
+ if (line === '}') {
111
+ braceDepth--
112
+ if (braceDepth === 0) {
113
+ currentClass = null
114
+ openClassLine = undefined
115
+ }
116
+ continue
117
+ }
118
+
119
+ // Check for annotation like <<interface>>
120
+ const annotMatch = line.match(/^<<(\w+)>>$/)
121
+ if (annotMatch) {
122
+ currentClass.annotation = annotMatch[1]!
123
+ continue
124
+ }
125
+
126
+ // A line that starts with `<<` but doesn't match the full
127
+ // `<<name>>` shape is an attempted annotation with broken syntax
128
+ // (e.g. a missing closing `>>`) rather than a member — surface that
129
+ // instead of silently mis-parsing it as a member named "<<foo" (#761).
130
+ if (line.startsWith('<<')) {
131
+ throw new Error(
132
+ `Line ${stmt.line}: Malformed class annotation "${line}" — expected "<<name>>" (e.g. "<<interface>>").`,
133
+ )
134
+ }
135
+
136
+ // Parse member: visibility, name, type, optional parens for method
137
+ addMember(currentClass, line, stmt.line)
138
+ continue
139
+ }
140
+
141
+ // --- click interaction: `click ClassName "url" "tooltip" _blank` / `click ClassName call fn()` ---
142
+ if (/^click\s+/i.test(line)) {
143
+ applyClickStatement(line, diagram.interactions)
144
+ continue
145
+ }
146
+
147
+ // --- Notes: `note "text"` / `note for ClassName "text"` ---
148
+ // Mermaid's grammar takes a quoted string (`noteText: STR`) and its
149
+ // renderer splits the text on `\n` after JSON-parsing it (svgDraw.js
150
+ // drawNote), so a literal `\n` in the source is a line break;
151
+ // normalizeBrTags handles that and `<br/>`, the form the rest of this
152
+ // repo's labels use. The class need not be declared first (or at all).
153
+ const noteMatch = line.match(/^note\s+(?:for\s+(\S+)\s+)?"([^"]*)"\s*$/)
154
+ if (noteMatch) {
155
+ const forClass = noteMatch[1]
156
+ diagram.notes.push({
157
+ text: normalizeBrTags(noteMatch[2]!),
158
+ ...(forClass ? { forClass } : {}),
159
+ })
160
+ continue
161
+ }
162
+
163
+ // --- Styling: `classDef`, `style`, `cssClass "A,B" name`, `class A,B name` ---
164
+ // Shared with the flowchart parser (packages/core/src/style-directives.ts). The
165
+ // `class A,B name` assignment form is checked before the declaration
166
+ // regexes below: a declaration never has a second bare-word token after
167
+ // the class id (`class Animal`, `class Animal~T~`, `class Animal {`), so
168
+ // the two can't collide. Mermaid's own class grammar only spells the
169
+ // attachment as `cssClass "A,B" name` / `A:::name`; the flowchart-style
170
+ // `class A,B name` is accepted here for parity with flowcharts.
171
+ if (tryApplyClassDef(line, diagram)) continue
172
+ if (tryApplyStyleStatement(line, diagram)) continue
173
+ if (tryApplyCssClass(line, diagram)) continue
174
+ if (tryApplyClassAssignment(line, diagram)) continue
175
+
176
+ // --- Namespace block start ---
177
+ const nsMatch = line.match(/^namespace\s+(\S+)\s*\{$/)
178
+ if (nsMatch) {
179
+ currentNamespace = { name: nsMatch[1]!, classIds: [] }
180
+ continue
181
+ }
182
+
183
+ // --- Namespace end ---
184
+ if (line === '}' && currentNamespace) {
185
+ diagram.namespaces.push(currentNamespace)
186
+ currentNamespace = null
187
+ continue
188
+ }
189
+
190
+ // --- Class block start: `class ClassName {` or `class ClassName:::style {` ---
191
+ const classBlockMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*\{$/)
192
+ if (classBlockMatch) {
193
+ const id = declareClass(classBlockMatch[1]!, classBlockMatch[2])
194
+ currentClass = classMap.get(id) ?? null
195
+ braceDepth = 1
196
+ openClassLine = stmt.line
197
+ continue
198
+ }
199
+
200
+ // --- Standalone class declaration (no body): `class ClassName` / `class ClassName:::style` ---
201
+ const classOnlyMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*$/)
202
+ if (classOnlyMatch) {
203
+ declareClass(classOnlyMatch[1]!, classOnlyMatch[2])
204
+ continue
205
+ }
206
+
207
+ // --- Single-line body: `class ClassName { <<interface>> }` / `class ClassName:::style { -int size }` ---
208
+ // One annotation or one member; `;`-separated members on a single line
209
+ // are already split into separate statements upstream, so a multi-member
210
+ // body can't reach here intact.
211
+ const inlineBodyMatch = line.match(/^class\s+(\S+?)\s*\{\s*(.*?)\s*\}$/)
212
+ if (inlineBodyMatch) {
213
+ const id = declareClass(inlineBodyMatch[1]!, undefined)
214
+ const cls = classMap.get(id)
215
+ const body = inlineBodyMatch[2]!
216
+ const annotMatch = body.match(/^<<(\w+)>>$/)
217
+ if (cls && annotMatch) {
218
+ cls.annotation = annotMatch[1]!
219
+ } else if (cls && body.startsWith('<<')) {
220
+ // Same broken-annotation-attempt case as the multi-line body
221
+ // branch above, just on the single-line form (#761).
222
+ throw new Error(
223
+ `Line ${stmt.line}: Malformed class annotation "${body}" — expected "<<name>>" (e.g. "<<interface>>").`,
224
+ )
225
+ } else if (cls && body) {
226
+ addMember(cls, body, stmt.line)
227
+ }
228
+ continue
229
+ }
230
+
231
+ // --- Inline attribute: `ClassName : +String name` ---
232
+ const inlineAttrMatch = line.match(/^(\S+?)\s*:\s*(.+)$/)
233
+ if (inlineAttrMatch) {
234
+ // Make sure this isn't a relationship line (those have arrows)
235
+ const rest = inlineAttrMatch[2]!
236
+ if (!rest.match(/<\|--|--|\*--|o--|-->|\.\.>|\.\.\|>/)) {
237
+ addMember(ensureClass(classMap, inlineAttrMatch[1]!), rest, stmt.line)
238
+ continue
239
+ }
240
+ }
241
+
242
+ // --- Relationship ---
243
+ // Pattern: [FROM] ["card"] ARROW ["card"] [TO] [: label]
244
+ // Arrows: <|--, *--, o--, -->, ..|>, ..>
245
+ // Can also be reversed: --o, --*, --|>
246
+ const rel = parseRelationship(line)
247
+ if (rel) {
248
+ // Ensure both classes exist. Either end may carry the `:::style`
249
+ // shorthand (Mermaid's docs warn against combining it with a relation
250
+ // statement, but it's better to strip it than to mint a class whose id
251
+ // is the literal `Animal:::someclass`).
252
+ rel.from = declareClass(rel.from, undefined, false)
253
+ rel.to = declareClass(rel.to, undefined, false)
254
+ diagram.relationships.push(rel)
255
+ continue
256
+ }
257
+
258
+ // A line containing a relationship-arrow token that still didn't parse
259
+ // above is a malformed/dangling relationship attempt — e.g. "Animal
260
+ // <|--" with no target, or an unsupported arrow shape — rather than an
261
+ // unrelated line that merely falls through. Scoped to lines that
262
+ // already look like an attempt (same "attempt, then validate" pattern
263
+ // #722 used for ER/xychart-beta) so genuinely unrecognized syntax with
264
+ // no arrow at all keeps falling through silently (#761).
265
+ if (RELATIONSHIP_ARROW_MARKER.test(line)) {
266
+ throw new Error(
267
+ `Line ${stmt.line}: Malformed class-diagram relationship "${line}". Expected "FROM ARROW TO" (optionally with cardinalities and a ": label"), e.g. "Animal <|-- Dog" or 'A "1" --> "*" B : label'. ARROW must be one of <|--, <|.., *--, o--, -->, --*, --o, --|>, ..>, ..|>, <--, <.., or --.`,
268
+ )
269
+ }
270
+ }
271
+
272
+ if (currentClass !== null) {
273
+ throw new Error(
274
+ `Line ${openClassLine}: Unclosed class body for "${currentClass.id}" — expected a closing "}" before the diagram ends.`,
275
+ )
276
+ }
277
+
278
+ diagram.classes = [...classMap.values()]
279
+ return diagram
280
+
281
+ /**
282
+ * Register a class from a declaration token, splitting off any `:::style`
283
+ * shorthand into `diagram.classAssignments`. Returns the bare id.
284
+ *
285
+ * @param generic - `~T~` generic parameter captured by the declaration regex
286
+ * @param inNamespace - whether to record the class in the open namespace
287
+ * (declarations do; relationship endpoints don't)
288
+ */
289
+ function declareClass(
290
+ token: string,
291
+ generic: string | undefined,
292
+ inNamespace: boolean = true,
293
+ ): string {
294
+ const { id: rawId, className } = splitClassShorthand(token)
295
+ let id = rawId
296
+ let resolvedGeneric = generic
297
+ if (!resolvedGeneric) {
298
+ // The declaration regexes only split a `~T~` generic off the bare id
299
+ // when nothing else trails it. When the `:::style` shorthand trails
300
+ // the generic instead (`Foo~T~:::hot`), the generic gets swallowed
301
+ // into the shorthand-stripped id here (`splitClassShorthand` returns
302
+ // `Foo~T~`) — strip it off now so the class is declared as `Foo`
303
+ // rather than the literal `Foo~T~` (#506).
304
+ const genericMatch = rawId.match(/^(.+?)~(\w+)~$/)
305
+ if (genericMatch) {
306
+ id = genericMatch[1]!
307
+ resolvedGeneric = genericMatch[2]!
308
+ }
309
+ }
310
+ const cls = ensureClass(classMap, id)
311
+ if (resolvedGeneric) {
312
+ cls.label = `${id}<${resolvedGeneric}>`
313
+ }
314
+ if (className) {
315
+ diagram.classAssignments.set(id, className)
316
+ }
317
+ if (inNamespace && currentNamespace) {
318
+ currentNamespace.classIds.push(id)
319
+ }
320
+ return id
321
+ }
322
+ }
323
+
324
+ /** Parse one member line and file it under the class's attributes or methods. */
325
+ function addMember(cls: ClassNode, line: string, lineNumber: number): void {
326
+ const member = parseMember(line, lineNumber)
327
+ if (!member) return
328
+ if (member.isMethod) {
329
+ cls.methods.push(member.member)
330
+ } else {
331
+ cls.attributes.push(member.member)
332
+ }
333
+ }
334
+
335
+ /** Ensure a class exists in the map, creating a default if needed */
336
+ function ensureClass(classMap: Map<string, ClassNode>, id: string): ClassNode {
337
+ let cls = classMap.get(id)
338
+ if (!cls) {
339
+ cls = { id, label: id, attributes: [], methods: [] }
340
+ classMap.set(id, cls)
341
+ }
342
+ return cls
343
+ }
344
+
345
+ /** Count occurrences of `needle` in `input`. */
346
+ function countOccurrence(input: string, needle: string): number {
347
+ return Math.max(0, input.split(needle).length - 1)
348
+ }
349
+
350
+ /**
351
+ * Convert one comma-free (or already re-joined) segment's `~`-delimited
352
+ * generics to angle brackets, pairing the outermost `~`s first so nested
353
+ * generics like `List~List~T~~` become `List<List<T>>`. A segment with a
354
+ * single `~` is left alone — there's nothing to pair it with — and an odd
355
+ * count with a leading `~` keeps that leading one as-is (mermaid treats it
356
+ * as literal text, not a delimiter). Mirrors mermaid's `processSet`.
357
+ */
358
+ function convertGenericSegment(input: string): string {
359
+ const tildeCount = countOccurrence(input, '~')
360
+ if (tildeCount <= 1) return input
361
+
362
+ let text = input
363
+ let keepLeadingTilde = false
364
+ if (tildeCount % 2 !== 0 && text.startsWith('~')) {
365
+ text = text.slice(1)
366
+ keepLeadingTilde = true
367
+ }
368
+
369
+ const chars = [...text]
370
+ let open = chars.indexOf('~')
371
+ let close = chars.lastIndexOf('~')
372
+ while (open !== -1 && close !== -1 && open !== close) {
373
+ chars[open] = '<'
374
+ chars[close] = '>'
375
+ open = chars.indexOf('~')
376
+ close = chars.lastIndexOf('~')
377
+ }
378
+ if (keepLeadingTilde) chars.unshift('~')
379
+ return chars.join('')
380
+ }
381
+
382
+ /**
383
+ * Convert mermaid's `~T~` generic syntax to the `<T>` form mermaid itself
384
+ * renders — `List~Observer~` → `List<Observer>`, `Map~K,V~` → `Map<K,V>`,
385
+ * `List~List~T~~` → `List<List<T>>`.
386
+ *
387
+ * Port of mermaid's `parseGenericTypes` (packages/mermaid/src/diagrams/
388
+ * common/common.ts): the text is split on commas so a comma *inside* a
389
+ * generic (`Map~K,V~`) can be re-joined with its neighbors — two adjacent
390
+ * comma-separated pieces that each carry exactly one `~` are the two halves
391
+ * of one generic — before each piece's `~` pairs are converted outermost-first.
392
+ */
393
+ export function parseGenericTypes(input: string): string {
394
+ const pieces = input.split(/(,)/)
395
+ const output: string[] = []
396
+ for (let i = 0; i < pieces.length; i++) {
397
+ let piece = pieces[i]!
398
+ if (piece === ',' && i > 0 && i + 1 < pieces.length) {
399
+ const previous = pieces[i - 1]!
400
+ const next = pieces[i + 1]!
401
+ if (
402
+ countOccurrence(previous, '~') === 1 &&
403
+ countOccurrence(next, '~') === 1
404
+ ) {
405
+ piece = `${previous},${next}`
406
+ i++
407
+ output.pop()
408
+ }
409
+ }
410
+ output.push(convertGenericSegment(piece))
411
+ }
412
+ return output.join('')
413
+ }
414
+
415
+ /** Parse a class member line (attribute or method) */
416
+ function parseMember(
417
+ line: string,
418
+ lineNumber: number,
419
+ ): { member: ClassMember; isMethod: boolean } | null {
420
+ const trimmed = line.trim().replace(/;$/, '')
421
+ if (!trimmed) return null
422
+
423
+ // Extract visibility prefix
424
+ let visibility: ClassMember['visibility'] = ''
425
+ let rest = trimmed
426
+ const visibilityChar = rest[0]
427
+ if (
428
+ visibilityChar === '+' ||
429
+ visibilityChar === '-' ||
430
+ visibilityChar === '#' ||
431
+ visibilityChar === '~'
432
+ ) {
433
+ visibility = visibilityChar
434
+ rest = rest.slice(1).trim()
435
+ }
436
+
437
+ // An attempted method signature with an opening "(" but no closing ")"
438
+ // at all can't be a genuine attribute either (an attribute name/type
439
+ // never legitimately contains a bare, unclosed paren) — surface it
440
+ // instead of silently parsing "eat(void" as a member literally named
441
+ // that (#761).
442
+ if (rest.includes('(') && !rest.includes(')')) {
443
+ throw new Error(
444
+ `Line ${lineNumber}: Malformed class member "${trimmed}" — unclosed "(" in a method signature. Expected e.g. "+eat() void" or "+eat(Food f) void".`,
445
+ )
446
+ }
447
+
448
+ // Mermaid renders `List~Observer~` as `List<Observer>` (its own
449
+ // `parseGenericTypes`). It's applied per field below — name, params and
450
+ // return type separately, the way mermaid's ClassMember does — rather
451
+ // than to the whole line, since the comma re-join inside
452
+ // parseGenericTypes would otherwise pair a `~` in the params with one in
453
+ // the return type. Doing it after the visibility prefix is stripped also
454
+ // keeps a package-visibility `~` from pairing with a generic's.
455
+
456
+ // Check if it's a method (has parentheses)
457
+ const methodMatch = rest.match(/^(.+?)\(([^)]*)\)(?:\s*(.+))?$/)
458
+ if (methodMatch) {
459
+ const name = parseGenericTypes(methodMatch[1]!.trim())
460
+ const rawParams = methodMatch[2]?.trim()
461
+ const params = rawParams ? parseGenericTypes(rawParams) : undefined // Store the parameter string
462
+ const rawType = methodMatch[3]?.trim()
463
+ const type = rawType ? parseGenericTypes(rawType) : undefined
464
+ // Check for static ($) or abstract (*) markers
465
+ const isStatic = name.endsWith('$') || rest.includes('$')
466
+ const isAbstract = name.endsWith('*') || rest.includes('*')
467
+ return {
468
+ member: {
469
+ visibility,
470
+ name: name.replace(/[$*]$/, ''),
471
+ type: type || undefined,
472
+ isStatic,
473
+ isAbstract,
474
+ isMethod: true,
475
+ params,
476
+ },
477
+ isMethod: true,
478
+ }
479
+ }
480
+
481
+ // It's an attribute: [Type] name or name Type
482
+ // Common patterns: "String name", "+int age", "name"
483
+ const parts = parseGenericTypes(rest).split(/\s+/)
484
+ let name: string
485
+ let type: string | undefined
486
+
487
+ if (parts.length >= 2) {
488
+ // "Type name" pattern
489
+ type = parts[0]
490
+ name = parts.slice(1).join(' ')
491
+ } else {
492
+ name = parts[0] ?? rest
493
+ }
494
+
495
+ const isStatic = name.endsWith('$')
496
+ const isAbstract = name.endsWith('*')
497
+
498
+ return {
499
+ member: {
500
+ visibility,
501
+ name: name.replace(/[$*]$/, ''),
502
+ type: type || undefined,
503
+ isStatic,
504
+ isAbstract,
505
+ isMethod: false,
506
+ },
507
+ isMethod: false,
508
+ }
509
+ }
510
+
511
+ /** Parse a relationship line into a ClassRelationship */
512
+ function parseRelationship(line: string): ClassRelationship | null {
513
+ // Relationship regex — handles all arrow types with optional cardinality and labels
514
+ // Pattern: FROM ["card"] ARROW ["card"] TO[:::style] [: label]
515
+ // The `:::style` shorthand on TO is matched as its own group so the `:`
516
+ // label separator that follows can't swallow it as a label of `::style`;
517
+ // it's re-joined onto the id here and split off again by the caller.
518
+ const match = line.match(
519
+ new RegExp(
520
+ `^(\\S+?)\\s+(?:"([^"]*?)"\\s+)?(${RELATIONSHIP_ARROW_SOURCE})\\s+(?:"([^"]*?)"\\s+)?(\\S+?)(?::::([\\w][\\w-]*))?(?:\\s*:\\s*(.+))?$`,
521
+ ),
522
+ )
523
+ if (!match) return null
524
+
525
+ const from = match[1]!
526
+ const rawFromCardinality = match[2]
527
+ const fromCardinality = rawFromCardinality
528
+ ? normalizeBrTags(rawFromCardinality)
529
+ : undefined
530
+ const arrow = match[3]!.trim()
531
+ const rawToCardinality = match[4]
532
+ const toCardinality = rawToCardinality
533
+ ? normalizeBrTags(rawToCardinality)
534
+ : undefined
535
+ const to = match[6] ? `${match[5]!}:::${match[6]}` : match[5]!
536
+ const rawLabel = match[7]?.trim()
537
+ const label = rawLabel ? normalizeBrTags(rawLabel) : undefined
538
+
539
+ const parsed = parseArrow(arrow)
540
+ if (!parsed) return null
541
+
542
+ return {
543
+ from,
544
+ to,
545
+ type: parsed.type,
546
+ markerAt: parsed.markerAt,
547
+ label,
548
+ fromCardinality,
549
+ toCardinality,
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Map arrow syntax to relationship type and marker placement side.
555
+ * Prefix markers (`<|--`, `*--`, `o--`) place the UML shape at the 'from' end.
556
+ * Suffix markers (`..|>`, `-->`, `..>`, `--*`, `--o`) place it at the 'to' end.
557
+ */
558
+ function parseArrow(
559
+ arrow: string,
560
+ ): { type: RelationshipType; markerAt: 'from' | 'to' } | null {
561
+ // Trim whitespace that might be captured by the regex
562
+ const a = arrow.trim()
563
+ switch (a) {
564
+ case '<|--':
565
+ return { type: 'inheritance', markerAt: 'from' }
566
+ case '--|>':
567
+ return { type: 'inheritance', markerAt: 'to' }
568
+ case '<|..':
569
+ return { type: 'realization', markerAt: 'from' }
570
+ case '..|>':
571
+ return { type: 'realization', markerAt: 'to' }
572
+ case '*--':
573
+ return { type: 'composition', markerAt: 'from' }
574
+ case '--*':
575
+ return { type: 'composition', markerAt: 'to' }
576
+ case 'o--':
577
+ return { type: 'aggregation', markerAt: 'from' }
578
+ case '--o':
579
+ return { type: 'aggregation', markerAt: 'to' }
580
+ case '-->':
581
+ return { type: 'association', markerAt: 'to' }
582
+ case '<--':
583
+ return { type: 'association', markerAt: 'from' }
584
+ case '..>':
585
+ return { type: 'dependency', markerAt: 'to' }
586
+ case '<..':
587
+ return { type: 'dependency', markerAt: 'from' }
588
+ case '--':
589
+ return { type: 'association', markerAt: 'to' }
590
+ default:
591
+ return null
592
+ }
593
+ }