@dxos/diagram 0.11.1 → 0.14.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 (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9406 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +108 -74
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +216 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +186 -0
  150. package/src/nudge.ts +387 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +66 -5
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +891 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
@@ -0,0 +1,1054 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Text to scene commands, over the generated Lezer tree. The grammar is deliberately permissive
7
+ // about attribute names and values (see `diagram.grammar`); everything the schema actually
8
+ // constrains is checked here, so a wrong attribute reports itself with a range the editor can
9
+ // underline instead of derailing the parse.
10
+ //
11
+
12
+ import type { SyntaxNode } from '@lezer/common';
13
+
14
+ import * as Scene from '../scene.ts';
15
+ import * as SemanticEngine from '../semantic-engine.ts';
16
+ import * as Semantic from '../semantic.ts';
17
+ import { parser } from './gen/diagram.ts';
18
+ import {
19
+ type AttrSpec,
20
+ DIAGRAM_ATTRS,
21
+ EDGE_ATTRS,
22
+ ELEMENT_ATTRS,
23
+ ELEMENT_KINDS,
24
+ type ElementKind,
25
+ GROUP_ATTRS,
26
+ NODE_ATTRS,
27
+ OBJECT_ATTRS,
28
+ REF_SHAPE,
29
+ } from './vocabulary.ts';
30
+
31
+ export type Problem = {
32
+ severity: 'error' | 'warning';
33
+ message: string;
34
+ from: number;
35
+ to: number;
36
+ };
37
+
38
+ export type Range = { from: number; to: number };
39
+
40
+ export type ParseResult = {
41
+ commands: Scene.Command[];
42
+ problems: Problem[];
43
+ /**
44
+ * Source range of each `objectId` and `objectId/elementId` — the two spellings
45
+ * `Diagnostics.Diagnostic.refs` uses, so a layout report can be shown against the line that
46
+ * produced it rather than at the top of the document.
47
+ */
48
+ ranges: Map<string, Range>;
49
+ };
50
+
51
+ const ESCAPES: Record<string, string> = { n: '\n', t: '\t', r: '\r' };
52
+
53
+ const unquote = (raw: string): string =>
54
+ raw.slice(1, -1).replace(/\\(.)/g, (_match, character: string) => ESCAPES[character] ?? character);
55
+
56
+ /** Spreads a field only when it is set, so an absent option never becomes an explicit `undefined`. */
57
+ const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } => {
58
+ const result: { [P in K]?: V } = {};
59
+ if (value !== undefined) {
60
+ result[key] = value;
61
+ }
62
+ return result;
63
+ };
64
+
65
+ /** Single-kind element nodes; the multi-kind ones carry a `BoxKind`/`PathKind` child instead. */
66
+ const SHAPE_KINDS: Record<string, string | undefined> = {
67
+ CircleElement: 'circle',
68
+ ArcElement: 'arc',
69
+ TextElement: 'text',
70
+ ArrowElement: 'arrow',
71
+ PortalElement: 'portal',
72
+ };
73
+
74
+ /** The DSL word for an element, which is also its scene `kind`. */
75
+ const kindOf = (shapeName: string, keyword: string | undefined): ElementKind | undefined => {
76
+ const word = keyword ?? SHAPE_KINDS[shapeName];
77
+ return ELEMENT_KINDS.find((kind) => kind === word);
78
+ };
79
+
80
+ const childrenOf = (node: SyntaxNode): SyntaxNode[] => {
81
+ const children: SyntaxNode[] = [];
82
+ for (let child = node.firstChild; child; child = child.nextSibling) {
83
+ children.push(child);
84
+ }
85
+ return children;
86
+ };
87
+
88
+ /** The message for a semantic id that would replace the connectors object. */
89
+ const CONNECTORS_CLASH = `"${Semantic.CONNECTORS}" names the diagram's connectors object; choose another id.`;
90
+
91
+ /** A document as read, before any semantic statement is laid out. */
92
+ export type Reading = ParseResult & {
93
+ /** The semantic statements, when the document has any. */
94
+ diagram?: Semantic.Diagram;
95
+ };
96
+
97
+ /**
98
+ * Reads a document. Syntax errors are reported rather than thrown, and the commands that did parse
99
+ * are returned alongside them, so an editor can keep rendering the last good shapes while a line
100
+ * is half-typed. Semantic statements are collected into a diagram but not laid out.
101
+ */
102
+ export const read = (text: string): Reading => {
103
+ const problems: Problem[] = [];
104
+ const commands: Scene.Command[] = [];
105
+ const ranges = new Map<string, Range>();
106
+
107
+ const slice = (node: SyntaxNode): string => text.slice(node.from, node.to);
108
+
109
+ const report = (node: Range, message: string, severity: Problem['severity'] = 'error'): void => {
110
+ problems.push({ severity, message, from: node.from, to: node.to });
111
+ };
112
+
113
+ const readId = (node: SyntaxNode): string => {
114
+ const raw = slice(node);
115
+ return raw.startsWith('"') ? unquote(raw) : raw;
116
+ };
117
+
118
+ /**
119
+ * A coordinate the rest of the system can use. The grammar accepts exponent notation because the
120
+ * printer emits it, which also admits `1e309` — `parseFloat` turns that into `Infinity`, and an
121
+ * infinite coordinate poisons layout, renders as nothing, and cannot be printed back at all
122
+ * (`formatNumber` refuses it). Rejecting it here keeps `parse` and `print` accepting the same
123
+ * documents.
124
+ */
125
+ const readNumber = (node: SyntaxNode): number | undefined => {
126
+ const value = Number.parseFloat(slice(node));
127
+ if (!Number.isFinite(value)) {
128
+ report(node, `"${slice(node)}" is not a finite number.`);
129
+ return undefined;
130
+ }
131
+ return value;
132
+ };
133
+
134
+ // Error recovery can leave a `Point` or `Range` holding one number instead of two, so every
135
+ // reader returns undefined rather than indexing past the end — `parse` reports problems, and a
136
+ // reader that threw would take the whole document down with the line being typed.
137
+ const readPoint = (node: SyntaxNode): Scene.Point | undefined => {
138
+ const [first, second] = childrenOf(node).filter((child) => child.name === 'Number');
139
+ const [x, y] = [first && readNumber(first), second && readNumber(second)];
140
+ return x !== undefined && y !== undefined ? { x, y } : undefined;
141
+ };
142
+
143
+ // `100x50`; `x` appears only as the separator, so a negative extent needs no special case.
144
+ const readSize = (node: SyntaxNode): { w: number; h: number } | undefined => {
145
+ const [width, height] = slice(node).split('x').map(Number.parseFloat);
146
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
147
+ report(node, `"${slice(node)}" is not a finite size.`);
148
+ return undefined;
149
+ }
150
+ return { w: width, h: height };
151
+ };
152
+
153
+ const readRange = (node: SyntaxNode): { startAngle: number; endAngle: number } | undefined => {
154
+ const [first, second] = childrenOf(node).filter((child) => child.name === 'Number');
155
+ const [startAngle, endAngle] = [first && readNumber(first), second && readNumber(second)];
156
+ return startAngle !== undefined && endAngle !== undefined ? { startAngle, endAngle } : undefined;
157
+ };
158
+
159
+ //
160
+ // Attributes. Each reader narrows by searching the schema's own literals, so a value that is
161
+ // not one of them comes back `undefined` with a problem attached rather than a widened type.
162
+ //
163
+
164
+ type Attr = { node: SyntaxNode; valueNode: SyntaxNode };
165
+
166
+ const readAttrs = (nodes: SyntaxNode[], allowed: readonly AttrSpec[], subject: string): Map<string, Attr> => {
167
+ const attrs = new Map<string, Attr>();
168
+ for (const node of nodes) {
169
+ const name = node.getChild('AttrName');
170
+ const valueNode = node.getChild('AttrValue');
171
+ if (!name || !valueNode) {
172
+ continue;
173
+ }
174
+ const key = slice(name);
175
+ if (!allowed.some((spec) => spec.name === key)) {
176
+ report(node, `Unknown attribute "${key}" on ${subject}.`);
177
+ continue;
178
+ }
179
+ if (attrs.has(key)) {
180
+ report(node, `Duplicate attribute "${key}" on ${subject}.`);
181
+ }
182
+ attrs.set(key, { node, valueNode });
183
+ }
184
+ return attrs;
185
+ };
186
+
187
+ const numberAttr = (attrs: Map<string, Attr>, name: string): number | undefined => {
188
+ const attr = attrs.get(name);
189
+ if (!attr) {
190
+ return undefined;
191
+ }
192
+ const value = Number.parseFloat(slice(attr.valueNode));
193
+ if (!Number.isFinite(value)) {
194
+ report(attr.node, `"${name}" takes a finite number.`);
195
+ return undefined;
196
+ }
197
+ return value;
198
+ };
199
+
200
+ const stringAttr = (attrs: Map<string, Attr>, name: string): string | undefined => {
201
+ const attr = attrs.get(name);
202
+ if (!attr) {
203
+ return undefined;
204
+ }
205
+ const raw = slice(attr.valueNode);
206
+ if (!raw.startsWith('"')) {
207
+ report(attr.node, `"${name}" takes a quoted string.`);
208
+ return undefined;
209
+ }
210
+ return unquote(raw);
211
+ };
212
+
213
+ const booleanAttr = (attrs: Map<string, Attr>, name: string): boolean | undefined => {
214
+ const attr = attrs.get(name);
215
+ if (!attr) {
216
+ return undefined;
217
+ }
218
+ const raw = slice(attr.valueNode);
219
+ if (raw !== 'true' && raw !== 'false') {
220
+ report(attr.node, `"${name}" takes true or false.`);
221
+ return undefined;
222
+ }
223
+ return raw === 'true';
224
+ };
225
+
226
+ const enumAttr = <T extends string>(attrs: Map<string, Attr>, name: string, values: readonly T[]): T | undefined => {
227
+ const attr = attrs.get(name);
228
+ if (!attr) {
229
+ return undefined;
230
+ }
231
+ const raw = slice(attr.valueNode);
232
+ const match = values.find((value) => value === raw);
233
+ if (match === undefined) {
234
+ report(attr.node, `"${name}" takes one of: ${values.join(', ')}.`);
235
+ }
236
+ return match;
237
+ };
238
+
239
+ /** The four style fields every element shares. */
240
+ const readStyle = (attrs: Map<string, Attr>) => ({
241
+ ...optional('color', enumAttr(attrs, 'color', Scene.Color.literals)),
242
+ ...optional('fill', enumAttr(attrs, 'fill', Scene.Fill.literals)),
243
+ ...optional('stroke', enumAttr(attrs, 'stroke', Scene.Stroke.literals)),
244
+ ...optional('weight', enumAttr(attrs, 'weight', Scene.Weight.literals)),
245
+ });
246
+
247
+ const readEndpoint = (node: SyntaxNode): { ref?: string; point?: Scene.Point } => {
248
+ const child = node.firstChild;
249
+ if (!child) {
250
+ return {};
251
+ }
252
+ switch (child.name) {
253
+ case 'Point':
254
+ return { point: readPoint(child) };
255
+ case 'Ref': {
256
+ const raw = slice(child);
257
+ // An id the grammar cannot lex bare is written quoted, so the printer can round-trip a ref
258
+ // like `1st/box` that a dialect produced from a numeric node id.
259
+ const ref = raw.startsWith('"') ? unquote(raw) : raw;
260
+ if (!REF_SHAPE.test(ref)) {
261
+ report(child, `"${ref}" is not a ref; write element, object/element, or either with #port.`);
262
+ }
263
+ return { ref };
264
+ }
265
+ default:
266
+ return {};
267
+ }
268
+ };
269
+
270
+ /** Point list with the incomplete ones dropped; the syntax error is already reported. */
271
+ const readPoints = (nodes: SyntaxNode[]): Scene.Point[] =>
272
+ nodes.flatMap((node) => {
273
+ const point = readPoint(node);
274
+ return point ? [point] : [];
275
+ });
276
+
277
+ const readElement = (node: SyntaxNode): Scene.Element | undefined => {
278
+ const shape = node.firstChild;
279
+ if (!shape) {
280
+ return undefined;
281
+ }
282
+ const parts = childrenOf(shape);
283
+ const idNode = parts.find((part) => part.name === 'Id');
284
+ if (!idNode) {
285
+ return undefined;
286
+ }
287
+ const id = readId(idNode);
288
+ const points = parts.filter((part) => part.name === 'Point');
289
+ const label = parts.find((part) => part.name === 'Label');
290
+ const text = label ? unquote(slice(label)) : undefined;
291
+
292
+ const kindNode = parts.find((part) => part.name === 'BoxKind' || part.name === 'PathKind');
293
+ const kind = kindOf(shape.name, kindNode ? slice(kindNode) : undefined);
294
+ if (kind === undefined) {
295
+ return undefined;
296
+ }
297
+
298
+ const attrs = readAttrs(
299
+ parts.filter((part) => part.name === 'Attribute'),
300
+ ELEMENT_ATTRS[kind],
301
+ `${kind} "${id}"`,
302
+ );
303
+ const style = readStyle(attrs);
304
+
305
+ switch (kind) {
306
+ case 'rect':
307
+ case 'ellipse':
308
+ case 'diamond':
309
+ case 'triangle': {
310
+ const sizeNode = parts.find((part) => part.name === 'Size');
311
+ const origin = points[0] && readPoint(points[0]);
312
+ const size = sizeNode && readSize(sizeNode);
313
+ if (!origin || !size) {
314
+ return undefined;
315
+ }
316
+ return {
317
+ kind,
318
+ id,
319
+ x: origin.x,
320
+ y: origin.y,
321
+ ...size,
322
+ ...optional('rotation', numberAttr(attrs, 'rotation')),
323
+ ...optional('text', text),
324
+ ...optional('corners', enumAttr(attrs, 'corners', Scene.Corners.literals)),
325
+ ...style,
326
+ };
327
+ }
328
+
329
+ case 'circle': {
330
+ const radiusNode = parts.find((part) => part.name === 'Number');
331
+ const centre = points[0] && readPoint(points[0]);
332
+ const radius = radiusNode && readNumber(radiusNode);
333
+ if (radius === undefined || !centre) {
334
+ return undefined;
335
+ }
336
+ return {
337
+ kind,
338
+ id,
339
+ cx: centre.x,
340
+ cy: centre.y,
341
+ r: radius,
342
+ ...optional('text', text),
343
+ ...style,
344
+ };
345
+ }
346
+
347
+ case 'line':
348
+ return {
349
+ kind,
350
+ id,
351
+ points: readPoints(points),
352
+ ...optional('closed', booleanAttr(attrs, 'closed')),
353
+ ...style,
354
+ };
355
+
356
+ case 'curve':
357
+ return { kind, id, points: readPoints(points), ...style };
358
+
359
+ case 'arc': {
360
+ const radiusNode = parts.find((part) => part.name === 'Number');
361
+ const rangeNode = parts.find((part) => part.name === 'Range');
362
+ const centre = points[0] && readPoint(points[0]);
363
+ const radius = radiusNode && readNumber(radiusNode);
364
+ const range = rangeNode && readRange(rangeNode);
365
+ if (radius === undefined || !centre || !range) {
366
+ return undefined;
367
+ }
368
+ return { kind, id, cx: centre.x, cy: centre.y, r: radius, ...range, ...style };
369
+ }
370
+
371
+ case 'text': {
372
+ const at = points[0] && readPoint(points[0]);
373
+ if (!at || text === undefined) {
374
+ return undefined;
375
+ }
376
+ return { kind, id, x: at.x, y: at.y, text, ...optional('w', numberAttr(attrs, 'w')), ...style };
377
+ }
378
+
379
+ case 'arrow': {
380
+ const connection = parts.find((part) => part.name === 'Connection');
381
+ const ends = connection ? childrenOf(connection).filter((child) => child.name === 'Endpoint') : [];
382
+ const [from, to] = ends.map(readEndpoint);
383
+ return {
384
+ kind,
385
+ id,
386
+ ...optional('from', from?.ref),
387
+ ...optional('to', to?.ref),
388
+ ...optional('start', from?.point),
389
+ ...optional('end', to?.point),
390
+ ...optional('text', text),
391
+ ...optional('head', enumAttr(attrs, 'head', Scene.ArrowHead.literals)),
392
+ ...optional('tail', enumAttr(attrs, 'tail', Scene.ArrowTail.literals)),
393
+ ...optional('relation', enumAttr(attrs, 'relation', Scene.Relation.literals)),
394
+ ...style,
395
+ };
396
+ }
397
+
398
+ case 'portal': {
399
+ const sizeNode = parts.find((part) => part.name === 'Size');
400
+ const origin = points[0] && readPoint(points[0]);
401
+ const size = sizeNode && readSize(sizeNode);
402
+ if (!origin || !size) {
403
+ return undefined;
404
+ }
405
+ const ref = stringAttr(attrs, 'ref');
406
+ if (ref === undefined) {
407
+ report(shape, `portal "${id}" needs ref="<dxn>": it is the drawing shown inside the frame.`);
408
+ return undefined;
409
+ }
410
+ return { kind, id, x: origin.x, y: origin.y, ...size, ref, ...optional('text', text), ...style };
411
+ }
412
+ }
413
+ };
414
+
415
+ const readElements = (body: SyntaxNode | null, objectId: string): Scene.Element[] =>
416
+ body
417
+ ? childrenOf(body)
418
+ .filter((child) => child.name === 'Element')
419
+ .flatMap((child) => {
420
+ const element = readElement(child);
421
+ if (!element) {
422
+ return [];
423
+ }
424
+ ranges.set(`${objectId}/${element.id}`, { from: child.from, to: child.to });
425
+ return [element];
426
+ })
427
+ : [];
428
+
429
+ //
430
+ // Semantic statements, collected into one diagram and checked once every statement is read, since
431
+ // an edge may name a node declared further down.
432
+ //
433
+
434
+ const diagram = Semantic.empty();
435
+ let semantic = false;
436
+ const rangeOf = (node: SyntaxNode): Range => ({ from: node.from, to: node.to });
437
+
438
+ const sizeAttr = (attrs: Map<string, Attr>, name: string): Semantic.Size | undefined => {
439
+ const attr = attrs.get(name);
440
+ if (!attr) {
441
+ return undefined;
442
+ }
443
+ const raw = slice(attr.valueNode);
444
+ const [width, height] = raw.split('x').map(Number.parseFloat);
445
+ if (!/^[^x]+x[^x]+$/.test(raw) || !(width > 0) || !(height > 0)) {
446
+ report(attr.node, `"${name}" takes a size like 384x224.`);
447
+ return undefined;
448
+ }
449
+ return { w: width, h: height };
450
+ };
451
+
452
+ /** `W:H` as the ratio W/H. */
453
+ const ratioAttr = (attrs: Map<string, Attr>, name: string): number | undefined => {
454
+ const attr = attrs.get(name);
455
+ if (!attr) {
456
+ return undefined;
457
+ }
458
+ const [width, height] = slice(attr.valueNode).split(':').map(Number.parseFloat);
459
+ if (attr.valueNode.getChild('Ratio') === null || !(width > 0) || !(height > 0)) {
460
+ report(attr.node, `"${name}" takes a ratio like 4:3.`);
461
+ return undefined;
462
+ }
463
+ return width / height;
464
+ };
465
+
466
+ const attributesOf = (statement: SyntaxNode) => childrenOf(statement).filter((child) => child.name === 'Attribute');
467
+
468
+ const readRelations = (statement: SyntaxNode): Semantic.Relation[] =>
469
+ childrenOf(statement)
470
+ .filter((child) => child.name === 'Relation')
471
+ .flatMap((relation) => {
472
+ const kindNode = relation.getChild('RelationKind');
473
+ const target = relation.getChild('Id');
474
+ if (!kindNode || !target) {
475
+ return [];
476
+ }
477
+ const word = slice(kindNode);
478
+ const kind = Semantic.RELATION_KINDS.find((candidate) => candidate === word);
479
+ if (!kind) {
480
+ report(kindNode, `Unknown relation "${word}"; use one of: ${Semantic.RELATION_KINDS.join(', ')}.`);
481
+ return [];
482
+ }
483
+ return [{ kind, target: readId(target), soft: relation.getChild('Soft') !== null, range: rangeOf(relation) }];
484
+ });
485
+
486
+ /** `cell(c,r)` coordinates; `_` leaves one open where the caller allows it. */
487
+ const readCoords = (node: SyntaxNode): (number | undefined)[] =>
488
+ childrenOf(node)
489
+ .filter((child) => child.name === 'Coord')
490
+ .map((coord) => {
491
+ const number = coord.getChild('Number');
492
+ return number ? readNumber(number) : undefined;
493
+ });
494
+
495
+ const readPin = (statement: SyntaxNode): Semantic.Pin | undefined => {
496
+ const pin = statement.getChild('Pin');
497
+ if (!pin) {
498
+ return undefined;
499
+ }
500
+ const point = pin.getChild('Point');
501
+ if (point) {
502
+ const at = readPoint(point);
503
+ return at ? { kind: 'point', ...at, range: rangeOf(pin) } : undefined;
504
+ }
505
+ const cell = pin.getChild('CellRef');
506
+ if (!cell) {
507
+ return undefined;
508
+ }
509
+ const [col, row] = readCoords(cell);
510
+ if (col === undefined || row === undefined || !Number.isInteger(col) || !Number.isInteger(row)) {
511
+ report(cell, 'A pin names a whole cell: @cell(<column>,<row>) with two integers.');
512
+ return undefined;
513
+ }
514
+ return { kind: 'cell', col, row, range: rangeOf(pin) };
515
+ };
516
+
517
+ const readNode = (statement: SyntaxNode, group: string | undefined): void => {
518
+ const idNode = statement.getChild('Id');
519
+ if (!idNode) {
520
+ return;
521
+ }
522
+ const id = readId(idNode);
523
+ const label = statement.getChild('Label');
524
+ const attrs = readAttrs(attributesOf(statement), NODE_ATTRS, `node "${id}"`);
525
+ const pins = childrenOf(statement).filter((child) => child.name === 'Pin');
526
+ if (pins.length > 1) {
527
+ report(pins[1], `node "${id}" has more than one pin.`);
528
+ }
529
+ if (id === Semantic.CONNECTORS) {
530
+ report(idNode, CONNECTORS_CLASH);
531
+ return;
532
+ }
533
+ if (diagram.nodes.some((node) => node.id === id) || diagram.groups.some((entry) => entry.id === id)) {
534
+ report(idNode, `"${id}" is already declared.`);
535
+ return;
536
+ }
537
+ const pin = readPin(statement);
538
+ const relations = readRelations(statement);
539
+ diagram.hinted ||= pin !== undefined || relations.length > 0;
540
+ diagram.nodes.push({
541
+ id,
542
+ label: label ? unquote(slice(label)) : id,
543
+ relations,
544
+ range: rangeOf(statement),
545
+ ...optional('group', group),
546
+ ...optional('pin', pin),
547
+ ...optional('ref', stringAttr(attrs, 'ref')),
548
+ ...optional('shape', enumAttr(attrs, 'shape', Scene.BoxKind.literals)),
549
+ ...optional('color', enumAttr(attrs, 'color', Scene.Color.literals)),
550
+ ...optional('fill', enumAttr(attrs, 'fill', Scene.Fill.literals)),
551
+ ...optional('stroke', enumAttr(attrs, 'stroke', Scene.Stroke.literals)),
552
+ });
553
+ ranges.set(id, rangeOf(statement));
554
+ };
555
+
556
+ const readEnd = (node: SyntaxNode): Semantic.End | undefined => {
557
+ const idNode = node.getChild('Id');
558
+ if (!idNode) {
559
+ return undefined;
560
+ }
561
+ const sideList = node.getChild('SideList');
562
+ const sides = sideList
563
+ ? childrenOf(sideList)
564
+ .filter((child) => child.name === 'Side')
565
+ .flatMap((child) => {
566
+ const word = slice(child);
567
+ const side = Semantic.SIDES.find((candidate) => candidate === word);
568
+ if (!side) {
569
+ report(child, `Unknown side "${word}"; use top, bottom, left or right.`);
570
+ }
571
+ return side ? [side] : [];
572
+ })
573
+ : undefined;
574
+ const soft = sides?.length && node.getChild('Soft') !== null ? true : undefined;
575
+ return {
576
+ node: readId(idNode),
577
+ range: rangeOf(node),
578
+ ...optional('sides', sides?.length ? sides : undefined),
579
+ ...optional('soft', soft),
580
+ };
581
+ };
582
+
583
+ const readWaypoints = (via: SyntaxNode): Semantic.Waypoint[] =>
584
+ childrenOf(via)
585
+ .filter((child) => child.name === 'Waypoint')
586
+ .flatMap((waypoint): Semantic.Waypoint[] => {
587
+ const cell = waypoint.getChild('CellRef');
588
+ const [x, y] = readCoords(cell ?? waypoint);
589
+ if (x === undefined && y === undefined) {
590
+ report(waypoint, 'A waypoint needs at least one coordinate; write `_` only for the one left free.');
591
+ return [];
592
+ }
593
+ return [{ unit: cell ? 'cell' : 'scene', range: rangeOf(waypoint), ...optional('x', x), ...optional('y', y) }];
594
+ });
595
+
596
+ /** Unnamed buses are per statement; named ones join across statements. */
597
+ let statementIndex = 0;
598
+ const pendingBuses: { edges: Semantic.Edge[]; name: string; node: SyntaxNode; label?: string; named: boolean }[] = [];
599
+
600
+ const readEdge = (statement: SyntaxNode): void => {
601
+ statementIndex++;
602
+ const lists = childrenOf(statement).filter((child) => child.name === 'EndList');
603
+ if (lists.length < 2) {
604
+ return;
605
+ }
606
+ const [sources, targets] = lists.map((list) =>
607
+ childrenOf(list)
608
+ .filter((child) => child.name === 'EdgeEnd')
609
+ .flatMap((end) => readEnd(end) ?? []),
610
+ );
611
+ const opNode = statement.getChild('EdgeOp');
612
+ const op = opNode ? slice(opNode) : '->';
613
+ const word = opNode?.getChild('RelationWord');
614
+ const relation = word && Object.hasOwn(Semantic.RELATIONSHIPS, op) ? Semantic.RELATIONSHIPS[op] : undefined;
615
+ if (word && !relation) {
616
+ report(
617
+ word,
618
+ `Unknown relationship "${op}"; use ->, <->, -- or one of: ${Object.keys(Semantic.RELATIONSHIPS).join(', ')}.`,
619
+ );
620
+ }
621
+ const label = statement.getChild('Label');
622
+ const text = label ? unquote(slice(label)) : undefined;
623
+ const attrs = readAttrs(attributesOf(statement), EDGE_ATTRS, 'edge');
624
+ const via = childrenOf(statement)
625
+ .filter((child) => child.name === 'Via')
626
+ .flatMap(readWaypoints);
627
+ const busNode = statement.getChild('Bus');
628
+ const busName = busNode?.getChild('Id');
629
+ const style: Semantic.EdgeStyle = {
630
+ ...optional('relation', relation),
631
+ ...optional('head', op === '--' ? 'none' : enumAttr(attrs, 'head', Scene.ArrowHead.literals)),
632
+ ...optional('tail', enumAttr(attrs, 'tail', Scene.ArrowTail.literals)),
633
+ ...optional('stroke', enumAttr(attrs, 'stroke', Scene.Stroke.literals)),
634
+ ...optional('color', enumAttr(attrs, 'color', Scene.Color.literals)),
635
+ };
636
+ const fan = sources.length > 1 || targets.length > 1;
637
+ if (busNode && sources.length > 1 && targets.length > 1) {
638
+ report(busNode, 'A bus joins one node to several: write `edge A -> B, C bus` or `edge B, C -> A bus`.');
639
+ }
640
+ const busTrunk = busNode !== null && fan && !(sources.length > 1 && targets.length > 1);
641
+ const pairs = sources.flatMap((from) => targets.map((to) => ({ from, to })));
642
+ const made: Semantic.Edge[] = [];
643
+ for (const { from, to } of pairs) {
644
+ const directions =
645
+ op === '<->'
646
+ ? [
647
+ { from, to },
648
+ { from: to, to: from },
649
+ ]
650
+ : [{ from, to }];
651
+ directions.forEach((direction, position) => {
652
+ const edge: Semantic.Edge = {
653
+ id: `${direction.from.node}-${direction.to.node}-${diagram.edges.length}`,
654
+ from: direction.from,
655
+ to: direction.to,
656
+ via: position === 0 ? via : [...via].reverse(),
657
+ range: rangeOf(statement),
658
+ ...style,
659
+ // A trunk carries the statement's label; the reverse of a two-way edge repeats nothing.
660
+ ...optional('label', busTrunk || position > 0 ? undefined : text),
661
+ };
662
+ diagram.edges.push(edge);
663
+ made.push(edge);
664
+ ranges.set(`edges/${edge.id}`, rangeOf(statement));
665
+ ranges.set(`edges/${edge.id}-path`, rangeOf(statement));
666
+ });
667
+ }
668
+ diagram.hinted ||=
669
+ via.length > 0 ||
670
+ busNode !== null ||
671
+ sources.some((end) => end.sides !== undefined) ||
672
+ targets.some((end) => end.sides !== undefined);
673
+ if (busNode && !(sources.length > 1 && targets.length > 1)) {
674
+ pendingBuses.push({
675
+ edges: made,
676
+ name: busName ? readId(busName) : `#${statementIndex}`,
677
+ node: busNode,
678
+ named: busName !== null,
679
+ ...optional('label', busTrunk ? text : undefined),
680
+ });
681
+ }
682
+ };
683
+
684
+ const readGroup = (statement: SyntaxNode, outer: string | undefined): void => {
685
+ const idNode = statement.getChild('Id');
686
+ if (!idNode) {
687
+ return;
688
+ }
689
+ const id = readId(idNode);
690
+ if (outer !== undefined) {
691
+ report(idNode, `Groups do not nest; the members of "${id}" join "${outer}".`);
692
+ } else if (id === Semantic.CONNECTORS) {
693
+ report(idNode, CONNECTORS_CLASH);
694
+ } else if (diagram.groups.some((group) => group.id === id) || diagram.nodes.some((node) => node.id === id)) {
695
+ report(idNode, `"${id}" is already declared.`);
696
+ } else {
697
+ const label = statement.getChild('Label');
698
+ const attrs = readAttrs(attributesOf(statement), GROUP_ATTRS, `group "${id}"`);
699
+ const relations = readRelations(statement);
700
+ const gap = numberAttr(attrs, 'gap');
701
+ const width = numberAttr(attrs, 'max-width');
702
+ const maxWidth = width !== undefined && Number.isInteger(width) && width >= 1 ? width : undefined;
703
+ if (width !== undefined && maxWidth === undefined) {
704
+ report(attrs.get('max-width')?.node ?? statement, '"max-width" takes a whole number of columns, at least 1.');
705
+ }
706
+ const compact = statement.getChild('Compact') !== null ? true : undefined;
707
+ diagram.hinted ||= relations.length > 0 || gap !== undefined || maxWidth !== undefined || compact === true;
708
+ diagram.groups.push({
709
+ id,
710
+ label: label ? unquote(slice(label)) : id,
711
+ relations,
712
+ range: rangeOf(statement),
713
+ ...optional('gap', gap),
714
+ ...optional('maxWidth', maxWidth),
715
+ ...optional('compact', compact),
716
+ ...optional('color', enumAttr(attrs, 'color', Scene.Color.literals)),
717
+ });
718
+ ranges.set(id, rangeOf(statement));
719
+ }
720
+ const owner = outer ?? id;
721
+ for (const child of childrenOf(statement.getChild('GroupBody') ?? statement)) {
722
+ if (child.name === 'NodeDecl') {
723
+ readNode(child, owner);
724
+ } else if (child.name === 'EdgeDecl') {
725
+ readEdge(child);
726
+ } else if (child.name === 'GroupDecl') {
727
+ readGroup(child, owner);
728
+ }
729
+ }
730
+ };
731
+
732
+ const readDiagram = (statement: SyntaxNode): void => {
733
+ const attrs = readAttrs(attributesOf(statement), DIAGRAM_ATTRS, 'diagram');
734
+ const point = statement.getChild('Origin')?.getChild('Point');
735
+ const origin = point && readPoint(point);
736
+ const flow = enumAttr(attrs, 'flow', Semantic.FLOWS);
737
+ const grid = sizeAttr(attrs, 'grid');
738
+ const box = sizeAttr(attrs, 'box');
739
+ const aspect = ratioAttr(attrs, 'aspect');
740
+ if (aspect) {
741
+ diagram.aspect = aspect;
742
+ }
743
+ if (origin) {
744
+ diagram.origin = origin;
745
+ }
746
+ if (flow) {
747
+ diagram.flow = flow;
748
+ }
749
+ if (grid) {
750
+ diagram.grid = grid;
751
+ }
752
+ if (box) {
753
+ diagram.box = box;
754
+ }
755
+ diagram.hinted ||= grid !== undefined || box !== undefined || aspect !== undefined;
756
+ };
757
+
758
+ /** Drops what names nothing, then forms the buses; run once every statement is read. */
759
+ const resolveSemantic = (): void => {
760
+ const nodeIds = new Set(diagram.nodes.map((node) => node.id));
761
+ const groupIds = new Set(diagram.groups.map((group) => group.id));
762
+ const known = (end: Semantic.End) => {
763
+ if (!nodeIds.has(end.node)) {
764
+ report(end.range, `Unknown node "${end.node}"; declare it with \`node ${end.node}\`.`);
765
+ return false;
766
+ }
767
+ return true;
768
+ };
769
+ diagram.edges = diagram.edges.filter((edge) => [edge.from, edge.to].map(known).every(Boolean));
770
+ const checkTargets = (owner: string, relations: Semantic.Relation[], ids: Set<string>, kind: string) =>
771
+ relations.filter((relation) => {
772
+ if (relation.target === owner) {
773
+ problems.push({
774
+ severity: 'error',
775
+ message: `"${owner}" cannot be placed relative to itself.`,
776
+ ...relation.range,
777
+ });
778
+ return false;
779
+ }
780
+ if (!ids.has(relation.target)) {
781
+ problems.push({ severity: 'error', message: `Unknown ${kind} "${relation.target}".`, ...relation.range });
782
+ return false;
783
+ }
784
+ return true;
785
+ });
786
+ diagram.nodes.forEach((node) => (node.relations = checkTargets(node.id, node.relations, nodeIds, 'node')));
787
+ diagram.groups.forEach((group) => (group.relations = checkTargets(group.id, group.relations, groupIds, 'group')));
788
+
789
+ const live = new Set(diagram.edges);
790
+ const byName = new Map<string, typeof pendingBuses>();
791
+ for (const entry of pendingBuses) {
792
+ byName.set(entry.name, [...(byName.get(entry.name) ?? []), entry]);
793
+ }
794
+ for (const [name, entries] of byName) {
795
+ const edges = entries.flatMap((entry) => entry.edges).filter((edge) => live.has(edge));
796
+ if (edges.length < 2) {
797
+ if (edges.length === 1) {
798
+ problems.push({
799
+ severity: 'warning',
800
+ message: entries[0].named
801
+ ? `Bus "${name}" has only one edge, so it has no trunk to share.`
802
+ : 'A bus needs several edges from or to one node: `edge A -> B, C bus` or `edge B, C -> A bus`.',
803
+ ...rangeOf(entries[0].node),
804
+ });
805
+ }
806
+ continue;
807
+ }
808
+ const hubOut = edges.every((edge) => edge.from.node === edges[0].from.node);
809
+ const hubIn = edges.every((edge) => edge.to.node === edges[0].to.node);
810
+ if (!hubOut && !hubIn) {
811
+ problems.push({
812
+ severity: 'warning',
813
+ message: `The edges of bus "${name}" share neither their source nor their target, so they are drawn apart.`,
814
+ ...rangeOf(entries[0].node),
815
+ });
816
+ continue;
817
+ }
818
+ const direction = hubOut ? 'out' : 'in';
819
+ const key = Semantic.busKey(hubOut ? edges[0].from.node : edges[0].to.node, name, direction);
820
+ const label = entries.find((entry) => entry.label !== undefined)?.label;
821
+ if (label !== undefined) {
822
+ diagram.busLabels.set(key, label);
823
+ }
824
+ for (const edge of edges) {
825
+ edge.bus = key;
826
+ }
827
+ const routed = edges.find((edge) => edge.via.length > 0);
828
+ if (routed) {
829
+ problems.push({
830
+ severity: 'warning',
831
+ message: 'Waypoints on a bus edge are ignored: the trunk and its spokes are routed together.',
832
+ ...routed.via[0].range,
833
+ });
834
+ }
835
+ }
836
+ };
837
+
838
+ const tree = parser.parse(text);
839
+
840
+ // Error nodes first, so a report reads top-down even though the walk below revisits the tree.
841
+ const cursor = tree.cursor();
842
+ do {
843
+ if (cursor.type.isError) {
844
+ problems.push({
845
+ severity: 'error',
846
+ message: 'Unexpected input.',
847
+ from: cursor.from,
848
+ // A zero-width error node would underline nothing; give it the character it stopped on.
849
+ to: cursor.to > cursor.from ? cursor.to : Math.min(cursor.from + 1, text.length),
850
+ });
851
+ }
852
+ } while (cursor.next());
853
+
854
+ for (const statement of childrenOf(tree.topNode)) {
855
+ const ids = childrenOf(statement).filter((child) => child.name === 'Id');
856
+ switch (statement.name) {
857
+ case 'ObjectDecl': {
858
+ if (ids.length === 0) {
859
+ break;
860
+ }
861
+ // `@ <origin>` and `name=value` both arrive wrapped in an `ObjectAttr`.
862
+ const objectAttrs = childrenOf(statement)
863
+ .filter((child) => child.name === 'ObjectAttr')
864
+ .flatMap(childrenOf);
865
+ const origin = objectAttrs.find((child) => child.name === 'Origin')?.getChild('Point');
866
+ const objectId = readId(ids[0]);
867
+ const attrs = readAttrs(
868
+ objectAttrs.filter((child) => child.name === 'Attribute'),
869
+ OBJECT_ATTRS,
870
+ `object "${objectId}"`,
871
+ );
872
+ ranges.set(objectId, { from: statement.from, to: statement.to });
873
+ commands.push({
874
+ op: 'upsert-object',
875
+ object: {
876
+ id: objectId,
877
+ ...optional('origin', origin ? readPoint(origin) : undefined),
878
+ ...optional('scale', numberAttr(attrs, 'scale')),
879
+ ...optional('index', stringAttr(attrs, 'index')),
880
+ ...optional('ref', stringAttr(attrs, 'ref')),
881
+ elements: readElements(statement.getChild('Body'), objectId),
882
+ },
883
+ });
884
+ break;
885
+ }
886
+
887
+ case 'ElementsDecl':
888
+ if (ids.length > 0) {
889
+ commands.push({
890
+ op: 'upsert-elements',
891
+ objectId: readId(ids[0]),
892
+ elements: readElements(statement.getChild('Body'), readId(ids[0])),
893
+ });
894
+ }
895
+ break;
896
+
897
+ case 'MoveStmt': {
898
+ const point = statement.getChild('Origin')?.getChild('Point');
899
+ const origin = point && readPoint(point);
900
+ if (ids.length > 0 && origin) {
901
+ commands.push({ op: 'move-object', objectId: readId(ids[0]), origin });
902
+ }
903
+ break;
904
+ }
905
+
906
+ case 'RemoveObjectStmt':
907
+ if (ids.length > 0) {
908
+ commands.push({ op: 'remove-object', objectId: readId(ids[0]) });
909
+ }
910
+ break;
911
+
912
+ case 'RemoveElementsStmt':
913
+ if (ids.length > 1) {
914
+ commands.push({
915
+ op: 'remove-elements',
916
+ objectId: readId(ids[0]),
917
+ elementIds: ids.slice(1).map(readId),
918
+ });
919
+ }
920
+ break;
921
+
922
+ case 'DiagramDecl':
923
+ semantic = true;
924
+ readDiagram(statement);
925
+ break;
926
+
927
+ case 'GroupDecl':
928
+ semantic = true;
929
+ readGroup(statement, undefined);
930
+ break;
931
+
932
+ case 'NodeDecl':
933
+ semantic = true;
934
+ readNode(statement, undefined);
935
+ break;
936
+
937
+ case 'EdgeDecl':
938
+ semantic = true;
939
+ readEdge(statement);
940
+ break;
941
+ }
942
+ }
943
+
944
+ if (!semantic) {
945
+ return { commands, problems, ranges };
946
+ }
947
+ resolveSemantic();
948
+ // The layout emits objects named after nodes and groups, plus one `edges` object; a scene
949
+ // statement claiming one of those ids would silently replace part of the diagram.
950
+ const claimed = new Set([
951
+ ...diagram.nodes.map((node) => node.id),
952
+ ...diagram.groups.map((group) => group.id),
953
+ 'edges',
954
+ ]);
955
+ for (const command of commands) {
956
+ if (command.op === 'upsert-object' && claimed.has(command.object.id) && diagram.nodes.length > 0) {
957
+ const range = ranges.get(command.object.id);
958
+ problems.push({
959
+ severity: 'warning',
960
+ message: `object "${command.object.id}" replaces the laid-out ${command.object.id === 'edges' ? 'connectors' : `"${command.object.id}"`}; use \`elements ${command.object.id} { … }\` to add to it instead.`,
961
+ from: range?.from ?? 0,
962
+ to: range?.to ?? 0,
963
+ });
964
+ }
965
+ }
966
+ return { commands, problems, ranges, diagram };
967
+ };
968
+
969
+ /** A reading with its semantic diagram laid out: the layout's commands first, then the scene statements. */
970
+ export const withLayout = (reading: Reading, solution?: SemanticEngine.Solution): ParseResult => ({
971
+ commands: solution ? [...solution.commands, ...reading.commands] : reading.commands,
972
+ problems: [...reading.problems, ...(solution?.issues ?? [])],
973
+ ranges: reading.ranges,
974
+ });
975
+
976
+ /**
977
+ * Reads a document and lays out its semantic statements with the grid search, synchronously, so
978
+ * an editor can lint and preview it; see {@link compile} for the full search.
979
+ */
980
+ export const parse = (text: string): ParseResult => {
981
+ const reading = read(text);
982
+ return withLayout(reading, reading.diagram && SemanticEngine.solve(reading.diagram));
983
+ };
984
+
985
+ /** Applies commands in order, the same semantics `Scene.Command` documents. */
986
+ export const toScene = (commands: readonly Scene.Command[]): Scene.Scene => {
987
+ const objects: Scene.WorldObject[] = [];
988
+ const indexOf = (id: string) => objects.findIndex((object) => object.id === id);
989
+
990
+ for (const command of commands) {
991
+ switch (command.op) {
992
+ case 'upsert-object': {
993
+ const at = indexOf(command.object.id);
994
+ if (at === -1) {
995
+ objects.push(command.object);
996
+ } else {
997
+ // `WorldObject.origin` is documented as "omit on upsert to keep the current position",
998
+ // which is also what the canvas builder does — diverging here would give the bench and
999
+ // the linter a different scene than the drawing actually holds.
1000
+ const { origin } = objects[at];
1001
+ objects[at] =
1002
+ command.object.origin === undefined && origin !== undefined
1003
+ ? { ...command.object, origin }
1004
+ : command.object;
1005
+ }
1006
+ break;
1007
+ }
1008
+ case 'upsert-elements': {
1009
+ const at = indexOf(command.objectId);
1010
+ if (at !== -1) {
1011
+ const replaced = new Set(command.elements.map((element) => element.id));
1012
+ objects[at] = {
1013
+ ...objects[at],
1014
+ elements: [...objects[at].elements.filter((element) => !replaced.has(element.id)), ...command.elements],
1015
+ };
1016
+ }
1017
+ break;
1018
+ }
1019
+ case 'remove-elements': {
1020
+ const at = indexOf(command.objectId);
1021
+ if (at !== -1) {
1022
+ const removed = new Set(command.elementIds);
1023
+ objects[at] = {
1024
+ ...objects[at],
1025
+ elements: objects[at].elements.filter((element) => !removed.has(element.id)),
1026
+ };
1027
+ }
1028
+ break;
1029
+ }
1030
+ case 'remove-object': {
1031
+ const at = indexOf(command.objectId);
1032
+ if (at !== -1) {
1033
+ objects.splice(at, 1);
1034
+ }
1035
+ break;
1036
+ }
1037
+ case 'move-object': {
1038
+ const at = indexOf(command.objectId);
1039
+ if (at !== -1) {
1040
+ objects[at] = { ...objects[at], origin: command.origin };
1041
+ }
1042
+ break;
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ return { objects };
1048
+ };
1049
+
1050
+ /** Convenience for the common case: a scene document, with whatever problems it carried. */
1051
+ export const parseScene = (text: string): { scene: Scene.Scene; problems: Problem[] } => {
1052
+ const { commands, problems } = parse(text);
1053
+ return { scene: toScene(commands), problems };
1054
+ };