@formicoidea/labre-framework-uml 0.42.0

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.
Files changed (114) hide show
  1. package/LICENSE +384 -0
  2. package/dist/actions.d.ts +430 -0
  3. package/dist/actions.js +724 -0
  4. package/dist/background.d.ts +153 -0
  5. package/dist/background.js +557 -0
  6. package/dist/board-hit.d.ts +137 -0
  7. package/dist/board-hit.js +191 -0
  8. package/dist/commands-manifest.d.ts +19 -0
  9. package/dist/commands-manifest.js +571 -0
  10. package/dist/commands.d.ts +4 -0
  11. package/dist/commands.js +983 -0
  12. package/dist/component.d.ts +521 -0
  13. package/dist/component.js +708 -0
  14. package/dist/consts.d.ts +365 -0
  15. package/dist/consts.js +463 -0
  16. package/dist/descriptor.d.ts +12 -0
  17. package/dist/descriptor.js +10 -0
  18. package/dist/drawio-decode.d.ts +32 -0
  19. package/dist/drawio-decode.js +96 -0
  20. package/dist/drawio-import.d.ts +133 -0
  21. package/dist/drawio-import.js +885 -0
  22. package/dist/edge-morph.d.ts +154 -0
  23. package/dist/edge-morph.js +153 -0
  24. package/dist/edge-styles.d.ts +78 -0
  25. package/dist/edge-styles.js +210 -0
  26. package/dist/effects.d.ts +9 -0
  27. package/dist/effects.js +6 -0
  28. package/dist/element-renderer.d.ts +64 -0
  29. package/dist/element-renderer.js +114 -0
  30. package/dist/element-view.d.ts +234 -0
  31. package/dist/element-view.js +430 -0
  32. package/dist/export.d.ts +61 -0
  33. package/dist/export.js +41 -0
  34. package/dist/filename.d.ts +17 -0
  35. package/dist/filename.js +26 -0
  36. package/dist/frame-tag.d.ts +70 -0
  37. package/dist/frame-tag.js +78 -0
  38. package/dist/grammar.d.ts +695 -0
  39. package/dist/grammar.js +1436 -0
  40. package/dist/import.d.ts +306 -0
  41. package/dist/import.js +1270 -0
  42. package/dist/index.d.ts +47 -0
  43. package/dist/index.js +67 -0
  44. package/dist/interchange.d.ts +125 -0
  45. package/dist/interchange.js +372 -0
  46. package/dist/keywords.d.ts +204 -0
  47. package/dist/keywords.js +361 -0
  48. package/dist/kinds.d.ts +73 -0
  49. package/dist/kinds.js +91 -0
  50. package/dist/legend.d.ts +30 -0
  51. package/dist/legend.js +255 -0
  52. package/dist/model.d.ts +862 -0
  53. package/dist/model.js +1316 -0
  54. package/dist/morph.d.ts +178 -0
  55. package/dist/morph.js +496 -0
  56. package/dist/node/act-glyphs.d.ts +30 -0
  57. package/dist/node/act-glyphs.js +142 -0
  58. package/dist/node/compartment-watcher.d.ts +107 -0
  59. package/dist/node/compartment-watcher.js +163 -0
  60. package/dist/node/fit.d.ts +85 -0
  61. package/dist/node/fit.js +129 -0
  62. package/dist/node/node-renderer.d.ts +49 -0
  63. package/dist/node/node-renderer.js +611 -0
  64. package/dist/node/node-view.d.ts +51 -0
  65. package/dist/node/node-view.js +89 -0
  66. package/dist/node/paint.d.ts +84 -0
  67. package/dist/node/paint.js +143 -0
  68. package/dist/node/sd-glyphs.d.ts +36 -0
  69. package/dist/node/sd-glyphs.js +79 -0
  70. package/dist/node/stm-glyphs.d.ts +22 -0
  71. package/dist/node/stm-glyphs.js +111 -0
  72. package/dist/node/tier-metrics.d.ts +38 -0
  73. package/dist/node/tier-metrics.js +45 -0
  74. package/dist/operators.d.ts +45 -0
  75. package/dist/operators.js +75 -0
  76. package/dist/plantuml-import.d.ts +113 -0
  77. package/dist/plantuml-import.js +1723 -0
  78. package/dist/plantuml.d.ts +94 -0
  79. package/dist/plantuml.js +985 -0
  80. package/dist/presets.d.ts +149 -0
  81. package/dist/presets.js +302 -0
  82. package/dist/profiles.d.ts +2 -0
  83. package/dist/profiles.js +358 -0
  84. package/dist/reading.d.ts +160 -0
  85. package/dist/reading.js +648 -0
  86. package/dist/roles.d.ts +257 -0
  87. package/dist/roles.js +997 -0
  88. package/dist/rules.d.ts +267 -0
  89. package/dist/rules.js +3090 -0
  90. package/dist/templates.d.ts +2 -0
  91. package/dist/templates.js +197 -0
  92. package/dist/toolbar/config.d.ts +228 -0
  93. package/dist/toolbar/config.js +833 -0
  94. package/dist/toolbar/icons.d.ts +440 -0
  95. package/dist/toolbar/icons.js +711 -0
  96. package/dist/toolbar/senior-tool.d.ts +11 -0
  97. package/dist/toolbar/senior-tool.js +21 -0
  98. package/dist/toolbar/uml-menu.d.ts +17 -0
  99. package/dist/toolbar/uml-menu.js +20 -0
  100. package/dist/toolbar/uml-senior-button.d.ts +18 -0
  101. package/dist/toolbar/uml-senior-button.js +22 -0
  102. package/dist/translations.d.ts +27 -0
  103. package/dist/translations.js +117 -0
  104. package/dist/view.d.ts +33 -0
  105. package/dist/view.js +236 -0
  106. package/dist/xmi-import.d.ts +174 -0
  107. package/dist/xmi-import.js +2268 -0
  108. package/dist/xmi.d.ts +61 -0
  109. package/dist/xmi.js +1860 -0
  110. package/dist/xml-reader.d.ts +157 -0
  111. package/dist/xml-reader.js +430 -0
  112. package/dist/xml.d.ts +72 -0
  113. package/dist/xml.js +96 -0
  114. package/package.json +38 -0
@@ -0,0 +1,1723 @@
1
+ import { UML_DIAGRAM_KIND_TAG, } from '@formicoidea/labre-core/model';
2
+ import { UML_FRAGMENT_BOX, UML_NODE_BOX, UML_REGION_BOX, UML_SUBJECT_BOX, } from './consts.js';
3
+ import { parseEndLabel, parseLifelineIdent, parseOperation, parseProperty, parseTransition, } from './grammar.js';
4
+ import { UML_SD_COLUMN_GAP, UML_SD_EVENT_STEP, UML_SD_FRAGMENT_PADDING, umlInventLayout, umlSequenceColumn, umlSequenceDestruction, umlSequenceExecution, umlSequenceFragment, umlSequenceSlot, } from './import.js';
5
+ import { stereotypesOf } from './keywords.js';
6
+ import { ADORNED_RELATION_KINDS } from './model.js';
7
+ /**
8
+ * The remarks this reader raises whose wording is FIXED, as `[key, English]`
9
+ * pairs — one entry per SHAPE of sentence, never one per exact string: a
10
+ * remark that names something out of the file (a line of PlantUML, a
11
+ * declaration's alias) carries `{{name}}` holes in the fallback here, filled
12
+ * at the call site by `messageParams` (`InterchangeNote.messageParams`) —
13
+ * never by minting a new key for every value the file happens to carry.
14
+ *
15
+ * A reader is a pure function of text (`docs/adr/0012`, P3) and has no `std`,
16
+ * so it cannot ask the host's catalogue for anything: it declares the key
17
+ * (and, where the sentence has a hole, the params) on the note, and
18
+ * `reportInterchangeImport` resolves both when it draws the report
19
+ * (`InterchangeNote.messageKey` / `.messageParams`). The English string stays
20
+ * here and stays the fallback, so the console table and a playground with no
21
+ * catalogue read exactly what they read before: `translateKey` fills a `{{…}}`
22
+ * hole from `messageParams` even with no `TranslationProvider` registered
23
+ * (`fillPlaceholders`), so the ENGLISH sentence a call site builds is
24
+ * unchanged by giving it a key. BPMN's `BPMN_IMPORT_REMARKS` is the same table
25
+ * for the same reasons, and this one is written to match it — including
26
+ * declaring it in the reader's OWN file rather than in a shared one, so the
27
+ * wording sits beside the line that raises it.
28
+ *
29
+ * Plural wording is the host's: where a count can be 0, 1 or many, the
30
+ * fallback stays grammatically neutral (`'{{count}} element(s)'`) rather than
31
+ * picking English's own singular/plural.
32
+ *
33
+ * They are contributed to the manifest by `./translations.ts`, with the
34
+ * framework, because they ship in the UML bundle rather than in core.
35
+ */
36
+ export const UML_PLANTUML_REMARKS = {
37
+ relationEnd: [
38
+ 'com.labre.uml.import.plantuml.relation-end',
39
+ 'The {{side}} end of the {{kind}} between "{{left}}" and "{{right}}" is written "{{raw}}". It is drawn on the board, but UML gives that relationship\'s ends no multiplicity, so no export writes it.',
40
+ ],
41
+ duplicateDeclaration: [
42
+ 'com.labre.uml.import.plantuml.duplicate-declaration',
43
+ 'This document declares "{{name}}" twice. The second declaration was read as a separate artefact under an id of Labre\'s own.',
44
+ ],
45
+ renderDirective: [
46
+ 'com.labre.uml.import.plantuml.render-directive',
47
+ '"{{line}}" says how to DRAW the diagram. Labre\'s canvas has a look of its own, so the directive is recorded here and not applied.',
48
+ ],
49
+ unreadLine: [
50
+ 'com.labre.uml.import.plantuml.unread-line',
51
+ 'Labre does not read "{{line}}", so it is not on the board. The line is recorded here exactly as the file wrote it.',
52
+ ],
53
+ stateSyntax: [
54
+ 'com.labre.uml.import.plantuml.state-syntax',
55
+ '"{{heading}}" is written in PlantUML\'s state-diagram syntax, which Labre reads as a state machine. The frame keeps the kind the title gave it.',
56
+ ],
57
+ autonumber: [
58
+ 'com.labre.uml.import.plantuml.autonumber',
59
+ '"{{line}}" numbers the messages as the renderer draws them. A Labre board writes no numbers on its arrows, so the directive is recorded here and not applied.',
60
+ ],
61
+ inventedSequenceLayout: [
62
+ 'com.labre.uml.import.plantuml.invented-sequence-layout',
63
+ 'PlantUML carries no coordinates, so "{{heading}}" was laid out by Labre — the participants side by side in the order the file declares them, and one step down the page per message. The ORDER is the file\'s; the positions are ours.',
64
+ ],
65
+ inventedLayout: [
66
+ 'com.labre.uml.import.plantuml.invented-layout',
67
+ 'PlantUML carries no coordinates, so "{{heading}}" was laid out by Labre — in rows by generalization depth, with every container sized to fit what it holds. The positions are ours, not the file\'s.',
68
+ ],
69
+ };
70
+ /* ── The vocabulary ───────────────────────────────────────────────────── */
71
+ /** Annex A's frame tags, read back: `class Orders` → the `class` kind. */
72
+ const KIND_OF_TAG = new Map(Object.entries(UML_DIAGRAM_KIND_TAG).map(([kind, tag]) => [
73
+ tag,
74
+ kind,
75
+ ]));
76
+ /**
77
+ * The declaration words this reader understands, and the artefact each makes.
78
+ *
79
+ * `enum` and `enumeration` are one kind under two spellings because PlantUML
80
+ * accepts both; `entity` reads as a class, which is what PlantUML draws it as.
81
+ * `rectangle`, `folder` and `frame` all read as §18.1.4's SUBJECT — a plain
82
+ * boundary box — because that is the only thing this pack draws round a group of
83
+ * use cases, and it is what our own writer emits for one.
84
+ */
85
+ const DECLARATION_KIND = {
86
+ class: 'class',
87
+ entity: 'class',
88
+ interface: 'interface',
89
+ enum: 'enumeration',
90
+ enumeration: 'enumeration',
91
+ object: 'object',
92
+ package: 'package',
93
+ namespace: 'package',
94
+ rectangle: 'subject',
95
+ folder: 'subject',
96
+ frame: 'subject',
97
+ actor: 'actor',
98
+ usecase: 'use-case',
99
+ component: 'component',
100
+ artifact: 'artifact',
101
+ node: 'node',
102
+ state: 'state',
103
+ };
104
+ /** The declaration words, as the alternation the line matcher is built on. */
105
+ const DECLARATION_WORDS = [
106
+ 'abstract class',
107
+ 'abstract',
108
+ ...Object.keys(DECLARATION_KIND),
109
+ ].join('|');
110
+ /**
111
+ * The state stereotypes `plantuml.ts` writes, read back to the glyph they stand
112
+ * for.
113
+ *
114
+ * The inverse of `PLANTUML_STATE_STEREOTYPE`, with the two conflations that
115
+ * table declares resolved the way it resolves them: `<<choice>>` comes back as a
116
+ * choice (a junction was borrowing the diamond) and `<<end>>` as a final state
117
+ * (a terminate was borrowing the stop). Reading them the other way would turn a
118
+ * diagram somebody drew into a different one on the way home.
119
+ */
120
+ const STATE_KIND_OF_STEREOTYPE = {
121
+ start: 'initial',
122
+ end: 'final-state',
123
+ choice: 'choice',
124
+ junction: 'junction',
125
+ fork: 'fork',
126
+ history: 'shallow-history',
127
+ deephistory: 'deep-history',
128
+ entrypoint: 'entry-point',
129
+ exitpoint: 'exit-point',
130
+ terminate: 'terminate',
131
+ };
132
+ /** The vertices §14.2.4.6 files under `Pseudostate`, as a set. */
133
+ const PSEUDOSTATE_KINDS = new Set([
134
+ 'initial',
135
+ 'choice',
136
+ 'junction',
137
+ 'shallow-history',
138
+ 'deep-history',
139
+ 'entry-point',
140
+ 'exit-point',
141
+ 'terminate',
142
+ 'fork',
143
+ ]);
144
+ /**
145
+ * Every relationship operator, and the role it states.
146
+ *
147
+ * Both directions of each, because a `.puml` in the wild is written either way
148
+ * round and the two say the SAME thing about different ends: `B --|> A` and
149
+ * `A <|-- B` are both "B is an A". `reversed` says the sentence's SUBJECT — the
150
+ * source, per the role table in `roles.ts` — is written on the RIGHT of the
151
+ * arrow, which is the case for every operator whose mark is drawn at the LEFT
152
+ * end: the triangle of `<|--` points at the general classifier, so the specific
153
+ * one is on the other side.
154
+ *
155
+ * Matched longest-first, which the order of this table IS: `<|--` has to be
156
+ * tried before `<--`, and `..>` before `..`.
157
+ */
158
+ const OPERATORS = [
159
+ { pattern: /^<\|-+$/, kind: 'generalization', reversed: true },
160
+ { pattern: /^<\|\.+$/, kind: 'realization', reversed: true },
161
+ { pattern: /^-+\|>$/, kind: 'generalization' },
162
+ { pattern: /^\.+\|>$/, kind: 'realization' },
163
+ // The diamond is the WHOLE (`roles.ts`: "is composed of"), so the end it is
164
+ // drawn at is the source — the opposite half of the same rule.
165
+ { pattern: /^o-+$/, kind: 'aggregation' },
166
+ { pattern: /^-+o$/, kind: 'aggregation', reversed: true },
167
+ { pattern: /^\*-+$/, kind: 'composition' },
168
+ { pattern: /^-+\*$/, kind: 'composition', reversed: true },
169
+ { pattern: /^<\.+$/, kind: 'dependency', reversed: true },
170
+ { pattern: /^\.+>$/, kind: 'dependency' },
171
+ { pattern: /^<-+$/, kind: 'association', reversed: true },
172
+ { pattern: /^-+>$/, kind: 'association' },
173
+ { pattern: /^-+$/, kind: 'association' },
174
+ // A dashed line with nothing on either end: Annex A's anchor, which is how a
175
+ // note is attached to what it comments on.
176
+ { pattern: /^\.+$/, kind: 'anchor' },
177
+ ];
178
+ /**
179
+ * The keywords §7.8.4 and §18.1.4 say a dashed arrow is told apart BY.
180
+ *
181
+ * Five relationships share one drawing — a dashed line with an open arrow-head —
182
+ * and the specification is explicit that the word written on the line is what
183
+ * separates them. So `<<include>>` on a `..>` is not a label at all: it is the
184
+ * role, and reading it as prose would import an include as an untyped dependency
185
+ * carrying a stereotype.
186
+ */
187
+ const KEYWORD_ROLE = {
188
+ include: 'include',
189
+ extend: 'extend',
190
+ deploy: 'deploy',
191
+ manifest: 'manifest',
192
+ use: 'dependency',
193
+ };
194
+ /** PlantUML's contextual beginning-and-end marker (`plantuml.ts`). */
195
+ const TERMINAL = '[*]';
196
+ /* ── Reading a line ───────────────────────────────────────────────────── */
197
+ /** A `<<stereo>>` / `«stereo»` group, wherever it was written. */
198
+ const STEREOTYPE = /<<([^>]*)>>|«([^»]*)»/g;
199
+ /** Direction hints inside an arrow — `-down->`, `-up->`: layout, not model. */
200
+ const DIRECTION_HINT = /-(?:left|right|up|down|le|ri|do)-/gi;
201
+ /** A colour or a style tail — `#red`, `#line:blue;line.dashed`. */
202
+ const STYLE_TAIL = /\s#\S+/g;
203
+ /** The arrow token, as the run of operator characters between two spaces. */
204
+ const ARROW = /\s([<>|o*.+#x{}^-]+)\s/;
205
+ /** `"Name" as alias <<stereo>> {` — every part, or `undefined` for nonsense. */
206
+ function parseDeclaration(tail) {
207
+ let rest = tail.trim();
208
+ const opens = rest.endsWith('{');
209
+ if (opens)
210
+ rest = rest.slice(0, -1).trim();
211
+ const stereotypes = [];
212
+ rest = rest
213
+ .replaceAll(STEREOTYPE, (_match, angled, guillemet) => {
214
+ for (const label of String(angled ?? guillemet ?? '').split(',')) {
215
+ const trimmed = label.trim();
216
+ if (trimmed)
217
+ stereotypes.push(trimmed);
218
+ }
219
+ return ' ';
220
+ })
221
+ .replaceAll(STYLE_TAIL, ' ')
222
+ .trim();
223
+ const named = /^(?:"([^"]*)"|\(([^)]*)\)|([^\s"]+))(?:\s+as\s+(\S+))?\s*$/.exec(rest);
224
+ if (!named)
225
+ return undefined;
226
+ return {
227
+ label: named[1] ?? named[2] ?? named[3] ?? '',
228
+ ...(named[4] ? { alias: named[4] } : {}),
229
+ stereotypes,
230
+ opens,
231
+ };
232
+ }
233
+ /** The label after the first ` : `, and the line without it. */
234
+ function splitLabel(line) {
235
+ const colon = line.indexOf(' : ');
236
+ if (colon < 0)
237
+ return { head: line };
238
+ return { head: line.slice(0, colon), label: line.slice(colon + 3).trim() };
239
+ }
240
+ /**
241
+ * `A "1"` → the artefact and the multiplicity written against its end.
242
+ *
243
+ * A side that is NOTHING BUT a quoted string is the artefact rather than a
244
+ * multiplicity: `"Order" -- "Line"` is two named ends, and reading them as two
245
+ * cardinalities on an arrow between nothing would lose the whole relationship.
246
+ */
247
+ function splitEnd(side, trailing) {
248
+ const matched = trailing
249
+ ? /^(.*?)\s*"([^"]*)"\s*$/.exec(side)
250
+ : /^\s*"([^"]*)"\s*(.*)$/.exec(side);
251
+ if (!matched)
252
+ return { name: side.trim() };
253
+ const name = (trailing ? matched[1] : matched[2]).trim();
254
+ const end = (trailing ? matched[2] : matched[1]).trim();
255
+ if (!name)
256
+ return { name: end };
257
+ return { name, ...(end ? { end } : {}) };
258
+ }
259
+ /** An operation is a member line with a parameter list; anything else is not. */
260
+ const isOperation = (line) => line.includes('(');
261
+ /** A line of an object's compartment as a SLOT — `model.ts`'s own reading. */
262
+ function slotOf(line) {
263
+ const property = parseProperty(line);
264
+ return {
265
+ name: property.name,
266
+ ...(property.defaultValue ? { value: property.defaultValue } : {}),
267
+ };
268
+ }
269
+ /** An empty model, so every list below is spelled once. */
270
+ function blankModel(id) {
271
+ return {
272
+ diagram: { id, kind: 'class', name: '', heading: '' },
273
+ classifiers: [],
274
+ packages: [],
275
+ actors: [],
276
+ useCases: [],
277
+ subjects: [],
278
+ notes: [],
279
+ components: [],
280
+ ports: [],
281
+ artifacts: [],
282
+ nodes: [],
283
+ activities: [],
284
+ stateMachines: [],
285
+ interactions: [],
286
+ relations: [],
287
+ warnings: [],
288
+ };
289
+ }
290
+ /**
291
+ * One block, read.
292
+ *
293
+ * A single pass with a frame stack, which is what `{ … }` nesting asks for and
294
+ * the only shape in which a package inside a package inside a package needs no
295
+ * special case. Nothing here looks ahead: a `.puml` is written in declaration
296
+ * order and read in it.
297
+ */
298
+ function parseBlock(lines, blockIndex, note) {
299
+ const model = blankModel(`plantuml-${blockIndex + 1}`);
300
+ const entries = new Map();
301
+ /** What each container holds, in declaration order — the layout reads it. */
302
+ const contains = new Map();
303
+ const stack = [{ type: 'root' }];
304
+ let titleKind;
305
+ let behaviour = false;
306
+ let minted = 0;
307
+ const top = () => stack[stack.length - 1];
308
+ /** The container a declaration written here belongs to, if any. */
309
+ const containerId = () => {
310
+ const frame = top();
311
+ return frame.type === 'container' ? frame.id : undefined;
312
+ };
313
+ const register = (id, entry = {}) => {
314
+ const parent = containerId();
315
+ entries.set(id, { id, ...entry, ...(parent ? { parentId: parent } : {}) });
316
+ if (!parent)
317
+ return;
318
+ const held = contains.get(parent);
319
+ if (held)
320
+ held.push(id);
321
+ else
322
+ contains.set(parent, [id]);
323
+ };
324
+ /** A declared artefact's shared half — the name compartment, parsed. */
325
+ const base = (id, declaration) => {
326
+ const stated = stereotypesOf(declaration.label);
327
+ return {
328
+ id,
329
+ name: stated.name,
330
+ keywords: [...declaration.stereotypes, ...stated.keywords],
331
+ isAbstract: stated.isAbstract,
332
+ };
333
+ };
334
+ /** The one state machine an `stm` sheet holds, created on first need. */
335
+ const machine = () => {
336
+ if (model.stateMachines.length === 0) {
337
+ model.stateMachines.push({
338
+ id: model.diagram.id,
339
+ name: model.diagram.name,
340
+ regions: [],
341
+ states: [],
342
+ finalStates: [],
343
+ pseudostates: [],
344
+ transitions: [],
345
+ });
346
+ }
347
+ return model.stateMachines[0];
348
+ };
349
+ function addClassifier(id, kind, declaration) {
350
+ const record = base(id, declaration);
351
+ // `object : Class` — §11.6.4's instance, and the one name this reader
352
+ // splits: everything else keeps whatever the author wrote.
353
+ let instanceOf;
354
+ let name = record.name;
355
+ if (kind === 'object') {
356
+ const colon = name.indexOf(':');
357
+ if (colon >= 0) {
358
+ instanceOf = name.slice(colon + 1).trim() || undefined;
359
+ name = name.slice(0, colon).trim();
360
+ }
361
+ }
362
+ const classifier = {
363
+ ...record,
364
+ name,
365
+ kind,
366
+ attributes: [],
367
+ operations: [],
368
+ slots: [],
369
+ lines: { attributes: [], operations: [] },
370
+ ...(instanceOf ? { instanceOf } : {}),
371
+ };
372
+ model.classifiers.push(classifier);
373
+ register(id, { classifier });
374
+ return classifier;
375
+ }
376
+ function addState(id, declaration) {
377
+ const state = {
378
+ ...base(id, declaration),
379
+ entry: [],
380
+ doActivity: [],
381
+ exit: [],
382
+ lines: [],
383
+ };
384
+ machine().states.push(state);
385
+ register(id, { state });
386
+ return state;
387
+ }
388
+ /** A member line, filed in the compartment its syntax puts it in. */
389
+ const addMember = (classifier, line) => {
390
+ if (isOperation(line))
391
+ classifier.lines.operations.push(line);
392
+ else
393
+ classifier.lines.attributes.push(line);
394
+ };
395
+ /** The id an arrow's end names — minting a vertex for `[*]` on the way. */
396
+ function endOf(raw, asSource) {
397
+ const name = raw.trim().replace(/^"(.*)"$/, '$1');
398
+ if (!name)
399
+ return undefined;
400
+ if (name === TERMINAL) {
401
+ // `[*]` is contextual (`plantuml.ts`): the source of an arrow is the
402
+ // filled disc, the target is the bullseye. One vertex per arrow, because
403
+ // that is what the picture draws.
404
+ behaviour = true;
405
+ const id = `${model.diagram.id}-terminal-${++minted}`;
406
+ const vertex = {
407
+ id,
408
+ name: '',
409
+ keywords: [],
410
+ isAbstract: false,
411
+ };
412
+ if (asSource)
413
+ machine().pseudostates.push({ ...vertex, kind: 'initial' });
414
+ else
415
+ machine().finalStates.push(vertex);
416
+ entries.set(id, { id });
417
+ return id;
418
+ }
419
+ const known = entries.get(name);
420
+ if (known)
421
+ return known.id;
422
+ // An arrow naming something the file never declared. PlantUML draws it as a
423
+ // bare box, and so does this: dropping the arrow would lose the statement
424
+ // the author actually made.
425
+ const declaration = {
426
+ label: name,
427
+ stereotypes: [],
428
+ opens: false,
429
+ };
430
+ const id = behaviour
431
+ ? addState(name, declaration).id
432
+ : addClassifier(name, 'class', declaration).id;
433
+ const entry = entries.get(id);
434
+ if (entry)
435
+ entry.implicit = true;
436
+ return id;
437
+ }
438
+ /**
439
+ * Forget an artefact an arrow minted, so the declaration that follows it can
440
+ * be read as the artefact rather than as a second one.
441
+ *
442
+ * The ID is what is kept: every relation already written names it, and a
443
+ * declaration that arrived late is still a declaration OF the thing the
444
+ * arrows have been pointing at.
445
+ */
446
+ function forget(entry) {
447
+ const held = model.stateMachines[0];
448
+ model.classifiers = model.classifiers.filter(record => record.id !== entry.id);
449
+ if (held) {
450
+ held.states = held.states.filter(record => record.id !== entry.id);
451
+ }
452
+ entries.delete(entry.id);
453
+ }
454
+ /**
455
+ * Is this id one of the machine's vertices?
456
+ *
457
+ * A REGION counts, and it is the one entry here that is not a vertex in the
458
+ * metamodel: §14.2.4 draws a composite state as a box and lets a transition
459
+ * end on it, and this pack draws that box as a `umlRegion`. An arrow onto one
460
+ * is therefore drawn — the author sees what they wrote — and is the one
461
+ * statement in this format that does not survive a round trip through the
462
+ * canvas, because `model.ts` deliberately attributes nothing to a background.
463
+ */
464
+ function isVertex(id) {
465
+ const held = model.stateMachines[0];
466
+ if (!held)
467
+ return false;
468
+ return (held.states.some(state => state.id === id) ||
469
+ held.finalStates.some(final => final.id === id) ||
470
+ held.pseudostates.some(pseudo => pseudo.id === id) ||
471
+ held.regions.some(region => region.id === id));
472
+ }
473
+ /* ── The four readers one line is offered to, in order ───────────── */
474
+ function readNote(line) {
475
+ if (!/^note\b/i.test(line))
476
+ return false;
477
+ const { head, label } = splitLabel(line);
478
+ // `note left of X : the prose` — a comment and the anchor Annex A attaches
479
+ // it with, which is two statements written on one line.
480
+ const anchored = /^note\s+(?:left|right|top|bottom)\s+of\s+(\S+)$/i.exec(head.trim());
481
+ if (anchored) {
482
+ const record = addNote(`${model.diagram.id}-note-${++minted}`, label ?? '');
483
+ const target = entries.get(anchored[1]);
484
+ if (target) {
485
+ model.relations.push({
486
+ kind: 'anchor',
487
+ sourceId: record.id,
488
+ targetId: target.id,
489
+ });
490
+ }
491
+ return true;
492
+ }
493
+ // `note "the prose" as N` — the one-line form.
494
+ const inline = /^note\s+"([^"]*)"\s+as\s+(\S+)$/i.exec(head.trim());
495
+ if (inline) {
496
+ addNote(inline[2], inline[1]);
497
+ return true;
498
+ }
499
+ // `note as N` … `end note` — the block form, which is what this pack
500
+ // writes, because a note is prose and routinely several lines.
501
+ const block = /^note(?:\s+as\s+(\S+))?$/i.exec(head.trim());
502
+ if (block) {
503
+ const record = addNote(block[1] ?? `${model.diagram.id}-note-${++minted}`, '');
504
+ stack.push({ type: 'note', note: record });
505
+ return true;
506
+ }
507
+ return false;
508
+ }
509
+ function addNote(id, body) {
510
+ const unique = entries.has(id) ? `${id}-${++minted}` : id;
511
+ const record = {
512
+ id: unique,
513
+ name: '',
514
+ keywords: [],
515
+ isAbstract: false,
516
+ body,
517
+ };
518
+ model.notes.push(record);
519
+ register(unique);
520
+ return record;
521
+ }
522
+ function readRelation(line) {
523
+ const { head, label } = splitLabel(line);
524
+ const text = head.replaceAll(DIRECTION_HINT, '--').trim();
525
+ const arrow = ARROW.exec(` ${text} `);
526
+ if (!arrow)
527
+ return false;
528
+ const operator = OPERATORS.find(entry => entry.pattern.test(arrow[1]));
529
+ if (!operator)
530
+ return false;
531
+ const cut = text.indexOf(` ${arrow[1]} `);
532
+ if (cut < 0)
533
+ return false;
534
+ const left = splitEnd(text.slice(0, cut), true);
535
+ const right = splitEnd(text.slice(cut + arrow[1].length + 2), false);
536
+ if (!left.name || !right.name)
537
+ return false;
538
+ // §7.8.4: the keyword written on a dashed arrow IS which dependency it is.
539
+ let kind = operator.kind;
540
+ let written = label;
541
+ const keyword = label
542
+ ? /^(?:<<([^>]*)>>|«([^»]*)»)$/.exec(label.trim())
543
+ : null;
544
+ const named = (keyword?.[1] ?? keyword?.[2] ?? '').trim().toLowerCase();
545
+ const role = KEYWORD_ROLE[named];
546
+ if (role && (kind === 'dependency' || kind === 'association')) {
547
+ // The keyword is DROPPED when it became the role, because the writer
548
+ // emits it back from the role — `«include»` kept as a label as well would
549
+ // be written on the line twice. `«use»` is the one that stays: a Usage is
550
+ // a plain Dependency here, nothing re-emits the word, and the author put
551
+ // it on the picture (§10.4.4's Figure 10.11 labels the socket with it).
552
+ if (role !== kind)
553
+ written = undefined;
554
+ kind = role;
555
+ }
556
+ const fromId = endOf(left.name, !operator.reversed);
557
+ const toId = endOf(right.name, Boolean(operator.reversed));
558
+ if (!fromId || !toId)
559
+ return false;
560
+ const sourceId = operator.reversed ? toId : fromId;
561
+ const targetId = operator.reversed ? fromId : toId;
562
+ // §14.2.4.8's transition is the one relationship this reader projects, for
563
+ // the reason `model.ts` projects it: the label carries a grammar no
564
+ // structural relationship has, and both writers read it off the machine.
565
+ const isTransition = behaviour && isVertex(sourceId) && isVertex(targetId);
566
+ const relationKind = isTransition ? 'transition' : kind;
567
+ // §11.5.4's adornments — the quoted strings PlantUML writes between an
568
+ // artefact and the arrow. They belong to the end they were written at, and
569
+ // an operator that READS BACKWARDS (`A <|-- B` makes B the source) swaps
570
+ // which relation end that is: the left string is always the left artefact's,
571
+ // whichever end of the relation the left artefact turned out to be.
572
+ const endAt = (text) => {
573
+ const raw = text?.trim();
574
+ if (!raw)
575
+ return undefined;
576
+ return ADORNED_RELATION_KINDS.has(relationKind)
577
+ ? parseEndLabel(raw)
578
+ : { raw };
579
+ };
580
+ const sourceEnd = endAt(operator.reversed ? right.end : left.end);
581
+ const targetEnd = endAt(operator.reversed ? left.end : right.end);
582
+ const relation = {
583
+ kind: relationKind,
584
+ sourceId,
585
+ targetId,
586
+ ...(written ? { label: written } : {}),
587
+ ...(sourceEnd ? { sourceEnd } : {}),
588
+ ...(targetEnd ? { targetEnd } : {}),
589
+ };
590
+ model.relations.push(relation);
591
+ if (isTransition) {
592
+ const transition = {
593
+ sourceId,
594
+ targetId,
595
+ ...parseTransition(written),
596
+ };
597
+ machine().transitions.push(transition);
598
+ }
599
+ // Both end labels are now DRAWN (ADR 0020), so there is nothing to carry.
600
+ // What is still worth saying is the one case the board keeps and no writer
601
+ // spells: a quoted string beside a relationship §11.5.4 gives ends no
602
+ // adornments to — a generalization, a dependency — is on the picture and in
603
+ // no export of it.
604
+ if (!ADORNED_RELATION_KINDS.has(relationKind)) {
605
+ for (const [side, end] of [
606
+ ['source', sourceEnd],
607
+ ['target', targetEnd],
608
+ ]) {
609
+ if (!end)
610
+ continue;
611
+ note({
612
+ kind: 'warning',
613
+ sourceId,
614
+ messageKey: UML_PLANTUML_REMARKS.relationEnd[0],
615
+ message: `The ${side} end of the ${relation.kind} between "${left.name}" and "${right.name}" is written "${end.raw}". It is drawn on the board, but UML gives that relationship's ends no multiplicity, so no export writes it.`,
616
+ messageParams: {
617
+ side,
618
+ kind: relation.kind,
619
+ left: left.name,
620
+ right: right.name,
621
+ raw: end.raw,
622
+ },
623
+ });
624
+ }
625
+ }
626
+ return true;
627
+ }
628
+ function readDeclaration(line) {
629
+ // `(Place an order) as UC` — the parenthesised use case, with no keyword.
630
+ const bare = /^\(([^)]*)\)\s*(?:as\s+(\S+))?$/.exec(line);
631
+ if (bare) {
632
+ return declare('usecase', {
633
+ label: bare[1],
634
+ ...(bare[2] ? { alias: bare[2] } : {}),
635
+ stereotypes: [],
636
+ opens: false,
637
+ });
638
+ }
639
+ const worded = new RegExp(`^(${DECLARATION_WORDS})\\b\\s*(.*)$`, 'i').exec(line);
640
+ if (!worded)
641
+ return false;
642
+ const word = worded[1].toLowerCase().replace(/\s+/g, ' ');
643
+ const declaration = parseDeclaration(worded[2]);
644
+ if (!declaration)
645
+ return false;
646
+ if (word === 'abstract class' || word === 'abstract') {
647
+ return declare('class', declaration, true);
648
+ }
649
+ return declare(word, declaration);
650
+ }
651
+ function declare(word, declaration, abstract = false) {
652
+ const kind = DECLARATION_KIND[word];
653
+ if (!kind)
654
+ return false;
655
+ const wanted = declaration.alias || declaration.label || word;
656
+ const standing = entries.get(wanted);
657
+ if (standing?.implicit)
658
+ forget(standing);
659
+ if (entries.has(wanted)) {
660
+ note({
661
+ kind: 'substituted-id',
662
+ sourceId: wanted,
663
+ messageKey: UML_PLANTUML_REMARKS.duplicateDeclaration[0],
664
+ message: `This document declares "${wanted}" twice. The second declaration was read as a separate artefact under an id of Labre's own.`,
665
+ messageParams: { name: wanted },
666
+ });
667
+ }
668
+ const id = entries.has(wanted) ? `${wanted}-${++minted}` : wanted;
669
+ switch (kind) {
670
+ case 'class':
671
+ case 'interface':
672
+ case 'enumeration':
673
+ case 'object': {
674
+ const classifier = addClassifier(id, kind, declaration);
675
+ if (abstract)
676
+ classifier.isAbstract = true;
677
+ // `{ … }` after a classifier is its MEMBER block, not a container.
678
+ if (declaration.opens)
679
+ stack.push({ type: 'members', classifier });
680
+ return true;
681
+ }
682
+ case 'package':
683
+ case 'subject': {
684
+ const record = base(id, declaration);
685
+ if (kind === 'package')
686
+ model.packages.push(record);
687
+ else
688
+ model.subjects.push(record);
689
+ register(id);
690
+ if (declaration.opens)
691
+ stack.push({ type: 'container', id });
692
+ return true;
693
+ }
694
+ case 'actor':
695
+ model.actors.push(base(id, declaration));
696
+ register(id);
697
+ return true;
698
+ case 'use-case':
699
+ model.useCases.push(base(id, declaration));
700
+ register(id);
701
+ return true;
702
+ case 'component':
703
+ model.components.push({
704
+ ...base(id, declaration),
705
+ ports: [],
706
+ provided: [],
707
+ required: [],
708
+ });
709
+ register(id);
710
+ return true;
711
+ case 'artifact':
712
+ model.artifacts.push(base(id, declaration));
713
+ register(id);
714
+ return true;
715
+ case 'node': {
716
+ // §19.4.4 draws a Device and an ExecutionEnvironment as a Node cube
717
+ // carrying a keyword, which is exactly what `plantuml.ts` writes.
718
+ const stated = declaration.stereotypes.map(each => each.toLowerCase());
719
+ const cube = stated.includes('device')
720
+ ? 'device'
721
+ : stated.includes('executionenvironment')
722
+ ? 'execution-environment'
723
+ : 'node';
724
+ model.nodes.push({ ...base(id, declaration), kind: cube });
725
+ register(id);
726
+ return true;
727
+ }
728
+ case 'state': {
729
+ behaviour = true;
730
+ const stereotype = declaration.stereotypes
731
+ .map(each => STATE_KIND_OF_STEREOTYPE[each.toLowerCase()])
732
+ .find(Boolean);
733
+ // The stereotype that named the GLYPH is PlantUML's notation for a
734
+ // shape, not an Annex C keyword: `state c <<choice>>` draws a diamond,
735
+ // and a diamond labelled «choice» would be the word written twice.
736
+ if (stereotype) {
737
+ declaration.stereotypes = declaration.stereotypes.filter(each => !STATE_KIND_OF_STEREOTYPE[each.toLowerCase()]);
738
+ }
739
+ const record = base(id, declaration);
740
+ if (stereotype && PSEUDOSTATE_KINDS.has(stereotype)) {
741
+ machine().pseudostates.push({
742
+ ...record,
743
+ kind: stereotype,
744
+ });
745
+ register(id);
746
+ return true;
747
+ }
748
+ if (stereotype === 'final-state') {
749
+ machine().finalStates.push(record);
750
+ register(id);
751
+ return true;
752
+ }
753
+ // A composite state — §14.2.4's box drawn round its sub-machine, which
754
+ // this pack draws as a REGION (`actions.ts`).
755
+ if (declaration.opens) {
756
+ machine().regions.push(record);
757
+ register(id);
758
+ stack.push({ type: 'container', id });
759
+ return true;
760
+ }
761
+ addState(id, declaration);
762
+ return true;
763
+ }
764
+ default:
765
+ return false;
766
+ }
767
+ }
768
+ /** `alias : line` — a member, or a state's internal activity. */
769
+ function readExternalMember(line) {
770
+ const split = /^(\S+)\s*:\s*(.*)$/.exec(line);
771
+ if (!split)
772
+ return false;
773
+ const entry = entries.get(split[1]);
774
+ if (!entry)
775
+ return false;
776
+ const words = split[2].trim();
777
+ if (entry.classifier) {
778
+ if (words)
779
+ addMember(entry.classifier, words);
780
+ return true;
781
+ }
782
+ if (!entry.state)
783
+ return false;
784
+ // §14.2.4.4's compartment, read exactly as `model.ts` reads it off the
785
+ // canvas: three labels, and everything else kept as a line of its own.
786
+ const behaviourLine = /^(entry|do|exit)\s*\/\s*(.*)$/i.exec(words);
787
+ const expression = behaviourLine?.[2].trim();
788
+ if (behaviourLine && expression) {
789
+ const label = behaviourLine[1].toLowerCase();
790
+ if (label === 'entry')
791
+ entry.state.entry.push(expression);
792
+ else if (label === 'do')
793
+ entry.state.doActivity.push(expression);
794
+ else
795
+ entry.state.exit.push(expression);
796
+ }
797
+ else if (words) {
798
+ entry.state.lines.push(words);
799
+ }
800
+ return true;
801
+ }
802
+ /* ── The pass ────────────────────────────────────────────────────── */
803
+ for (const raw of lines) {
804
+ const line = raw.trim();
805
+ if (!line || line.startsWith("'"))
806
+ continue;
807
+ const frame = top();
808
+ if (frame.type === 'members') {
809
+ if (line === '}') {
810
+ stack.pop();
811
+ continue;
812
+ }
813
+ // §9.5.4's own compartment separators, which are notation rather than a
814
+ // member: on this canvas the compartments are the roles on the tiers.
815
+ if (/^(-{2,}|\.{2,}|={2,}|_{2,})$/.test(line))
816
+ continue;
817
+ addMember(frame.classifier, line);
818
+ continue;
819
+ }
820
+ if (frame.type === 'note') {
821
+ if (/^end\s*note$/i.test(line)) {
822
+ stack.pop();
823
+ continue;
824
+ }
825
+ frame.note.body = frame.note.body ? `${frame.note.body}\n${line}` : line;
826
+ continue;
827
+ }
828
+ if (line === '}') {
829
+ if (stack.length > 1)
830
+ stack.pop();
831
+ continue;
832
+ }
833
+ const title = /^title\s+(.+)$/i.exec(line);
834
+ if (title) {
835
+ const words = title[1].trim().split(/\s+/);
836
+ const tagged = KIND_OF_TAG.get(words[0]);
837
+ if (tagged && words.length > 1) {
838
+ titleKind = tagged;
839
+ model.diagram.name = words.slice(1).join(' ');
840
+ }
841
+ else {
842
+ model.diagram.name = title[1].trim();
843
+ }
844
+ continue;
845
+ }
846
+ if (/^skinparam\b/i.test(line) || line.startsWith('!')) {
847
+ note({
848
+ kind: 'carried',
849
+ element: line.split(/\s+/)[0],
850
+ messageKey: UML_PLANTUML_REMARKS.renderDirective[0],
851
+ message: `"${line}" says how to DRAW the diagram. Labre's canvas has a look of its own, so the directive is recorded here and not applied.`,
852
+ messageParams: { line },
853
+ });
854
+ continue;
855
+ }
856
+ if (readNote(line))
857
+ continue;
858
+ if (readRelation(line))
859
+ continue;
860
+ if (readDeclaration(line))
861
+ continue;
862
+ if (readExternalMember(line))
863
+ continue;
864
+ note({
865
+ kind: 'carried',
866
+ messageKey: UML_PLANTUML_REMARKS.unreadLine[0],
867
+ message: `Labre does not read "${line}", so it is not on the board. The line is recorded here exactly as the file wrote it.`,
868
+ messageParams: { line },
869
+ });
870
+ }
871
+ /* ── Finishing the model ─────────────────────────────────────────── */
872
+ // The compartments, parsed — the same two readings `model.ts` makes of the
873
+ // same lines, so an imported class and a drawn one hold the same record.
874
+ for (const classifier of model.classifiers) {
875
+ const attributeLines = classifier.lines.attributes;
876
+ const isObject = classifier.kind === 'object';
877
+ classifier.attributes = isObject ? [] : attributeLines.map(parseProperty);
878
+ classifier.operations = classifier.lines.operations.map(parseOperation);
879
+ classifier.slots = isObject ? attributeLines.map(slotOf) : [];
880
+ }
881
+ // The nesting §14.2.4 draws: a vertex inside a composite state belongs to it.
882
+ const held = model.stateMachines[0];
883
+ if (held) {
884
+ const isRegion = (id) => held.regions.some(region => region.id === id);
885
+ for (const region of held.regions) {
886
+ const parent = entries.get(region.id)?.parentId;
887
+ if (parent && isRegion(parent))
888
+ region.parentId = parent;
889
+ }
890
+ for (const vertex of [
891
+ ...held.states,
892
+ ...held.finalStates,
893
+ ...held.pseudostates,
894
+ ]) {
895
+ const parent = entries.get(vertex.id)?.parentId;
896
+ if (parent && isRegion(parent))
897
+ vertex.regionId = parent;
898
+ }
899
+ }
900
+ const kind = titleKind ??
901
+ (behaviour ? 'stm' : model.useCases.length > 0 ? 'uc' : 'class');
902
+ model.diagram.kind = kind;
903
+ model.diagram.heading =
904
+ `${UML_DIAGRAM_KIND_TAG[kind] ?? kind} ${model.diagram.name}`.trim();
905
+ if (held)
906
+ held.name = model.diagram.name;
907
+ if (titleKind && titleKind !== 'stm' && behaviour) {
908
+ note({
909
+ kind: 'warning',
910
+ sourceId: model.diagram.id,
911
+ messageKey: UML_PLANTUML_REMARKS.stateSyntax[0],
912
+ message: `"${model.diagram.heading}" is written in PlantUML's state-diagram syntax, which Labre reads as a state machine. The frame keeps the kind the title gave it.`,
913
+ messageParams: { heading: model.diagram.heading },
914
+ });
915
+ }
916
+ layOut(model, contains);
917
+ return model;
918
+ }
919
+ /* ── Where PlantUML says nothing (D4) ─────────────────────────────────── */
920
+ /** The footprint each artefact of a model wants, by id, in document order. */
921
+ function footprints(model) {
922
+ const out = [];
923
+ const node = (record, kind) => out.push({ id: record.id, size: UML_NODE_BOX[kind] });
924
+ for (const record of model.packages)
925
+ node(record, 'package');
926
+ for (const record of model.classifiers)
927
+ node(record, record.kind);
928
+ for (const record of model.subjects) {
929
+ out.push({ id: record.id, size: UML_SUBJECT_BOX });
930
+ }
931
+ for (const record of model.actors)
932
+ node(record, 'actor');
933
+ for (const record of model.useCases)
934
+ node(record, 'use-case');
935
+ for (const record of model.components)
936
+ node(record, 'component');
937
+ for (const record of model.artifacts)
938
+ node(record, 'artifact');
939
+ for (const record of model.nodes)
940
+ node(record, record.kind);
941
+ for (const machine of model.stateMachines) {
942
+ for (const record of machine.regions) {
943
+ out.push({ id: record.id, size: UML_REGION_BOX });
944
+ }
945
+ for (const record of machine.states)
946
+ node(record, 'state');
947
+ for (const record of machine.finalStates)
948
+ node(record, 'final-state');
949
+ for (const record of machine.pseudostates)
950
+ node(record, record.kind);
951
+ }
952
+ for (const interaction of model.interactions) {
953
+ for (const record of interaction.lifelines)
954
+ node(record, 'lifeline');
955
+ for (const record of interaction.executions)
956
+ node(record, 'execution');
957
+ for (const record of interaction.destructions)
958
+ node(record, 'destruction');
959
+ for (const record of interaction.fragments) {
960
+ out.push({ id: record.id, size: UML_FRAGMENT_BOX });
961
+ }
962
+ }
963
+ for (const record of model.notes)
964
+ node(record, 'note');
965
+ return out;
966
+ }
967
+ /**
968
+ * Give the model the geometry PlantUML does not carry.
969
+ *
970
+ * Written onto the IR's own `bounds` rather than handed to the materializer as
971
+ * hints, because on this canvas geometry IS the statement: a class is in a
972
+ * package when it is drawn inside it, and both writers read the nesting back
973
+ * that way. A layout that lost `package P { class C }` would mean an import
974
+ * followed by an export was not the file that went in.
975
+ */
976
+ function layOut(model, contains) {
977
+ const boxes = umlInventLayout(footprints(model).map(entry => {
978
+ const children = contains.get(entry.id);
979
+ return { ...entry, ...(children ? { children } : {}) };
980
+ }), model.relations);
981
+ const place = (record) => {
982
+ const box = boxes.get(record.id);
983
+ if (box)
984
+ record.bounds = box;
985
+ };
986
+ for (const record of model.packages)
987
+ place(record);
988
+ for (const record of model.classifiers)
989
+ place(record);
990
+ for (const record of model.subjects)
991
+ place(record);
992
+ for (const record of model.actors)
993
+ place(record);
994
+ for (const record of model.useCases)
995
+ place(record);
996
+ for (const record of model.components)
997
+ place(record);
998
+ for (const record of model.artifacts)
999
+ place(record);
1000
+ for (const record of model.nodes)
1001
+ place(record);
1002
+ for (const record of model.notes)
1003
+ place(record);
1004
+ for (const machine of model.stateMachines) {
1005
+ for (const record of machine.regions)
1006
+ place(record);
1007
+ for (const record of machine.states)
1008
+ place(record);
1009
+ for (const record of machine.finalStates)
1010
+ place(record);
1011
+ for (const record of machine.pseudostates)
1012
+ place(record);
1013
+ }
1014
+ }
1015
+ /* ── The reader ───────────────────────────────────────────────────────── */
1016
+ /**
1017
+ * Read PlantUML source as one model per `@startuml … @enduml` block.
1018
+ *
1019
+ * Pure: a string in, records out. No `std`, no DOM, no clock, no randomness —
1020
+ * the same source always produces the same models, down to the coordinates.
1021
+ */
1022
+ export function importPlantuml(source) {
1023
+ const notes = [];
1024
+ const note = (entry) => notes.push(entry);
1025
+ const lines = source.replaceAll('\r\n', '\n').split('\n');
1026
+ const blocks = [];
1027
+ let open;
1028
+ for (const line of lines) {
1029
+ if (/^\s*@startuml\b/i.test(line)) {
1030
+ open = [];
1031
+ blocks.push(open);
1032
+ continue;
1033
+ }
1034
+ if (/^\s*@enduml\b/i.test(line)) {
1035
+ open = undefined;
1036
+ continue;
1037
+ }
1038
+ if (open)
1039
+ open.push(line);
1040
+ }
1041
+ // A snippet with no `@startuml` is the commonest thing anybody pastes, and
1042
+ // refusing it would be this reader insisting on a wrapper every editor plugin
1043
+ // in the ecosystem treats as optional.
1044
+ if (blocks.length === 0)
1045
+ blocks.push(lines);
1046
+ // Which of PlantUML's languages each block is written in, decided ONCE per
1047
+ // block rather than line by line: `A --> B` is an association on a class
1048
+ // diagram and a reply on a sequence diagram, and nothing but the block as a
1049
+ // whole can say which (see {@link looksSequential}).
1050
+ const models = blocks.map((block, index) => looksSequential(block)
1051
+ ? parseSequenceBlock(block, index, note)
1052
+ : parseBlock(block, index, note));
1053
+ for (const model of models) {
1054
+ if (footprints(model).length === 0)
1055
+ continue;
1056
+ notes.push({
1057
+ kind: 'invented-layout',
1058
+ sourceId: model.diagram.id,
1059
+ // A sequence sheet is laid out by a different rule, and says so: §17.4.4
1060
+ // makes the vertical axis TIME, so the order of the file's lines is the
1061
+ // one thing about the drawing that is the author's, and the columns and
1062
+ // the spacing are ours.
1063
+ // Two SHAPES of sentence, so two keys — never one key whose wording
1064
+ // depends on a branch the host cannot see.
1065
+ messageKey: model.interactions.length > 0
1066
+ ? UML_PLANTUML_REMARKS.inventedSequenceLayout[0]
1067
+ : UML_PLANTUML_REMARKS.inventedLayout[0],
1068
+ message: model.interactions.length > 0
1069
+ ? `PlantUML carries no coordinates, so "${model.diagram.heading}" was laid out by Labre — the participants side by side in the order the file declares them, and one step down the page per message. The ORDER is the file's; the positions are ours.`
1070
+ : `PlantUML carries no coordinates, so "${model.diagram.heading}" was laid out by Labre — in rows by generalization depth, with every container sized to fit what it holds. The positions are ours, not the file's.`,
1071
+ messageParams: { heading: model.diagram.heading },
1072
+ });
1073
+ }
1074
+ return { models, notes };
1075
+ }
1076
+ /* ── Sequence diagrams (§17) ──────────────────────────────────────────── */
1077
+ /**
1078
+ * The words that can only be a SEQUENCE diagram.
1079
+ *
1080
+ * A `.puml` does not say which of PlantUML's dozen languages a block is written
1081
+ * in; the renderer works it out from the lines, and so does this. Every entry
1082
+ * here is a keyword no other PlantUML diagram uses, which is what makes the test
1083
+ * cheap and honest — an `actor` on its own is NOT in the list, because a use
1084
+ * case diagram declares one too.
1085
+ */
1086
+ const SEQUENCE_KEYWORD = /^(participant|activate|deactivate|autonumber|destroy|create|alt|opt|loop|par|break|critical|ref\s+over|hide\s+footbox)\b/i;
1087
+ /**
1088
+ * The arrow spellings that can ONLY be a message.
1089
+ *
1090
+ * Narrower than {@link sequenceArrow}, and the difference is the whole
1091
+ * ambiguity: `A --> B` is a message on a sequence diagram and an ASSOCIATION on
1092
+ * a class diagram, and the double dash is what both languages use. A SINGLE dash
1093
+ * (`->`, `<-`) and a DOUBLED head (`->>`, `<<-`) are the two spellings no other
1094
+ * PlantUML diagram writes, so they are the two this test asks about — a block
1095
+ * whose only arrows are `-->` is read as a class diagram, which is what it
1096
+ * almost always is.
1097
+ */
1098
+ const SEQUENCE_ONLY_ARROW = /^(?:<<-{1,2}|-{1,2}>>|<-|->)$/;
1099
+ /**
1100
+ * §17.4.4's five arrows, as PlantUML spells them — and the one function that
1101
+ * tells a message from a class-diagram relationship.
1102
+ *
1103
+ * The drawing IS the reading, exactly as §17.4.4 says: a dashed line is a reply,
1104
+ * an open (doubled) head is an asynchronous send, and a plain solid head is a
1105
+ * synchronous call. `reversed` says the sender is on the RIGHT, which is what
1106
+ * `<-` means.
1107
+ *
1108
+ * `undefined` for anything else — `<|--`, `o--`, `..>` — so a class diagram read
1109
+ * by mistake as a sequence one still finds no messages in it.
1110
+ */
1111
+ function sequenceArrow(token) {
1112
+ const matched = /^(<<?)?(-{1,2})(>>?)?$/.exec(token);
1113
+ if (!matched)
1114
+ return undefined;
1115
+ const [, left, dashes, right] = matched;
1116
+ // One head, never two and never none: `--` on its own is a class diagram's
1117
+ // association, and `<->` is not a message either.
1118
+ if ((left && right) || (!left && !right))
1119
+ return undefined;
1120
+ const open = left === '<<' || right === '>>';
1121
+ const dashed = dashes.length >= 2;
1122
+ return {
1123
+ kind: dashed ? 'message-reply' : open ? 'message-async' : 'message-sync',
1124
+ reversed: Boolean(left),
1125
+ };
1126
+ }
1127
+ /** §17.6.4's operators, read back off the block keyword that opened them. */
1128
+ const FRAGMENT_OPERATOR_OF_WORD = {
1129
+ alt: 'alt',
1130
+ opt: 'opt',
1131
+ loop: 'loop',
1132
+ par: 'par',
1133
+ break: 'break',
1134
+ critical: 'critical',
1135
+ seq: 'seq',
1136
+ strict: 'strict',
1137
+ neg: 'neg',
1138
+ assert: 'assert',
1139
+ ignore: 'ignore',
1140
+ consider: 'consider',
1141
+ };
1142
+ /**
1143
+ * The declarations that can only be a CLASS diagram — §9's classifiers and
1144
+ * §12's package.
1145
+ *
1146
+ * The veto on the arrow test below. PlantUML accepts `A -> B` inside a class
1147
+ * block as a **directed association**, so the single dash is not the sequence's
1148
+ * alone once a block has declared a classifier: a file that says `class A`,
1149
+ * `class B` and `A -> B` is two classes and an arrow between them, and reading
1150
+ * it as a conversation would turn two types into two participants. A block that
1151
+ * declares a classifier AND writes `participant` is still a sequence — the
1152
+ * keyword list is evidence of its own and this only narrows the arrow.
1153
+ */
1154
+ const CLASSIFIER_DECLARATION = /^(?:abstract\s+)?(?:class|interface|enum|enumeration|entity|package|namespace)\s+\S/i;
1155
+ /** Is this block written in PlantUML's sequence language? */
1156
+ function looksSequential(lines) {
1157
+ const declaresClassifier = lines.some(raw => CLASSIFIER_DECLARATION.test(raw.trim()));
1158
+ for (const raw of lines) {
1159
+ const line = raw.trim();
1160
+ if (!line || line.startsWith("'"))
1161
+ continue;
1162
+ const title = /^title\s+(\S+)/i.exec(line);
1163
+ if (title && KIND_OF_TAG.get(title[1]) === 'sd')
1164
+ return true;
1165
+ if (SEQUENCE_KEYWORD.test(line))
1166
+ return true;
1167
+ if (declaresClassifier)
1168
+ continue;
1169
+ const arrow = ARROW.exec(` ${line.replaceAll(DIRECTION_HINT, '--')} `);
1170
+ if (arrow && SEQUENCE_ONLY_ARROW.test(arrow[1]))
1171
+ return true;
1172
+ }
1173
+ return false;
1174
+ }
1175
+ /** A guard, however the author bracketed it: `[x > 0]` and `x > 0` are one. */
1176
+ function unbracket(text) {
1177
+ const written = (text ?? '').trim();
1178
+ if (!written)
1179
+ return undefined;
1180
+ const inside = /^\[(.*)\]$/.exec(written);
1181
+ const guard = (inside ? inside[1] : written).trim();
1182
+ return guard || undefined;
1183
+ }
1184
+ /**
1185
+ * The lifelines a fragment covers, as the RECTANGLE covers them: every column
1186
+ * from the leftmost participant it touches to the rightmost, in declaration
1187
+ * order.
1188
+ *
1189
+ * PlantUML names no coverage — it is derived from what the block's events
1190
+ * touch — and the file may well touch the first and third participants and not
1191
+ * the second. §17.6.4 draws ONE box round them, and a box reaching from the
1192
+ * first to the third is drawn over the second whether the text mentions it or
1193
+ * not. The span is therefore the honest reading, and the one that makes the
1194
+ * file, the drawing and a re-export agree: `model.ts` reads coverage off the
1195
+ * canvas as "the spine runs through the box", so a gapped list read here would
1196
+ * come back filled in and the second export would not be the first.
1197
+ */
1198
+ function coveredSpan(order, touched) {
1199
+ const indices = order
1200
+ .map((id, index) => (touched.has(id) ? index : -1))
1201
+ .filter(index => index >= 0);
1202
+ if (indices.length === 0)
1203
+ return [];
1204
+ return order.slice(indices[0], indices[indices.length - 1] + 1);
1205
+ }
1206
+ /** §17.3.4's `<name> ['[' <selector> ']'] [':' <type>]`, as the IR holds it. */
1207
+ function lifelineIdentOf(text) {
1208
+ const ident = parseLifelineIdent(text);
1209
+ const name = ident.selector
1210
+ ? `${ident.name ?? ''}[${ident.selector}]`
1211
+ : (ident.name ?? '');
1212
+ return { name, ...(ident.type ? { type: ident.type } : {}) };
1213
+ }
1214
+ /**
1215
+ * One `@startuml … @enduml` block written in the sequence language, read.
1216
+ *
1217
+ * A separate pass from {@link parseBlock} and not a mode inside it, because the
1218
+ * two languages disagree about the same characters: `A --> B` is an association
1219
+ * on a class diagram and a REPLY on a sequence diagram, and a reader that tried
1220
+ * to be both would have to guess line by line instead of once per block.
1221
+ *
1222
+ * ## Where the coordinates come from
1223
+ *
1224
+ * Nowhere in the file — PlantUML carries none — so they are invented, and
1225
+ * invented in the one shape that survives a round trip: the participants are
1226
+ * columns {@link UML_SD_COLUMN_GAP} apart in declaration order, and every EVENT
1227
+ * gets a slot of its own, {@link UML_SD_EVENT_STEP} below the last. A slot per
1228
+ * event rather than per message is what stops an `activate` written after an
1229
+ * arrow from coming back before it.
1230
+ */
1231
+ function parseSequenceBlock(lines, blockIndex, note) {
1232
+ const model = blankModel(`plantuml-${blockIndex + 1}`);
1233
+ const interaction = {
1234
+ id: model.diagram.id,
1235
+ name: '',
1236
+ lifelines: [],
1237
+ messages: [],
1238
+ fragments: [],
1239
+ executions: [],
1240
+ destructions: [],
1241
+ };
1242
+ let minted = 0;
1243
+ let slot = 0;
1244
+ /** The next slot, consumed. */
1245
+ const take = () => umlSequenceSlot(slot++);
1246
+ /** The current height, without consuming a slot — for what is not an event. */
1247
+ const here = () => umlSequenceSlot(slot);
1248
+ const byAlias = new Map();
1249
+ const columnOf = new Map();
1250
+ /** The spines each fragment ended up covering, kept until the columns exist. */
1251
+ const fragmentSpan = new Map();
1252
+ let titleKind;
1253
+ /**
1254
+ * The lifeline an alias names — declared, or minted on first use.
1255
+ *
1256
+ * PlantUML declares participants implicitly all the time (`Alice -> Bob` with
1257
+ * no `participant` line above it), and the column order is the order they are
1258
+ * first mentioned in, which is exactly what this map records. A declaration
1259
+ * that catches up with an implicit mention updates the SAME record, so every
1260
+ * message already read still points at it.
1261
+ */
1262
+ const lifelineOf = (alias, declaration) => {
1263
+ const known = byAlias.get(alias);
1264
+ if (known) {
1265
+ if (declaration) {
1266
+ const stated = stereotypesOf(declaration.label);
1267
+ const ident = lifelineIdentOf(stated.name);
1268
+ known.name = ident.name;
1269
+ known.keywords = [...declaration.stereotypes, ...stated.keywords];
1270
+ if (ident.type)
1271
+ known.type = ident.type;
1272
+ }
1273
+ return known;
1274
+ }
1275
+ const stated = declaration
1276
+ ? stereotypesOf(declaration.label)
1277
+ : { name: alias, keywords: [], isAbstract: false };
1278
+ const ident = lifelineIdentOf(stated.name);
1279
+ const record = {
1280
+ id: alias,
1281
+ name: ident.name,
1282
+ keywords: declaration
1283
+ ? [...declaration.stereotypes, ...stated.keywords]
1284
+ : stated.keywords,
1285
+ isAbstract: stated.isAbstract,
1286
+ ...(ident.type ? { type: ident.type } : {}),
1287
+ };
1288
+ byAlias.set(alias, record);
1289
+ columnOf.set(record.id, interaction.lifelines.length);
1290
+ interaction.lifelines.push(record);
1291
+ return record;
1292
+ };
1293
+ /** Every open `alt`, outermost first. */
1294
+ const open = [];
1295
+ /** Every open `activate`, by lifeline — PlantUML nests them. */
1296
+ const running = new Map();
1297
+ /** Everything an event touches is covered by every fragment it is inside. */
1298
+ const touch = (...ids) => {
1299
+ for (const fragment of open) {
1300
+ for (const id of ids)
1301
+ if (id)
1302
+ fragment.covered.add(id);
1303
+ }
1304
+ };
1305
+ let pendingCreate;
1306
+ /** The arrow written immediately above, for `destroy` to claim (§17.4.4). */
1307
+ let lastMessage;
1308
+ const closeFragment = () => {
1309
+ const fragment = open.pop();
1310
+ if (!fragment)
1311
+ return;
1312
+ const y1 = take();
1313
+ const covered = fragment.covered.size > 0
1314
+ ? coveredSpan(interaction.lifelines.map(lifeline => lifeline.id), fragment.covered)
1315
+ : interaction.lifelines.map(lifeline => lifeline.id);
1316
+ fragment.record.coveredLifelineIds = covered;
1317
+ fragment.record.operands = fragment.bands.map((band, index) => ({
1318
+ ...(band.guard ? { guard: band.guard } : {}),
1319
+ y0: band.y0,
1320
+ y1: index + 1 < fragment.bands.length ? fragment.bands[index + 1].y0 : y1,
1321
+ }));
1322
+ const y0 = fragment.bands[0]?.y0 ?? y1;
1323
+ fragment.record.bounds = { x: 0, y: y0, w: 0, h: Math.max(1, y1 - y0) };
1324
+ fragmentSpan.set(fragment.record.id, covered);
1325
+ // A nested fragment's coverage is its parent's too — the parent's box has
1326
+ // to reach round everything drawn inside it.
1327
+ touch(...covered);
1328
+ };
1329
+ const readMessage = (line) => {
1330
+ const { head, label } = splitLabel(line);
1331
+ const text = head.replaceAll(DIRECTION_HINT, '--').trim();
1332
+ const arrow = ARROW.exec(` ${text} `);
1333
+ if (!arrow)
1334
+ return false;
1335
+ const spelling = sequenceArrow(arrow[1]);
1336
+ if (!spelling)
1337
+ return false;
1338
+ const cut = text.indexOf(` ${arrow[1]} `);
1339
+ if (cut < 0)
1340
+ return false;
1341
+ const left = text
1342
+ .slice(0, cut)
1343
+ .trim()
1344
+ .replace(/^"(.*)"$/, '$1');
1345
+ let right = text.slice(cut + arrow[1].length + 2).trim();
1346
+ // §17.4.4's two shorthands, written after the arrow's target: `**` creates
1347
+ // the participant, `!!` destroys it. `++` and `--` are PlantUML's own for
1348
+ // an activation, which is §17.2.4's bar.
1349
+ let creates = false;
1350
+ let destroys = false;
1351
+ let activates = false;
1352
+ let deactivates = false;
1353
+ for (;;) {
1354
+ const tail = /^(.*?)\s*(\*\*|!!|\+\+|--)$/.exec(right);
1355
+ if (!tail)
1356
+ break;
1357
+ right = tail[1].trim();
1358
+ if (tail[2] === '**')
1359
+ creates = true;
1360
+ else if (tail[2] === '!!')
1361
+ destroys = true;
1362
+ else if (tail[2] === '++')
1363
+ activates = true;
1364
+ else
1365
+ deactivates = true;
1366
+ }
1367
+ right = right.replace(/^"(.*)"$/, '$1');
1368
+ if (!left || !right)
1369
+ return false;
1370
+ const from = lifelineOf(spelling.reversed ? right : left);
1371
+ const to = lifelineOf(spelling.reversed ? left : right);
1372
+ touch(from.id, to.id);
1373
+ const kind = creates || pendingCreate === to.id ? 'message-create' : spelling.kind;
1374
+ pendingCreate = undefined;
1375
+ const message = {
1376
+ id: `${model.diagram.id}-message-${++minted}`,
1377
+ kind,
1378
+ sourceId: from.id,
1379
+ targetId: to.id,
1380
+ ...(label ? { label } : {}),
1381
+ y: take(),
1382
+ };
1383
+ interaction.messages.push(message);
1384
+ lastMessage = message;
1385
+ if (destroys) {
1386
+ const record = {
1387
+ id: `${model.diagram.id}-destruction-${++minted}`,
1388
+ name: '',
1389
+ keywords: [],
1390
+ isAbstract: false,
1391
+ lifelineId: to.id,
1392
+ y: take(),
1393
+ };
1394
+ interaction.destructions.push(record);
1395
+ message.kind = 'message-delete';
1396
+ message.targetId = record.id;
1397
+ lastMessage = undefined;
1398
+ }
1399
+ if (activates) {
1400
+ const record = {
1401
+ id: `${model.diagram.id}-execution-${++minted}`,
1402
+ name: '',
1403
+ keywords: [],
1404
+ isAbstract: false,
1405
+ lifelineId: to.id,
1406
+ y0: take(),
1407
+ y1: 0,
1408
+ };
1409
+ interaction.executions.push(record);
1410
+ const stack = running.get(to.id);
1411
+ if (stack)
1412
+ stack.push(record);
1413
+ else
1414
+ running.set(to.id, [record]);
1415
+ }
1416
+ if (deactivates) {
1417
+ const record = running.get(from.id)?.pop();
1418
+ if (record)
1419
+ record.y1 = take();
1420
+ }
1421
+ return true;
1422
+ };
1423
+ const readSequenceNote = (line) => {
1424
+ if (!/^(note|rnote|hnote)\b/i.test(line))
1425
+ return false;
1426
+ const { head, label } = splitLabel(line);
1427
+ const anchored = /^(?:note|rnote|hnote)\s+(?:(?:left|right)\s+of|over)\s+([^:]+)$/i.exec(head.trim());
1428
+ const record = {
1429
+ id: `${model.diagram.id}-note-${++minted}`,
1430
+ name: '',
1431
+ keywords: [],
1432
+ isAbstract: false,
1433
+ body: label ?? '',
1434
+ // Clear of the conversation, to the right of the last column: a note is
1435
+ // prose about the exchange rather than part of it, and a box dropped on a
1436
+ // spine would be read as a participant.
1437
+ bounds: {
1438
+ x: interaction.lifelines.length * UML_SD_COLUMN_GAP +
1439
+ UML_SD_FRAGMENT_PADDING,
1440
+ y: here(),
1441
+ ...UML_NODE_BOX.note,
1442
+ },
1443
+ };
1444
+ model.notes.push(record);
1445
+ if (anchored) {
1446
+ for (const each of anchored[1].split(',')) {
1447
+ const alias = each.trim().replace(/^"(.*)"$/, '$1');
1448
+ if (!alias)
1449
+ continue;
1450
+ model.relations.push({
1451
+ kind: 'anchor',
1452
+ sourceId: record.id,
1453
+ targetId: lifelineOf(alias).id,
1454
+ });
1455
+ }
1456
+ }
1457
+ return true;
1458
+ };
1459
+ const readParticipant = (line) => {
1460
+ const worded = /^(participant|actor|boundary|control|entity|database|collections|queue)\b\s*(.*)$/i.exec(line);
1461
+ if (!worded)
1462
+ return false;
1463
+ const declaration = parseDeclaration(worded[2]);
1464
+ if (!declaration)
1465
+ return false;
1466
+ const word = worded[1].toLowerCase();
1467
+ const alias = declaration.alias || declaration.label;
1468
+ if (!alias)
1469
+ return false;
1470
+ const lifeline = lifelineOf(alias, declaration);
1471
+ // §17.3.4 draws every head as a rectangle; PlantUML's `actor` draws a stick
1472
+ // figure, and the word is the author's own statement that this participant
1473
+ // is a person. Kept as the Annex C keyword `plantuml.ts` reads back.
1474
+ if (word !== 'participant' &&
1475
+ !lifeline.keywords.some(each => each.toLowerCase() === word)) {
1476
+ lifeline.keywords = [...lifeline.keywords, word];
1477
+ }
1478
+ return true;
1479
+ };
1480
+ for (const raw of lines) {
1481
+ const line = raw.trim();
1482
+ if (!line || line.startsWith("'"))
1483
+ continue;
1484
+ const title = /^title\s+(.+)$/i.exec(line);
1485
+ if (title) {
1486
+ const words = title[1].trim().split(/\s+/);
1487
+ const tagged = KIND_OF_TAG.get(words[0]);
1488
+ if (tagged && words.length > 1) {
1489
+ titleKind = tagged;
1490
+ model.diagram.name = words.slice(1).join(' ');
1491
+ }
1492
+ else {
1493
+ model.diagram.name = title[1].trim();
1494
+ }
1495
+ continue;
1496
+ }
1497
+ if (/^autonumber\b/i.test(line)) {
1498
+ note({
1499
+ kind: 'carried',
1500
+ element: 'autonumber',
1501
+ messageKey: UML_PLANTUML_REMARKS.autonumber[0],
1502
+ message: `"${line}" numbers the messages as the renderer draws them. A Labre board writes no numbers on its arrows, so the directive is recorded here and not applied.`,
1503
+ messageParams: { line },
1504
+ });
1505
+ continue;
1506
+ }
1507
+ if (/^skinparam\b/i.test(line) ||
1508
+ line.startsWith('!') ||
1509
+ /^(hide|show)\b/i.test(line) ||
1510
+ /^==.*==$/.test(line) ||
1511
+ line === '...' ||
1512
+ /^newpage\b/i.test(line)) {
1513
+ note({
1514
+ kind: 'carried',
1515
+ element: line.split(/\s+/)[0],
1516
+ messageKey: UML_PLANTUML_REMARKS.renderDirective[0],
1517
+ message: `"${line}" says how to DRAW the diagram. Labre's canvas has a look of its own, so the directive is recorded here and not applied.`,
1518
+ messageParams: { line },
1519
+ });
1520
+ continue;
1521
+ }
1522
+ // ── The blocks §17.6.4 draws as a rectangle with a pentagon ────────
1523
+ const block = /^(alt|opt|loop|par|break|critical|group)\b\s*(.*)$/i.exec(line);
1524
+ if (block) {
1525
+ const word = block[1].toLowerCase();
1526
+ const tail = block[2].trim();
1527
+ // `group <operator>` is PlantUML's generic block and is what
1528
+ // `plantuml.ts` writes for the six operators the language has no keyword
1529
+ // for. The first word of its text is read back as the operator when it
1530
+ // names one, and is the fragment's guard when it does not.
1531
+ const generic = word === 'group' ? /^(\S+)\s*(.*)$/.exec(tail) : null;
1532
+ const named = generic ? generic[1].toLowerCase() : word;
1533
+ const operator = FRAGMENT_OPERATOR_OF_WORD[named];
1534
+ const guard = unbracket(operator && generic ? generic[2] : generic ? tail : tail);
1535
+ const y0 = take();
1536
+ const record = {
1537
+ id: `${model.diagram.id}-fragment-${++minted}`,
1538
+ name: guard ?? '',
1539
+ keywords: [],
1540
+ isAbstract: false,
1541
+ operator: operator ?? 'alt',
1542
+ operands: [],
1543
+ coveredLifelineIds: [],
1544
+ };
1545
+ interaction.fragments.push(record);
1546
+ open.push({
1547
+ record,
1548
+ bands: [{ y0, ...(guard ? { guard } : {}) }],
1549
+ covered: new Set(),
1550
+ });
1551
+ lastMessage = undefined;
1552
+ continue;
1553
+ }
1554
+ const separator = /^else\b\s*(.*)$/i.exec(line);
1555
+ if (separator) {
1556
+ const current = open[open.length - 1];
1557
+ if (current) {
1558
+ const guard = unbracket(separator[1]);
1559
+ current.bands.push({ y0: take(), ...(guard ? { guard } : {}) });
1560
+ }
1561
+ lastMessage = undefined;
1562
+ continue;
1563
+ }
1564
+ if (/^end\b/i.test(line)) {
1565
+ closeFragment();
1566
+ lastMessage = undefined;
1567
+ continue;
1568
+ }
1569
+ // §17.7.4's InteractionUse — `ref over a, b : Name`.
1570
+ const reference = /^ref\s+over\s+([^:]+)(?::\s*(.*))?$/i.exec(line);
1571
+ if (reference) {
1572
+ const over = reference[1]
1573
+ .split(',')
1574
+ .map(each => each.trim().replace(/^"(.*)"$/, '$1'))
1575
+ .filter(Boolean)
1576
+ .map(alias => lifelineOf(alias).id);
1577
+ const y0 = take();
1578
+ const y1 = take();
1579
+ const record = {
1580
+ id: `${model.diagram.id}-fragment-${++minted}`,
1581
+ name: (reference[2] ?? '').trim(),
1582
+ keywords: [],
1583
+ isAbstract: false,
1584
+ operator: 'ref',
1585
+ operands: [{ y0, y1 }],
1586
+ coveredLifelineIds: over,
1587
+ bounds: { x: 0, y: y0, w: 0, h: Math.max(1, y1 - y0) },
1588
+ };
1589
+ interaction.fragments.push(record);
1590
+ fragmentSpan.set(record.id, over);
1591
+ touch(...over);
1592
+ lastMessage = undefined;
1593
+ continue;
1594
+ }
1595
+ // ── The bar §17.2.4 draws on a spine ───────────────────────────────
1596
+ const activation = /^(activate|deactivate)\s+(\S+)/i.exec(line);
1597
+ if (activation) {
1598
+ const lifeline = lifelineOf(activation[2].replace(/^"(.*)"$/, '$1'));
1599
+ touch(lifeline.id);
1600
+ if (activation[1].toLowerCase() === 'activate') {
1601
+ const record = {
1602
+ id: `${model.diagram.id}-execution-${++minted}`,
1603
+ name: '',
1604
+ keywords: [],
1605
+ isAbstract: false,
1606
+ lifelineId: lifeline.id,
1607
+ y0: take(),
1608
+ y1: 0,
1609
+ };
1610
+ interaction.executions.push(record);
1611
+ const stack = running.get(lifeline.id);
1612
+ if (stack)
1613
+ stack.push(record);
1614
+ else
1615
+ running.set(lifeline.id, [record]);
1616
+ }
1617
+ else {
1618
+ const record = running.get(lifeline.id)?.pop();
1619
+ if (record)
1620
+ record.y1 = take();
1621
+ }
1622
+ lastMessage = undefined;
1623
+ continue;
1624
+ }
1625
+ const created = /^create\s+(?:participant\s+|actor\s+)?(\S+)/i.exec(line);
1626
+ if (created) {
1627
+ // `create b` says the NEXT arrow lands on a participant that does not
1628
+ // exist yet (§17.4.4's createMessage). It is not an event of its own.
1629
+ pendingCreate = lifelineOf(created[1].replace(/^"(.*)"$/, '$1')).id;
1630
+ continue;
1631
+ }
1632
+ const destroyed = /^destroy\s+(\S+)/i.exec(line);
1633
+ if (destroyed) {
1634
+ const lifeline = lifelineOf(destroyed[1].replace(/^"(.*)"$/, '$1'));
1635
+ touch(lifeline.id);
1636
+ const record = {
1637
+ id: `${model.diagram.id}-destruction-${++minted}`,
1638
+ name: '',
1639
+ keywords: [],
1640
+ isAbstract: false,
1641
+ lifelineId: lifeline.id,
1642
+ y: take(),
1643
+ };
1644
+ interaction.destructions.push(record);
1645
+ // §17.4.4 draws a deleteMessage as an arrow ENDING on the cross, and
1646
+ // PlantUML writes the pair as two lines. An arrow written immediately
1647
+ // above, onto this very participant, is that message — which is what
1648
+ // makes the round trip exact, since `plantuml.ts` emits exactly that pair.
1649
+ if (lastMessage &&
1650
+ lastMessage.kind === 'message-sync' &&
1651
+ lastMessage.targetId === lifeline.id) {
1652
+ lastMessage.kind = 'message-delete';
1653
+ lastMessage.targetId = record.id;
1654
+ }
1655
+ lastMessage = undefined;
1656
+ continue;
1657
+ }
1658
+ if (readSequenceNote(line))
1659
+ continue;
1660
+ if (readMessage(line))
1661
+ continue;
1662
+ if (readParticipant(line))
1663
+ continue;
1664
+ note({
1665
+ kind: 'carried',
1666
+ messageKey: UML_PLANTUML_REMARKS.unreadLine[0],
1667
+ message: `Labre does not read "${line}", so it is not on the board. The line is recorded here exactly as the file wrote it.`,
1668
+ messageParams: { line },
1669
+ });
1670
+ }
1671
+ /* ── Finishing the sheet ───────────────────────────────────────────── */
1672
+ // A block the file never closed: §17.6.4's rectangle still has a bottom, and
1673
+ // the bottom of the drawing is where it is.
1674
+ while (open.length > 0)
1675
+ closeFragment();
1676
+ for (const stack of running.values()) {
1677
+ for (const record of stack) {
1678
+ if (record.y1 === 0)
1679
+ record.y1 = umlSequenceSlot(slot);
1680
+ }
1681
+ }
1682
+ const height = umlSequenceSlot(slot) + UML_SD_EVENT_STEP;
1683
+ const columns = new Map();
1684
+ for (const lifeline of interaction.lifelines) {
1685
+ const box = umlSequenceColumn(columnOf.get(lifeline.id) ?? 0, height);
1686
+ lifeline.bounds = box;
1687
+ columns.set(lifeline.id, box);
1688
+ }
1689
+ for (const record of interaction.executions) {
1690
+ const column = record.lifelineId
1691
+ ? columns.get(record.lifelineId)
1692
+ : undefined;
1693
+ if (column) {
1694
+ record.bounds = umlSequenceExecution(column, record.y0, record.y1);
1695
+ }
1696
+ }
1697
+ for (const record of interaction.destructions) {
1698
+ const column = record.lifelineId
1699
+ ? columns.get(record.lifelineId)
1700
+ : undefined;
1701
+ if (column)
1702
+ record.bounds = umlSequenceDestruction(column, record.y);
1703
+ }
1704
+ for (const record of interaction.fragments) {
1705
+ const span = fragmentSpan.get(record.id) ?? record.coveredLifelineIds;
1706
+ const covered = span
1707
+ .map(id => columns.get(id))
1708
+ .filter((box) => box !== undefined);
1709
+ const y0 = record.bounds?.y ?? 0;
1710
+ const y1 = y0 + (record.bounds?.h ?? UML_SD_EVENT_STEP);
1711
+ record.bounds = umlSequenceFragment(covered.length > 0 ? covered : [...columns.values()], y0, y1);
1712
+ if (record.coveredLifelineIds.length === 0) {
1713
+ record.coveredLifelineIds = interaction.lifelines.map(each => each.id);
1714
+ }
1715
+ }
1716
+ const kind = titleKind ?? 'sd';
1717
+ model.diagram.kind = kind;
1718
+ model.diagram.heading =
1719
+ `${UML_DIAGRAM_KIND_TAG[kind] ?? kind} ${model.diagram.name}`.trim();
1720
+ interaction.name = model.diagram.name;
1721
+ model.interactions.push(interaction);
1722
+ return model;
1723
+ }