@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.
- package/LICENSE +384 -0
- package/dist/actions.d.ts +430 -0
- package/dist/actions.js +724 -0
- package/dist/background.d.ts +153 -0
- package/dist/background.js +557 -0
- package/dist/board-hit.d.ts +137 -0
- package/dist/board-hit.js +191 -0
- package/dist/commands-manifest.d.ts +19 -0
- package/dist/commands-manifest.js +571 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +983 -0
- package/dist/component.d.ts +521 -0
- package/dist/component.js +708 -0
- package/dist/consts.d.ts +365 -0
- package/dist/consts.js +463 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/drawio-decode.d.ts +32 -0
- package/dist/drawio-decode.js +96 -0
- package/dist/drawio-import.d.ts +133 -0
- package/dist/drawio-import.js +885 -0
- package/dist/edge-morph.d.ts +154 -0
- package/dist/edge-morph.js +153 -0
- package/dist/edge-styles.d.ts +78 -0
- package/dist/edge-styles.js +210 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +64 -0
- package/dist/element-renderer.js +114 -0
- package/dist/element-view.d.ts +234 -0
- package/dist/element-view.js +430 -0
- package/dist/export.d.ts +61 -0
- package/dist/export.js +41 -0
- package/dist/filename.d.ts +17 -0
- package/dist/filename.js +26 -0
- package/dist/frame-tag.d.ts +70 -0
- package/dist/frame-tag.js +78 -0
- package/dist/grammar.d.ts +695 -0
- package/dist/grammar.js +1436 -0
- package/dist/import.d.ts +306 -0
- package/dist/import.js +1270 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +67 -0
- package/dist/interchange.d.ts +125 -0
- package/dist/interchange.js +372 -0
- package/dist/keywords.d.ts +204 -0
- package/dist/keywords.js +361 -0
- package/dist/kinds.d.ts +73 -0
- package/dist/kinds.js +91 -0
- package/dist/legend.d.ts +30 -0
- package/dist/legend.js +255 -0
- package/dist/model.d.ts +862 -0
- package/dist/model.js +1316 -0
- package/dist/morph.d.ts +178 -0
- package/dist/morph.js +496 -0
- package/dist/node/act-glyphs.d.ts +30 -0
- package/dist/node/act-glyphs.js +142 -0
- package/dist/node/compartment-watcher.d.ts +107 -0
- package/dist/node/compartment-watcher.js +163 -0
- package/dist/node/fit.d.ts +85 -0
- package/dist/node/fit.js +129 -0
- package/dist/node/node-renderer.d.ts +49 -0
- package/dist/node/node-renderer.js +611 -0
- package/dist/node/node-view.d.ts +51 -0
- package/dist/node/node-view.js +89 -0
- package/dist/node/paint.d.ts +84 -0
- package/dist/node/paint.js +143 -0
- package/dist/node/sd-glyphs.d.ts +36 -0
- package/dist/node/sd-glyphs.js +79 -0
- package/dist/node/stm-glyphs.d.ts +22 -0
- package/dist/node/stm-glyphs.js +111 -0
- package/dist/node/tier-metrics.d.ts +38 -0
- package/dist/node/tier-metrics.js +45 -0
- package/dist/operators.d.ts +45 -0
- package/dist/operators.js +75 -0
- package/dist/plantuml-import.d.ts +113 -0
- package/dist/plantuml-import.js +1723 -0
- package/dist/plantuml.d.ts +94 -0
- package/dist/plantuml.js +985 -0
- package/dist/presets.d.ts +149 -0
- package/dist/presets.js +302 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +358 -0
- package/dist/reading.d.ts +160 -0
- package/dist/reading.js +648 -0
- package/dist/roles.d.ts +257 -0
- package/dist/roles.js +997 -0
- package/dist/rules.d.ts +267 -0
- package/dist/rules.js +3090 -0
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +197 -0
- package/dist/toolbar/config.d.ts +228 -0
- package/dist/toolbar/config.js +833 -0
- package/dist/toolbar/icons.d.ts +440 -0
- package/dist/toolbar/icons.js +711 -0
- package/dist/toolbar/senior-tool.d.ts +11 -0
- package/dist/toolbar/senior-tool.js +21 -0
- package/dist/toolbar/uml-menu.d.ts +17 -0
- package/dist/toolbar/uml-menu.js +20 -0
- package/dist/toolbar/uml-senior-button.d.ts +18 -0
- package/dist/toolbar/uml-senior-button.js +22 -0
- package/dist/translations.d.ts +27 -0
- package/dist/translations.js +117 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +236 -0
- package/dist/xmi-import.d.ts +174 -0
- package/dist/xmi-import.js +2268 -0
- package/dist/xmi.d.ts +61 -0
- package/dist/xmi.js +1860 -0
- package/dist/xml-reader.d.ts +157 -0
- package/dist/xml-reader.js +430 -0
- package/dist/xml.d.ts +72 -0
- package/dist/xml.js +96 -0
- 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
|
+
}
|