@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
package/dist/grammar.js
ADDED
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UML member grammar, as pure parsers.
|
|
3
|
+
*
|
|
4
|
+
* ## What these read
|
|
5
|
+
*
|
|
6
|
+
* Nine BNFs, transcribed from UML 2.5.1 and cited where they are used. The
|
|
7
|
+
* first three are phase 1's, and they are the STRUCTURAL half — what a
|
|
8
|
+
* compartment of a classifier says. Three more arrived with the behaviour sheets
|
|
9
|
+
* and are the same discipline applied to a LINE rather than to a box: what a
|
|
10
|
+
* transition, an activity edge and a state's internal compartment say. The
|
|
11
|
+
* seventh is what is written beside ONE END of a line. The last two are the
|
|
12
|
+
* sequence sheet's, and they are the same discipline applied to an INTERACTION:
|
|
13
|
+
* what a lifeline's head says and what an arrow between two of them says.
|
|
14
|
+
*
|
|
15
|
+
* - **§9.5.4** (printed p. 113) — a Property:
|
|
16
|
+
* `[<visibility>] [`/`] <name> [`:` <prop-type>] [`[` <multiplicity-range> `]`]
|
|
17
|
+
* [`=` <default>] [`{` <prop-modifier> [`,` <prop-modifier>]* `}`]`
|
|
18
|
+
* - **§9.6.4** (p. 117) — an Operation:
|
|
19
|
+
* `[<visibility>] <name> `(` [<parameter-list>] `)` [`:` [<return-type>]
|
|
20
|
+
* [`[` <multiplicity-range> `]`] [`{` <oper-property>* `}`]]`, with
|
|
21
|
+
* `<parameter>` from §9.4.4 (p. 110):
|
|
22
|
+
* `[<direction>] <parameter-name> `:` <type-expression>
|
|
23
|
+
* [`[`<multiplicity-range>`]`] [`=` <default>]`
|
|
24
|
+
* - **§7.5.4** (p. 34) — a multiplicity range: `[ <lower> `..` ] <upper>`
|
|
25
|
+
* - **§11.5.4** (p. 201) — what may be written beside an Association end: a
|
|
26
|
+
* name string, a multiplicity, a `<prop-modifier>` in braces, a
|
|
27
|
+
* `<visibility>` symbol
|
|
28
|
+
* - **§14.2.4.8** (p. 331) — a Transition label:
|
|
29
|
+
* `[<trigger> [`,` <trigger>]*] [`[` <guard> `]`] [`/` <behavior-expression>]`,
|
|
30
|
+
* with `<trigger>` from §13.3.4 (p. 293): a call or signal event's name, a
|
|
31
|
+
* `when` change event, an `after` / `at` time event, or the bare `all`
|
|
32
|
+
* - **§15.2.4** (p. 379) — an ActivityEdge's annotations: a name near the
|
|
33
|
+
* arrow, a guard "as text in square brackets near tail of the line", and
|
|
34
|
+
* `weight-annotation ::= `{` `weight` `=` <value-specification> `}``
|
|
35
|
+
* - **§14.2.4.4** (p. 320) — a State's internal activities:
|
|
36
|
+
* `<behavior-type-label> [`/` <behavior-expression>]`, the label being one of
|
|
37
|
+
* `entry`, `do` and `exit`
|
|
38
|
+
* - **§17.3.4** (p. 572) — a Lifeline head:
|
|
39
|
+
* `[<connectable-element-name> [`[` <selector> `]`]] [`:`
|
|
40
|
+
* <connectable-element-type>] [<decomposition>]`, or the bare `self`
|
|
41
|
+
* - **§17.4.4** (p. 576) — a Message label, request and reply together:
|
|
42
|
+
* `[<assignment-target> `=`] <message-name> [`(` [<argument-list>] `)`]
|
|
43
|
+
* [`:` <value-specification>]`, or the bare `*`
|
|
44
|
+
*
|
|
45
|
+
* ## Why they never throw
|
|
46
|
+
*
|
|
47
|
+
* A compartment is FREE TEXT an architect types while thinking. Half of it is a
|
|
48
|
+
* half-finished line at any moment, and a parser that threw would turn a
|
|
49
|
+
* keystroke into a broken export. So every entry point degrades: a line the
|
|
50
|
+
* grammar does not recognise comes back as `{ name: <the line> }` and travels
|
|
51
|
+
* through the exporters as an element named exactly what the author wrote. The
|
|
52
|
+
* picture keeps saying what it said; the file says the same thing, less
|
|
53
|
+
* structured.
|
|
54
|
+
*
|
|
55
|
+
* ## Purity
|
|
56
|
+
*
|
|
57
|
+
* Strings in, plain records out. No `std`, no DOM, no clock, no randomness — the
|
|
58
|
+
* same discipline `c4/export.ts` and `bpmn/export.ts` hold, and for the same
|
|
59
|
+
* reason: a host can call these, a test can call them with literals, and the
|
|
60
|
+
* same compartment always parses to the same record.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* A visibility marker as its VisibilityKind — `undefined` for anything else.
|
|
64
|
+
*
|
|
65
|
+
* `~` is `package` and not "default": §7.4 gives all four a glyph, and an
|
|
66
|
+
* omitted marker means the author has not said, which is a different statement
|
|
67
|
+
* from package visibility and is why the callers leave the field absent.
|
|
68
|
+
*/
|
|
69
|
+
export function visibilityOf(marker) {
|
|
70
|
+
switch (marker) {
|
|
71
|
+
case '+':
|
|
72
|
+
return 'public';
|
|
73
|
+
case '-':
|
|
74
|
+
return 'private';
|
|
75
|
+
case '#':
|
|
76
|
+
return 'protected';
|
|
77
|
+
case '~':
|
|
78
|
+
return 'package';
|
|
79
|
+
default:
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A multiplicity range, brackets optional — `undefined` when it is not one.
|
|
85
|
+
*
|
|
86
|
+
* §7.5.4's `[ <lower> '..' ] <upper>`, plus the two readings the clause states
|
|
87
|
+
* around it:
|
|
88
|
+
*
|
|
89
|
+
* - a bare `<upper>` means `lower = upper` — `1` is exactly one;
|
|
90
|
+
* - `*` is the unlimited upper bound, and a bare `*` is §7.5.4's shorthand for
|
|
91
|
+
* `0..*` (the clause's own "0..* … may be abbreviated to *").
|
|
92
|
+
*
|
|
93
|
+
* The brackets are stripped rather than required, because the SAME range is
|
|
94
|
+
* written both ways depending on where it sits: `[0..1]` inside a property line
|
|
95
|
+
* (§9.5.4 puts it in brackets) and `0..1` beside an association end (§11.5.4
|
|
96
|
+
* does not). One parser, both call sites.
|
|
97
|
+
*
|
|
98
|
+
* `<lower>` and `<upper>` are ValueSpecifications in the metamodel, so `n..m`
|
|
99
|
+
* with symbolic bounds is legal UML. It parses to `undefined` here on purpose:
|
|
100
|
+
* this module's callers write bounds into files as integers, and a made-up
|
|
101
|
+
* integer for `n` would be a fact the author never stated. A symbolic range
|
|
102
|
+
* survives instead as part of the line's own text.
|
|
103
|
+
*/
|
|
104
|
+
export function parseMultiplicity(raw) {
|
|
105
|
+
const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '').trim();
|
|
106
|
+
if (!inner)
|
|
107
|
+
return undefined;
|
|
108
|
+
const parts = inner.split('..');
|
|
109
|
+
if (parts.length > 2)
|
|
110
|
+
return undefined;
|
|
111
|
+
const bound = (value) => {
|
|
112
|
+
const text = value.trim();
|
|
113
|
+
if (text === '*')
|
|
114
|
+
return '*';
|
|
115
|
+
return /^\d+$/.test(text) ? Number(text) : undefined;
|
|
116
|
+
};
|
|
117
|
+
if (parts.length === 1) {
|
|
118
|
+
const only = bound(parts[0]);
|
|
119
|
+
if (only === undefined)
|
|
120
|
+
return undefined;
|
|
121
|
+
// §7.5.4: a lone `*` abbreviates `0..*`; any other lone bound is exact.
|
|
122
|
+
return only === '*'
|
|
123
|
+
? { lower: 0, upper: '*' }
|
|
124
|
+
: { lower: only, upper: only };
|
|
125
|
+
}
|
|
126
|
+
const lower = bound(parts[0]);
|
|
127
|
+
const upper = bound(parts[1]);
|
|
128
|
+
// An unlimited LOWER bound is not a range: `*..1` says nothing UML can hold.
|
|
129
|
+
if (typeof lower !== 'number' || upper === undefined)
|
|
130
|
+
return undefined;
|
|
131
|
+
return { lower, upper };
|
|
132
|
+
}
|
|
133
|
+
/** `{ readOnly, subsets x }` at the end of a line — the modifier group. */
|
|
134
|
+
const MODIFIER_GROUP = /\{([^}]*)\}\s*$/;
|
|
135
|
+
/** `[0..*]` at the end of what is left once default and modifiers are off. */
|
|
136
|
+
const MULTIPLICITY_GROUP = /\[([^\]]*)\]\s*$/;
|
|
137
|
+
/** The four `<visibility>` glyphs, as a leading marker. */
|
|
138
|
+
const VISIBILITY_MARKER = /^([+\-#~])\s*/;
|
|
139
|
+
/** Splits a `{…}` group into its comma-separated modifiers. */
|
|
140
|
+
function modifiersOf(group) {
|
|
141
|
+
if (!group)
|
|
142
|
+
return [];
|
|
143
|
+
return group
|
|
144
|
+
.split(',')
|
|
145
|
+
.map(entry => entry.trim())
|
|
146
|
+
.filter(entry => entry.length > 0);
|
|
147
|
+
}
|
|
148
|
+
/** Whether a modifier list states `{static}`, however it was capitalised. */
|
|
149
|
+
function statedStatic(modifiers) {
|
|
150
|
+
return modifiers.some(modifier => modifier.toLowerCase() === 'static');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* One attribute line, per §9.5.4.
|
|
154
|
+
*
|
|
155
|
+
* Parsed from the RIGHT, which is what makes the optional parts optional: the
|
|
156
|
+
* modifier group, then the default, then the multiplicity, then the type — each
|
|
157
|
+
* peeled off the tail it is the last thing on. Parsing left to right would have
|
|
158
|
+
* to guess where the name ends, and a name with a space in it (`order date`) is
|
|
159
|
+
* legal UML.
|
|
160
|
+
*
|
|
161
|
+
* `=` is split on its FIRST occurrence, because §9.5.4 puts the default last of
|
|
162
|
+
* the three and an expression may contain `==`. A `:` inside a default —
|
|
163
|
+
* `= {a: 1}` — therefore survives, since the type split happens on what is left
|
|
164
|
+
* after the default is gone.
|
|
165
|
+
*
|
|
166
|
+
* A line the grammar cannot see structure in keeps all of itself:
|
|
167
|
+
* `parseProperty('???')` is a property NAMED `???`. That is the degradation
|
|
168
|
+
* contract at the top of this file, and it is what lets a compartment be typed
|
|
169
|
+
* into rather than filled in.
|
|
170
|
+
*/
|
|
171
|
+
export function parseProperty(line) {
|
|
172
|
+
let rest = line.trim();
|
|
173
|
+
const modifierMatch = MODIFIER_GROUP.exec(rest);
|
|
174
|
+
const modifiers = modifiersOf(modifierMatch?.[1]);
|
|
175
|
+
if (modifierMatch)
|
|
176
|
+
rest = rest.slice(0, modifierMatch.index).trim();
|
|
177
|
+
let defaultValue;
|
|
178
|
+
const equals = rest.indexOf('=');
|
|
179
|
+
if (equals >= 0) {
|
|
180
|
+
const value = rest.slice(equals + 1).trim();
|
|
181
|
+
if (value)
|
|
182
|
+
defaultValue = value;
|
|
183
|
+
rest = rest.slice(0, equals).trim();
|
|
184
|
+
}
|
|
185
|
+
let multiplicity;
|
|
186
|
+
const multiplicityMatch = MULTIPLICITY_GROUP.exec(rest);
|
|
187
|
+
if (multiplicityMatch) {
|
|
188
|
+
multiplicity = parseMultiplicity(multiplicityMatch[1]);
|
|
189
|
+
// Dropped from the name either way: `orders[n]` is a multiplicity the
|
|
190
|
+
// grammar could not read, not part of what the property is called.
|
|
191
|
+
rest = rest.slice(0, multiplicityMatch.index).trim();
|
|
192
|
+
}
|
|
193
|
+
let type;
|
|
194
|
+
const colon = rest.indexOf(':');
|
|
195
|
+
if (colon >= 0) {
|
|
196
|
+
const named = rest.slice(colon + 1).trim();
|
|
197
|
+
if (named)
|
|
198
|
+
type = named;
|
|
199
|
+
rest = rest.slice(0, colon).trim();
|
|
200
|
+
}
|
|
201
|
+
const visibilityMatch = VISIBILITY_MARKER.exec(rest);
|
|
202
|
+
const visibility = visibilityMatch
|
|
203
|
+
? visibilityOf(visibilityMatch[1])
|
|
204
|
+
: undefined;
|
|
205
|
+
if (visibilityMatch)
|
|
206
|
+
rest = rest.slice(visibilityMatch[0].length).trim();
|
|
207
|
+
const isDerived = rest.startsWith('/');
|
|
208
|
+
if (isDerived)
|
|
209
|
+
rest = rest.slice(1).trim();
|
|
210
|
+
const property = {
|
|
211
|
+
isDerived,
|
|
212
|
+
name: rest,
|
|
213
|
+
modifiers,
|
|
214
|
+
};
|
|
215
|
+
if (visibility)
|
|
216
|
+
property.visibility = visibility;
|
|
217
|
+
if (type)
|
|
218
|
+
property.type = type;
|
|
219
|
+
if (multiplicity)
|
|
220
|
+
property.multiplicity = multiplicity;
|
|
221
|
+
if (defaultValue)
|
|
222
|
+
property.defaultValue = defaultValue;
|
|
223
|
+
if (statedStatic(modifiers))
|
|
224
|
+
property.isStatic = true;
|
|
225
|
+
return property;
|
|
226
|
+
}
|
|
227
|
+
/** `in`, `out`, `inout` or `return` as a leading word of a parameter. */
|
|
228
|
+
const DIRECTION_WORD = /^(in|out|inout|return)\s+/i;
|
|
229
|
+
/**
|
|
230
|
+
* The index of the `)` matching the `(` at `open` — `-1` when there is none.
|
|
231
|
+
*
|
|
232
|
+
* Depth-counted rather than `lastIndexOf(')')`, because a default value may hold
|
|
233
|
+
* parentheses (`x : Point = Point(0, 0)`) and the return part comes after the
|
|
234
|
+
* list, not inside it.
|
|
235
|
+
*/
|
|
236
|
+
function matchingParen(text, open) {
|
|
237
|
+
let depth = 0;
|
|
238
|
+
for (let index = open; index < text.length; index++) {
|
|
239
|
+
const char = text[index];
|
|
240
|
+
if (char === '(')
|
|
241
|
+
depth++;
|
|
242
|
+
else if (char === ')') {
|
|
243
|
+
depth--;
|
|
244
|
+
if (depth === 0)
|
|
245
|
+
return index;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return -1;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A `<parameter-list>` split on its TOP-LEVEL commas.
|
|
252
|
+
*
|
|
253
|
+
* A naive `split(',')` would cut `Map(String, Int)` and `[0, 1]` in half, and
|
|
254
|
+
* both are ordinary inside a type expression or a default.
|
|
255
|
+
*/
|
|
256
|
+
function splitParameters(list) {
|
|
257
|
+
const parts = [];
|
|
258
|
+
let depth = 0;
|
|
259
|
+
let current = '';
|
|
260
|
+
for (const char of list) {
|
|
261
|
+
if (char === '(' || char === '[' || char === '{')
|
|
262
|
+
depth++;
|
|
263
|
+
else if (char === ')' || char === ']' || char === '}')
|
|
264
|
+
depth--;
|
|
265
|
+
if (char === ',' && depth === 0) {
|
|
266
|
+
parts.push(current);
|
|
267
|
+
current = '';
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
current += char;
|
|
271
|
+
}
|
|
272
|
+
parts.push(current);
|
|
273
|
+
return parts.map(part => part.trim()).filter(part => part.length > 0);
|
|
274
|
+
}
|
|
275
|
+
/** One `<parameter>`, per §9.4.4. Peeled from the right like a property. */
|
|
276
|
+
function parseParameter(raw) {
|
|
277
|
+
let rest = raw.trim();
|
|
278
|
+
// §9.4.4 admits `{ordered}` and friends on a parameter; they are read off and
|
|
279
|
+
// dropped rather than modelled — no writer here has a slot for them, and
|
|
280
|
+
// leaving the group glued to the default would corrupt the default.
|
|
281
|
+
const modifierMatch = MODIFIER_GROUP.exec(rest);
|
|
282
|
+
if (modifierMatch)
|
|
283
|
+
rest = rest.slice(0, modifierMatch.index).trim();
|
|
284
|
+
let defaultValue;
|
|
285
|
+
const equals = rest.indexOf('=');
|
|
286
|
+
if (equals >= 0) {
|
|
287
|
+
const value = rest.slice(equals + 1).trim();
|
|
288
|
+
if (value)
|
|
289
|
+
defaultValue = value;
|
|
290
|
+
rest = rest.slice(0, equals).trim();
|
|
291
|
+
}
|
|
292
|
+
let multiplicity;
|
|
293
|
+
const multiplicityMatch = MULTIPLICITY_GROUP.exec(rest);
|
|
294
|
+
if (multiplicityMatch) {
|
|
295
|
+
multiplicity = parseMultiplicity(multiplicityMatch[1]);
|
|
296
|
+
rest = rest.slice(0, multiplicityMatch.index).trim();
|
|
297
|
+
}
|
|
298
|
+
let type;
|
|
299
|
+
const colon = rest.indexOf(':');
|
|
300
|
+
if (colon >= 0) {
|
|
301
|
+
const named = rest.slice(colon + 1).trim();
|
|
302
|
+
if (named)
|
|
303
|
+
type = named;
|
|
304
|
+
rest = rest.slice(0, colon).trim();
|
|
305
|
+
}
|
|
306
|
+
let direction;
|
|
307
|
+
const directionMatch = DIRECTION_WORD.exec(rest);
|
|
308
|
+
if (directionMatch) {
|
|
309
|
+
direction = directionMatch[1].toLowerCase();
|
|
310
|
+
rest = rest.slice(directionMatch[0].length).trim();
|
|
311
|
+
}
|
|
312
|
+
const parameter = { name: rest };
|
|
313
|
+
if (direction)
|
|
314
|
+
parameter.direction = direction;
|
|
315
|
+
if (type)
|
|
316
|
+
parameter.type = type;
|
|
317
|
+
if (multiplicity)
|
|
318
|
+
parameter.multiplicity = multiplicity;
|
|
319
|
+
if (defaultValue)
|
|
320
|
+
parameter.defaultValue = defaultValue;
|
|
321
|
+
return parameter;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* One operation line, per §9.6.4.
|
|
325
|
+
*
|
|
326
|
+
* The parentheses are the anchor: everything before the `(` is the visibility
|
|
327
|
+
* and the name, everything inside is the `<parameter-list>`, and everything
|
|
328
|
+
* after is the optional return part and the `<oper-property>` group. A line with
|
|
329
|
+
* no parentheses is not an operation the grammar can see — it degrades to a
|
|
330
|
+
* NAME, exactly as a property does, and an author halfway through typing
|
|
331
|
+
* `place` keeps their word.
|
|
332
|
+
*
|
|
333
|
+
* `{abstract}` is reported separately from `modifiers` (which keeps it too),
|
|
334
|
+
* because §9.2.4's marker decides a metamodel FLAG — `isAbstract` on the
|
|
335
|
+
* Operation — where the rest of the group is notation a writer passes through.
|
|
336
|
+
*/
|
|
337
|
+
export function parseOperation(line) {
|
|
338
|
+
const trimmed = line.trim();
|
|
339
|
+
const open = trimmed.indexOf('(');
|
|
340
|
+
const close = open >= 0 ? matchingParen(trimmed, open) : -1;
|
|
341
|
+
if (open < 0 || close < 0) {
|
|
342
|
+
return { name: trimmed, parameters: [], modifiers: [] };
|
|
343
|
+
}
|
|
344
|
+
let head = trimmed.slice(0, open).trim();
|
|
345
|
+
const list = trimmed.slice(open + 1, close);
|
|
346
|
+
let tail = trimmed.slice(close + 1).trim();
|
|
347
|
+
const modifierMatch = MODIFIER_GROUP.exec(tail);
|
|
348
|
+
const modifiers = modifiersOf(modifierMatch?.[1]);
|
|
349
|
+
if (modifierMatch)
|
|
350
|
+
tail = tail.slice(0, modifierMatch.index).trim();
|
|
351
|
+
let returnType;
|
|
352
|
+
let returnMultiplicity;
|
|
353
|
+
if (tail.startsWith(':')) {
|
|
354
|
+
let returns = tail.slice(1).trim();
|
|
355
|
+
const multiplicityMatch = MULTIPLICITY_GROUP.exec(returns);
|
|
356
|
+
if (multiplicityMatch) {
|
|
357
|
+
returnMultiplicity = parseMultiplicity(multiplicityMatch[1]);
|
|
358
|
+
returns = returns.slice(0, multiplicityMatch.index).trim();
|
|
359
|
+
}
|
|
360
|
+
if (returns)
|
|
361
|
+
returnType = returns;
|
|
362
|
+
}
|
|
363
|
+
const visibilityMatch = VISIBILITY_MARKER.exec(head);
|
|
364
|
+
const visibility = visibilityMatch
|
|
365
|
+
? visibilityOf(visibilityMatch[1])
|
|
366
|
+
: undefined;
|
|
367
|
+
if (visibilityMatch)
|
|
368
|
+
head = head.slice(visibilityMatch[0].length).trim();
|
|
369
|
+
const operation = {
|
|
370
|
+
name: head,
|
|
371
|
+
parameters: splitParameters(list).map(parseParameter),
|
|
372
|
+
modifiers,
|
|
373
|
+
};
|
|
374
|
+
if (visibility)
|
|
375
|
+
operation.visibility = visibility;
|
|
376
|
+
if (returnType)
|
|
377
|
+
operation.returnType = returnType;
|
|
378
|
+
if (returnMultiplicity)
|
|
379
|
+
operation.returnMultiplicity = returnMultiplicity;
|
|
380
|
+
if (statedStatic(modifiers))
|
|
381
|
+
operation.isStatic = true;
|
|
382
|
+
if (modifiers.some(modifier => modifier.toLowerCase() === 'abstract')) {
|
|
383
|
+
operation.isAbstract = true;
|
|
384
|
+
}
|
|
385
|
+
return operation;
|
|
386
|
+
}
|
|
387
|
+
/* ── A compartment ────────────────────────────────────────────────────── */
|
|
388
|
+
/**
|
|
389
|
+
* The lines of a compartment that SAY something — trimmed, in order.
|
|
390
|
+
*
|
|
391
|
+
* Two kinds of line are dropped and both are notation rather than content:
|
|
392
|
+
*
|
|
393
|
+
* - a blank line, which is spacing;
|
|
394
|
+
* - an ellipsis — `...` or `…` — which is §9.2.4's ELISION marker. It means
|
|
395
|
+
* "there are more features, not shown", and a file that wrote it back as a
|
|
396
|
+
* property named `...` would turn "some are hidden" into "one is called dot
|
|
397
|
+
* dot dot".
|
|
398
|
+
*/
|
|
399
|
+
export function parseCompartment(text) {
|
|
400
|
+
const raw = text === null || text === undefined ? '' : String(text);
|
|
401
|
+
return raw
|
|
402
|
+
.split('\n')
|
|
403
|
+
.map(line => line.trim())
|
|
404
|
+
.filter(line => line.length > 0 && line !== '...' && line !== '…');
|
|
405
|
+
}
|
|
406
|
+
/** The first token of an end label: a bracketed group, or a run of non-space. */
|
|
407
|
+
const END_LABEL_HEAD = /^(\[[^\]]*\]|\S+)\s*([\s\S]*)$/;
|
|
408
|
+
/**
|
|
409
|
+
* `0..* items` → a multiplicity and a role; `- owner` → a private role.
|
|
410
|
+
*
|
|
411
|
+
* MULTIPLICITY FIRST, and only from the leading token: §11.5.4 places both a
|
|
412
|
+
* name and a range beside the end with no separator between them, so something
|
|
413
|
+
* has to decide which half is which, and the leading token is the half that can
|
|
414
|
+
* be decided WITHOUT guessing — `0..*`, `1`, `*` and `[0..1]` are a closed
|
|
415
|
+
* grammar (§7.5.4), and a word is not. A leading token that does not parse as a
|
|
416
|
+
* range therefore stays part of the name, which is what keeps `1st choice` an
|
|
417
|
+
* end called "1st choice" rather than a multiplicity of one.
|
|
418
|
+
*
|
|
419
|
+
* The visibility marker is read on either side of the range — `- owner` and
|
|
420
|
+
* `+ 0..1 a` both — because §11.5.4 lists the three adornments without fixing an
|
|
421
|
+
* order between them, and an author who writes the marker first is writing the
|
|
422
|
+
* order §9.5.4 uses for a property.
|
|
423
|
+
*
|
|
424
|
+
* Never throws and never returns nothing: an end label is free text an architect
|
|
425
|
+
* typed beside a line, and the same degradation the rest of this module holds to
|
|
426
|
+
* applies — a label the grammar recognises nothing in comes back as `{ raw }`
|
|
427
|
+
* plus a role that is the whole of it.
|
|
428
|
+
*/
|
|
429
|
+
export function parseEndLabel(label) {
|
|
430
|
+
const raw = label === null || label === undefined ? '' : String(label).trim();
|
|
431
|
+
if (!raw)
|
|
432
|
+
return { raw: '' };
|
|
433
|
+
let rest = raw;
|
|
434
|
+
let visibility;
|
|
435
|
+
let multiplicity;
|
|
436
|
+
/** `+`, `-`, `#`, `~` off the front of what is left, once. */
|
|
437
|
+
const takeVisibility = () => {
|
|
438
|
+
if (visibility !== undefined)
|
|
439
|
+
return;
|
|
440
|
+
const marker = VISIBILITY_MARKER.exec(rest);
|
|
441
|
+
if (!marker)
|
|
442
|
+
return;
|
|
443
|
+
const read = visibilityOf(marker[1]);
|
|
444
|
+
if (!read)
|
|
445
|
+
return;
|
|
446
|
+
visibility = read;
|
|
447
|
+
rest = rest.slice(marker[0].length).trim();
|
|
448
|
+
};
|
|
449
|
+
/** The leading token as a range, and consumed only if it is one. */
|
|
450
|
+
const takeMultiplicity = () => {
|
|
451
|
+
const head = END_LABEL_HEAD.exec(rest);
|
|
452
|
+
if (!head)
|
|
453
|
+
return;
|
|
454
|
+
const read = parseMultiplicity(head[1]);
|
|
455
|
+
if (!read)
|
|
456
|
+
return;
|
|
457
|
+
multiplicity = read;
|
|
458
|
+
rest = head[2].trim();
|
|
459
|
+
};
|
|
460
|
+
takeMultiplicity();
|
|
461
|
+
takeVisibility();
|
|
462
|
+
// `+ 0..1 a`: the marker was in front of the range, so the range is only now
|
|
463
|
+
// the leading token.
|
|
464
|
+
if (!multiplicity)
|
|
465
|
+
takeMultiplicity();
|
|
466
|
+
takeVisibility();
|
|
467
|
+
const role = rest.trim();
|
|
468
|
+
return {
|
|
469
|
+
...(multiplicity ? { multiplicity } : {}),
|
|
470
|
+
...(role ? { role } : {}),
|
|
471
|
+
...(visibility ? { visibility } : {}),
|
|
472
|
+
raw,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/** The glyph §7.4 gives a VisibilityKind — the inverse of {@link visibilityOf}. */
|
|
476
|
+
export function visibilityMarker(visibility) {
|
|
477
|
+
switch (visibility) {
|
|
478
|
+
case 'public':
|
|
479
|
+
return '+';
|
|
480
|
+
case 'private':
|
|
481
|
+
return '-';
|
|
482
|
+
case 'protected':
|
|
483
|
+
return '#';
|
|
484
|
+
case 'package':
|
|
485
|
+
return '~';
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* §7.5.4's range, spelled the way a diagram spells it — `1`, `0..1`, `0..*`.
|
|
490
|
+
*
|
|
491
|
+
* Only ONE of the clause's two abbreviations is used: an exact range is written
|
|
492
|
+
* as the single bound (`1..1` is `1`, which is how every diagram in the
|
|
493
|
+
* specification writes it), and an unbounded one is written in full (`0..*`
|
|
494
|
+
* rather than the bare `*` §7.5.4 also permits). The asymmetry is deliberate —
|
|
495
|
+
* `*` is the spelling that reads as "unfinished" beside a line, and the full
|
|
496
|
+
* form is what PlantUML, Papyrus and draw.io all print. Both parse back to the
|
|
497
|
+
* same record, so nothing is lost either way; this is the one that is read.
|
|
498
|
+
*/
|
|
499
|
+
export function formatMultiplicity(multiplicity) {
|
|
500
|
+
const { lower, upper } = multiplicity;
|
|
501
|
+
if (lower === upper)
|
|
502
|
+
return String(upper);
|
|
503
|
+
return `${lower}..${upper}`;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* An end's adornments, back in one string — `0..* -items`.
|
|
507
|
+
*
|
|
508
|
+
* The inverse of {@link parseEndLabel}, in §11.5.4's own order: the range, then
|
|
509
|
+
* the visibility glyph, then the name. An end the grammar read nothing
|
|
510
|
+
* structural out of prints its {@link UmlAssociationEnd.raw} instead, so a label
|
|
511
|
+
* this module does not understand still comes back exactly as it was typed —
|
|
512
|
+
* the whole point of keeping `raw`.
|
|
513
|
+
*/
|
|
514
|
+
export function formatEndLabel(end) {
|
|
515
|
+
const parts = [];
|
|
516
|
+
if (end.multiplicity)
|
|
517
|
+
parts.push(formatMultiplicity(end.multiplicity));
|
|
518
|
+
const marker = end.visibility ? visibilityMarker(end.visibility) : '';
|
|
519
|
+
if (end.role)
|
|
520
|
+
parts.push(`${marker}${end.role}`);
|
|
521
|
+
else if (marker)
|
|
522
|
+
parts.push(marker);
|
|
523
|
+
return parts.length > 0 ? parts.join(' ') : end.raw;
|
|
524
|
+
}
|
|
525
|
+
/** `after` / `at` / `when`, as a leading keyword of a trigger. */
|
|
526
|
+
const TRIGGER_KEYWORD = /^(after|at|when)\b\s*/i;
|
|
527
|
+
/**
|
|
528
|
+
* One trigger, classified per §13.3.4.
|
|
529
|
+
*
|
|
530
|
+
* Total and never throwing, exactly like every parser above it: a word the
|
|
531
|
+
* clause has no notation for is a `call-or-signal`, which is the reading
|
|
532
|
+
* §14.2.4.8 itself gives to an undecorated name. So the fallback is the
|
|
533
|
+
* specification's own default rather than an error state.
|
|
534
|
+
*
|
|
535
|
+
* The keywords are matched case-insensitively and reported LOWERCASE in
|
|
536
|
+
* {@link UmlTrigger.kind} while {@link UmlTrigger.text} keeps the author's
|
|
537
|
+
* capitals: the classification is a fact about the notation, the text is a fact
|
|
538
|
+
* about the drawing, and neither should be made to speak for the other.
|
|
539
|
+
*/
|
|
540
|
+
export function parseTrigger(raw) {
|
|
541
|
+
const text = raw.trim();
|
|
542
|
+
// §13.3.4: "Any AnyReceiveEvent is denoted by `all`". A bare word, and the
|
|
543
|
+
// only trigger with nothing after it.
|
|
544
|
+
if (text.toLowerCase() === 'all')
|
|
545
|
+
return { kind: 'any-receive', text };
|
|
546
|
+
const keyword = TRIGGER_KEYWORD.exec(text);
|
|
547
|
+
if (keyword) {
|
|
548
|
+
const word = keyword[1].toLowerCase();
|
|
549
|
+
const expression = text.slice(keyword[0].length).trim();
|
|
550
|
+
const kind = word === 'when'
|
|
551
|
+
? 'change'
|
|
552
|
+
: word === 'after'
|
|
553
|
+
? 'relative-time'
|
|
554
|
+
: 'absolute-time';
|
|
555
|
+
// `after` with nothing after it is a keyword somebody is still typing: the
|
|
556
|
+
// kind is what the word says, and the expression is simply not stated yet.
|
|
557
|
+
return { kind, text, ...(expression ? { expression } : {}) };
|
|
558
|
+
}
|
|
559
|
+
// `<name> ['(' [<assignment-specification>] ')']` — §13.3.4's call and signal
|
|
560
|
+
// events, which share one syntax and are told apart nowhere on a drawing.
|
|
561
|
+
const open = text.indexOf('(');
|
|
562
|
+
if (open >= 0 && text.endsWith(')')) {
|
|
563
|
+
const name = text.slice(0, open).trim();
|
|
564
|
+
const assignment = text.slice(open + 1, -1).trim();
|
|
565
|
+
return {
|
|
566
|
+
kind: 'call-or-signal',
|
|
567
|
+
text,
|
|
568
|
+
...(name ? { name } : {}),
|
|
569
|
+
...(assignment ? { assignment } : {}),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
kind: 'call-or-signal',
|
|
574
|
+
text,
|
|
575
|
+
...(text ? { name: text } : {}),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The inside of a `[…]` guard — `undefined` when there is nothing in it.
|
|
580
|
+
*
|
|
581
|
+
* The brackets are OPTIONAL on the way in, for the same reason
|
|
582
|
+
* {@link parseMultiplicity} strips its own: the same guard is written both ways
|
|
583
|
+
* depending on where it sits. §15.2.4 puts an activity edge's guard in brackets
|
|
584
|
+
* beside the line and §14.2.4.8 puts a transition's in brackets inside the
|
|
585
|
+
* label, while a caller that has already split a label on its brackets holds the
|
|
586
|
+
* bare expression. One parser, both call sites.
|
|
587
|
+
*/
|
|
588
|
+
export function parseGuard(raw) {
|
|
589
|
+
if (raw === null || raw === undefined)
|
|
590
|
+
return undefined;
|
|
591
|
+
const inner = String(raw).trim().replace(/^\[/, '').replace(/\]$/, '').trim();
|
|
592
|
+
return inner ? inner : undefined;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The index of the first character at BRACKET DEPTH ZERO that satisfies a test,
|
|
596
|
+
* from `start` — `-1` when there is none.
|
|
597
|
+
*
|
|
598
|
+
* Depth-counted over `[`, `(` and `{` together, which is what makes the three
|
|
599
|
+
* parts of a transition label separable at all: a guard holds `[a/b > 1]` and a
|
|
600
|
+
* call event holds `raise(a, b)`, so a naive `indexOf('/')` cuts one of them in
|
|
601
|
+
* half. The same discipline {@link matchingParen} and {@link splitParameters}
|
|
602
|
+
* already hold for an operation line.
|
|
603
|
+
*/
|
|
604
|
+
function indexAtTopLevel(text, start, test) {
|
|
605
|
+
let depth = 0;
|
|
606
|
+
for (let index = start; index < text.length; index++) {
|
|
607
|
+
const char = text[index];
|
|
608
|
+
// Tested BEFORE the depth is adjusted, so that an OPENING bracket can be
|
|
609
|
+
// what is looked for: the guard's own `[` is at depth zero, and a version
|
|
610
|
+
// that counted it first would never see one.
|
|
611
|
+
if (depth === 0 && test(char))
|
|
612
|
+
return index;
|
|
613
|
+
if (char === '[' || char === '(' || char === '{')
|
|
614
|
+
depth++;
|
|
615
|
+
else if (char === ']' || char === ')' || char === '}')
|
|
616
|
+
depth--;
|
|
617
|
+
}
|
|
618
|
+
return -1;
|
|
619
|
+
}
|
|
620
|
+
/** A top-level split on `,` — the `<trigger> [',' <trigger>]*` of §14.2.4.8. */
|
|
621
|
+
function splitTriggers(list) {
|
|
622
|
+
const parts = [];
|
|
623
|
+
let depth = 0;
|
|
624
|
+
let current = '';
|
|
625
|
+
for (const char of list) {
|
|
626
|
+
if (char === '[' || char === '(' || char === '{')
|
|
627
|
+
depth++;
|
|
628
|
+
else if (char === ']' || char === ')' || char === '}')
|
|
629
|
+
depth--;
|
|
630
|
+
if (char === ',' && depth === 0) {
|
|
631
|
+
parts.push(current);
|
|
632
|
+
current = '';
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
current += char;
|
|
636
|
+
}
|
|
637
|
+
parts.push(current);
|
|
638
|
+
return parts.map(part => part.trim()).filter(part => part.length > 0);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* One transition label, per §14.2.4.8:
|
|
642
|
+
*
|
|
643
|
+
* `[<trigger> [',' <trigger>]*] ['[' <guard> ']'] ['/' <behavior-expression>]`
|
|
644
|
+
*
|
|
645
|
+
* Every part is optional and an empty label is a legal transition — §14.2.4.8's
|
|
646
|
+
* outermost brackets say so, and a completion transition (one that fires when
|
|
647
|
+
* its source state finishes) is written with no label at all. So the empty
|
|
648
|
+
* string parses to `{ triggers: [] }` rather than to a trigger named nothing.
|
|
649
|
+
*
|
|
650
|
+
* ## Read left to right, and anchored on the GUARD
|
|
651
|
+
*
|
|
652
|
+
* The guard is the only part with a delimiter at both ends, so it is found
|
|
653
|
+
* first: the first top-level `[` opens it and its matching `]` closes it.
|
|
654
|
+
* Everything before is the trigger list, and the first top-level `/` after it
|
|
655
|
+
* opens the effect. A label with no guard splits on its first top-level `/`
|
|
656
|
+
* instead, which is the same rule with the anchor missing.
|
|
657
|
+
*
|
|
658
|
+
* ## The one degradation, stated
|
|
659
|
+
*
|
|
660
|
+
* A bare change event holding a slash — `when x/2 > 1`, with no guard and no
|
|
661
|
+
* effect — reads its `/` as the effect separator and comes back as the trigger
|
|
662
|
+
* `when x` with the effect `2 > 1`. §13.3.4's `<value-specification>` is an
|
|
663
|
+
* arbitrary expression and the notation gives it no delimiters, so no reading of
|
|
664
|
+
* a flat string can tell the two apart; bracketing the expression, which is
|
|
665
|
+
* legal, resolves it. Stated here rather than discovered in an export.
|
|
666
|
+
*/
|
|
667
|
+
export function parseTransition(label) {
|
|
668
|
+
const text = label === null || label === undefined ? '' : String(label).trim();
|
|
669
|
+
if (!text)
|
|
670
|
+
return { triggers: [] };
|
|
671
|
+
const guardOpen = indexAtTopLevel(text, 0, char => char === '[');
|
|
672
|
+
let head = text;
|
|
673
|
+
let guard;
|
|
674
|
+
let tail = '';
|
|
675
|
+
if (guardOpen >= 0) {
|
|
676
|
+
// The matching `]` — depth-counted from the `[` itself, so a guard holding
|
|
677
|
+
// an indexed expression (`[a[0] > 1]`) closes where the author closed it.
|
|
678
|
+
let depth = 0;
|
|
679
|
+
let guardClose = -1;
|
|
680
|
+
for (let index = guardOpen; index < text.length; index++) {
|
|
681
|
+
const char = text[index];
|
|
682
|
+
if (char === '[')
|
|
683
|
+
depth++;
|
|
684
|
+
else if (char === ']') {
|
|
685
|
+
depth--;
|
|
686
|
+
if (depth === 0) {
|
|
687
|
+
guardClose = index;
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
head = text.slice(0, guardOpen);
|
|
693
|
+
if (guardClose >= 0) {
|
|
694
|
+
guard = parseGuard(text.slice(guardOpen + 1, guardClose));
|
|
695
|
+
tail = text.slice(guardClose + 1);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
// An unclosed `[` is a guard somebody is still typing. Everything after it
|
|
699
|
+
// is the guard, and there is no effect to find.
|
|
700
|
+
guard = parseGuard(text.slice(guardOpen + 1));
|
|
701
|
+
tail = '';
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
const slash = indexAtTopLevel(text, 0, char => char === '/');
|
|
706
|
+
if (slash >= 0) {
|
|
707
|
+
head = text.slice(0, slash);
|
|
708
|
+
tail = text.slice(slash);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
let effect;
|
|
712
|
+
const slash = indexAtTopLevel(tail, 0, char => char === '/');
|
|
713
|
+
if (slash >= 0) {
|
|
714
|
+
const written = tail.slice(slash + 1).trim();
|
|
715
|
+
if (written)
|
|
716
|
+
effect = written;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
triggers: splitTriggers(head),
|
|
720
|
+
...(guard ? { guard } : {}),
|
|
721
|
+
...(effect ? { effect } : {}),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
/** `{weight = 3}` — §15.2.4's `weight-annotation`, however it is spaced. */
|
|
725
|
+
const WEIGHT_ANNOTATION = /^weight\s*=\s*(.+)$/i;
|
|
726
|
+
/**
|
|
727
|
+
* One activity edge's centre label, split into what §15.2.4 writes around a
|
|
728
|
+
* line.
|
|
729
|
+
*
|
|
730
|
+
* Three annotations sharing one text, because this canvas gives a connector ONE
|
|
731
|
+
* label (`docs/adr/0018`) where the notation places three things at three points
|
|
732
|
+
* along the arrow. The delimiters are the notation's own — brackets for the
|
|
733
|
+
* guard, braces for the weight — so an author who writes
|
|
734
|
+
* `ready [stock > 0] {weight = 2}` gets all three read back, and one who writes
|
|
735
|
+
* only a word gets a named edge.
|
|
736
|
+
*
|
|
737
|
+
* Whatever is left once the bracketed and braced groups are lifted out is the
|
|
738
|
+
* NAME, which is why a label with no delimiters is a name and not a guard: a
|
|
739
|
+
* guard is bracketed in §15.2.4 and inventing one from a bare word would put a
|
|
740
|
+
* condition in a file that the drawing does not show.
|
|
741
|
+
*
|
|
742
|
+
* A braced group that is not a `weight-annotation` — `{stream}`, `{ordered}` —
|
|
743
|
+
* is dropped rather than folded into the name: it is notation this pack does not
|
|
744
|
+
* model, and gluing it onto the edge's name would rename the edge.
|
|
745
|
+
*/
|
|
746
|
+
export function parseActivityEdge(label) {
|
|
747
|
+
const text = label === null || label === undefined ? '' : String(label).trim();
|
|
748
|
+
if (!text)
|
|
749
|
+
return {};
|
|
750
|
+
let guard;
|
|
751
|
+
let weight;
|
|
752
|
+
let rest = text.replace(/\{([^}]*)\}/g, (_whole, group) => {
|
|
753
|
+
const annotation = WEIGHT_ANNOTATION.exec(group.trim());
|
|
754
|
+
if (annotation && weight === undefined)
|
|
755
|
+
weight = annotation[1].trim();
|
|
756
|
+
return ' ';
|
|
757
|
+
});
|
|
758
|
+
rest = rest.replace(/\[([^\]]*)\]/g, (_whole, group) => {
|
|
759
|
+
if (guard === undefined)
|
|
760
|
+
guard = parseGuard(group);
|
|
761
|
+
return ' ';
|
|
762
|
+
});
|
|
763
|
+
const name = rest.replace(/\s+/g, ' ').trim();
|
|
764
|
+
return {
|
|
765
|
+
...(name ? { name } : {}),
|
|
766
|
+
...(guard ? { guard } : {}),
|
|
767
|
+
...(weight ? { weight } : {}),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* `entry / x`, `do / y`, `exit / z` — §14.2.4.4's internal activities.
|
|
772
|
+
*
|
|
773
|
+
* `undefined` for every other line, and that is what makes the reading safe: a
|
|
774
|
+
* state's compartment holds internal TRANSITIONS too (§14.2.4.4's next
|
|
775
|
+
* compartment, `{<trigger>}* ['[' <guard> ']'] [/<behavior-expression>]`) and
|
|
776
|
+
* ordinary prose besides. Only the three labels the clause names are lifted out;
|
|
777
|
+
* everything else stays a line of the compartment, which is what
|
|
778
|
+
* {@link parseCompartment} already hands over.
|
|
779
|
+
*
|
|
780
|
+
* The separator is optional on the way in — `entry x` reads as `entry / x` —
|
|
781
|
+
* because the slash is the one character of this syntax an author routinely
|
|
782
|
+
* forgets, and the label alone is unambiguous about what the rest of the line
|
|
783
|
+
* is.
|
|
784
|
+
*/
|
|
785
|
+
export function parseStateBehavior(line) {
|
|
786
|
+
const match = /^(entry|do|exit)\b\s*(?:\/\s*)?(.*)$/i.exec(line.trim());
|
|
787
|
+
if (!match)
|
|
788
|
+
return undefined;
|
|
789
|
+
const expression = match[2].trim();
|
|
790
|
+
return {
|
|
791
|
+
kind: match[1].toLowerCase(),
|
|
792
|
+
...(expression ? { expression } : {}),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/* ── Behaviour: the labels, printed back ──────────────────────────────── */
|
|
796
|
+
/**
|
|
797
|
+
* §15.2.4's three annotations, back in one string — `name [guard] {weight = w}`.
|
|
798
|
+
*
|
|
799
|
+
* The inverse of {@link parseActivityEdge}, and it lives beside it for the
|
|
800
|
+
* reason every parser and printer pair should: the notation is one grammar, and
|
|
801
|
+
* two files spelling it separately is how a guard comes back without its
|
|
802
|
+
* brackets. Read by the PlantUML writer (which puts it on the arrow) and by the
|
|
803
|
+
* importers' materializer (which puts it in the connector's centre label), so a
|
|
804
|
+
* flow drawn from a file and one exported to one carry the same words.
|
|
805
|
+
*/
|
|
806
|
+
export function formatActivityEdgeLabel(label) {
|
|
807
|
+
const parts = [];
|
|
808
|
+
if (label.name)
|
|
809
|
+
parts.push(label.name);
|
|
810
|
+
if (label.guard)
|
|
811
|
+
parts.push(`[${label.guard}]`);
|
|
812
|
+
if (label.weight)
|
|
813
|
+
parts.push(`{weight = ${label.weight}}`);
|
|
814
|
+
return parts.join(' ');
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* §14.2.4.8's label, back in one string — `trigger1, trigger2 [guard] / effect`.
|
|
818
|
+
*
|
|
819
|
+
* The inverse of {@link parseTransition}, and a round trip rather than the
|
|
820
|
+
* author's own text passed through: the model holds the parsed parts, and
|
|
821
|
+
* printing them in the BNF's own order is what makes a label somebody typed
|
|
822
|
+
* loosely — a guard before its trigger, a missing space — come out spelled the
|
|
823
|
+
* way the clause spells it.
|
|
824
|
+
*/
|
|
825
|
+
export function formatTransitionLabel(label) {
|
|
826
|
+
const parts = [];
|
|
827
|
+
if (label.triggers.length > 0)
|
|
828
|
+
parts.push(label.triggers.join(', '));
|
|
829
|
+
if (label.guard)
|
|
830
|
+
parts.push(`[${label.guard}]`);
|
|
831
|
+
if (label.effect)
|
|
832
|
+
parts.push(`/ ${label.effect}`);
|
|
833
|
+
return parts.join(' ');
|
|
834
|
+
}
|
|
835
|
+
/** The one `ok` value, shared so a checker never allocates on the happy path. */
|
|
836
|
+
const OK = { ok: true };
|
|
837
|
+
const bad = (reason) => ({ ok: false, reason });
|
|
838
|
+
/**
|
|
839
|
+
* Whether a line's `()`, `[]` and `{}` close in the order they opened.
|
|
840
|
+
*
|
|
841
|
+
* One depth over all three, exactly as {@link splitParameters} and
|
|
842
|
+
* {@link indexAtTopLevel} count them: this module never needs to know WHICH
|
|
843
|
+
* bracket is open, only that the line is still being typed. An unclosed group is
|
|
844
|
+
* the single most common half-finished state of a compartment, and every parser
|
|
845
|
+
* above has a documented degradation for it — which is why the checkers ask
|
|
846
|
+
* about it FIRST and say nothing else about such a line.
|
|
847
|
+
*/
|
|
848
|
+
function unbalanced(text) {
|
|
849
|
+
let depth = 0;
|
|
850
|
+
for (const char of text) {
|
|
851
|
+
if (char === '(' || char === '[' || char === '{')
|
|
852
|
+
depth++;
|
|
853
|
+
else if (char === ')' || char === ']' || char === '}') {
|
|
854
|
+
depth--;
|
|
855
|
+
if (depth < 0)
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return depth !== 0;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The head of {@link parseProperty}'s peel, for diagnostics alone: the modifier
|
|
863
|
+
* group and the default value lifted off, with what was found reported.
|
|
864
|
+
*
|
|
865
|
+
* Six lines duplicated from the parser rather than the parser refactored to
|
|
866
|
+
* yield them, and deliberately: `parseProperty` is read by three exporters and
|
|
867
|
+
* two importers, and a checker is not a reason to reshape it. The duplication is
|
|
868
|
+
* pinned by `grammar.unit.spec.ts`, which asserts both halves against the same
|
|
869
|
+
* lines.
|
|
870
|
+
*/
|
|
871
|
+
function peelPropertyTail(text) {
|
|
872
|
+
let rest = text;
|
|
873
|
+
const modifierMatch = MODIFIER_GROUP.exec(rest);
|
|
874
|
+
const emptyModifierGroup = modifierMatch !== null && modifiersOf(modifierMatch[1]).length === 0;
|
|
875
|
+
if (modifierMatch)
|
|
876
|
+
rest = rest.slice(0, modifierMatch.index).trim();
|
|
877
|
+
const equals = rest.indexOf('=');
|
|
878
|
+
const danglingDefault = equals >= 0 && rest.slice(equals + 1).trim() === '';
|
|
879
|
+
if (equals >= 0)
|
|
880
|
+
rest = rest.slice(0, equals).trim();
|
|
881
|
+
return { rest, emptyModifierGroup, danglingDefault };
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* One attribute line, checked against §9.5.4 —
|
|
885
|
+
* `[<visibility>] ['/'] <name> [':' <type>] ['[' <mult> ']'] ['=' <default>]
|
|
886
|
+
* ['{' <modifiers> '}']`.
|
|
887
|
+
*
|
|
888
|
+
* Built on {@link parseProperty} and changing nothing about it. An empty line is
|
|
889
|
+
* `ok`: the family that calls this never passes one, and a total function is
|
|
890
|
+
* easier to reason about than one with a precondition.
|
|
891
|
+
*
|
|
892
|
+
* ## The parenthesis case, and why it is worth its own sentence
|
|
893
|
+
*
|
|
894
|
+
* `place(order)` typed into the ATTRIBUTES compartment is the single most common
|
|
895
|
+
* mistake this rule catches, and it is not a spelling mistake — it is a line in
|
|
896
|
+
* the wrong box. The check runs on what is left once the default value is peeled
|
|
897
|
+
* off, so `origin : Point = Point(0, 0)` keeps its parentheses and its silence.
|
|
898
|
+
*/
|
|
899
|
+
export function checkPropertyLine(line) {
|
|
900
|
+
const text = line.trim();
|
|
901
|
+
if (!text)
|
|
902
|
+
return OK;
|
|
903
|
+
if (unbalanced(text))
|
|
904
|
+
return bad('a bracket is never closed');
|
|
905
|
+
const { rest, emptyModifierGroup, danglingDefault } = peelPropertyTail(text);
|
|
906
|
+
if (emptyModifierGroup)
|
|
907
|
+
return bad('the { } group is empty');
|
|
908
|
+
if (danglingDefault)
|
|
909
|
+
return bad('nothing follows the "="');
|
|
910
|
+
if (rest.includes('(')) {
|
|
911
|
+
return bad('this is an operation — it belongs in the operation compartment');
|
|
912
|
+
}
|
|
913
|
+
const multiplicityMatch = MULTIPLICITY_GROUP.exec(rest);
|
|
914
|
+
if (multiplicityMatch &&
|
|
915
|
+
parseMultiplicity(multiplicityMatch[1]) === undefined) {
|
|
916
|
+
return bad('the [ ] multiplicity is not a range — write 1, 0..1 or 0..*');
|
|
917
|
+
}
|
|
918
|
+
const head = multiplicityMatch
|
|
919
|
+
? rest.slice(0, multiplicityMatch.index).trim()
|
|
920
|
+
: rest;
|
|
921
|
+
const colon = head.indexOf(':');
|
|
922
|
+
if (colon >= 0 && head.slice(colon + 1).trim() === '') {
|
|
923
|
+
return bad('nothing follows the ":"');
|
|
924
|
+
}
|
|
925
|
+
if (parseProperty(text).name === '')
|
|
926
|
+
return bad('there is no name');
|
|
927
|
+
return OK;
|
|
928
|
+
}
|
|
929
|
+
/** One `<parameter>` of §9.4.4, on the same contract as its line. */
|
|
930
|
+
function checkParameter(raw) {
|
|
931
|
+
const { rest, emptyModifierGroup, danglingDefault } = peelPropertyTail(raw.trim());
|
|
932
|
+
if (emptyModifierGroup)
|
|
933
|
+
return bad('a parameter’s { } group is empty');
|
|
934
|
+
if (danglingDefault)
|
|
935
|
+
return bad('nothing follows a parameter’s "="');
|
|
936
|
+
const multiplicityMatch = MULTIPLICITY_GROUP.exec(rest);
|
|
937
|
+
if (multiplicityMatch &&
|
|
938
|
+
parseMultiplicity(multiplicityMatch[1]) === undefined) {
|
|
939
|
+
return bad('a parameter’s [ ] multiplicity is not a range');
|
|
940
|
+
}
|
|
941
|
+
const head = multiplicityMatch
|
|
942
|
+
? rest.slice(0, multiplicityMatch.index).trim()
|
|
943
|
+
: rest;
|
|
944
|
+
const colon = head.indexOf(':');
|
|
945
|
+
if (colon >= 0 && head.slice(colon + 1).trim() === '') {
|
|
946
|
+
return bad('nothing follows a parameter’s ":"');
|
|
947
|
+
}
|
|
948
|
+
if (parseParameter(raw).name === '')
|
|
949
|
+
return bad('a parameter has no name');
|
|
950
|
+
return OK;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* One operation line, checked against §9.6.4 —
|
|
954
|
+
* `[<visibility>] <name> '(' [<parameter-list>] ')' [':' [<return-type>]
|
|
955
|
+
* ['[' <mult> ']'] ['{' <oper-property>* '}']]`.
|
|
956
|
+
*
|
|
957
|
+
* Built on {@link parseOperation} and changing nothing about it. This is the one
|
|
958
|
+
* checker with a genuine FALL-BACK to report: a line with no parentheses is not
|
|
959
|
+
* an operation the grammar can see at all, and `parseOperation` says so by
|
|
960
|
+
* returning the whole line as a name. An author halfway through typing `place`
|
|
961
|
+
* gets that silence from the rule's `audit` severity, not from the grammar.
|
|
962
|
+
*/
|
|
963
|
+
export function checkOperationLine(line) {
|
|
964
|
+
const text = line.trim();
|
|
965
|
+
if (!text)
|
|
966
|
+
return OK;
|
|
967
|
+
if (unbalanced(text))
|
|
968
|
+
return bad('a bracket is never closed');
|
|
969
|
+
const open = text.indexOf('(');
|
|
970
|
+
if (open < 0) {
|
|
971
|
+
return bad('there is no ( ) parameter list — write name(…) : Type');
|
|
972
|
+
}
|
|
973
|
+
const close = matchingParen(text, open);
|
|
974
|
+
if (close < 0)
|
|
975
|
+
return bad('the ( is never closed');
|
|
976
|
+
const head = text.slice(0, open).trim();
|
|
977
|
+
const withoutVisibility = head.replace(VISIBILITY_MARKER, '').trim();
|
|
978
|
+
if (withoutVisibility === '')
|
|
979
|
+
return bad('there is no name');
|
|
980
|
+
let tail = text.slice(close + 1).trim();
|
|
981
|
+
const modifierMatch = MODIFIER_GROUP.exec(tail);
|
|
982
|
+
if (modifierMatch && modifiersOf(modifierMatch[1]).length === 0) {
|
|
983
|
+
return bad('the { } group is empty');
|
|
984
|
+
}
|
|
985
|
+
if (modifierMatch)
|
|
986
|
+
tail = tail.slice(0, modifierMatch.index).trim();
|
|
987
|
+
if (tail !== '') {
|
|
988
|
+
if (!tail.startsWith(':')) {
|
|
989
|
+
return bad('the text after the ) is neither ": Type" nor a { } group');
|
|
990
|
+
}
|
|
991
|
+
let returns = tail.slice(1).trim();
|
|
992
|
+
const multiplicityMatch = MULTIPLICITY_GROUP.exec(returns);
|
|
993
|
+
if (multiplicityMatch &&
|
|
994
|
+
parseMultiplicity(multiplicityMatch[1]) === undefined) {
|
|
995
|
+
return bad('the [ ] multiplicity is not a range — write 1, 0..1 or 0..*');
|
|
996
|
+
}
|
|
997
|
+
if (multiplicityMatch) {
|
|
998
|
+
returns = returns.slice(0, multiplicityMatch.index).trim();
|
|
999
|
+
}
|
|
1000
|
+
if (returns === '')
|
|
1001
|
+
return bad('nothing follows the ":"');
|
|
1002
|
+
}
|
|
1003
|
+
for (const parameter of splitParameters(text.slice(open + 1, close))) {
|
|
1004
|
+
const verdict = checkParameter(parameter);
|
|
1005
|
+
if (!verdict.ok)
|
|
1006
|
+
return verdict;
|
|
1007
|
+
}
|
|
1008
|
+
return OK;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* One transition label, checked against §14.2.4.8 —
|
|
1012
|
+
* `[<trigger> [',' <trigger>]*] ['[' <guard> ']'] ['/' <behavior-expression>]`.
|
|
1013
|
+
*
|
|
1014
|
+
* Built on {@link parseTransition} and changing nothing about it. An EMPTY label
|
|
1015
|
+
* is `ok` and that is the clause's own reading: a completion transition — one
|
|
1016
|
+
* that fires when its source state finishes — is written with no label at all,
|
|
1017
|
+
* so a rule indicting the empty string would indict half the state machines ever
|
|
1018
|
+
* drawn.
|
|
1019
|
+
*
|
|
1020
|
+
* The parser's two documented tolerances are exactly what this reports: an
|
|
1021
|
+
* unclosed `[` ("a guard somebody is still typing") and a `/` with nothing
|
|
1022
|
+
* behind it.
|
|
1023
|
+
*/
|
|
1024
|
+
export function checkTransitionLabel(label) {
|
|
1025
|
+
const text = label === null || label === undefined ? '' : String(label).trim();
|
|
1026
|
+
if (!text)
|
|
1027
|
+
return OK;
|
|
1028
|
+
if (unbalanced(text))
|
|
1029
|
+
return bad('a bracket is never closed');
|
|
1030
|
+
const guardOpen = indexAtTopLevel(text, 0, char => char === '[');
|
|
1031
|
+
let head = text;
|
|
1032
|
+
let tail = '';
|
|
1033
|
+
if (guardOpen >= 0) {
|
|
1034
|
+
let depth = 0;
|
|
1035
|
+
let guardClose = -1;
|
|
1036
|
+
for (let index = guardOpen; index < text.length; index++) {
|
|
1037
|
+
const char = text[index];
|
|
1038
|
+
if (char === '[')
|
|
1039
|
+
depth++;
|
|
1040
|
+
else if (char === ']') {
|
|
1041
|
+
depth--;
|
|
1042
|
+
if (depth === 0) {
|
|
1043
|
+
guardClose = index;
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
head = text.slice(0, guardOpen);
|
|
1049
|
+
tail = text.slice(guardClose + 1);
|
|
1050
|
+
if (parseGuard(text.slice(guardOpen + 1, guardClose)) === undefined) {
|
|
1051
|
+
return bad('the [ ] guard is empty');
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
const slash = indexAtTopLevel(text, 0, char => char === '/');
|
|
1056
|
+
if (slash >= 0) {
|
|
1057
|
+
head = text.slice(0, slash);
|
|
1058
|
+
tail = text.slice(slash);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
const slash = indexAtTopLevel(tail, 0, char => char === '/');
|
|
1062
|
+
if (slash >= 0 && tail.slice(slash + 1).trim() === '') {
|
|
1063
|
+
return bad('nothing follows the "/"');
|
|
1064
|
+
}
|
|
1065
|
+
if (slash < 0 && tail.trim() !== '') {
|
|
1066
|
+
return bad('the text after the guard is neither a "/" effect nor nothing');
|
|
1067
|
+
}
|
|
1068
|
+
// `a,,b` — the parser drops the empty part and the file loses a trigger the
|
|
1069
|
+
// author typed a comma for.
|
|
1070
|
+
if (head.trim() !== '') {
|
|
1071
|
+
let depth = 0;
|
|
1072
|
+
let current = '';
|
|
1073
|
+
const parts = [];
|
|
1074
|
+
for (const char of head) {
|
|
1075
|
+
if (char === '[' || char === '(' || char === '{')
|
|
1076
|
+
depth++;
|
|
1077
|
+
else if (char === ']' || char === ')' || char === '}')
|
|
1078
|
+
depth--;
|
|
1079
|
+
if (char === ',' && depth === 0) {
|
|
1080
|
+
parts.push(current);
|
|
1081
|
+
current = '';
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
current += char;
|
|
1085
|
+
}
|
|
1086
|
+
parts.push(current);
|
|
1087
|
+
if (parts.some(part => part.trim() === '')) {
|
|
1088
|
+
return bad('a trigger between two commas is empty');
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return OK;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* One multiplicity, checked against §7.5.4 — `[<lower> '..'] <upper>`, brackets
|
|
1095
|
+
* optional.
|
|
1096
|
+
*
|
|
1097
|
+
* The thinnest of the four: {@link parseMultiplicity} already answers
|
|
1098
|
+
* `undefined` for everything that is not a range, so this is that answer with a
|
|
1099
|
+
* sentence attached. The one case worth naming is the SYMBOLIC range — `n..m` is
|
|
1100
|
+
* legal UML and parses to `undefined` here on purpose (the module's callers write
|
|
1101
|
+
* integer bounds into files, and inventing one for `n` would be a fact the author
|
|
1102
|
+
* never stated), so the reason says what the writers can hold rather than what
|
|
1103
|
+
* the specification permits.
|
|
1104
|
+
*/
|
|
1105
|
+
export function checkMultiplicity(raw) {
|
|
1106
|
+
const text = raw.trim();
|
|
1107
|
+
if (!text)
|
|
1108
|
+
return OK;
|
|
1109
|
+
if (parseMultiplicity(text) !== undefined)
|
|
1110
|
+
return OK;
|
|
1111
|
+
return bad('this is not a multiplicity — write 1, 0..1, 0..* or *, with whole numbers');
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* A token that is UNAMBIGUOUSLY an attempt at §7.5.4's range: it opens with a
|
|
1115
|
+
* digit or a `*`, and it is either all digits and stars or it carries the `..`
|
|
1116
|
+
* of a range.
|
|
1117
|
+
*
|
|
1118
|
+
* The gate exists because §11.5.4 puts the range and the end's NAME side by side
|
|
1119
|
+
* with no separator, so the only thing that tells them apart is the shape of the
|
|
1120
|
+
* leading token — {@link parseEndLabel} says exactly that, and a checker must
|
|
1121
|
+
* not be stricter than the parser it checks. `1st choice` is an end called "1st
|
|
1122
|
+
* choice" there and stays one here; `1..n` is a range with a symbolic bound,
|
|
1123
|
+
* which this module's writers cannot hold, and is reported.
|
|
1124
|
+
*/
|
|
1125
|
+
const RANGE_ATTEMPT = /^(?:[\d*][\d*]*|[\d*][^\s]*\.\.[^\s]*)$/;
|
|
1126
|
+
/**
|
|
1127
|
+
* The multiplicity an ASSOCIATION END's label opens with, when it opens with one
|
|
1128
|
+
* at all — §11.5.4 beside §7.5.4.
|
|
1129
|
+
*
|
|
1130
|
+
* Reads the label exactly as {@link parseEndLabel} does (visibility marker off
|
|
1131
|
+
* the front, then the leading token, brackets optional) and then asks
|
|
1132
|
+
* {@link checkMultiplicity} about that token — but only when the token is an
|
|
1133
|
+
* attempt at a range. Everything else on an end label is the ROLE name, which is
|
|
1134
|
+
* free text and has no syntax to be wrong about.
|
|
1135
|
+
*
|
|
1136
|
+
* So `0..*`, `1`, `[0..1]` and `0..* items` are silent, `1..n` and `0...*` are
|
|
1137
|
+
* reported, and `items`, `- owner` and `1st choice` are none of this rule's
|
|
1138
|
+
* business.
|
|
1139
|
+
*/
|
|
1140
|
+
export function checkEndLabelMultiplicity(label) {
|
|
1141
|
+
const raw = label === null || label === undefined ? '' : String(label).trim();
|
|
1142
|
+
if (!raw)
|
|
1143
|
+
return OK;
|
|
1144
|
+
const withoutVisibility = raw.replace(VISIBILITY_MARKER, '').trim();
|
|
1145
|
+
const head = END_LABEL_HEAD.exec(withoutVisibility);
|
|
1146
|
+
if (!head)
|
|
1147
|
+
return OK;
|
|
1148
|
+
const token = head[1].replace(/^\[/, '').replace(/\]$/, '').trim();
|
|
1149
|
+
if (!RANGE_ATTEMPT.test(token))
|
|
1150
|
+
return OK;
|
|
1151
|
+
return checkMultiplicity(token);
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* One lifeline head, per §17.3.4.
|
|
1155
|
+
*
|
|
1156
|
+
* Peeled from the RIGHT like every other parser here: the `:` type first (the
|
|
1157
|
+
* last of the three parts), then the trailing `[ ]` selector, and what is left
|
|
1158
|
+
* is the name. Splitting on the FIRST top-level `:` keeps a type expression that
|
|
1159
|
+
* carries one of its own (`: Map[String, Int]`) whole, and the depth count is
|
|
1160
|
+
* what keeps a selector holding a colon (`accounts[k : Key]`) out of the split.
|
|
1161
|
+
*
|
|
1162
|
+
* Degrades like the rest of this module: a head the grammar cannot see structure
|
|
1163
|
+
* in is a lifeline NAMED exactly what the author wrote, and an empty head is an
|
|
1164
|
+
* empty record rather than a throw — §17.3.4 says a `<lifelineident>` cannot be
|
|
1165
|
+
* empty, and reporting that is `label-presence`'s job and not a parser's.
|
|
1166
|
+
*/
|
|
1167
|
+
export function parseLifelineIdent(text) {
|
|
1168
|
+
const trimmed = text === null || text === undefined ? '' : String(text).trim();
|
|
1169
|
+
if (!trimmed)
|
|
1170
|
+
return {};
|
|
1171
|
+
let rest = trimmed;
|
|
1172
|
+
let type;
|
|
1173
|
+
const colon = indexAtTopLevel(rest, 0, char => char === ':');
|
|
1174
|
+
if (colon >= 0) {
|
|
1175
|
+
const named = rest.slice(colon + 1).trim();
|
|
1176
|
+
if (named)
|
|
1177
|
+
type = named;
|
|
1178
|
+
rest = rest.slice(0, colon).trim();
|
|
1179
|
+
}
|
|
1180
|
+
let selector;
|
|
1181
|
+
// The same trailing `[…]` group §9.5.4 puts a multiplicity in; here the clause
|
|
1182
|
+
// is §17.3.4 and what is inside is a `<selector>` expression. One regex, two
|
|
1183
|
+
// productions — the shape is genuinely the same, and a second copy of it under
|
|
1184
|
+
// a second name would be two things to keep in step.
|
|
1185
|
+
const selectorMatch = MULTIPLICITY_GROUP.exec(rest);
|
|
1186
|
+
if (selectorMatch) {
|
|
1187
|
+
const inside = selectorMatch[1].trim();
|
|
1188
|
+
if (inside)
|
|
1189
|
+
selector = inside;
|
|
1190
|
+
rest = rest.slice(0, selectorMatch.index).trim();
|
|
1191
|
+
}
|
|
1192
|
+
const ident = {};
|
|
1193
|
+
if (rest)
|
|
1194
|
+
ident.name = rest;
|
|
1195
|
+
if (type)
|
|
1196
|
+
ident.type = type;
|
|
1197
|
+
if (selector)
|
|
1198
|
+
ident.selector = selector;
|
|
1199
|
+
return ident;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* A top-level split on `,` that KEEPS the empty parts.
|
|
1203
|
+
*
|
|
1204
|
+
* {@link splitParameters} drops them, which is right for a WRITER — an author
|
|
1205
|
+
* mid-comma has not typed a parameter yet — and is exactly what a CHECKER has to
|
|
1206
|
+
* see: `place(a,,b)` is a label whose file will hold two arguments where three
|
|
1207
|
+
* commas' worth were typed.
|
|
1208
|
+
*/
|
|
1209
|
+
function rawArguments(list) {
|
|
1210
|
+
const parts = [];
|
|
1211
|
+
let depth = 0;
|
|
1212
|
+
let current = '';
|
|
1213
|
+
for (const char of list) {
|
|
1214
|
+
if (char === '(' || char === '[' || char === '{')
|
|
1215
|
+
depth++;
|
|
1216
|
+
else if (char === ')' || char === ']' || char === '}')
|
|
1217
|
+
depth--;
|
|
1218
|
+
if (char === ',' && depth === 0) {
|
|
1219
|
+
parts.push(current);
|
|
1220
|
+
current = '';
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
current += char;
|
|
1224
|
+
}
|
|
1225
|
+
parts.push(current);
|
|
1226
|
+
return parts;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* The index of §17.4.4's assignment `=` at bracket depth zero — `-1` when the
|
|
1230
|
+
* label has none.
|
|
1231
|
+
*
|
|
1232
|
+
* Skips the COMPARISONS an argument or a value specification may hold: `==`,
|
|
1233
|
+
* `!=`, `<=` and `>=` are part of an expression, and taking one for the
|
|
1234
|
+
* assignment would cut a label in half at a character the author meant as
|
|
1235
|
+
* arithmetic.
|
|
1236
|
+
*/
|
|
1237
|
+
function assignmentIndex(text) {
|
|
1238
|
+
let from = 0;
|
|
1239
|
+
for (;;) {
|
|
1240
|
+
const found = indexAtTopLevel(text, from, char => char === '=');
|
|
1241
|
+
if (found < 0)
|
|
1242
|
+
return -1;
|
|
1243
|
+
const before = text[found - 1];
|
|
1244
|
+
const after = text[found + 1];
|
|
1245
|
+
if (after !== '=' &&
|
|
1246
|
+
before !== '=' &&
|
|
1247
|
+
before !== '!' &&
|
|
1248
|
+
before !== '<' &&
|
|
1249
|
+
before !== '>') {
|
|
1250
|
+
return found;
|
|
1251
|
+
}
|
|
1252
|
+
from = found + 1;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* One message label, per §17.4.4.
|
|
1257
|
+
*
|
|
1258
|
+
* The parentheses are the anchor, exactly as they are for an operation
|
|
1259
|
+
* ({@link parseOperation}): everything before the `(` is the assignment target
|
|
1260
|
+
* and the name, everything inside is the argument list, and everything after is
|
|
1261
|
+
* the optional `: <value-specification>`. A label with no parentheses is split
|
|
1262
|
+
* on its first top-level `:` instead, so `ok : true` still says what it says.
|
|
1263
|
+
*
|
|
1264
|
+
* `*` — §17.4.4's shorthand for "a message of any type" — comes back as a
|
|
1265
|
+
* message NAMED `*`. That is the degradation contract of this module rather than
|
|
1266
|
+
* a reading of the clause: the shorthand stands for a combined fragment this
|
|
1267
|
+
* canvas does not draw (ADR 0022), so the honest thing is to keep the author's
|
|
1268
|
+
* character and let the writers pass it through.
|
|
1269
|
+
*/
|
|
1270
|
+
export function parseMessageLabel(text) {
|
|
1271
|
+
const trimmed = text === null || text === undefined ? '' : String(text).trim();
|
|
1272
|
+
if (!trimmed)
|
|
1273
|
+
return { name: '' };
|
|
1274
|
+
let rest = trimmed;
|
|
1275
|
+
let args;
|
|
1276
|
+
let returnValue;
|
|
1277
|
+
const open = rest.indexOf('(');
|
|
1278
|
+
const close = open >= 0 ? matchingParen(rest, open) : -1;
|
|
1279
|
+
if (open >= 0 && close >= 0) {
|
|
1280
|
+
args = splitParameters(rest.slice(open + 1, close));
|
|
1281
|
+
const tail = rest.slice(close + 1).trim();
|
|
1282
|
+
if (tail.startsWith(':')) {
|
|
1283
|
+
const value = tail.slice(1).trim();
|
|
1284
|
+
if (value)
|
|
1285
|
+
returnValue = value;
|
|
1286
|
+
}
|
|
1287
|
+
rest = rest.slice(0, open).trim();
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
const colon = indexAtTopLevel(rest, 0, char => char === ':');
|
|
1291
|
+
if (colon >= 0) {
|
|
1292
|
+
const value = rest.slice(colon + 1).trim();
|
|
1293
|
+
if (value)
|
|
1294
|
+
returnValue = value;
|
|
1295
|
+
rest = rest.slice(0, colon).trim();
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
let assign;
|
|
1299
|
+
const equals = assignmentIndex(rest);
|
|
1300
|
+
if (equals >= 0) {
|
|
1301
|
+
const target = rest.slice(0, equals).trim();
|
|
1302
|
+
if (target)
|
|
1303
|
+
assign = target;
|
|
1304
|
+
rest = rest.slice(equals + 1).trim();
|
|
1305
|
+
}
|
|
1306
|
+
const label = { name: rest };
|
|
1307
|
+
if (assign)
|
|
1308
|
+
label.assign = assign;
|
|
1309
|
+
if (args !== undefined)
|
|
1310
|
+
label.args = args;
|
|
1311
|
+
if (returnValue)
|
|
1312
|
+
label.returnValue = returnValue;
|
|
1313
|
+
return label;
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* One lifeline head, checked against §17.3.4 —
|
|
1317
|
+
* `[<name> ['[' <selector> ']']] [':' <type>]`.
|
|
1318
|
+
*
|
|
1319
|
+
* Built on {@link parseLifelineIdent} and changing nothing about it. An empty
|
|
1320
|
+
* head is `ok` here even though §17.3.4 says a `<lifelineident>` cannot be
|
|
1321
|
+
* empty: the family that calls this never passes an empty line, and "this
|
|
1322
|
+
* lifeline has no name" is a question `label-presence` already asks with its own
|
|
1323
|
+
* id and its own sentence (`uml.unnamed-lifeline`). Two rules reporting one
|
|
1324
|
+
* emptied head would be two brackets on one word to fix.
|
|
1325
|
+
*
|
|
1326
|
+
* ## The parenthesis case
|
|
1327
|
+
*
|
|
1328
|
+
* `place(order)` typed into a lifeline head is a MESSAGE label written on the
|
|
1329
|
+
* wrong artefact, which is the same mistake — and the same sentence — that
|
|
1330
|
+
* {@link checkPropertyLine} catches when an operation is typed into an attribute
|
|
1331
|
+
* compartment. §17.3.4 gives a head no parentheses at all.
|
|
1332
|
+
*/
|
|
1333
|
+
export function checkLifelineIdent(line) {
|
|
1334
|
+
const text = line === null || line === undefined ? '' : String(line).trim();
|
|
1335
|
+
if (!text)
|
|
1336
|
+
return OK;
|
|
1337
|
+
if (unbalanced(text))
|
|
1338
|
+
return bad('a bracket is never closed');
|
|
1339
|
+
// §17.3.4's own alternative, whole and on its own.
|
|
1340
|
+
if (text === 'self')
|
|
1341
|
+
return OK;
|
|
1342
|
+
const colon = indexAtTopLevel(text, 0, char => char === ':');
|
|
1343
|
+
if (colon >= 0 && text.slice(colon + 1).trim() === '') {
|
|
1344
|
+
return bad('nothing follows the ":"');
|
|
1345
|
+
}
|
|
1346
|
+
// …and ONE colon, which is the production's own count: §17.3.4 writes
|
|
1347
|
+
// `[<name>] [: <class-name>]` and nothing after the class name. A second
|
|
1348
|
+
// top-level colon does not make the parse throw — `parseLifelineIdent` takes
|
|
1349
|
+
// everything past the first one as the type, which is how `: :` comes back as
|
|
1350
|
+
// a participant of class ":" — so this is the one case where the check is
|
|
1351
|
+
// stricter than "the parse lost something", and it is stricter about a
|
|
1352
|
+
// character rather than about a spelling. The bracketed depth is what keeps a
|
|
1353
|
+
// type expression carrying its own colon (`: Map[K : V]`) out of it.
|
|
1354
|
+
if (colon >= 0) {
|
|
1355
|
+
const type = text.slice(colon + 1);
|
|
1356
|
+
if (indexAtTopLevel(type, 0, char => char === ':') >= 0) {
|
|
1357
|
+
return bad('a lifeline head carries one ":", and this one has two');
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
const head = colon >= 0 ? text.slice(0, colon).trim() : text;
|
|
1361
|
+
if (head.includes('(')) {
|
|
1362
|
+
return bad('a lifeline head carries no parentheses — this reads as a message label');
|
|
1363
|
+
}
|
|
1364
|
+
const selectorMatch = MULTIPLICITY_GROUP.exec(head);
|
|
1365
|
+
if (selectorMatch && selectorMatch[1].trim() === '') {
|
|
1366
|
+
return bad('the [ ] selector is empty');
|
|
1367
|
+
}
|
|
1368
|
+
const ident = parseLifelineIdent(text);
|
|
1369
|
+
if (ident.name === undefined && ident.type === undefined) {
|
|
1370
|
+
return bad('there is no name and no type');
|
|
1371
|
+
}
|
|
1372
|
+
return OK;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* One message label, checked against §17.4.4 — the request and the reply
|
|
1376
|
+
* productions together, since the arrow carries one text.
|
|
1377
|
+
*
|
|
1378
|
+
* Built on {@link parseMessageLabel} and changing nothing about it. An EMPTY
|
|
1379
|
+
* label is `ok`, and that is the clause's own reading twice over: §17.4.4 makes
|
|
1380
|
+
* the label optional, and a sequence diagram is drawn arrows-first with the
|
|
1381
|
+
* words added afterwards. `*` is `ok` for the same reason
|
|
1382
|
+
* {@link parseMessageLabel} keeps it — it is a spelling the specification
|
|
1383
|
+
* prints.
|
|
1384
|
+
*
|
|
1385
|
+
* Reports exactly the four ways the lenient parse LOSES something: a bracket
|
|
1386
|
+
* still open, an argument between two commas, text after the `)` that is neither
|
|
1387
|
+
* a return value nor nothing, and a `:` or an `=` with nothing on the side that
|
|
1388
|
+
* has to carry words.
|
|
1389
|
+
*/
|
|
1390
|
+
export function checkMessageLabel(line) {
|
|
1391
|
+
const text = line === null || line === undefined ? '' : String(line).trim();
|
|
1392
|
+
if (!text)
|
|
1393
|
+
return OK;
|
|
1394
|
+
if (text === '*')
|
|
1395
|
+
return OK;
|
|
1396
|
+
if (unbalanced(text))
|
|
1397
|
+
return bad('a bracket is never closed');
|
|
1398
|
+
let head = text;
|
|
1399
|
+
const open = text.indexOf('(');
|
|
1400
|
+
// An unclosed `(` never reaches here: `unbalanced` has already answered.
|
|
1401
|
+
const close = open >= 0 ? matchingParen(text, open) : -1;
|
|
1402
|
+
if (open >= 0 && close >= 0) {
|
|
1403
|
+
const list = text.slice(open + 1, close);
|
|
1404
|
+
if (list.trim() !== '' &&
|
|
1405
|
+
rawArguments(list).some(part => part.trim() === '')) {
|
|
1406
|
+
return bad('an argument between two commas is empty');
|
|
1407
|
+
}
|
|
1408
|
+
const tail = text.slice(close + 1).trim();
|
|
1409
|
+
if (tail !== '' && !tail.startsWith(':')) {
|
|
1410
|
+
return bad('the text after the ")" is neither a ":" value nor nothing');
|
|
1411
|
+
}
|
|
1412
|
+
if (tail.startsWith(':') && tail.slice(1).trim() === '') {
|
|
1413
|
+
return bad('nothing follows the ":"');
|
|
1414
|
+
}
|
|
1415
|
+
head = text.slice(0, open).trim();
|
|
1416
|
+
}
|
|
1417
|
+
else {
|
|
1418
|
+
const colon = indexAtTopLevel(text, 0, char => char === ':');
|
|
1419
|
+
if (colon >= 0) {
|
|
1420
|
+
if (text.slice(colon + 1).trim() === '') {
|
|
1421
|
+
return bad('nothing follows the ":"');
|
|
1422
|
+
}
|
|
1423
|
+
head = text.slice(0, colon).trim();
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
const equals = assignmentIndex(head);
|
|
1427
|
+
if (equals >= 0) {
|
|
1428
|
+
if (head.slice(0, equals).trim() === '') {
|
|
1429
|
+
return bad('nothing precedes the "="');
|
|
1430
|
+
}
|
|
1431
|
+
head = head.slice(equals + 1).trim();
|
|
1432
|
+
}
|
|
1433
|
+
if (head === '')
|
|
1434
|
+
return bad('there is no message name');
|
|
1435
|
+
return OK;
|
|
1436
|
+
}
|