@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,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
|
+
}
|