dsh-model-everything 0.0.0-stage → 0.2.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 +21 -0
- package/README.en-US.md +332 -0
- package/README.md +332 -3
- package/cordis.patch.yml +20 -0
- package/examples/README.md +48 -0
- package/examples/control-dispatcher.json +113 -0
- package/examples/control-dispatcher.overbudget.json +113 -0
- package/examples/ecommerce-data-model.json +185 -0
- package/examples/order-state-machine.after.json +116 -0
- package/examples/order-state-machine.before.json +101 -0
- package/examples/user-migration.after.json +29 -0
- package/examples/user-migration.before.json +29 -0
- package/gate/locale/en.json +6 -0
- package/gate/locale/zh.json +6 -0
- package/gate/package.json +5 -0
- package/lib/baseline.js +249 -0
- package/lib/compose-tool.js +80 -0
- package/lib/concurrency-tool.js +40 -0
- package/lib/concurrency.js +206 -0
- package/lib/data-engine.js +1141 -0
- package/lib/data-tool.js +87 -0
- package/lib/design-engine.js +1149 -0
- package/lib/design-tool.js +63 -0
- package/lib/engine.js +4343 -0
- package/lib/export-tool.js +78 -0
- package/lib/exporters.js +434 -0
- package/lib/gate.js +274 -0
- package/lib/granularity.js +237 -0
- package/lib/index.js +237 -0
- package/lib/json-value.js +18 -0
- package/lib/model-file.js +48 -0
- package/lib/option-shape.js +62 -0
- package/lib/report-diff-tool.js +63 -0
- package/lib/report-view.js +433 -0
- package/lib/structure-tool.js +91 -0
- package/lib/structure.js +711 -0
- package/lib/tool.js +94 -0
- package/lib/types/baseline.d.ts +65 -0
- package/lib/types/compose-tool.d.ts +7 -0
- package/lib/types/concurrency-tool.d.ts +8 -0
- package/lib/types/concurrency.d.ts +37 -0
- package/lib/types/data-engine.d.ts +211 -0
- package/lib/types/data-tool.d.ts +10 -0
- package/lib/types/design-engine.d.ts +215 -0
- package/lib/types/design-tool.d.ts +11 -0
- package/lib/types/engine.d.ts +494 -0
- package/lib/types/export-tool.d.ts +7 -0
- package/lib/types/exporters.d.ts +37 -0
- package/lib/types/gate.d.ts +91 -0
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +45 -0
- package/lib/types/json-value.d.ts +21 -0
- package/lib/types/model-file.d.ts +7 -0
- package/lib/types/option-shape.d.ts +22 -0
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/report-view.d.ts +50 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +152 -0
- package/lib/types/tool.d.ts +11 -0
- package/lib/types/uml-communication.d.ts +109 -0
- package/lib/types/uml-composite.d.ts +107 -0
- package/lib/types/uml-interaction-overview.d.ts +91 -0
- package/lib/types/uml-profile.d.ts +112 -0
- package/lib/types/uml-structure.d.ts +53 -0
- package/lib/types/uml-syntax.d.ts +296 -0
- package/lib/types/uml-timing.d.ts +152 -0
- package/lib/types/uml-tool.d.ts +12 -0
- package/lib/types/uml-usecase.d.ts +58 -0
- package/lib/types/uml.d.ts +303 -0
- package/lib/uml-communication.js +353 -0
- package/lib/uml-composite.js +442 -0
- package/lib/uml-interaction-overview.js +277 -0
- package/lib/uml-profile.js +373 -0
- package/lib/uml-structure.js +306 -0
- package/lib/uml-syntax.js +545 -0
- package/lib/uml-timing.js +974 -0
- package/lib/uml-tool.js +213 -0
- package/lib/uml-usecase.js +532 -0
- package/lib/uml.js +2110 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +128 -6
- package/skills/model-everything/SKILL.md +450 -0
- package/skills/model-everything/references/dsh-model-schema.md +437 -0
- package/skills/model-everything/references/gap-routing-guide.md +42 -0
- package/skills/model-everything/references/hash-spec.md +152 -0
- package/skills/model-everything/references/logic-verification-guide.md +422 -0
- package/skills/model-everything-datamodel/SKILL.md +130 -0
- package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
- package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
- package/skills/model-everything-design/SKILL.md +115 -0
- package/skills/model-everything-interleaving/SKILL.md +63 -0
- package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
- package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
- package/skills/model-everything-report-reading/SKILL.md +137 -0
- package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
- package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
- package/skills/model-everything-structure/SKILL.md +96 -0
- package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
- package/skills/model-everything-uml/SKILL.md +60 -0
- package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
- package/src/baseline.ts +288 -0
- package/src/compose-tool.ts +82 -0
- package/src/concurrency-tool.ts +44 -0
- package/src/concurrency.ts +254 -0
- package/src/data-engine.ts +1208 -0
- package/src/data-tool.ts +91 -0
- package/src/design-engine.ts +1263 -0
- package/src/design-tool.ts +68 -0
- package/src/engine.ts +4564 -0
- package/src/export-tool.ts +81 -0
- package/src/exporters.ts +399 -0
- package/src/gate.ts +330 -0
- package/src/granularity.ts +312 -0
- package/src/index.ts +289 -0
- package/src/json-value.ts +20 -0
- package/src/model-file.ts +49 -0
- package/src/option-shape.ts +64 -0
- package/src/report-diff-tool.ts +67 -0
- package/src/report-view.ts +483 -0
- package/src/structure-tool.ts +93 -0
- package/src/structure.ts +786 -0
- package/src/tool.ts +103 -0
- package/src/uml-communication.ts +428 -0
- package/src/uml-composite.ts +520 -0
- package/src/uml-interaction-overview.ts +321 -0
- package/src/uml-profile.ts +439 -0
- package/src/uml-structure.ts +317 -0
- package/src/uml-syntax.ts +764 -0
- package/src/uml-timing.ts +1106 -0
- package/src/uml-tool.ts +230 -0
- package/src/uml-usecase.ts +566 -0
- package/src/uml.ts +2139 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The timing diagram module.
|
|
3
|
+
*
|
|
4
|
+
* A timing diagram is an interaction seen against a clock: which participants exist,
|
|
5
|
+
* what state each one is in over time, and what they send each other. It is not a
|
|
6
|
+
* machine and not a trace, so it has its own model — lifelines, the time points they
|
|
7
|
+
* change state at, the messages between them, and the duration constraints — its own
|
|
8
|
+
* renderer, and its own review.
|
|
9
|
+
*
|
|
10
|
+
* PlantUML is the only notation that draws one. Mermaid has no timing diagram, and the
|
|
11
|
+
* engine's capability table says so with a reason instead of drawing something else
|
|
12
|
+
* under the name.
|
|
13
|
+
*
|
|
14
|
+
* @module model-everything-uml-timing
|
|
15
|
+
*/
|
|
16
|
+
import { UmlError, directiveLine, readUmlDirectives, declaredFamily, umlSourceLines, } from './uml-syntax.js';
|
|
17
|
+
const LIFELINE_KINDS = ['robust', 'concise', 'binary', 'clock', 'analog', 'rectangle'];
|
|
18
|
+
/** Keywords that mark a lifeline declaration, and its drawing style. */
|
|
19
|
+
const LIFELINE_DECLARATION = /^(robust|concise|binary|clock|analog|rectangle)\b/i;
|
|
20
|
+
/**
|
|
21
|
+
* A moment on the axis: `50`, `@50`, `at 50`, `@+150`, `@-200`.
|
|
22
|
+
*
|
|
23
|
+
* The sign is matched with the token, because the two mean different things:
|
|
24
|
+
* `@100 <-> @+150` is "150 from here", while `@-200` is the absolute run-up before the
|
|
25
|
+
* moment the reader cares about. Reading either off the line instead of off the token is
|
|
26
|
+
* how one of them silently becomes the other.
|
|
27
|
+
*/
|
|
28
|
+
const TIME_TOKEN = /(?:@|at\s+)?\s*([+-]?)(\d+(?:\.\d+)?)/i;
|
|
29
|
+
/**
|
|
30
|
+
* The time a line carries, read from the marker rather than from the line.
|
|
31
|
+
*
|
|
32
|
+
* `TIME_TOKEN.exec` is anchored nowhere useful on a line like `S1 @300`: its first match
|
|
33
|
+
* is the `1` inside the lifeline id, so the state change would land at time 1. Only the
|
|
34
|
+
* digits that follow the `@` (or the `at` keyword) are a time.
|
|
35
|
+
*/
|
|
36
|
+
function timeAfterMarker(text) {
|
|
37
|
+
const marker = /(?:@|at\s+)\s*([+-]?)(\d+(?:\.\d+)?)/i.exec(text);
|
|
38
|
+
if (marker !== null)
|
|
39
|
+
return { sign: marker[1], digits: marker[2] };
|
|
40
|
+
// No marker: a bare number is the time (`@WB` blocks write `0 is idle`).
|
|
41
|
+
const bare = /^\s*([+-]?)(\d+(?:\.\d+)?)\s*$/.exec(text);
|
|
42
|
+
return bare === null ? undefined : { sign: bare[1], digits: bare[2] };
|
|
43
|
+
}
|
|
44
|
+
const TIME_POINT = /^(?:@|at\s+)\s*[+-]?\d+(?:\.\d+)?\s*$/i;
|
|
45
|
+
/** `WU @100`: the same marker, addressed to one lifeline. */
|
|
46
|
+
const LIFELINE_TIME = /^([A-Za-z_][A-Za-z0-9_]*)\s*(?:@|at\s+)\s*[+-]?\d+(?:\.\d+)?\s*$/i;
|
|
47
|
+
/**
|
|
48
|
+
* `S1 has 0,1,2,hello`: the order the states are drawn in, and their display names.
|
|
49
|
+
*
|
|
50
|
+
* It is a drawing instruction for one lifeline, not a state change, so it is named and
|
|
51
|
+
* discarded rather than read as a change to a state nobody declared.
|
|
52
|
+
*/
|
|
53
|
+
const STATE_ORDER = /^([A-Za-z_][A-Za-z0-9_]*)\s+has\s+(.+)$/i;
|
|
54
|
+
/**
|
|
55
|
+
* `WU is Waiting`, `WU@50 is Waiting`, `WU at 50 is Waiting`.
|
|
56
|
+
*
|
|
57
|
+
* The marker is captured off the id: reading it off the start of the line instead is how
|
|
58
|
+
* `WU at 0 is Idle` loses its time and lands at zero by accident rather than by saying so.
|
|
59
|
+
*/
|
|
60
|
+
const STATE_LINE = /^([A-Za-z_][A-Za-z0-9_]*)\s*((?:@|at\s+)\s*[+-]?\d+(?:\.\d+)?)?\s*(?:is|=|:)\s*(.+)$/i;
|
|
61
|
+
/** A right-hand state label: `Waiting is "waiting for a URL"`. */
|
|
62
|
+
const STATE_LABEL = /^(.+?)\s+is\s+(?:"([^"]*)"|'([^']*)')\s*$/;
|
|
63
|
+
/**
|
|
64
|
+
* `is Waiting : some note` and `is Waiting #LightCyan;line:Aqua`.
|
|
65
|
+
*
|
|
66
|
+
* Neither is part of the state: one is a comment printed beside the change, the other is
|
|
67
|
+
* a colour. They are stripped from the name, and a model that wants the colour back has
|
|
68
|
+
* to say so in its own terms rather than in the diagram's.
|
|
69
|
+
*
|
|
70
|
+
* The scan runs outside quotes only. `is "{1}#Gold"` is a state whose name contains a
|
|
71
|
+
* hash - an indeterminate value with a colour, which is how a timing diagram draws one -
|
|
72
|
+
* and stripping at that hash would silently rename the state.
|
|
73
|
+
*/
|
|
74
|
+
function stripStateAnnotations(text) {
|
|
75
|
+
let out = '';
|
|
76
|
+
let quote;
|
|
77
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
78
|
+
const char = text[index];
|
|
79
|
+
if (quote !== undefined) {
|
|
80
|
+
out += char;
|
|
81
|
+
if (char === quote)
|
|
82
|
+
quote = undefined;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === '"' || char === "'") {
|
|
86
|
+
quote = char;
|
|
87
|
+
out += char;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === '#')
|
|
91
|
+
break;
|
|
92
|
+
if (char === ':')
|
|
93
|
+
break;
|
|
94
|
+
out += char;
|
|
95
|
+
}
|
|
96
|
+
return out.trim();
|
|
97
|
+
}
|
|
98
|
+
/** `WU -> WB : URL`, optionally with its own time: `WU -> WB @50 : URL`. */
|
|
99
|
+
const MESSAGE_LINE = /^([A-Za-z_][A-Za-z0-9_]*)\s*->\s*([A-Za-z_][A-Za-z0-9_]*)\s*((?:@|at\s+)\s*[+-]?\d+(?:\.\d+)?)?\s*(?::\s*(.*))?$/;
|
|
100
|
+
/** `@100 <-> @+150 : {150 ms}`, and the `@100 <-> @200 : text` spelling. */
|
|
101
|
+
const CONSTRAINT_LINE = /^(?:@|at\s+)\s*[+-]?\d+(?:\.\d+)?\s*<->\s*((?:@|at\s+)\s*[+-]?\d+(?:\.\d+)?)\s*(?::\s*(.*))?$/i;
|
|
102
|
+
/** `scale 100 as 100 ms` and `scale 1 as 1 s`: the axis unit, written on the scale. */
|
|
103
|
+
const SCALE_UNIT = /^scale\s+\S+\s+as\s+(.+)$/i;
|
|
104
|
+
/** `scale 100` alone: a drawing width, not a unit. */
|
|
105
|
+
const SCALE_WIDTH = /^scale\b/i;
|
|
106
|
+
/** PlantUML timing constructs this model does not carry. They are named, never dropped. */
|
|
107
|
+
const FOREIGN_TIMING_CONSTRUCT = /^(analog|highlight|note|constraint|time-axis|date|now|show|hide|title|header|footer|legend|caption|skinparam|scale)\b/i;
|
|
108
|
+
const RESERVED_IDS = new Set(['is', 'at', 'as', 'and', 'robust', 'concise', 'binary', 'clock', 'analog', 'rectangle', 'note', 'highlight', 'scale', 'title', 'hide', 'show']);
|
|
109
|
+
function isLifelineKind(value) {
|
|
110
|
+
return typeof value === 'string' && LIFELINE_KINDS.includes(value);
|
|
111
|
+
}
|
|
112
|
+
function finiteNumber(value) {
|
|
113
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
114
|
+
}
|
|
115
|
+
function quote(text) {
|
|
116
|
+
return '"' + text.replace(/"/g, "'") + '"';
|
|
117
|
+
}
|
|
118
|
+
/** Strip one layer of quoting, and the trailing state-label quotation PlantUML allows. */
|
|
119
|
+
function unquote(text) {
|
|
120
|
+
const trimmed = text.trim();
|
|
121
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2)
|
|
122
|
+
return trimmed.slice(1, -1);
|
|
123
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
|
124
|
+
return trimmed.slice(1, -1);
|
|
125
|
+
return trimmed;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Validate a candidate model and normalize it.
|
|
129
|
+
*
|
|
130
|
+
* The contract other families keep: an undeclared key or a wrong type is a refusal with
|
|
131
|
+
* `UML_INPUT`, never a silently dropped field. A model that renders into a diagram that
|
|
132
|
+
* is not the model is worse than no diagram.
|
|
133
|
+
*/
|
|
134
|
+
export function validateTimingModel(input) {
|
|
135
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
136
|
+
throw new UmlError('a timing model must be an object with schemaVersion 1, lifelines, states, segments and messages', 'UML_INPUT');
|
|
137
|
+
}
|
|
138
|
+
const model = input;
|
|
139
|
+
if (model.schemaVersion !== 1)
|
|
140
|
+
throw new UmlError('a timing model must declare schemaVersion 1', 'UML_INPUT');
|
|
141
|
+
if (model.unit !== undefined && (typeof model.unit !== 'string' || model.unit.trim() === '')) {
|
|
142
|
+
throw new UmlError('`unit` must be a non-empty string when it is present', 'UML_INPUT');
|
|
143
|
+
}
|
|
144
|
+
const lifelines = model.lifelines ?? [];
|
|
145
|
+
const states = model.states ?? [];
|
|
146
|
+
const segments = model.segments ?? [];
|
|
147
|
+
const messages = model.messages ?? [];
|
|
148
|
+
const constraints = model.constraints ?? [];
|
|
149
|
+
for (const [name, value] of [['lifelines', lifelines], ['states', states], ['segments', segments], ['messages', messages], ['constraints', constraints]]) {
|
|
150
|
+
if (!Array.isArray(value))
|
|
151
|
+
throw new UmlError(name + ' must be an array', 'UML_INPUT');
|
|
152
|
+
}
|
|
153
|
+
const lifelineIds = new Set();
|
|
154
|
+
for (const lifeline of lifelines) {
|
|
155
|
+
if (lifeline === null || typeof lifeline !== 'object' || typeof lifeline.id !== 'string' || lifeline.id.trim() === '') {
|
|
156
|
+
throw new UmlError('every lifeline needs a non-empty string id', 'UML_INPUT');
|
|
157
|
+
}
|
|
158
|
+
if (lifelineIds.has(lifeline.id))
|
|
159
|
+
throw new UmlError('duplicate lifeline id ' + quote(lifeline.id), 'UML_INPUT');
|
|
160
|
+
lifelineIds.add(lifeline.id);
|
|
161
|
+
if (lifeline.kind !== undefined && !isLifelineKind(lifeline.kind)) {
|
|
162
|
+
throw new UmlError('unknown lifeline kind ' + quote(String(lifeline.kind)) + '; expected ' + LIFELINE_KINDS.join(' | '), 'UML_INPUT');
|
|
163
|
+
}
|
|
164
|
+
if (lifeline.period !== undefined && (!finiteNumber(lifeline.period) || lifeline.period <= 0)) {
|
|
165
|
+
throw new UmlError('lifeline ' + quote(lifeline.id) + ': period must be a positive number', 'UML_INPUT');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const stateIds = new Set();
|
|
169
|
+
for (const state of states) {
|
|
170
|
+
if (state === null || typeof state !== 'object' || typeof state.id !== 'string' || state.id.trim() === '') {
|
|
171
|
+
throw new UmlError('every state needs a non-empty string id', 'UML_INPUT');
|
|
172
|
+
}
|
|
173
|
+
if (stateIds.has(state.id))
|
|
174
|
+
throw new UmlError('duplicate state id ' + quote(state.id), 'UML_INPUT');
|
|
175
|
+
stateIds.add(state.id);
|
|
176
|
+
}
|
|
177
|
+
const undeclared = new Set();
|
|
178
|
+
const segmentsOut = [];
|
|
179
|
+
for (const segment of segments) {
|
|
180
|
+
if (segment === null || typeof segment !== 'object')
|
|
181
|
+
throw new UmlError('every segment must be an object', 'UML_INPUT');
|
|
182
|
+
if (typeof segment.lifeline !== 'string' || segment.lifeline.trim() === '')
|
|
183
|
+
throw new UmlError('every segment needs the lifeline it belongs to', 'UML_INPUT');
|
|
184
|
+
if (typeof segment.state !== 'string' || segment.state.trim() === '')
|
|
185
|
+
throw new UmlError('every segment needs the state it shows', 'UML_INPUT');
|
|
186
|
+
if (!finiteNumber(segment.from))
|
|
187
|
+
throw new UmlError('segment on ' + quote(segment.lifeline) + ': `from` must be a number', 'UML_INPUT');
|
|
188
|
+
if (!lifelineIds.has(segment.lifeline))
|
|
189
|
+
throw new UmlError('segment on ' + quote(segment.lifeline) + ' names a lifeline the model does not declare', 'UML_INPUT');
|
|
190
|
+
if (!stateIds.has(segment.state))
|
|
191
|
+
throw new UmlError('segment from ' + String(segment.from) + ' names the state ' + quote(segment.state) + ', which the model does not declare', 'UML_INPUT');
|
|
192
|
+
segmentsOut.push({
|
|
193
|
+
lifeline: segment.lifeline,
|
|
194
|
+
state: segment.state,
|
|
195
|
+
from: segment.from,
|
|
196
|
+
...(segment.atStart === undefined ? {} : { atStart: segment.atStart }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const messagesOut = [];
|
|
200
|
+
for (const message of messages) {
|
|
201
|
+
if (message === null || typeof message !== 'object')
|
|
202
|
+
throw new UmlError('every message must be an object', 'UML_INPUT');
|
|
203
|
+
for (const side of ['from', 'to']) {
|
|
204
|
+
const id = message[side];
|
|
205
|
+
if (typeof id !== 'string' || id.trim() === '')
|
|
206
|
+
throw new UmlError('every message needs a non-empty `' + side + '` lifeline id', 'UML_INPUT');
|
|
207
|
+
if (!lifelineIds.has(id))
|
|
208
|
+
undeclared.add(id);
|
|
209
|
+
}
|
|
210
|
+
if (!finiteNumber(message.time))
|
|
211
|
+
throw new UmlError('message ' + quote(message.from + ' -> ' + message.to) + ': `time` must be a number', 'UML_INPUT');
|
|
212
|
+
messagesOut.push({
|
|
213
|
+
from: message.from,
|
|
214
|
+
to: message.to,
|
|
215
|
+
time: message.time,
|
|
216
|
+
...(message.label === undefined ? {} : { label: message.label }),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const constraintsOut = [];
|
|
220
|
+
for (const constraint of constraints) {
|
|
221
|
+
if (constraint === null || typeof constraint !== 'object')
|
|
222
|
+
throw new UmlError('every constraint must be an object', 'UML_INPUT');
|
|
223
|
+
if (!finiteNumber(constraint.fromTime) || !finiteNumber(constraint.toTime)) {
|
|
224
|
+
throw new UmlError('every constraint needs numeric `fromTime` and `toTime`', 'UML_INPUT');
|
|
225
|
+
}
|
|
226
|
+
constraintsOut.push({
|
|
227
|
+
fromTime: constraint.fromTime,
|
|
228
|
+
toTime: constraint.toTime,
|
|
229
|
+
...(constraint.label === undefined ? {} : { label: constraint.label }),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// One canonical shape, so a review does not report the module's own conventions as drift.
|
|
233
|
+
// `robust` is how a lifeline is drawn when the model does not say, and a change at the
|
|
234
|
+
// axis origin is the lifeline's first change by definition - both are what the parser
|
|
235
|
+
// produces, so stating them here as well makes the supplied model and the parsed one the
|
|
236
|
+
// same object for every diagram either can draw.
|
|
237
|
+
const origin = axisOrigin({ schemaVersion: 1, lifelines, states, segments, messages, constraints });
|
|
238
|
+
return {
|
|
239
|
+
schemaVersion: 1,
|
|
240
|
+
...(model.unit === undefined ? {} : { unit: model.unit }),
|
|
241
|
+
lifelines: lifelines.map((lifeline) => ({
|
|
242
|
+
id: lifeline.id,
|
|
243
|
+
// A label is a name shown instead of the id, so one equal to the id says nothing the id
|
|
244
|
+
// does not. The parser applies the same rule, which is what lets a supplied model and
|
|
245
|
+
// the model its diagram reads back as be the same object.
|
|
246
|
+
...(lifeline.label === undefined || lifeline.label === lifeline.id ? {} : { label: lifeline.label }),
|
|
247
|
+
kind: lifeline.kind ?? 'robust',
|
|
248
|
+
...(lifeline.period === undefined ? {} : { period: lifeline.period }),
|
|
249
|
+
})),
|
|
250
|
+
states: states.map((state) => ({
|
|
251
|
+
id: state.id,
|
|
252
|
+
...(state.label === undefined || state.label === state.id ? {} : { label: state.label }),
|
|
253
|
+
})),
|
|
254
|
+
segments: segmentsOut.map((segment) => (segment.from === origin && segment.atStart !== true ? { ...segment, atStart: true } : segment)),
|
|
255
|
+
messages: messagesOut,
|
|
256
|
+
constraints: constraintsOut,
|
|
257
|
+
...(undeclared.size === 0 ? {} : { undeclared: [...undeclared].sort() }),
|
|
258
|
+
...(model.narrative === undefined ? {} : { narrative: model.narrative }),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The state a change names, in model terms.
|
|
263
|
+
*
|
|
264
|
+
* A diagram draws the reader's label, so `timingState Waiting = "waiting for a URL"`
|
|
265
|
+
* says which id that label belongs to. Without the directive the drawn name *is* the id,
|
|
266
|
+
* which is what a hand-written diagram means.
|
|
267
|
+
*/
|
|
268
|
+
function resolveState(states, name) {
|
|
269
|
+
for (const state of states) {
|
|
270
|
+
if (state.id === name)
|
|
271
|
+
return { id: state.id, ...(state.label === undefined ? {} : { label: state.label }) };
|
|
272
|
+
}
|
|
273
|
+
for (const state of states) {
|
|
274
|
+
if (state.label === name)
|
|
275
|
+
return { id: state.id, ...(state.label === undefined ? {} : { label: state.label }) };
|
|
276
|
+
}
|
|
277
|
+
return { id: name };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Where the axis starts: the earliest moment any element is dated at.
|
|
281
|
+
*
|
|
282
|
+
* A lifeline's first state change at that moment begins the bar, which is why a diagram
|
|
283
|
+
* cannot show a lifeline whose earliest change is later without drawing its earlier
|
|
284
|
+
* stretch as something.
|
|
285
|
+
*/
|
|
286
|
+
function axisOrigin(model) {
|
|
287
|
+
const times = [
|
|
288
|
+
...model.segments.map((segment) => segment.from),
|
|
289
|
+
...model.messages.map((message) => message.time),
|
|
290
|
+
...model.constraints.flatMap((constraint) => [constraint.fromTime, constraint.toTime]),
|
|
291
|
+
];
|
|
292
|
+
return times.length === 0 ? 0 : Math.min(...times);
|
|
293
|
+
}
|
|
294
|
+
function labelOfLifeline(lifeline) {
|
|
295
|
+
return lifeline.label ?? lifeline.id;
|
|
296
|
+
}
|
|
297
|
+
function stateById(model, id) {
|
|
298
|
+
return model.states.find((state) => state.id === id);
|
|
299
|
+
}
|
|
300
|
+
/** The state name PlantUML draws: the reader's label when there is one, the id otherwise. */
|
|
301
|
+
function stateName(model, id) {
|
|
302
|
+
const state = stateById(model, id);
|
|
303
|
+
const label = state?.label ?? id;
|
|
304
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(label) ? label : quote(label);
|
|
305
|
+
}
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Rendering
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
/** The header, then every fact the PlantUML timing syntax cannot spell, as directives. */
|
|
310
|
+
function renderDirectives(model, lines, declaredStart) {
|
|
311
|
+
lines.push(directiveLine('plantuml', 'uml v1 notation=plantuml family=timing'));
|
|
312
|
+
if (model.unit !== undefined)
|
|
313
|
+
lines.push(directiveLine('plantuml', 'timingUnit ' + phrase(model.unit)));
|
|
314
|
+
for (const lifeline of model.lifelines) {
|
|
315
|
+
lines.push(directiveLine('plantuml', 'timingLifeline ' + lifeline.id + (lifeline.period === undefined ? '' : ' period=' + String(lifeline.period))));
|
|
316
|
+
}
|
|
317
|
+
for (const state of model.states) {
|
|
318
|
+
// Every state, whether or not it carries a label. The parser keeps a drawn name in the
|
|
319
|
+
// labels map rather than on the model, so a labelled state re-rendered from what the
|
|
320
|
+
// parser produced has no `label` to emit; writing the directive unconditionally - with
|
|
321
|
+
// the id as the name when there is no label - makes the second render byte-identical to
|
|
322
|
+
// the first. The list of declared states is part of the model either way.
|
|
323
|
+
lines.push(directiveLine('plantuml', 'timingState ' + state.id + ' = ' + phrase(state.label ?? state.id)));
|
|
324
|
+
}
|
|
325
|
+
const written = new Set();
|
|
326
|
+
for (const segment of model.segments) {
|
|
327
|
+
const key = segment.lifeline + ' ' + String(segment.from);
|
|
328
|
+
const explicit = segment.atStart === true || declaredStart.has(key);
|
|
329
|
+
if (!explicit || written.has(key))
|
|
330
|
+
continue;
|
|
331
|
+
written.add(key);
|
|
332
|
+
lines.push(directiveLine('plantuml', 'timingAtStart ' + key));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** A directive value that may contain spaces, written back as one quoted field. */
|
|
336
|
+
function phrase(text) {
|
|
337
|
+
return '"' + text.replace(/"/g, "'") + '"';
|
|
338
|
+
}
|
|
339
|
+
function readPhrase(text) {
|
|
340
|
+
const trimmed = text.trim();
|
|
341
|
+
const quoted = /^"(.*)"$/.exec(trimmed);
|
|
342
|
+
return quoted === null ? trimmed : quoted[1];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Every dated element, in non-decreasing time order.
|
|
346
|
+
*
|
|
347
|
+
* PlantUML reads `is`/`at`/message lines in the order they appear and takes a decreasing
|
|
348
|
+
* time as an error, so the renderer sorts rather than trusting the model's array order.
|
|
349
|
+
* The index keeps a stable order between elements that share a time.
|
|
350
|
+
*/
|
|
351
|
+
function datedItems(model) {
|
|
352
|
+
const items = [];
|
|
353
|
+
/** The axis origin: the earliest moment any element of this diagram names. */
|
|
354
|
+
const origin = axisOrigin(model);
|
|
355
|
+
/** Times already marked, so two elements at one moment share a single `@t` line. */
|
|
356
|
+
const marked = new Set();
|
|
357
|
+
let index = 0;
|
|
358
|
+
const next = () => { index += 1; return index; };
|
|
359
|
+
const markLine = (lifeline, time) => {
|
|
360
|
+
const key = lifeline + '@' + String(time);
|
|
361
|
+
if (marked.has(key))
|
|
362
|
+
return;
|
|
363
|
+
marked.add(key);
|
|
364
|
+
items.push({ time, index: next(), line: lifeline + ' @' + String(time) });
|
|
365
|
+
};
|
|
366
|
+
for (const segment of model.segments) {
|
|
367
|
+
const name = stateName(model, segment.state);
|
|
368
|
+
// One spelling per meaning. A change at the axis origin *is* the lifeline's first
|
|
369
|
+
// change: there is no earlier moment for a bar to have started from, so `at <origin> is`
|
|
370
|
+
// says the same thing as a bare `@<origin>` marker followed by `is`. Drawing the second
|
|
371
|
+
// spelling when a model left `atStart` implicit made render -> parse -> render disagree:
|
|
372
|
+
// the parser records the origin change as `atStart`, and the second render then wrote
|
|
373
|
+
// the first spelling. The model may say either; the drawing says one.
|
|
374
|
+
if (segment.atStart === true || segment.from === origin) {
|
|
375
|
+
items.push({ time: segment.from, index: next(), line: segment.lifeline + ' at ' + String(segment.from) + ' is ' + name });
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
markLine(segment.lifeline, segment.from);
|
|
379
|
+
items.push({ time: segment.from, index: next(), line: segment.lifeline + ' is ' + name });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const message of model.messages) {
|
|
383
|
+
items.push({ time: message.time, index: next(), line: message.from + ' -> ' + message.to + ' @' + String(message.time) + (message.label === undefined ? '' : ' : ' + message.label) });
|
|
384
|
+
}
|
|
385
|
+
for (const constraint of model.constraints) {
|
|
386
|
+
items.push({ time: constraint.fromTime, index: next(), line: '@' + String(constraint.fromTime) + ' <-> @' + String(constraint.toTime) + (constraint.label === undefined ? '' : ' : ' + constraint.label) });
|
|
387
|
+
}
|
|
388
|
+
return items.sort((left, right) => left.time - right.time || left.index - right.index);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Render a timing model as PlantUML.
|
|
392
|
+
*
|
|
393
|
+
* @param input - candidate `TimingModelV1`.
|
|
394
|
+
* @param notation - only `plantuml` draws a timing diagram; another notation is refused
|
|
395
|
+
* by the capability table before reaching here.
|
|
396
|
+
*/
|
|
397
|
+
export function renderTiming(input, notation) {
|
|
398
|
+
const model = validateTimingModel(input);
|
|
399
|
+
const warnings = [...(model.undeclared ?? []).map((id) => 'UML_RENDER_UNDECLARED_REFERENCE: the model refers to ' + quote(id) + ', which no lifeline declares; PlantUML will draw a bare timeline for it.')];
|
|
400
|
+
if (notation !== 'plantuml') {
|
|
401
|
+
throw new UmlError('a timing diagram can only be drawn in plantuml: mermaid has no timing diagram. Render it with notation "plantuml".', 'UML_VIEW_UNREPRESENTABLE');
|
|
402
|
+
}
|
|
403
|
+
const origin = axisOrigin(model);
|
|
404
|
+
// A lifeline's first change is what the notation draws its bar from. When that is not
|
|
405
|
+
// the axis origin, the drawn diagram carries a stretch the model never declared — and
|
|
406
|
+
// `atStart` is exactly how a model says "that stretch is real, I meant it".
|
|
407
|
+
const firstChange = new Map();
|
|
408
|
+
for (const segment of model.segments) {
|
|
409
|
+
const seen = firstChange.get(segment.lifeline);
|
|
410
|
+
if (seen === undefined || segment.from < seen)
|
|
411
|
+
firstChange.set(segment.lifeline, segment.from);
|
|
412
|
+
}
|
|
413
|
+
const declaredStart = new Set();
|
|
414
|
+
for (const segment of model.segments) {
|
|
415
|
+
if (segment.from !== firstChange.get(segment.lifeline))
|
|
416
|
+
continue;
|
|
417
|
+
if (segment.atStart !== true) {
|
|
418
|
+
if (segment.from !== origin) {
|
|
419
|
+
warnings.push('UML_RENDER_IMPLICIT_START: ' + quote(segment.lifeline) + ' first changes state at ' + String(segment.from) + ', after the axis starts at ' + String(origin) + ', so the notation draws ' + quote(segment.state) + ' from the start of the axis. Set atStart to say the lifeline begins there.');
|
|
420
|
+
}
|
|
421
|
+
// The drawn diagram still has to read back as this model, so the start the notation
|
|
422
|
+
// implies is written down rather than left to be guessed by the next parse.
|
|
423
|
+
declaredStart.add(segment.lifeline + ' ' + String(segment.from));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const lines = ['@startuml'];
|
|
427
|
+
renderDirectives(model, lines, declaredStart);
|
|
428
|
+
for (const lifeline of model.lifelines) {
|
|
429
|
+
const kind = lifeline.kind ?? 'robust';
|
|
430
|
+
const label = labelOfLifeline(lifeline);
|
|
431
|
+
const period = lifeline.kind === 'clock' && lifeline.period !== undefined ? ' with period ' + String(lifeline.period) : '';
|
|
432
|
+
lines.push(kind + ' ' + quote(label) + ' as ' + lifeline.id + period);
|
|
433
|
+
}
|
|
434
|
+
const ordered = datedItems(model);
|
|
435
|
+
if (wasReordered(ordered)) {
|
|
436
|
+
warnings.push('UML_RENDER_TIME_ORDER: the model lists dated elements out of chronological order; the diagram is written in time order, because that is the only order the notation reads.');
|
|
437
|
+
}
|
|
438
|
+
for (const item of ordered)
|
|
439
|
+
lines.push(item.line);
|
|
440
|
+
lines.push('@enduml');
|
|
441
|
+
return { primary: lines.join('\n') + '\n', warnings };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Whether sorting changed the model's own order of dated elements.
|
|
445
|
+
*
|
|
446
|
+
* The renderer sorts because the notation only reads a non-decreasing axis. That is a
|
|
447
|
+
* silent rewrite of what the caller wrote down, so it is reported. The item index is the
|
|
448
|
+
* model's order, so "already ordered" is exactly "indices still ascend".
|
|
449
|
+
*/
|
|
450
|
+
function wasReordered(ordered) {
|
|
451
|
+
if (ordered.length < 2)
|
|
452
|
+
return false;
|
|
453
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
454
|
+
if (ordered[index].index < ordered[index - 1].index)
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* `robust "Browser" as WB`, `clock "CLK" as CLK with period 10`, `robust WB`.
|
|
461
|
+
*
|
|
462
|
+
* The documented clock form is `with period`, and the bare `period` is accepted too
|
|
463
|
+
* because PlantUML accepts it. `pulse` and `offset` are named in `discarded` rather than
|
|
464
|
+
* dropped: they shape the wave a clock is drawn as, and a reader who wrote them down
|
|
465
|
+
* deserves to be told the model does not carry them.
|
|
466
|
+
*/
|
|
467
|
+
function readLifeline(line) {
|
|
468
|
+
const match = /^(robust|concise|binary|clock|analog|rectangle)\b\s*(.*)$/i.exec(line);
|
|
469
|
+
if (match === null)
|
|
470
|
+
return undefined;
|
|
471
|
+
const kind = match[1].toLowerCase();
|
|
472
|
+
const rest = match[2].trim().replace(/[;{]\s*$/, '').trim();
|
|
473
|
+
if (rest === '')
|
|
474
|
+
return undefined;
|
|
475
|
+
const discarded = [];
|
|
476
|
+
const periodMatch = /\s+(?:with\s+)?period\s+(\d+(?:\.\d+)?)/i.exec(rest);
|
|
477
|
+
const period = periodMatch === null ? undefined : Number(periodMatch[1]);
|
|
478
|
+
const withoutPeriod = periodMatch === null ? rest : rest.slice(0, periodMatch.index).trim();
|
|
479
|
+
const tail = periodMatch === null ? '' : rest.slice(periodMatch.index + periodMatch[0].length).trim();
|
|
480
|
+
if (tail !== '') {
|
|
481
|
+
const pulse = /\bpulse\s+(\d+(?:\.\d+)?)/i.exec(tail);
|
|
482
|
+
const offset = /\boffset\s+(\d+(?:\.\d+)?)/i.exec(tail);
|
|
483
|
+
if (pulse !== null)
|
|
484
|
+
discarded.push('pulse ' + pulse[1]);
|
|
485
|
+
if (offset !== null)
|
|
486
|
+
discarded.push('offset ' + offset[1]);
|
|
487
|
+
const leftover = tail.replace(/\b(?:pulse|offset)\s+\d+(?:\.\d+)?/gi, '').trim();
|
|
488
|
+
if (leftover !== '')
|
|
489
|
+
discarded.push(leftover);
|
|
490
|
+
}
|
|
491
|
+
const aliased = /^(?:"([^"]*)"|'([^']*)'|(\S+))\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/.exec(withoutPeriod);
|
|
492
|
+
if (aliased !== null) {
|
|
493
|
+
const label = aliased[1] ?? aliased[2] ?? aliased[3];
|
|
494
|
+
return { id: aliased[4], label, kind, ...(period === undefined ? {} : { period }), ...(discarded.length === 0 ? {} : { discarded }) };
|
|
495
|
+
}
|
|
496
|
+
const quoted = /^"([^"]*)"$/.exec(withoutPeriod);
|
|
497
|
+
if (quoted !== null)
|
|
498
|
+
return { id: quoted[1].trim(), kind, ...(period === undefined ? {} : { period }), ...(discarded.length === 0 ? {} : { discarded }) };
|
|
499
|
+
return { id: withoutPeriod, kind, ...(period === undefined ? {} : { period }), ...(discarded.length === 0 ? {} : { discarded }) };
|
|
500
|
+
}
|
|
501
|
+
/** `clock clk with period 1`: a lifeline declared without the kind keyword. */
|
|
502
|
+
const BARE_CLOCK = /^(?:clock\s+)?([A-Za-z_][A-Za-z0-9_]*)\s+with\s+period\s+(\d+(?:\.\d+)?)\s*$/i;
|
|
503
|
+
function readTimingDirectives(values) {
|
|
504
|
+
const unit = values.get('timingUnit')?.[0];
|
|
505
|
+
const periods = new Map();
|
|
506
|
+
for (const entry of values.get('timingLifeline') ?? []) {
|
|
507
|
+
const match = /^(\S+)\s+period=(\d+(?:\.\d+)?)$/.exec(entry.trim());
|
|
508
|
+
if (match !== null)
|
|
509
|
+
periods.set(match[1], Number(match[2]));
|
|
510
|
+
}
|
|
511
|
+
const stateLabels = new Map();
|
|
512
|
+
const drawnToId = new Map();
|
|
513
|
+
for (const entry of values.get('timingState') ?? []) {
|
|
514
|
+
const separator = entry.indexOf(' = ');
|
|
515
|
+
if (separator <= 0)
|
|
516
|
+
continue;
|
|
517
|
+
const id = entry.slice(0, separator).trim();
|
|
518
|
+
const label = readPhrase(entry.slice(separator + 3));
|
|
519
|
+
stateLabels.set(id, label);
|
|
520
|
+
if (label !== '' && !drawnToId.has(label))
|
|
521
|
+
drawnToId.set(label, id);
|
|
522
|
+
}
|
|
523
|
+
const atStart = new Set();
|
|
524
|
+
for (const entry of values.get('timingAtStart') ?? []) {
|
|
525
|
+
const match = /^(\S+)\s+(-?\d+(?:\.\d+)?)$/.exec(entry.trim());
|
|
526
|
+
if (match !== null)
|
|
527
|
+
atStart.add(match[1] + '@' + match[2]);
|
|
528
|
+
}
|
|
529
|
+
return { ...(unit === undefined ? {} : { unit: readPhrase(unit) }), periods, stateLabels, drawnToId, atStart };
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Read PlantUML timing text back into a model.
|
|
533
|
+
*
|
|
534
|
+
* Two sources are read together, and the diagram wins where they disagree: the diagram
|
|
535
|
+
* is what a reader sees. The directives carry only what the syntax cannot spell — the
|
|
536
|
+
* exact state ids, the explicit end of a segment, the unit — so a hand-written diagram
|
|
537
|
+
* with no directives still parses into a complete model.
|
|
538
|
+
*/
|
|
539
|
+
export function parseTiming(text, notation) {
|
|
540
|
+
if (notation !== 'plantuml') {
|
|
541
|
+
throw new UmlError('a timing diagram can only be read out of plantuml: mermaid has no timing diagram to parse.', 'UML_VIEW_UNREPRESENTABLE');
|
|
542
|
+
}
|
|
543
|
+
const directives = readUmlDirectives(text.split(/\r?\n/), 'plantuml');
|
|
544
|
+
const hints = readTimingDirectives(directives.values);
|
|
545
|
+
const warnings = [];
|
|
546
|
+
const discardedConstructs = [];
|
|
547
|
+
const labels = {};
|
|
548
|
+
const lifelines = [];
|
|
549
|
+
const lifelineIds = new Set();
|
|
550
|
+
// States come from the diagram, in the order the lifelines enter them: a state the
|
|
551
|
+
// diagram never shows has nothing to order against. `timingState` directives are read
|
|
552
|
+
// as the id a drawn label belongs to (`resolveState`), not as declarations of their
|
|
553
|
+
// own, so a state that exists only in a directive stays out of the model.
|
|
554
|
+
const states = [];
|
|
555
|
+
const stateIds = new Set();
|
|
556
|
+
const segments = [];
|
|
557
|
+
const pending = [];
|
|
558
|
+
const messages = [];
|
|
559
|
+
const constraints = [];
|
|
560
|
+
const undeclared = new Set();
|
|
561
|
+
let currentTime;
|
|
562
|
+
let lastTime;
|
|
563
|
+
let diagramUnit;
|
|
564
|
+
const declaredLifeline = (id) => lifelineIds.has(id);
|
|
565
|
+
/** Resolve a time token: the `+` says "relative to the last marker" (`@+100`). */
|
|
566
|
+
const resolveToken = (token, base) => {
|
|
567
|
+
if (token === undefined)
|
|
568
|
+
return undefined;
|
|
569
|
+
const value = Number(token.sign + token.digits);
|
|
570
|
+
if (!Number.isFinite(value))
|
|
571
|
+
return undefined;
|
|
572
|
+
return token.sign === '+' && base !== undefined ? base + value : value;
|
|
573
|
+
};
|
|
574
|
+
const mark = (value, lineNumber) => {
|
|
575
|
+
if (lastTime !== undefined && value < lastTime) {
|
|
576
|
+
warnings.push('UML_PARSE_TIME_ORDER at line ' + String(lineNumber) + ': time ' + String(value) + ' comes after ' + String(lastTime) + '; PlantUML draws a non-decreasing axis.');
|
|
577
|
+
}
|
|
578
|
+
lastTime = value;
|
|
579
|
+
currentTime = value;
|
|
580
|
+
};
|
|
581
|
+
for (const source of umlSourceLines(text, 'plantuml')) {
|
|
582
|
+
const line = source.text;
|
|
583
|
+
const lifeline = readLifeline(line);
|
|
584
|
+
if (lifeline !== undefined) {
|
|
585
|
+
if (lifelineIds.has(lifeline.id)) {
|
|
586
|
+
warnings.push('UML_PARSE_DUPLICATE_LIFELINE at line ' + String(source.number) + ': ' + quote(lifeline.id) + ' is declared twice; the first declaration wins.');
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
lifelineIds.add(lifeline.id);
|
|
590
|
+
const period = lifeline.period ?? hints.periods.get(lifeline.id);
|
|
591
|
+
lifelines.push({
|
|
592
|
+
id: lifeline.id,
|
|
593
|
+
// A label is what a reader sees *instead of* the id. When the declaration drew the id
|
|
594
|
+
// itself the two are the same name, and recording it as a label would put a field on
|
|
595
|
+
// the parsed model that the model it was drawn from never had. A diagram that means
|
|
596
|
+
// to name the lifeline writes the name in the declaration, and that comes back.
|
|
597
|
+
...(lifeline.label === undefined || lifeline.label === lifeline.id ? {} : { label: lifeline.label }),
|
|
598
|
+
...(lifeline.kind === undefined ? {} : { kind: lifeline.kind }),
|
|
599
|
+
...(period === undefined ? {} : { period }),
|
|
600
|
+
});
|
|
601
|
+
if (lifeline.label !== undefined)
|
|
602
|
+
labels[lifeline.id] = lifeline.label;
|
|
603
|
+
// What the declaration carried and the model does not: named, with its line.
|
|
604
|
+
for (const detail of lifeline.discarded ?? []) {
|
|
605
|
+
discardedConstructs.push({ construct: detail.split(' ')[0].toLowerCase(), line: source.number, text: line });
|
|
606
|
+
}
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const bareClock = BARE_CLOCK.exec(line);
|
|
610
|
+
if (bareClock !== null && readLifeline(line) === undefined) {
|
|
611
|
+
const id = bareClock[1];
|
|
612
|
+
if (!lifelineIds.has(id)) {
|
|
613
|
+
lifelineIds.add(id);
|
|
614
|
+
const period = Number(bareClock[2]);
|
|
615
|
+
lifelines.push({ id, kind: 'clock', ...(Number.isFinite(period) ? { period } : {}) });
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const scaleUnit = SCALE_UNIT.exec(line);
|
|
620
|
+
if (scaleUnit !== null) {
|
|
621
|
+
diagramUnit = scaleUnit[1].trim();
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (SCALE_WIDTH.test(line))
|
|
625
|
+
continue;
|
|
626
|
+
const point = TIME_POINT.exec(line);
|
|
627
|
+
if (point !== null) {
|
|
628
|
+
const value = resolveToken(timeAfterMarker(line), currentTime);
|
|
629
|
+
if (value !== undefined)
|
|
630
|
+
mark(value, source.number);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const constraint = CONSTRAINT_LINE.exec(line);
|
|
634
|
+
if (constraint !== null) {
|
|
635
|
+
const fromTime = resolveToken(timeAfterMarker(line), currentTime);
|
|
636
|
+
if (fromTime === undefined)
|
|
637
|
+
continue;
|
|
638
|
+
// The second endpoint is relative to the first, not to the last marker:
|
|
639
|
+
// `@100 <-> @+150` is 150 after 100.
|
|
640
|
+
const toTime = resolveToken(timeAfterMarker(constraint[1]), fromTime);
|
|
641
|
+
if (toTime === undefined)
|
|
642
|
+
continue;
|
|
643
|
+
constraints.push({
|
|
644
|
+
fromTime,
|
|
645
|
+
toTime,
|
|
646
|
+
...(constraint[2] === undefined || constraint[2].trim() === '' ? {} : { label: constraint[2].trim() }),
|
|
647
|
+
});
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const message = MESSAGE_LINE.exec(line);
|
|
651
|
+
if (message !== null) {
|
|
652
|
+
const inline = message[3] === undefined ? undefined : resolveToken(timeAfterMarker(message[3]), currentTime);
|
|
653
|
+
if (inline === undefined && currentTime === undefined) {
|
|
654
|
+
warnings.push('UML_PARSE_MESSAGE_WITHOUT_TIME at line ' + String(source.number) + ': ' + quote(line) + ' carries no time and no preceding @ marker was read; the message is drawn at time 0.');
|
|
655
|
+
}
|
|
656
|
+
const at = inline ?? currentTime ?? 0;
|
|
657
|
+
if (!declaredLifeline(message[1]))
|
|
658
|
+
undeclared.add(message[1]);
|
|
659
|
+
if (!declaredLifeline(message[2]))
|
|
660
|
+
undeclared.add(message[2]);
|
|
661
|
+
messages.push({ from: message[1], to: message[2], time: at, ...(message[4] === undefined || message[4].trim() === '' ? {} : { label: message[4].trim() }) });
|
|
662
|
+
if (inline !== undefined)
|
|
663
|
+
mark(inline, source.number);
|
|
664
|
+
else
|
|
665
|
+
currentTime = at;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
// `S1 has 0,1,2,hello` would also match the state form below (with `has` as the
|
|
669
|
+
// separator), so it is ruled out first: it is a drawing order, not a state change.
|
|
670
|
+
const stateOrder = STATE_ORDER.exec(line);
|
|
671
|
+
if (stateOrder !== null) {
|
|
672
|
+
discardedConstructs.push({ construct: 'has', line: source.number, text: line });
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const stateLine = STATE_LINE.exec(line);
|
|
676
|
+
if (stateLine !== null) {
|
|
677
|
+
const lifelineId = stateLine[1];
|
|
678
|
+
if (RESERVED_IDS.has(lifelineId.toLowerCase())) {
|
|
679
|
+
discardedConstructs.push({ construct: lifelineId.toLowerCase(), line: source.number, text: line });
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const marker = stateLine[2];
|
|
683
|
+
const inline = marker === undefined ? undefined : resolveToken(timeAfterMarker(marker), currentTime);
|
|
684
|
+
if (inline === undefined && currentTime === undefined) {
|
|
685
|
+
warnings.push('UML_PARSE_STATE_WITHOUT_TIME at line ' + String(source.number) + ': ' + quote(line) + ' carries no time; it is read as time 0.');
|
|
686
|
+
}
|
|
687
|
+
const time = inline ?? currentTime ?? 0;
|
|
688
|
+
if (!declaredLifeline(lifelineId))
|
|
689
|
+
undeclared.add(lifelineId);
|
|
690
|
+
const name = unquote(stripStateAnnotations(stateLine[3]));
|
|
691
|
+
if (name === '')
|
|
692
|
+
continue;
|
|
693
|
+
// The directive comes first: it says which id the drawn name belongs to, and the
|
|
694
|
+
// drawn name is often the label rather than the id (`WU is "waiting for a URL"`).
|
|
695
|
+
const directedId = hints.drawnToId.get(name);
|
|
696
|
+
const labelMatch = STATE_LABEL.exec(name);
|
|
697
|
+
const drawn = directedId ?? (labelMatch === null ? name : labelMatch[1].trim());
|
|
698
|
+
const stateLabel = directedId === undefined
|
|
699
|
+
? (labelMatch === null ? undefined : (labelMatch[2] ?? labelMatch[3]))
|
|
700
|
+
: hints.stateLabels.get(directedId);
|
|
701
|
+
const resolved = resolveState(states, drawn);
|
|
702
|
+
const stateId = resolved.id;
|
|
703
|
+
if (!stateIds.has(stateId)) {
|
|
704
|
+
stateIds.add(stateId);
|
|
705
|
+
const label = resolved.label ?? stateLabel;
|
|
706
|
+
// As with a lifeline: a label is a name shown instead of the id. `timingState Waiting
|
|
707
|
+
// = "waiting for a URL"` names the state, and that comes back; a diagram that drew
|
|
708
|
+
// the id plainly says nothing extra, so nothing extra is recorded.
|
|
709
|
+
states.push({ id: stateId, ...(label === undefined || label === stateId ? {} : { label }) });
|
|
710
|
+
}
|
|
711
|
+
pending.push({ lifeline: lifelineId, time, name: stateId, line: source.number, atStart: marker !== undefined });
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
// `WU @100` last: with no state name it is only a marker, and only after the state
|
|
715
|
+
// form has been ruled out can it be read as one.
|
|
716
|
+
const lifelineTime = LIFELINE_TIME.exec(line);
|
|
717
|
+
if (lifelineTime !== null) {
|
|
718
|
+
const value = resolveToken(timeAfterMarker(line), currentTime);
|
|
719
|
+
if (value !== undefined)
|
|
720
|
+
mark(value, source.number);
|
|
721
|
+
if (!declaredLifeline(lifelineTime[1]))
|
|
722
|
+
undeclared.add(lifelineTime[1]);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (FOREIGN_TIMING_CONSTRUCT.test(line)) {
|
|
726
|
+
const keyword = /^[A-Za-z-]+/.exec(line)?.[0] ?? line;
|
|
727
|
+
discardedConstructs.push({ construct: keyword.toLowerCase(), line: source.number, text: line });
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
discardedConstructs.push({ construct: 'unread line', line: source.number, text: line });
|
|
731
|
+
}
|
|
732
|
+
// A state runs until the next state change on the same lifeline. The diagram says
|
|
733
|
+
// nothing about where the last one ends, so neither does the model: the field that
|
|
734
|
+
// used to carry it was dropped when the round trip kept losing it.
|
|
735
|
+
//
|
|
736
|
+
// The change at the axis origin begins its lifeline, so it is `atStart` — that is what
|
|
737
|
+
// a renderer draws and what a parser reads back, and marking it here is what keeps the
|
|
738
|
+
// two agreeing instead of each guessing.
|
|
739
|
+
const origin = Math.min(...pending.map((entry) => entry.time ?? 0), ...messages.map((message) => message.time), ...constraints.flatMap((constraint) => [constraint.fromTime, constraint.toTime]));
|
|
740
|
+
const byLifeline = new Map();
|
|
741
|
+
for (const entry of pending) {
|
|
742
|
+
const list = byLifeline.get(entry.lifeline);
|
|
743
|
+
if (list === undefined)
|
|
744
|
+
byLifeline.set(entry.lifeline, [entry]);
|
|
745
|
+
else
|
|
746
|
+
list.push(entry);
|
|
747
|
+
}
|
|
748
|
+
for (const [lifelineId, entries] of byLifeline) {
|
|
749
|
+
// Two changes at one moment on one lifeline are one change: the later line wins,
|
|
750
|
+
// which is how PlantUML reads them too.
|
|
751
|
+
const collapsed = [];
|
|
752
|
+
for (const entry of [...entries].sort((left, right) => (left.time ?? 0) - (right.time ?? 0) || left.line - right.line)) {
|
|
753
|
+
const previous = collapsed[collapsed.length - 1];
|
|
754
|
+
if (previous !== undefined && (previous.time ?? 0) === (entry.time ?? 0))
|
|
755
|
+
collapsed[collapsed.length - 1] = entry;
|
|
756
|
+
else
|
|
757
|
+
collapsed.push(entry);
|
|
758
|
+
}
|
|
759
|
+
for (const entry of collapsed) {
|
|
760
|
+
const from = entry.time ?? 0;
|
|
761
|
+
// The change at the axis origin begins its lifeline's bar: that is what a renderer
|
|
762
|
+
// draws, so it is what a parse reads back. A later first change is not a start.
|
|
763
|
+
const atStart = entry.atStart || from === origin || hints.atStart.has(lifelineId + '@' + String(from));
|
|
764
|
+
segments.push({ lifeline: lifelineId, state: entry.name, from, ...(atStart ? { atStart: true } : {}) });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Stable by `from`: two lifelines changing state at the same moment keep the model's
|
|
768
|
+
// own order between them, so a round trip does not reshuffle them.
|
|
769
|
+
segments.sort((left, right) => left.from - right.from);
|
|
770
|
+
// A state earns its place in the model by being drawn, and in the order the lifelines
|
|
771
|
+
// enter it. The renderer walks `segments`, so a state listed anywhere else would not
|
|
772
|
+
// round-trip.
|
|
773
|
+
const orderedStates = [];
|
|
774
|
+
const seenStates = new Set();
|
|
775
|
+
for (const segment of segments) {
|
|
776
|
+
if (seenStates.has(segment.state))
|
|
777
|
+
continue;
|
|
778
|
+
seenStates.add(segment.state);
|
|
779
|
+
const known = states.find((state) => state.id === segment.state);
|
|
780
|
+
orderedStates.push(known ?? { id: segment.state });
|
|
781
|
+
}
|
|
782
|
+
const unit = hints.unit ?? diagramUnit;
|
|
783
|
+
const model = {
|
|
784
|
+
schemaVersion: 1,
|
|
785
|
+
...(unit === undefined ? {} : { unit }),
|
|
786
|
+
lifelines,
|
|
787
|
+
states: orderedStates,
|
|
788
|
+
segments,
|
|
789
|
+
messages,
|
|
790
|
+
constraints,
|
|
791
|
+
...(undeclared.size === 0 ? {} : { undeclared: [...undeclared].sort() }),
|
|
792
|
+
};
|
|
793
|
+
return { model, labels, warnings, discardedConstructs, discardedEdges: 0 };
|
|
794
|
+
}
|
|
795
|
+
export const TIMING_FINDING_CODES = [
|
|
796
|
+
'UML042_TIMING_NO_LIFELINES',
|
|
797
|
+
'UML043_TIMING_EMPTY_LIFELINE',
|
|
798
|
+
'UML044_TIMING_TIME_ORDER',
|
|
799
|
+
'UML045_TIMING_SIMULTANEOUS_STATES',
|
|
800
|
+
'UML046_TIMING_UNDECLARED_REFERENCE',
|
|
801
|
+
'UML047_TIMING_CONSTRAINT_RANGE',
|
|
802
|
+
'UML048_TIMING_STATE_UNUSED',
|
|
803
|
+
'UML049_TIMING_UNDOCUMENTED',
|
|
804
|
+
];
|
|
805
|
+
/**
|
|
806
|
+
* What a reader would get wrong about a timing diagram.
|
|
807
|
+
*
|
|
808
|
+
* The checks read the model alone: whether a duration is achievable is a question about
|
|
809
|
+
* the system, not about the diagram, and it is not answered here.
|
|
810
|
+
*/
|
|
811
|
+
export function reviewTimingModel(model, labels) {
|
|
812
|
+
const findings = [];
|
|
813
|
+
const name = (id) => labels[id] ?? id;
|
|
814
|
+
if (model.lifelines.length === 0) {
|
|
815
|
+
findings.push({
|
|
816
|
+
code: 'UML042_TIMING_NO_LIFELINES',
|
|
817
|
+
severity: 'error',
|
|
818
|
+
message: 'the diagram declares no lifeline, so there is no participant whose state over time it could show.',
|
|
819
|
+
evidence: { lifelines: 0 },
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
const touched = new Set();
|
|
823
|
+
for (const segment of model.segments)
|
|
824
|
+
touched.add(segment.lifeline);
|
|
825
|
+
for (const message of model.messages) {
|
|
826
|
+
touched.add(message.from);
|
|
827
|
+
touched.add(message.to);
|
|
828
|
+
}
|
|
829
|
+
for (const lifeline of model.lifelines) {
|
|
830
|
+
if (!touched.has(lifeline.id)) {
|
|
831
|
+
findings.push({
|
|
832
|
+
code: 'UML043_TIMING_EMPTY_LIFELINE',
|
|
833
|
+
severity: 'warning',
|
|
834
|
+
message: 'lifeline ' + name(lifeline.id) + ' carries no state change and no message: it is drawn but says nothing.',
|
|
835
|
+
evidence: { id: lifeline.id },
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Negative times are legal in a timing diagram: they are the run-up before the moment
|
|
840
|
+
// the reader cares about (`@-200`, then `@100`). So nothing here rejects a sign.
|
|
841
|
+
//
|
|
842
|
+
// The line order of a diagram is chronological, and so is the model's array order when
|
|
843
|
+
// it is written down. Segment i after segment j on one lifeline at a time before j is
|
|
844
|
+
// therefore an impossible diagram — a state that ends before the one it follows.
|
|
845
|
+
const byLifeline = new Map();
|
|
846
|
+
for (const segment of model.segments) {
|
|
847
|
+
const list = byLifeline.get(segment.lifeline);
|
|
848
|
+
if (list === undefined)
|
|
849
|
+
byLifeline.set(segment.lifeline, [segment]);
|
|
850
|
+
else
|
|
851
|
+
list.push(segment);
|
|
852
|
+
}
|
|
853
|
+
for (const [lifelineId, list] of byLifeline) {
|
|
854
|
+
for (let index = 1; index < list.length; index += 1) {
|
|
855
|
+
if (list[index].from >= list[index - 1].from)
|
|
856
|
+
continue;
|
|
857
|
+
findings.push({
|
|
858
|
+
code: 'UML044_TIMING_TIME_ORDER',
|
|
859
|
+
severity: 'error',
|
|
860
|
+
message: name(lifelineId) + ' changes state at ' + String(list[index].from) + ' after a change at ' + String(list[index - 1].from) + ': the earlier state would end before it begins.',
|
|
861
|
+
evidence: { lifeline: lifelineId, previous: list[index - 1].from, time: list[index].from },
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
// One lifeline is in one state at a time. Two changes at the same moment are read as
|
|
866
|
+
// one by every renderer, so the model saying otherwise is a model the diagram cannot
|
|
867
|
+
// carry — PlantUML keeps the later line and drops the earlier one without a word.
|
|
868
|
+
const moments = new Map();
|
|
869
|
+
for (const segment of model.segments) {
|
|
870
|
+
const key = segment.lifeline + '@' + String(segment.from);
|
|
871
|
+
const list = moments.get(key);
|
|
872
|
+
if (list === undefined)
|
|
873
|
+
moments.set(key, [segment.state]);
|
|
874
|
+
else
|
|
875
|
+
list.push(segment.state);
|
|
876
|
+
}
|
|
877
|
+
for (const [key, states] of moments) {
|
|
878
|
+
if (states.length < 2)
|
|
879
|
+
continue;
|
|
880
|
+
const [lifelineId, time] = key.split('@');
|
|
881
|
+
findings.push({
|
|
882
|
+
code: 'UML045_TIMING_SIMULTANEOUS_STATES',
|
|
883
|
+
severity: 'error',
|
|
884
|
+
message: name(lifelineId) + ' is put into ' + String(states.length) + ' states at time ' + time + ' (' + states.join(', ') + '): one lifeline is in one state at a time, and a renderer keeps only the last.',
|
|
885
|
+
evidence: { lifeline: lifelineId, time: Number(time), states },
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
// The array is the diagram's line order, so it is chronological by definition. One that
|
|
889
|
+
// is not cannot be written down as it stands, and the renderer would have to reorder a
|
|
890
|
+
// model the author declared — which it reports but a review should not let pass.
|
|
891
|
+
for (let index = 1; index < model.segments.length; index += 1) {
|
|
892
|
+
const previous = model.segments[index - 1];
|
|
893
|
+
const current = model.segments[index];
|
|
894
|
+
if (current.from >= previous.from)
|
|
895
|
+
continue;
|
|
896
|
+
findings.push({
|
|
897
|
+
code: 'UML044_TIMING_TIME_ORDER',
|
|
898
|
+
severity: 'error',
|
|
899
|
+
message: 'segment ' + String(index) + ' (' + name(current.lifeline) + ' at ' + String(current.from) + ') is written before a later change (' + name(previous.lifeline) + ' at ' + String(previous.from) + '): a diagram is written in time order.',
|
|
900
|
+
detail: 'Sort the segments by `from` (the lifeline order within one moment is yours), or render with uml_render, which warns and sorts.',
|
|
901
|
+
evidence: { index, from: current.from, previousFrom: previous.from },
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const undeclared = model.undeclared ?? [];
|
|
905
|
+
if (undeclared.length > 0) {
|
|
906
|
+
findings.push({
|
|
907
|
+
code: 'UML046_TIMING_UNDECLARED_REFERENCE',
|
|
908
|
+
severity: 'error',
|
|
909
|
+
message: 'the diagram refers to ' + String(undeclared.length) + ' element(s) it never declares, so the line points at nothing a reader can look up.',
|
|
910
|
+
detail: undeclared.join(', '),
|
|
911
|
+
evidence: { ids: undeclared },
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
for (const constraint of model.constraints) {
|
|
915
|
+
if (constraint.toTime < constraint.fromTime) {
|
|
916
|
+
findings.push({
|
|
917
|
+
code: 'UML047_TIMING_CONSTRAINT_RANGE',
|
|
918
|
+
severity: 'error',
|
|
919
|
+
message: 'a duration constraint runs from ' + String(constraint.fromTime) + ' to ' + String(constraint.toTime) + ': the interval ends before it starts.',
|
|
920
|
+
evidence: { fromTime: constraint.fromTime, toTime: constraint.toTime },
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const usedStates = new Set(model.segments.map((segment) => segment.state));
|
|
925
|
+
for (const state of model.states) {
|
|
926
|
+
if (!usedStates.has(state.id)) {
|
|
927
|
+
findings.push({
|
|
928
|
+
code: 'UML048_TIMING_STATE_UNUSED',
|
|
929
|
+
severity: 'warning',
|
|
930
|
+
message: 'state ' + name(state.id) + ' is declared but no lifeline is ever monitored in it.',
|
|
931
|
+
evidence: { id: state.id },
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
for (const lifeline of model.lifelines) {
|
|
936
|
+
if (lifeline.label === undefined && labels[lifeline.id] === undefined) {
|
|
937
|
+
findings.push({
|
|
938
|
+
code: 'UML049_TIMING_UNDOCUMENTED',
|
|
939
|
+
severity: 'info',
|
|
940
|
+
message: 'lifeline ' + lifeline.id + ' carries no name a reader can map back to the system: give it a display label.',
|
|
941
|
+
evidence: { id: lifeline.id },
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
for (const state of model.states) {
|
|
946
|
+
if (state.label === undefined) {
|
|
947
|
+
findings.push({
|
|
948
|
+
code: 'UML049_TIMING_UNDOCUMENTED',
|
|
949
|
+
severity: 'info',
|
|
950
|
+
message: 'state ' + state.id + ' carries no name a reader can map back to the system: give it a display label or a narrative entry.',
|
|
951
|
+
evidence: { id: state.id },
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return findings;
|
|
956
|
+
}
|
|
957
|
+
/** The timing family, as the engine's registry expects it. */
|
|
958
|
+
export const timingModule = {
|
|
959
|
+
id: 'timing',
|
|
960
|
+
render: (input, notation, _options) => renderTiming(input, notation),
|
|
961
|
+
parse: (text, notation) => parseTiming(text, notation),
|
|
962
|
+
// The validator is this family's canonical shape: a lifeline is always drawn with a kind,
|
|
963
|
+
// and a change at the axis origin is always the lifeline's first change. Comparing a raw
|
|
964
|
+
// input against what the parser reads back would report those two conventions as drift.
|
|
965
|
+
normalize: (input) => {
|
|
966
|
+
try {
|
|
967
|
+
return validateTimingModel(input);
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
return undefined;
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
review: (model, labels) => reviewTimingModel(model, labels),
|
|
974
|
+
};
|