dsh-model-everything 0.0.0-stage → 0.1.0

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