@squinch/core 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.
- package/LICENSE +202 -0
- package/NOTICE +41 -0
- package/dist/api.d.ts +91 -0
- package/dist/api.js +232 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +9 -0
- package/dist/diff/diff.d.ts +30 -0
- package/dist/diff/diff.js +365 -0
- package/dist/fonts.generated.d.ts +1 -0
- package/dist/fonts.generated.js +6 -0
- package/dist/grammar/parser.js +22 -0
- package/dist/grammar/parser.terms.js +115 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/layout/layout.d.ts +197 -0
- package/dist/layout/layout.js +1721 -0
- package/dist/metrics.d.ts +20 -0
- package/dist/metrics.generated.d.ts +4 -0
- package/dist/metrics.generated.js +4 -0
- package/dist/metrics.js +57 -0
- package/dist/model/build.d.ts +8 -0
- package/dist/model/build.js +1343 -0
- package/dist/model/packs.d.ts +13 -0
- package/dist/model/packs.js +29 -0
- package/dist/model/source.d.ts +10 -0
- package/dist/model/source.js +28 -0
- package/dist/model/suggest.d.ts +2 -0
- package/dist/model/suggest.js +24 -0
- package/dist/model/types.d.ts +226 -0
- package/dist/model/types.js +24 -0
- package/dist/packs/node-fs.d.ts +1 -0
- package/dist/packs/node-fs.js +37 -0
- package/dist/packs/registry.d.ts +61 -0
- package/dist/packs/registry.js +122 -0
- package/dist/packs/sanitize.d.ts +12 -0
- package/dist/packs/sanitize.js +127 -0
- package/dist/packs/sysGlyphs.d.ts +2 -0
- package/dist/packs/sysGlyphs.js +21 -0
- package/dist/render/adaptive.d.ts +13 -0
- package/dist/render/adaptive.js +112 -0
- package/dist/render/html/runtime.d.ts +1 -0
- package/dist/render/html/runtime.generated.d.ts +1 -0
- package/dist/render/html/runtime.generated.js +6 -0
- package/dist/render/html/runtime.js +362 -0
- package/dist/render/html.d.ts +39 -0
- package/dist/render/html.js +235 -0
- package/dist/render/svg.d.ts +75 -0
- package/dist/render/svg.js +1403 -0
- package/dist/render/validate.d.ts +4 -0
- package/dist/render/validate.js +9 -0
- package/dist/themes/index.d.ts +84 -0
- package/dist/themes/index.js +90 -0
- package/dist/view/dive.d.ts +55 -0
- package/dist/view/dive.js +57 -0
- package/dist/view/navigate.d.ts +38 -0
- package/dist/view/navigate.js +81 -0
- package/dist/view/resolve.d.ts +92 -0
- package/dist/view/resolve.js +591 -0
- package/fonts/inter-400.ttf +0 -0
- package/fonts/inter-500.ttf +0 -0
- package/fonts/inter-600.ttf +0 -0
- package/fonts/mono-400.ttf +0 -0
- package/metrics.json +510 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
import { drawable } from "../metrics.js";
|
|
2
|
+
// @ts-ignore generated
|
|
3
|
+
import { parser } from "../grammar/parser.js";
|
|
4
|
+
import { iconExists, packExists, iconIds, allPackNames } from "./packs.js";
|
|
5
|
+
import { suggest } from "./suggest.js";
|
|
6
|
+
import { normalizeFiles } from "./source.js";
|
|
7
|
+
import { themes } from "../themes/index.js";
|
|
8
|
+
import { ZONE_KINDS, HUES, EDGE_STYLES, EDGE_ANIMATE } from "./types.js";
|
|
9
|
+
export function buildModel(src) {
|
|
10
|
+
return buildProject([{ name: "input", src }]);
|
|
11
|
+
}
|
|
12
|
+
/** Every author-written string that reaches the SVG as text, with where to
|
|
13
|
+
* point when something is wrong with it. Labels, descriptions, taglines,
|
|
14
|
+
* titles and titleblock values all render verbatim; ids never do. */
|
|
15
|
+
function textsInModel(model) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const n of model.nodes.values()) {
|
|
18
|
+
out.push([`label \`${n.name}\``, n.label, n.loc, n.file]);
|
|
19
|
+
if (n.description)
|
|
20
|
+
out.push([`description of \`${n.name}\``, n.description, n.loc, n.file]);
|
|
21
|
+
}
|
|
22
|
+
for (const c of model.containers.values()) {
|
|
23
|
+
if (c.label)
|
|
24
|
+
out.push([`label \`${c.name}\``, c.label, c.loc, c.file]);
|
|
25
|
+
const d = c.attrs["description"];
|
|
26
|
+
if (d)
|
|
27
|
+
out.push([`description of \`${c.name}\``, d, c.loc, c.file]);
|
|
28
|
+
}
|
|
29
|
+
for (const e of model.edges)
|
|
30
|
+
if (e.label)
|
|
31
|
+
out.push([`edge label`, e.label, e.loc, e.file]);
|
|
32
|
+
for (const z of model.zones) {
|
|
33
|
+
if (z.label)
|
|
34
|
+
out.push([`zone \`${z.id}\``, z.label, z.loc, z.file]);
|
|
35
|
+
if (z.detail)
|
|
36
|
+
out.push([`detail of zone \`${z.id}\``, z.detail, z.loc, z.file]);
|
|
37
|
+
}
|
|
38
|
+
for (const v of model.views) {
|
|
39
|
+
if (v.title)
|
|
40
|
+
out.push([`title of view \`${v.name}\``, v.title, v.loc, v.file]);
|
|
41
|
+
for (const [k, val] of Object.entries(v.titleblock ?? {}))
|
|
42
|
+
out.push([`titleblock \`${k}\``, val, v.loc, v.file]);
|
|
43
|
+
for (const note of v.notes)
|
|
44
|
+
out.push([`note`, note.text, v.loc, v.file]);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
export function buildProject(input) {
|
|
49
|
+
// LF space from here down: every Loc, label and description a caller gets
|
|
50
|
+
// back is measured against normalized source (see model/source.ts).
|
|
51
|
+
const files = normalizeFiles(input);
|
|
52
|
+
const diagnostics = [];
|
|
53
|
+
const model = {
|
|
54
|
+
packs: [],
|
|
55
|
+
nodes: new Map(),
|
|
56
|
+
containers: new Map(),
|
|
57
|
+
edges: [],
|
|
58
|
+
zones: [],
|
|
59
|
+
flows: [],
|
|
60
|
+
views: [],
|
|
61
|
+
};
|
|
62
|
+
const makeCtx = (name, src) => ({
|
|
63
|
+
name,
|
|
64
|
+
src,
|
|
65
|
+
text: (n) => src.slice(n.from, n.to),
|
|
66
|
+
str: (n) => src.slice(n.from + 1, n.to - 1),
|
|
67
|
+
loc: (n) => {
|
|
68
|
+
const before = src.slice(0, n.from);
|
|
69
|
+
return {
|
|
70
|
+
from: n.from,
|
|
71
|
+
to: n.to,
|
|
72
|
+
line: before.split("\n").length,
|
|
73
|
+
col: n.from - before.lastIndexOf("\n"),
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const error = (ctx, at, message, fix) => diagnostics.push({
|
|
78
|
+
severity: "error", message, fix, file: ctx.name,
|
|
79
|
+
loc: "from" in at && "line" in at ? at : ctx.loc(at),
|
|
80
|
+
});
|
|
81
|
+
const warn = (ctx, at, message, fix) => diagnostics.push({
|
|
82
|
+
severity: "warning", message, fix, file: ctx.name,
|
|
83
|
+
loc: "from" in at && "line" in at ? at : ctx.loc(at),
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* A declared theme name is static — it can be checked the moment the model is
|
|
87
|
+
* built. It used to be validated only inside `render`, and `check` renders
|
|
88
|
+
* every view with an explicit `light`, so a typo'd `theme` passed `check`
|
|
89
|
+
* with "1 file(s) OK" and then failed the very next `render`. For a tool
|
|
90
|
+
* whose loop is check-then-render, check has to be the authority.
|
|
91
|
+
*/
|
|
92
|
+
const checkTheme = (ctx, at, name) => {
|
|
93
|
+
if (name in themes)
|
|
94
|
+
return;
|
|
95
|
+
const s = suggest(name, Object.keys(themes));
|
|
96
|
+
error(ctx, at, `unknown theme \`${name}\``, s ? `did you mean \`${s}\`?` : `themes: ${Object.keys(themes).join(" | ")}`);
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* `color:` takes one of the nine hue words and nothing else. Hex is refused
|
|
100
|
+
* with its own message rather than a did-you-mean: it is not a typo, it is
|
|
101
|
+
* the one thing the vocabulary exists to keep out (a literal cannot be right
|
|
102
|
+
* on both canvases, and the adaptive merge swaps tokens, not values).
|
|
103
|
+
*/
|
|
104
|
+
const checkHue = (ctx, at, value) => {
|
|
105
|
+
if (HUES.includes(value))
|
|
106
|
+
return value;
|
|
107
|
+
if (/^(#|rgb|hsl)/i.test(value))
|
|
108
|
+
error(ctx, at, `\`color\` takes a hue, never hex`, `one of: ${HUES.join(" | ")}`);
|
|
109
|
+
else {
|
|
110
|
+
const s = suggest(value, [...HUES]);
|
|
111
|
+
error(ctx, at, `unknown color \`${value}\``, s ? `did you mean \`${s}\`?` : `one of: ${HUES.join(" | ")}`);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
};
|
|
115
|
+
const attrsOf = (ctx, block) => {
|
|
116
|
+
const attrs = {};
|
|
117
|
+
const tags = [];
|
|
118
|
+
let description;
|
|
119
|
+
if (block)
|
|
120
|
+
for (const a of block.getChildren("Attr")) {
|
|
121
|
+
// Both halves are optional in a *partial* parse, which is the state the
|
|
122
|
+
// editor is in for most of the time anyone is typing: `tags:` with the
|
|
123
|
+
// value not written yet leaves an Attr with no Value, and `tags` with
|
|
124
|
+
// no colon leaves one with no Ident. Asserting either non-null threw
|
|
125
|
+
// `Cannot read properties of null`, killing the build — and with it the
|
|
126
|
+
// language server, on a keystroke. Same class as the round-4 crash on
|
|
127
|
+
// `scope`/`title`/`theme`; this pair was missed because nothing had
|
|
128
|
+
// ever fed the builder a half-written attribute.
|
|
129
|
+
const identNode = a.getChild("Ident");
|
|
130
|
+
const v = a.getChild("Value");
|
|
131
|
+
if (!identNode || !v)
|
|
132
|
+
continue;
|
|
133
|
+
const key = ctx.text(identNode);
|
|
134
|
+
const tagNodes = v.getChildren("Tag");
|
|
135
|
+
if (tagNodes.length) {
|
|
136
|
+
// Only `tags:` collects. This used to key off the *value* having Tag
|
|
137
|
+
// children, so `owner: #team` silently tagged the node — forgiving of
|
|
138
|
+
// a typo'd key, but an accidental tag is a lie in every tag lens.
|
|
139
|
+
if (key === "tags") {
|
|
140
|
+
tags.push(...tagNodes.map((t) => ctx.text(t).slice(1)));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const list = tagNodes.map((t) => ctx.text(t)).join(" ");
|
|
144
|
+
error(ctx, a, `\`${key}\` has a tag value — tags live in \`tags:\``, `write \`tags: ${list}\`, or quote it if \`${key}\` really means the text \`"${list}"\``);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (key === "description") {
|
|
148
|
+
if (description !== undefined)
|
|
149
|
+
warn(ctx, a, `\`description\` appears twice — the second wins`, `keep one`);
|
|
150
|
+
description = v.getChild("String") ? ctx.str(v.getChild("String")) : ctx.text(v);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
if (key in attrs)
|
|
154
|
+
warn(ctx, a, `\`${key}\` appears twice — the second wins`, `keep one`);
|
|
155
|
+
attrs[key] = v.getChild("String") ? ctx.str(v.getChild("String")) : ctx.text(v);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { attrs, tags, description };
|
|
159
|
+
};
|
|
160
|
+
// ── phase A: declarations, per file ───────────────────────────────────────
|
|
161
|
+
const rawEdges = [];
|
|
162
|
+
const rawViews = [];
|
|
163
|
+
const rawZones = [];
|
|
164
|
+
const rawFlows = [];
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
const ctx = makeCtx(f.name, f.src);
|
|
167
|
+
const tree = parser.parse(f.src);
|
|
168
|
+
let sawSyntaxError = false;
|
|
169
|
+
const errorLines = new Set(); // one syntax error per line, not a cascade
|
|
170
|
+
// String spans, so text hints below never fire on label text — a label may
|
|
171
|
+
// legitimately read "Orders [US, EU]", and suppressing a real syntax error
|
|
172
|
+
// because of one would be strictly worse than staying quiet.
|
|
173
|
+
const strings = [];
|
|
174
|
+
tree.iterate({
|
|
175
|
+
enter(n) {
|
|
176
|
+
if (n.name === "String")
|
|
177
|
+
strings.push([n.from, n.to]);
|
|
178
|
+
if (n.type.isError) {
|
|
179
|
+
sawSyntaxError = true;
|
|
180
|
+
const line = f.src.slice(0, n.from).split("\n").length;
|
|
181
|
+
if (errorLines.has(line))
|
|
182
|
+
return;
|
|
183
|
+
errorLines.add(line);
|
|
184
|
+
const at = f.src.slice(Math.max(0, n.from - 12), n.from + 12).replace(/\n/g, "⏎");
|
|
185
|
+
error(ctx, n.node, `syntax error near \`${at}\``);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
// The most common authoring mistake: a `layout { }` block inside a
|
|
190
|
+
// system/container. It only manifests as cascading syntax errors, so name
|
|
191
|
+
// it explicitly with the fix.
|
|
192
|
+
if (sawSyntaxError) {
|
|
193
|
+
for (const c of tree.topNode.getChildren("Container")) {
|
|
194
|
+
const bodyText = f.src.slice(c.from, c.to);
|
|
195
|
+
const m = /^[ \t]*layout[ \t]*\{/m.exec(bodyText);
|
|
196
|
+
if (!m)
|
|
197
|
+
continue;
|
|
198
|
+
const identNode = c.getChild("Ident");
|
|
199
|
+
const sys = identNode ? ctx.text(identNode) : "NAME";
|
|
200
|
+
const from = c.from + m.index + (m[0].length - m[0].trimStart().length);
|
|
201
|
+
error(ctx, ctx.loc({ from, to: from + "layout".length }), `\`layout\` block inside \`${sys}\` — layout hints live in views, not systems`, `move it below the system: view ${sys} { layout { … } }`);
|
|
202
|
+
}
|
|
203
|
+
// An opening brace on its own line. The C#/Java habit; statements end
|
|
204
|
+
// at newline, so `system s "S"` terminates and the lone `{` is noise.
|
|
205
|
+
for (const m of f.src.matchAll(/^([ \t]*(?:system|container|zone|view|flow)\b[^{\n]*?)[ \t]*\n[ \t]*\{/gm)) {
|
|
206
|
+
const at = m.index + m[0].lastIndexOf("{");
|
|
207
|
+
if (strings.some(([a2, b2]) => at >= a2 && at < b2))
|
|
208
|
+
continue;
|
|
209
|
+
error(ctx, ctx.loc({ from: at, to: at + 1 }), "the `{` must sit on the declaration's own line — statements end at newline", `write \`${m[1].trim()} {\``);
|
|
210
|
+
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
211
|
+
const d = diagnostics[i];
|
|
212
|
+
if (d.file === ctx.name && d.message.startsWith("syntax error near")
|
|
213
|
+
&& d.loc.from >= m.index && d.loc.from <= at + 1)
|
|
214
|
+
diagnostics.splice(i, 1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Fan-in. `a -> b, c` fans out, so `x, y -> z` is the guess the mirror
|
|
218
|
+
// image invites — but an edge has one source, and the comma list on the
|
|
219
|
+
// left parses as garbage.
|
|
220
|
+
for (const m of f.src.matchAll(/^[ \t]*([\w.]+(?:[ \t]*,[ \t]*[\w.]+)+)[ \t]*(->|~>|<->|--)[ \t]*([\w.]+)/gm)) {
|
|
221
|
+
if (strings.some(([a2, b2]) => m.index >= a2 && m.index < b2))
|
|
222
|
+
continue;
|
|
223
|
+
const sources = m[1].split(",").map((x) => x.trim());
|
|
224
|
+
error(ctx, ctx.loc({ from: m.index, to: m.index + m[0].length }), `an edge has one source — \`${m[1]}\` cannot fan in`, `write ${sources.map((x) => `\`${x} ${m[2]} ${m[3]}\``).join(", ")}; to draw them as one trunk, add \`channel ${m[1]} ${m[2]} ${m[3]}\` to the view's layout`);
|
|
225
|
+
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
226
|
+
const d = diagnostics[i];
|
|
227
|
+
if (d.file === ctx.name
|
|
228
|
+
&& (d.message.startsWith("syntax error near") || d.message.startsWith("unknown id"))
|
|
229
|
+
&& d.loc.from >= m.index && d.loc.from <= m.index + m[0].length + 20)
|
|
230
|
+
diagnostics.splice(i, 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// The two person forms, crossed. `person analyst "Analyst"` declares one
|
|
234
|
+
// at the top level; `analyst = person "Analyst"` declares one inline.
|
|
235
|
+
// Round 16: an agent wrote `analyst = person analyst "Analyst"` — both
|
|
236
|
+
// at once — and got a bare syntax error. Either form alone is right.
|
|
237
|
+
for (const m of f.src.matchAll(/^[ \t]*([\w-]+)[ \t]*=[ \t]*person[ \t]+([\w-]+)[ \t]+(?=")/gm)) {
|
|
238
|
+
const at = m.index + m[0].length - m[2].length - 1;
|
|
239
|
+
if (strings.some(([a, b]) => at >= a && at < b))
|
|
240
|
+
continue;
|
|
241
|
+
error(ctx, ctx.loc({ from: at, to: at + m[2].length }), `\`${m[1]} = person ${m[2]}\` names the person twice`, `write \`${m[1]} = person\` and keep the label, or \`person ${m[2]}\` on its own`);
|
|
242
|
+
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
243
|
+
const d = diagnostics[i];
|
|
244
|
+
if (d.file === ctx.name && d.message.startsWith("syntax error near")
|
|
245
|
+
&& d.loc.from >= m.index && d.loc.from <= m.index + m[0].length + 40)
|
|
246
|
+
diagnostics.splice(i, 1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// An unquoted attribute value with a space in it. `owner: team-orders`
|
|
250
|
+
// parses, so `owner: payments team` is the natural next guess — and it
|
|
251
|
+
// produced a bare syntax error pointing at the brace. Round 15 lost an
|
|
252
|
+
// iteration to exactly this. Tags are the only legitimately multi-token
|
|
253
|
+
// value and they start with `#`, so requiring a letter first keeps
|
|
254
|
+
// `tags: #a #b` out of the match.
|
|
255
|
+
for (const m of f.src.matchAll(/([\w-]+)[ \t]*:[ \t]*([A-Za-z][\w.-]*(?:[ \t]+[A-Za-z][\w.-]*)+)[ \t]*(?=[\n;,}])/g)) {
|
|
256
|
+
if (strings.some(([a, b]) => m.index >= a && m.index < b))
|
|
257
|
+
continue;
|
|
258
|
+
error(ctx, ctx.loc({ from: m.index, to: m.index + m[0].length }), `\`${m[1]}\` has a space in its value, so it needs quotes`, `write \`${m[1]}: "${m[2]}"\``);
|
|
259
|
+
const end = f.src.indexOf("\n}", m.index);
|
|
260
|
+
const stop = end === -1 ? f.src.length : end + 2;
|
|
261
|
+
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
262
|
+
const d = diagnostics[i];
|
|
263
|
+
if (d.file === ctx.name && d.message.startsWith("syntax error near")
|
|
264
|
+
&& d.loc.from >= m.index && d.loc.from <= stop)
|
|
265
|
+
diagnostics.splice(i, 1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Commas splitting a tag *value* — `tags: #pci, #core`. Everywhere else
|
|
269
|
+
// a comma is now an optional separator, but not here: after the comma an
|
|
270
|
+
// LR(1) parser cannot tell another tag from the next attribute key, so
|
|
271
|
+
// this one stays illegal and says so instead. (The rank-group version of
|
|
272
|
+
// this diagnostic is gone — `rows [a, b]` parses now.)
|
|
273
|
+
for (const m of f.src.matchAll(/[\w-]+[ \t]*:[ \t]*#[\w-]+(?:[ \t]*,[ \t]*#[\w-]+)+/g)) {
|
|
274
|
+
if (strings.some(([a, b]) => m.index >= a && m.index < b))
|
|
275
|
+
continue;
|
|
276
|
+
const comma = m.index + m[0].indexOf(",");
|
|
277
|
+
error(ctx, ctx.loc({ from: comma, to: comma + 1 }), "comma splits the tag list — tags separate with spaces", `write \`${m[0].replace(/[ \t]*,[ \t]*/g, " ")}\``);
|
|
278
|
+
// One mistake, one diagnostic. A malformed attr block derails the parse
|
|
279
|
+
// for the rest of its enclosing block, not just the matched text, so
|
|
280
|
+
// the window runs to the block's close — same widening the stray-layout
|
|
281
|
+
// case below needed, for the same reason.
|
|
282
|
+
const end = f.src.indexOf("\n}", m.index);
|
|
283
|
+
const stop = end === -1 ? f.src.length : end + 2;
|
|
284
|
+
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
285
|
+
const d = diagnostics[i];
|
|
286
|
+
if (d.file === ctx.name && d.message.startsWith("syntax error near")
|
|
287
|
+
&& d.loc.from >= m.index && d.loc.from <= stop)
|
|
288
|
+
diagnostics.splice(i, 1);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// The same mistake one level out: a `layout` block at the top of the
|
|
292
|
+
// file, belonging to no view. It fell through to a bare syntax error
|
|
293
|
+
// pointing at whatever preceded it, which names neither the problem nor
|
|
294
|
+
// the fix. `layout` is only ever legal inside a `view`.
|
|
295
|
+
const containers = tree.topNode.getChildren("Container");
|
|
296
|
+
const views = tree.topNode.getChildren("View");
|
|
297
|
+
for (const m of f.src.matchAll(/^[ \t]*layout[ \t]*\{/gm)) {
|
|
298
|
+
const at = m.index + (m[0].length - m[0].trimStart().length);
|
|
299
|
+
if ([...containers, ...views].some((n) => at >= n.from && at < n.to))
|
|
300
|
+
continue;
|
|
301
|
+
// if the file already declares a view, put it there; otherwise name one
|
|
302
|
+
const first = views[0]?.getChild("Path");
|
|
303
|
+
const into = first ? ctx.text(first) : "main";
|
|
304
|
+
error(ctx, ctx.loc({ from: at, to: at + "layout".length }), "`layout` block at the top level — layout hints live inside a view", first
|
|
305
|
+
? `move it inside \`view ${into}\``
|
|
306
|
+
: `wrap it: view ${into} { include * layout { … } }`);
|
|
307
|
+
// One mistake, one diagnostic. The stray block derails the parser for
|
|
308
|
+
// the rest of the file, so it also produced a bare `syntax error near`
|
|
309
|
+
// for every line of itself — noise in front of the message that
|
|
310
|
+
// actually names the cause.
|
|
311
|
+
const end = f.src.indexOf("\n}", at);
|
|
312
|
+
const stop = end === -1 ? f.src.length : end + 2;
|
|
313
|
+
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
314
|
+
const d = diagnostics[i];
|
|
315
|
+
if (d.file === ctx.name && d.message.startsWith("syntax error near")
|
|
316
|
+
&& d.loc.from >= at && d.loc.from <= stop)
|
|
317
|
+
diagnostics.splice(i, 1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Declare one node; works at any depth, including the file top level. */
|
|
322
|
+
function declareNode(decl, parentPath) {
|
|
323
|
+
const identNode = decl.getChild("Ident");
|
|
324
|
+
if (!identNode)
|
|
325
|
+
return; // partial node from error recovery
|
|
326
|
+
const name = ctx.text(identNode);
|
|
327
|
+
const path = parentPath ? `${parentPath}.${name}` : name;
|
|
328
|
+
const clash = model.nodes.get(path) ?? model.containers.get(path);
|
|
329
|
+
if (clash) {
|
|
330
|
+
error(ctx, decl, `duplicate id \`${name}\` in ${parentPath || "file"}`, clash.file && clash.file !== ctx.name
|
|
331
|
+
? `already declared in ${clash.file}`
|
|
332
|
+
: `ids must be unique within their container`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const iconRef = decl.getChild("IconRef");
|
|
336
|
+
let icon;
|
|
337
|
+
if (!iconRef && decl.getChild("box"))
|
|
338
|
+
icon = { pack: "builtin", id: "box" };
|
|
339
|
+
if (iconRef && iconRef.getChildren("Ident").length === 2) {
|
|
340
|
+
const [p, i] = iconRef.getChildren("Ident").map(ctx.text);
|
|
341
|
+
if (!packExists(p)) {
|
|
342
|
+
const s = suggest(p, allPackNames());
|
|
343
|
+
error(ctx, iconRef, `unknown pack \`${p}\``, s ? `did you mean \`${s}\`?` : undefined);
|
|
344
|
+
}
|
|
345
|
+
else if (!iconExists(p, i)) {
|
|
346
|
+
const s = suggest(i, iconIds(p));
|
|
347
|
+
error(ctx, iconRef, `unknown icon \`${p}/${i}\``, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
|
|
348
|
+
}
|
|
349
|
+
else
|
|
350
|
+
icon = { pack: p, id: i };
|
|
351
|
+
}
|
|
352
|
+
const meta = attrsOf(ctx, decl.getChild("AttrBlock"));
|
|
353
|
+
// `badge:` is an icon reference like `glyph:` and gets the same two
|
|
354
|
+
// errors — an unchecked ref would draw a blank plate, exit 0, and leave
|
|
355
|
+
// the typo to be noticed by eye. The value is in practice a `logos/*`
|
|
356
|
+
// brand mark composited onto the icon plate (SPEC §nodes): the sanctioned
|
|
357
|
+
// way to say "this thing is Databricks'" for vendors that publish no
|
|
358
|
+
// icon grant.
|
|
359
|
+
if (meta.attrs["badge"]) {
|
|
360
|
+
const [p, i] = meta.attrs["badge"].split("/");
|
|
361
|
+
if (!p || !i || !packExists(p)) {
|
|
362
|
+
const s = p && suggest(p, allPackNames());
|
|
363
|
+
error(ctx, decl, `unknown pack \`${p ?? meta.attrs["badge"]}\` in badge`, s ? `did you mean \`${s}/${i ?? ""}\`?` : `use \`badge: <pack>/<id>\``);
|
|
364
|
+
}
|
|
365
|
+
else if (!iconExists(p, i)) {
|
|
366
|
+
const s = suggest(i, iconIds(p));
|
|
367
|
+
error(ctx, decl, `unknown icon \`${p}/${i}\` in badge`, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const kinds = decl.getChildren("NodeKind").map((k) => ctx.text(k));
|
|
371
|
+
// `= person "Name"` is the same node the top-level `person id "Label"`
|
|
372
|
+
// form builds. Only a direct child matches here — a `person` used as a
|
|
373
|
+
// kind sits inside a `NodeKind`, so the two never collide.
|
|
374
|
+
if (!iconRef && decl.getChild("person")) {
|
|
375
|
+
icon = { pack: "builtin", id: "person" };
|
|
376
|
+
if (!kinds.includes("person"))
|
|
377
|
+
kinds.push("person");
|
|
378
|
+
}
|
|
379
|
+
const labelNode = decl.getChild("String");
|
|
380
|
+
// `#tag` written in kind position (grammar: NodeDecl). Merged with any
|
|
381
|
+
// block `tags:` and de-duped, so both spellings compose on one node.
|
|
382
|
+
const headTags = decl.getChildren("Tag").map((t) => ctx.text(t).slice(1));
|
|
383
|
+
model.nodes.set(path, {
|
|
384
|
+
path, name,
|
|
385
|
+
label: labelNode ? ctx.str(labelNode) : name,
|
|
386
|
+
icon, kinds,
|
|
387
|
+
description: meta.description,
|
|
388
|
+
tags: [...new Set([...headTags, ...meta.tags])],
|
|
389
|
+
attrs: meta.attrs,
|
|
390
|
+
color: meta.attrs["color"] ? checkHue(ctx, decl, meta.attrs["color"]) : undefined,
|
|
391
|
+
loc: ctx.loc(decl),
|
|
392
|
+
file: ctx.name,
|
|
393
|
+
});
|
|
394
|
+
model.containers.get(parentPath)?.children.push(path);
|
|
395
|
+
}
|
|
396
|
+
function walkContainer(body, parentPath) {
|
|
397
|
+
for (const decl of body.getChildren("NodeDecl"))
|
|
398
|
+
declareNode(decl, parentPath);
|
|
399
|
+
for (const sub of body.getChildren("Container"))
|
|
400
|
+
walkContainerDecl(sub, parentPath);
|
|
401
|
+
for (const e of body.getChildren("EdgeStmt"))
|
|
402
|
+
rawEdges.push({ node: e, scope: parentPath, ctx });
|
|
403
|
+
const meta = attrsOf(ctx, body);
|
|
404
|
+
const c = model.containers.get(parentPath);
|
|
405
|
+
if (c) {
|
|
406
|
+
Object.assign(c.attrs, meta.attrs);
|
|
407
|
+
if (meta.description)
|
|
408
|
+
c.attrs["description"] = meta.description;
|
|
409
|
+
c.tags.push(...meta.tags);
|
|
410
|
+
if (meta.attrs["color"])
|
|
411
|
+
c.color = checkHue(ctx, body, meta.attrs["color"]);
|
|
412
|
+
// `glyph:` used to be the one icon reference nobody checked: the view
|
|
413
|
+
// layer splits it on `/` and shrugs, so a typo drew a `?` plate, exited
|
|
414
|
+
// 0, and left you to notice by eye. Same two errors as a zone `icon:`.
|
|
415
|
+
if (meta.attrs["glyph"]) {
|
|
416
|
+
const [p, i] = meta.attrs["glyph"].split("/");
|
|
417
|
+
if (!p || !i || !packExists(p)) {
|
|
418
|
+
const s = p && suggest(p, allPackNames());
|
|
419
|
+
error(ctx, body, `unknown pack \`${p ?? meta.attrs["glyph"]}\` in glyph`, s ? `did you mean \`${s}/${i ?? ""}\`?` : `use \`glyph: <pack>/<id>\``);
|
|
420
|
+
}
|
|
421
|
+
else if (!iconExists(p, i)) {
|
|
422
|
+
const s = suggest(i, iconIds(p));
|
|
423
|
+
error(ctx, body, `unknown icon \`${p}/${i}\` in glyph`, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function walkContainerDecl(decl, parentPath) {
|
|
429
|
+
const identNode = decl.getChild("Ident");
|
|
430
|
+
if (!identNode)
|
|
431
|
+
return; // partial node from error recovery; syntax error already reported
|
|
432
|
+
const name = ctx.text(identNode);
|
|
433
|
+
const path = parentPath ? `${parentPath}.${name}` : name;
|
|
434
|
+
const clash = model.nodes.get(path) ?? model.containers.get(path);
|
|
435
|
+
if (clash) {
|
|
436
|
+
error(ctx, decl, `duplicate id \`${name}\``, clash.file && clash.file !== ctx.name ? `already declared in ${clash.file}` : undefined);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const labelNode = decl.getChild("String");
|
|
440
|
+
const kind = decl.getChild("system") ? "system" : "container";
|
|
441
|
+
// Only `external` means anything on a container: DESIGN §3 gives it the
|
|
442
|
+
// hatched card surface, and "someone else's system" is a fact about the
|
|
443
|
+
// whole system. `datastore` and `person` describe a single node and have
|
|
444
|
+
// no card treatment, so they are refused rather than quietly kept.
|
|
445
|
+
const kinds = [];
|
|
446
|
+
for (const k of decl.getChildren("NodeKind")) {
|
|
447
|
+
const word = ctx.text(k);
|
|
448
|
+
if (word === "external") {
|
|
449
|
+
if (!kinds.length)
|
|
450
|
+
kinds.push("external");
|
|
451
|
+
}
|
|
452
|
+
else
|
|
453
|
+
error(ctx, k, `\`${word}\` on \`${kind} ${name}\` — only \`external\` applies to a ${kind}`, `\`${word}\` describes one node: put it on a node inside \`${name}\`, or drop it`);
|
|
454
|
+
}
|
|
455
|
+
model.containers.set(path, {
|
|
456
|
+
path, name, kind: kind, kinds,
|
|
457
|
+
label: labelNode ? ctx.str(labelNode) : undefined,
|
|
458
|
+
// Head tags (`system s "S" #core {`); a body `tags:` pushes on top of
|
|
459
|
+
// these in walkContainer, and effectiveTags de-dupes downstream.
|
|
460
|
+
children: [], attrs: {},
|
|
461
|
+
tags: decl.getChildren("Tag").map((t) => ctx.text(t).slice(1)),
|
|
462
|
+
loc: ctx.loc(decl), file: ctx.name,
|
|
463
|
+
});
|
|
464
|
+
model.containers.get(parentPath)?.children.push(path);
|
|
465
|
+
const body = decl.getChild("ContainerBody");
|
|
466
|
+
if (body)
|
|
467
|
+
walkContainer(body, path);
|
|
468
|
+
}
|
|
469
|
+
const top = tree.topNode;
|
|
470
|
+
for (const p of top.getChildren("PackStmt")) {
|
|
471
|
+
const identNode = p.getChild("Ident"); // `pack` alone, mid-typing
|
|
472
|
+
if (!identNode)
|
|
473
|
+
continue;
|
|
474
|
+
const name = ctx.text(identNode);
|
|
475
|
+
if (!packExists(name)) {
|
|
476
|
+
const s = suggest(name, allPackNames());
|
|
477
|
+
error(ctx, p, `unknown pack \`${name}\``, s ? `did you mean \`${s}\`?` : undefined);
|
|
478
|
+
}
|
|
479
|
+
else if (!model.packs.includes(name))
|
|
480
|
+
model.packs.push(name);
|
|
481
|
+
}
|
|
482
|
+
const ft = top.getChildren("FileTheme")[0];
|
|
483
|
+
const ftIdent = ft?.getChild("Ident");
|
|
484
|
+
if (ft && !ftIdent)
|
|
485
|
+
error(ctx, ft, "`theme` needs a theme name", "theme dark");
|
|
486
|
+
else if (ftIdent) {
|
|
487
|
+
model.fileTheme = ctx.text(ftIdent);
|
|
488
|
+
checkTheme(ctx, ftIdent, model.fileTheme);
|
|
489
|
+
}
|
|
490
|
+
for (const p of top.getChildren("PersonDecl")) {
|
|
491
|
+
const identNode = p.getChild("Ident");
|
|
492
|
+
if (!identNode)
|
|
493
|
+
continue; // partial node from error recovery
|
|
494
|
+
const name = ctx.text(identNode);
|
|
495
|
+
const labelNode = p.getChild("String");
|
|
496
|
+
model.nodes.set(name, {
|
|
497
|
+
path: name, name,
|
|
498
|
+
label: labelNode ? ctx.str(labelNode) : name,
|
|
499
|
+
icon: { pack: "builtin", id: "person" },
|
|
500
|
+
kinds: ["person"],
|
|
501
|
+
tags: p.getChildren("Tag").map((t) => ctx.text(t).slice(1)),
|
|
502
|
+
attrs: {}, loc: ctx.loc(p), file: ctx.name,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
for (const decl of top.getChildren("NodeDecl"))
|
|
506
|
+
declareNode(decl, "");
|
|
507
|
+
for (const decl of top.getChildren("Container"))
|
|
508
|
+
walkContainerDecl(decl, "");
|
|
509
|
+
for (const e of top.getChildren("EdgeStmt"))
|
|
510
|
+
rawEdges.push({ node: e, scope: "", ctx });
|
|
511
|
+
for (const z of top.getChildren("ZoneDecl"))
|
|
512
|
+
rawZones.push({ node: z, ctx });
|
|
513
|
+
for (const fl of top.getChildren("FlowDecl"))
|
|
514
|
+
rawFlows.push({ node: fl, ctx });
|
|
515
|
+
for (const v of top.getChildren("View"))
|
|
516
|
+
rawViews.push({ node: v, ctx });
|
|
517
|
+
}
|
|
518
|
+
// ── phase B: resolution across all files ─────────────────────────────────
|
|
519
|
+
const allPaths = () => [...model.nodes.keys(), ...model.containers.keys()];
|
|
520
|
+
// id → the names it lists, read straight off the parse tree rather than from
|
|
521
|
+
// `model.zones`, which is not built until phase C: the outer boundary is
|
|
522
|
+
// normally written first, so by the time its `contains` is resolved the inner
|
|
523
|
+
// zone it names has not been seen yet, and a fix that cannot list the members
|
|
524
|
+
// to copy is barely a fix.
|
|
525
|
+
const zoneMemberNames = new Map();
|
|
526
|
+
for (const { node, ctx } of rawZones) {
|
|
527
|
+
const i = node.getChild("Ident");
|
|
528
|
+
const body = node.getChild("ZoneBody");
|
|
529
|
+
if (!i)
|
|
530
|
+
continue;
|
|
531
|
+
// A zone sharing a name with a node or container is legal to the parser
|
|
532
|
+
// and poison to everything downstream: both are addressable in `rows`, so
|
|
533
|
+
// `rows [vpc]` would rank one of them without saying which, and "a zone id
|
|
534
|
+
// is an error where a node is meant" presumes the names never collide.
|
|
535
|
+
const zid = ctx.text(i);
|
|
536
|
+
if (model.nodes.has(zid) || model.containers.has(zid))
|
|
537
|
+
error(ctx, i, `zone \`${zid}\` has the same id as a ${model.nodes.has(zid) ? "node" : "system"}`, `rename one — \`rows\`, \`place\` and edges could name either, and the pick would be silent`);
|
|
538
|
+
zoneMemberNames.set(ctx.text(i), (body?.getChildren("ContainsStmt") ?? []).flatMap((c) => c.getChild("PathList")?.getChildren("Path").map((p) => ctx.text(p)) ?? []));
|
|
539
|
+
}
|
|
540
|
+
/** `zones: true` for the three ranking hints. A zone is one unit for layout —
|
|
541
|
+
* SPEC §5: "ranks apply to the zone as a whole … `rows` can pin a zone by its
|
|
542
|
+
* id" — and `unitOf` has always mapped a zone id to itself, so the engine
|
|
543
|
+
* could rank one all along. Only this function said otherwise, which made a
|
|
544
|
+
* documented capability a hard error and cost agents a `check` in three
|
|
545
|
+
* separate rounds. Everywhere else a zone id is still wrong: you cannot draw
|
|
546
|
+
* an edge to a boundary, and `contains` takes nodes. */
|
|
547
|
+
function resolve(ref, scope, at, ctx, opts = {}) {
|
|
548
|
+
if (opts.zones && zoneMemberNames.has(ref))
|
|
549
|
+
return ref;
|
|
550
|
+
const scopes = [];
|
|
551
|
+
let s = scope;
|
|
552
|
+
while (s) {
|
|
553
|
+
scopes.push(s);
|
|
554
|
+
s = s.includes(".") ? s.slice(0, s.lastIndexOf(".")) : "";
|
|
555
|
+
}
|
|
556
|
+
scopes.push("");
|
|
557
|
+
for (const sc of scopes) {
|
|
558
|
+
const candidate = sc ? `${sc}.${ref}` : ref;
|
|
559
|
+
if (model.nodes.has(candidate) || model.containers.has(candidate))
|
|
560
|
+
return candidate;
|
|
561
|
+
}
|
|
562
|
+
// Naming a zone where a node belongs is the one wrong guess that reads as
|
|
563
|
+
// right: zones nest by *sharing members*, so the outer one repeats the
|
|
564
|
+
// inner one's leaves rather than naming it. Two of twenty round-5 agents
|
|
565
|
+
// wrote `zone account { contains gw, vnet }` with `vnet` a zone of its own,
|
|
566
|
+
// and "unknown id `vnet`" — with no suggestion, since no node is remotely
|
|
567
|
+
// like it — told them nothing about which of the two ideas was wrong.
|
|
568
|
+
// Every zone id, not just the ones declared above this point: zone order in
|
|
569
|
+
// the file is free, and the outer boundary is usually written first.
|
|
570
|
+
// An "id" containing a comma is never a real reference — idents cannot
|
|
571
|
+
// hold one — it is error-recovery debris from a malformed statement (a
|
|
572
|
+
// fan-in like `x, y -> z`), and that line already carries a targeted
|
|
573
|
+
// syntax diagnostic. Reporting `unknown id \`x, y\`` on top adds a second
|
|
574
|
+
// error that reads as a second mistake.
|
|
575
|
+
if (ref.includes(","))
|
|
576
|
+
return undefined;
|
|
577
|
+
if (zoneMemberNames.has(ref)) {
|
|
578
|
+
const inner = zoneMemberNames.get(ref);
|
|
579
|
+
error(ctx, at, `\`${ref}\` is a zone, not a node`, `zones nest by containing the same members — list ${inner.length ? `\`${inner.join("`, `")}\`` : `\`${ref}\`'s own members`} here too, rather than naming \`${ref}\``);
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
// suggest full paths: a bare `create` that only exists as `a.b.create`
|
|
583
|
+
// must be shown with its path, or the fix reads as a no-op.
|
|
584
|
+
const leaf = ref.split(".").pop();
|
|
585
|
+
const candidates = allPaths();
|
|
586
|
+
const sameLeaf = candidates.filter((p) => p.split(".").pop() === leaf && p !== ref);
|
|
587
|
+
const sug = sameLeaf[0] ?? suggest(ref, candidates) ?? suggest(leaf, candidates.map((p) => p.split(".").pop()));
|
|
588
|
+
const shown = sug && !candidates.includes(sug)
|
|
589
|
+
? candidates.find((p) => p.split(".").pop() === sug) ?? sug
|
|
590
|
+
: sug;
|
|
591
|
+
error(ctx, at, `unknown id \`${ref}\``, shown ? `did you mean \`${shown}\`?` : undefined);
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
let edgeN = 0;
|
|
595
|
+
for (const { node, scope, ctx } of rawEdges) {
|
|
596
|
+
const pathNode = node.getChild("Path");
|
|
597
|
+
const arrowNode = node.getChild("Arrow");
|
|
598
|
+
if (!pathNode || !arrowNode)
|
|
599
|
+
continue; // partial node from error recovery
|
|
600
|
+
const fromPath = resolve(ctx.text(pathNode), scope, node, ctx);
|
|
601
|
+
const arrow = ctx.text(arrowNode);
|
|
602
|
+
const labelNode = node.getChild("String");
|
|
603
|
+
const meta = attrsOf(ctx, node.getChild("AttrBlock"));
|
|
604
|
+
// an edge mid-typing (`a -> `) parses without a PathList — the editor
|
|
605
|
+
// asks us to build on every keystroke, so partial trees must not throw
|
|
606
|
+
const targets = node.getChild("PathList")?.getChildren("Path") ?? [];
|
|
607
|
+
// Attr validation, once per statement. Attrs used to be stored verbatim
|
|
608
|
+
// with no checking, so `animate: banana` rendered as ordinary flow and
|
|
609
|
+
// `animte: false` was a silent no-op — exactly the dropped-hint class the
|
|
610
|
+
// check contract forbids. Values follow the zone-colour pattern; keys get
|
|
611
|
+
// a warning because SPEC names an attr (`description`) that parses ahead
|
|
612
|
+
// of being wired.
|
|
613
|
+
const EDGE_ATTR_KEYS = ["description", "animate", "style", "color"];
|
|
614
|
+
const color = meta.attrs.color ? checkHue(ctx, node, meta.attrs.color) : undefined;
|
|
615
|
+
for (const key of Object.keys(meta.attrs)) {
|
|
616
|
+
if (EDGE_ATTR_KEYS.includes(key))
|
|
617
|
+
continue;
|
|
618
|
+
const sug = suggest(key, EDGE_ATTR_KEYS);
|
|
619
|
+
warn(ctx, node, `unknown edge attribute \`${key}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${EDGE_ATTR_KEYS.join(", ")}`);
|
|
620
|
+
}
|
|
621
|
+
const style = meta.attrs.style;
|
|
622
|
+
if (style !== undefined && !EDGE_STYLES.includes(style)) {
|
|
623
|
+
const sug = suggest(style, [...EDGE_STYLES]);
|
|
624
|
+
error(ctx, node, `unknown edge style \`${style}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${EDGE_STYLES.join(", ")}`);
|
|
625
|
+
}
|
|
626
|
+
const animate = meta.attrs.animate;
|
|
627
|
+
if (animate !== undefined && !EDGE_ANIMATE.includes(animate)) {
|
|
628
|
+
const sug = suggest(animate, [...EDGE_ANIMATE]);
|
|
629
|
+
error(ctx, node, `unknown animate value \`${animate}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${EDGE_ANIMATE.join(", ")}`);
|
|
630
|
+
}
|
|
631
|
+
if (arrow === "~>" && style === "solid")
|
|
632
|
+
error(ctx, node, "async edges are dashed by design — `style: solid` would erase the convention", "use `style: dotted` for a different pattern, or a sync arrow `->` if the call is synchronous");
|
|
633
|
+
// `comet` is deliberately absent: it rides a separate element rather than
|
|
634
|
+
// the stroke, so it needs no dash pattern to be visible. That makes it the
|
|
635
|
+
// only way to animate a synchronous edge without inventing a dash the
|
|
636
|
+
// author never asked for — a feature, not an oversight in this list.
|
|
637
|
+
const travels = animate === "flow" || animate === "reverse" || animate === "slow" || animate === "fast";
|
|
638
|
+
if (arrow !== "~>" && travels && style !== "dashed" && style !== "dotted")
|
|
639
|
+
error(ctx, node, `\`animate: ${animate}\` needs a visible pattern to travel, and this edge is solid`, "add `style: dashed` (or `dotted`), or use `animate: pulse`, which works on solid lines");
|
|
640
|
+
if (animate === "packets" && style !== undefined)
|
|
641
|
+
error(ctx, node, "`animate: packets` draws its own sparse pattern — `style:` has nothing to add", "drop the `style:` attribute");
|
|
642
|
+
for (const target of targets) {
|
|
643
|
+
const toPath = resolve(ctx.text(target), scope, target, ctx);
|
|
644
|
+
edgeN++;
|
|
645
|
+
if (!fromPath || !toPath)
|
|
646
|
+
continue;
|
|
647
|
+
model.edges.push({
|
|
648
|
+
id: `e${edgeN}`,
|
|
649
|
+
from: fromPath, to: toPath, arrow,
|
|
650
|
+
label: labelNode ? ctx.str(labelNode) : undefined,
|
|
651
|
+
attrs: meta.attrs,
|
|
652
|
+
// trailing `#tag`s merge with any in the attr block, same as a node's
|
|
653
|
+
tags: [...new Set([...node.getChildren("Tag").map((t) => ctx.text(t).slice(1)), ...meta.tags])],
|
|
654
|
+
color,
|
|
655
|
+
loc: ctx.loc(node), file: ctx.name,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// A dashed sync edge in a model with (default-dashed) async edges wears the
|
|
660
|
+
// async convention: same pattern, and the same stroke once `color:` is on
|
|
661
|
+
// both. `dotted` is the sanctioned alternative, so the fix is one word.
|
|
662
|
+
if (model.edges.some((e) => e.arrow === "~>" && e.attrs.style !== "dotted"))
|
|
663
|
+
for (const e of model.edges)
|
|
664
|
+
if (e.arrow !== "~>" && e.attrs.style === "dashed")
|
|
665
|
+
diagnostics.push({
|
|
666
|
+
severity: "warning",
|
|
667
|
+
message: "a dashed sync edge is indistinguishable from an async one — dashes are the async convention",
|
|
668
|
+
fix: "use `style: dotted`, or `~>` if the call really is asynchronous",
|
|
669
|
+
loc: e.loc, file: e.file,
|
|
670
|
+
});
|
|
671
|
+
// duplicate edges (same endpoints + label) merge with a warning
|
|
672
|
+
const seen = new Map();
|
|
673
|
+
model.edges = model.edges.filter((e) => {
|
|
674
|
+
const key = `${e.from}|${e.arrow}|${e.to}|${e.label ?? ""}`;
|
|
675
|
+
if (seen.has(key)) {
|
|
676
|
+
diagnostics.push({
|
|
677
|
+
severity: "warning",
|
|
678
|
+
message: `duplicate edge ${e.from} ${e.arrow} ${e.to} merged`,
|
|
679
|
+
loc: e.loc, file: e.file,
|
|
680
|
+
});
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
seen.set(key, e);
|
|
684
|
+
return true;
|
|
685
|
+
});
|
|
686
|
+
// DESIGN §3: "Lint nudges labels > ~40 chars." A label wraps to at most two
|
|
687
|
+
// lines and is then ellipsized, so past this it is not a styling preference —
|
|
688
|
+
// the reader loses text that is still in the source, and only a `<title>` or
|
|
689
|
+
// a hover recovers it. 40 is DESIGN's number and it is well chosen: across
|
|
690
|
+
// the 456 labels this repo owns, exactly 4 exceed it, all of them in the
|
|
691
|
+
// lookbook case that exists to stress long labels.
|
|
692
|
+
const LABEL_MAX = 40;
|
|
693
|
+
for (const n of [...model.nodes.values(), ...model.containers.values()]) {
|
|
694
|
+
const label = ("label" in n && n.label) || n.name;
|
|
695
|
+
if (label.length <= LABEL_MAX)
|
|
696
|
+
continue;
|
|
697
|
+
warn({ name: n.file ?? "input" }, n.loc, `label is ${label.length} characters — it will be cut off`, "labels wrap to two lines then ellipsize: shorten it and put the detail in `description:`");
|
|
698
|
+
}
|
|
699
|
+
// Text the bundled faces cannot draw. `measure` falls back to an average
|
|
700
|
+
// advance for an unknown character, so layout stays sane and nothing errors
|
|
701
|
+
// — but the glyph is not in the subset, so it renders as *nothing*. A
|
|
702
|
+
// committed lookbook title read "Payments — money path" and drew "Payments
|
|
703
|
+
// money path" for as long as the em dash sat outside the repertoire, and no
|
|
704
|
+
// gate noticed, because the SVG was valid and deterministic either way.
|
|
705
|
+
//
|
|
706
|
+
// The repertoire covers ASCII plus the punctuation people type; anything
|
|
707
|
+
// past it (an accented name, a CJK label) is a real limitation of a
|
|
708
|
+
// subsetted bundle and the author deserves to hear it here rather than find
|
|
709
|
+
// a hole in the render.
|
|
710
|
+
const undrawable = (text) => [...new Set([...text])].filter((ch) => ch !== "\n" && !drawable(ch));
|
|
711
|
+
for (const [where, text, loc, file] of textsInModel(model)) {
|
|
712
|
+
const missing = undrawable(text);
|
|
713
|
+
if (!missing.length)
|
|
714
|
+
continue;
|
|
715
|
+
warn({ name: file ?? "input" }, loc, `${where} uses ${missing.map((c) => `\`${c}\``).join(", ")}, which the bundled font cannot draw`, `it renders as a gap, not a glyph — use an ASCII equivalent, or open an issue to add ` +
|
|
716
|
+
`${missing.length === 1 ? "it" : "them"} to the subset`);
|
|
717
|
+
}
|
|
718
|
+
// An empty container is legal but almost never meant. It still gets an auto
|
|
719
|
+
// view (SPEC §5), and that view has nothing in it — which used to render a
|
|
720
|
+
// 0×0 SVG that `render` called ok and resvg then refused. A cold agent wrote
|
|
721
|
+
// `system partner "Partner System" external { }` as a stand-in for someone
|
|
722
|
+
// else's estate, which is a node, not a system: you are not modelling its
|
|
723
|
+
// insides, so there is no altitude to descend to.
|
|
724
|
+
for (const c of model.containers.values()) {
|
|
725
|
+
if (c.children.length)
|
|
726
|
+
continue;
|
|
727
|
+
const label = c.label ? `"${c.label}"` : `"${c.name}"`;
|
|
728
|
+
warn({ name: c.file ?? "input" }, c.loc, `\`${c.kind} ${c.name}\` is empty`, `a ${c.kind} you are not breaking down is a node: `
|
|
729
|
+
+ `\`${c.name} = box ${label}${c.kinds.includes("external") ? " external" : ""}\``);
|
|
730
|
+
}
|
|
731
|
+
// ── phase C: views ────────────────────────────────────────────────────────
|
|
732
|
+
// flows: chains resolve against the finished namespace AND must walk
|
|
733
|
+
// existing edges — a flow annotates structure, it never creates it
|
|
734
|
+
for (const { node: fl, ctx } of rawFlows) {
|
|
735
|
+
const identNode = fl.getChild("Ident");
|
|
736
|
+
const bodyNode = fl.getChild("FlowBody");
|
|
737
|
+
if (!identNode || !bodyNode)
|
|
738
|
+
continue; // partial node from error recovery
|
|
739
|
+
const id = ctx.text(identNode);
|
|
740
|
+
if (model.flows.some((f) => f.id === id)) {
|
|
741
|
+
error(ctx, fl, `duplicate flow \`${id}\``);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const labelNode = fl.getChild("String");
|
|
745
|
+
// flows read best with bare ids (SPEC example: api -> create -> db) — a
|
|
746
|
+
// bare ref binds when exactly one node path ends with it
|
|
747
|
+
const resolveFlowPath = (ref, at) => {
|
|
748
|
+
if (model.nodes.has(ref) || model.containers.has(ref))
|
|
749
|
+
return ref;
|
|
750
|
+
if (!ref.includes(".")) {
|
|
751
|
+
const matches = [...model.nodes.keys()].filter((k) => k.endsWith(`.${ref}`));
|
|
752
|
+
if (matches.length === 1)
|
|
753
|
+
return matches[0];
|
|
754
|
+
if (matches.length > 1) {
|
|
755
|
+
error(ctx, at, `ambiguous flow step \`${ref}\``, `use a full path: ${matches.join(" | ")}`);
|
|
756
|
+
return undefined;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return resolve(ref, "", at, ctx);
|
|
760
|
+
};
|
|
761
|
+
const steps = [];
|
|
762
|
+
for (const chain of bodyNode.getChildren("FlowChain")) {
|
|
763
|
+
const paths = chain.getChildren("Path").map((p) => resolveFlowPath(ctx.text(p), p));
|
|
764
|
+
for (let i = 0; i < paths.length - 1; i++) {
|
|
765
|
+
const from = paths[i], to = paths[i + 1];
|
|
766
|
+
if (!from || !to)
|
|
767
|
+
continue;
|
|
768
|
+
const exists = model.edges.some((e) => (e.from === from && e.to === to) ||
|
|
769
|
+
(e.arrow === "<->" && e.from === to && e.to === from));
|
|
770
|
+
if (!exists) {
|
|
771
|
+
error(ctx, chain, `flow \`${id}\` step ${steps.length + 1}: no edge \`${from}\` → \`${to}\``, `flows number existing edges — declare \`${from} -> ${to}\` first`);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
steps.push({ from, to });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (steps.length === 0)
|
|
778
|
+
warn(ctx, fl, `flow \`${id}\` has no steps`);
|
|
779
|
+
model.flows.push({
|
|
780
|
+
id, steps,
|
|
781
|
+
label: labelNode ? ctx.str(labelNode) : undefined,
|
|
782
|
+
loc: ctx.loc(fl), file: ctx.name,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
// zones: members resolve against the finished namespace (SPEC §Zones)
|
|
786
|
+
for (const { node: z, ctx } of rawZones) {
|
|
787
|
+
const identNode = z.getChild("Ident");
|
|
788
|
+
const bodyNode = z.getChild("ZoneBody");
|
|
789
|
+
if (!identNode || !bodyNode)
|
|
790
|
+
continue; // partial node from error recovery
|
|
791
|
+
const id = ctx.text(identNode);
|
|
792
|
+
if (model.zones.some((existing) => existing.id === id)) {
|
|
793
|
+
error(ctx, z, `duplicate zone \`${id}\``, `zone ids are global — rename one of the declarations`);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const labelNode = z.getChild("String");
|
|
797
|
+
const kindNode = z.getChild("ZoneKind");
|
|
798
|
+
let kind = "custom";
|
|
799
|
+
if (kindNode) {
|
|
800
|
+
const raw = ctx.text(kindNode);
|
|
801
|
+
if (ZONE_KINDS.includes(raw))
|
|
802
|
+
kind = raw;
|
|
803
|
+
else {
|
|
804
|
+
const sug = suggest(raw, [...ZONE_KINDS]);
|
|
805
|
+
error(ctx, kindNode, `unknown zone kind \`${raw}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${ZONE_KINDS.join(", ")}`);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const members = [];
|
|
810
|
+
// `contains <zone-id>` is sugar for the inner zone's own members. Zones
|
|
811
|
+
// nest by *sharing* leaves, and agents reach for naming the inner zone
|
|
812
|
+
// instead — the diagnostic below `resolve` teaches the expansion, and this
|
|
813
|
+
// just performs it, which is what that fix text already tells them to type.
|
|
814
|
+
// Recursive, because the inner zone may itself be written that way; `seen`
|
|
815
|
+
// makes a cycle (or a zone naming itself) terminate as an empty expansion,
|
|
816
|
+
// caught downstream by the has-no-members warning.
|
|
817
|
+
const expandZone = (ref, seen) => {
|
|
818
|
+
if (seen.has(ref))
|
|
819
|
+
return [];
|
|
820
|
+
seen.add(ref);
|
|
821
|
+
return (zoneMemberNames.get(ref) ?? []).flatMap((m) => zoneMemberNames.has(m) ? expandZone(m, seen) : [m]);
|
|
822
|
+
};
|
|
823
|
+
const viaSugar = new Set();
|
|
824
|
+
for (const c of bodyNode.getChildren("ContainsStmt")) {
|
|
825
|
+
const list = c.getChild("PathList");
|
|
826
|
+
if (!list)
|
|
827
|
+
continue;
|
|
828
|
+
for (const p of list.getChildren("Path")) {
|
|
829
|
+
const raw = ctx.text(p);
|
|
830
|
+
const sugar = zoneMemberNames.has(raw);
|
|
831
|
+
for (const ref of sugar ? expandZone(raw, new Set([id])) : [raw]) {
|
|
832
|
+
const resolved = resolve(ref, "", p, ctx);
|
|
833
|
+
if (!resolved)
|
|
834
|
+
continue;
|
|
835
|
+
if (members.includes(resolved)) {
|
|
836
|
+
// an expansion overlapping something listed by hand is nesting
|
|
837
|
+
// working as designed, not a mistake — only warn when the author
|
|
838
|
+
// wrote the same leaf twice themselves
|
|
839
|
+
if (!sugar && !viaSugar.has(resolved))
|
|
840
|
+
warn(ctx, p, `\`${resolved}\` listed twice in zone \`${id}\``);
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
members.push(resolved);
|
|
844
|
+
if (sugar)
|
|
845
|
+
viaSugar.add(resolved);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (members.length === 0)
|
|
851
|
+
warn(ctx, z, `zone \`${id}\` has no members`, `add \`contains <path>, …\``);
|
|
852
|
+
// optional chip attrs: icon (pack/id, validated like node icons) and
|
|
853
|
+
// label (which border corner the chip straddles)
|
|
854
|
+
const zAttrs = attrsOf(ctx, bodyNode);
|
|
855
|
+
let icon;
|
|
856
|
+
if (zAttrs.attrs.icon) {
|
|
857
|
+
const [p, i] = zAttrs.attrs.icon.split("/");
|
|
858
|
+
if (!p || !i || !packExists(p)) {
|
|
859
|
+
const s = p && suggest(p, allPackNames());
|
|
860
|
+
error(ctx, z, `unknown pack \`${p ?? zAttrs.attrs.icon}\` in zone icon`, s ? `did you mean \`${s}/${i ?? ""}\`?` : `use \`icon: <pack>/<id>\``);
|
|
861
|
+
}
|
|
862
|
+
else if (!iconExists(p, i)) {
|
|
863
|
+
const s = suggest(i, iconIds(p));
|
|
864
|
+
error(ctx, z, `unknown icon \`${p}/${i}\``, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
|
|
865
|
+
}
|
|
866
|
+
else
|
|
867
|
+
icon = { pack: p, id: i };
|
|
868
|
+
}
|
|
869
|
+
// Zones took theme *roles* before the hue vocabulary existed (`account`,
|
|
870
|
+
// `network`, …). Those words now describe what a zone IS, and the kind
|
|
871
|
+
// already tints it; saying so beats a Levenshtein guess that would map
|
|
872
|
+
// `account` to nothing useful.
|
|
873
|
+
const LEGACY_ROLE = {
|
|
874
|
+
account: "red", network: "blue", cloud: "violet", neutral: "gray", ink: "gray", muted: "gray",
|
|
875
|
+
};
|
|
876
|
+
let color;
|
|
877
|
+
if (zAttrs.attrs.color) {
|
|
878
|
+
const v = zAttrs.attrs.color;
|
|
879
|
+
if (v in LEGACY_ROLE)
|
|
880
|
+
error(ctx, z, `\`${v}\` is not a color — zones are tinted by kind`, `to override the tint, use a hue: \`color: ${LEGACY_ROLE[v]}\` (one of: ${HUES.join(" | ")})`);
|
|
881
|
+
else
|
|
882
|
+
color = checkHue(ctx, z, v);
|
|
883
|
+
}
|
|
884
|
+
const LABEL_POS = ["top-left", "top-right", "bottom-left", "bottom-right"];
|
|
885
|
+
let labelPos = "top-left";
|
|
886
|
+
if (zAttrs.attrs.label) {
|
|
887
|
+
if (LABEL_POS.includes(zAttrs.attrs.label))
|
|
888
|
+
labelPos = zAttrs.attrs.label;
|
|
889
|
+
else {
|
|
890
|
+
const s = suggest(zAttrs.attrs.label, LABEL_POS);
|
|
891
|
+
error(ctx, z, `unknown zone label position \`${zAttrs.attrs.label}\``, s ? `did you mean \`${s}\`?` : `one of: ${LABEL_POS.join(", ")}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
model.zones.push({
|
|
895
|
+
id, kind, members, icon, labelPos, color,
|
|
896
|
+
detail: zAttrs.attrs.detail,
|
|
897
|
+
label: labelNode ? ctx.str(labelNode) : undefined,
|
|
898
|
+
loc: ctx.loc(z), file: ctx.name,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
for (const { node: v, ctx } of rawViews) {
|
|
902
|
+
const nameNode = v.getChild("Path");
|
|
903
|
+
const bodyNode = v.getChild("ViewBody");
|
|
904
|
+
if (!nameNode || !bodyNode)
|
|
905
|
+
continue; // partial node from error recovery
|
|
906
|
+
const name = ctx.text(nameNode);
|
|
907
|
+
const body = bodyNode;
|
|
908
|
+
const view = {
|
|
909
|
+
name,
|
|
910
|
+
only: [], include: [], includeStar: false, exclude: [], expand: [], expandStar: false, detail: [],
|
|
911
|
+
context: "auto", highlight: [], colors: [], showDescriptions: false, legend: false, notes: [],
|
|
912
|
+
layout: { place: [], routes: [], align: [], channels: [] },
|
|
913
|
+
loc: ctx.loc(v), file: ctx.name,
|
|
914
|
+
};
|
|
915
|
+
// These three took their operand with a `!`, and every one of them threw a
|
|
916
|
+
// raw `Cannot read properties of null` — exit 2, no location, no fix — the
|
|
917
|
+
// moment the operand was missing or the wrong shape. That is any keystroke
|
|
918
|
+
// between typing `title` and typing its string, so it crashed the language
|
|
919
|
+
// server too, and it is what a cold agent hit by writing `scope *` (which
|
|
920
|
+
// parses as a ScopeStmt with no Path). Same class as the `layout`-inside-a-
|
|
921
|
+
// system crash from round 2: a `!` on a node that invalid input makes null.
|
|
922
|
+
const scopeStmt = body.getChildren("ScopeStmt")[0];
|
|
923
|
+
const scopePath = scopeStmt?.getChild("Path");
|
|
924
|
+
if (scopeStmt && !scopePath)
|
|
925
|
+
error(ctx, scopeStmt, "`scope` needs a single container path", "scope narrows a view to one container; to widen a view to everything use `include *`");
|
|
926
|
+
else if (scopePath)
|
|
927
|
+
view.scope = resolve(ctx.text(scopePath), "", scopeStmt, ctx);
|
|
928
|
+
else if (model.containers.has(name))
|
|
929
|
+
view.scope = name; // auto: view <container>
|
|
930
|
+
const title = body.getChildren("TitleStmt")[0];
|
|
931
|
+
const titleStr = title?.getChild("String");
|
|
932
|
+
if (title && !titleStr)
|
|
933
|
+
error(ctx, title, "`title` needs a quoted string", 'title "My Diagram"');
|
|
934
|
+
else if (titleStr)
|
|
935
|
+
view.title = ctx.str(titleStr);
|
|
936
|
+
const theme = body.getChildren("ThemeStmt")[0];
|
|
937
|
+
const themeIdent = theme?.getChild("Ident");
|
|
938
|
+
if (theme && !themeIdent)
|
|
939
|
+
error(ctx, theme, "`theme` needs a theme name", "theme dark");
|
|
940
|
+
else if (themeIdent) {
|
|
941
|
+
view.theme = ctx.text(themeIdent);
|
|
942
|
+
checkTheme(ctx, themeIdent, view.theme);
|
|
943
|
+
}
|
|
944
|
+
const inScope = view.scope ?? "";
|
|
945
|
+
// `only` is the view's filter — the *which* axis, where `scope` is *where*.
|
|
946
|
+
for (const on of body.getChildren("OnlyStmt")) {
|
|
947
|
+
for (const t of on.getChild("TargetList")?.getChildren("Target") ?? []) {
|
|
948
|
+
const tag = t.getChild("Tag");
|
|
949
|
+
if (tag)
|
|
950
|
+
view.only.push({ tag: ctx.text(tag).slice(1) });
|
|
951
|
+
else {
|
|
952
|
+
const path = t.getChild("Path");
|
|
953
|
+
const r = path && resolve(ctx.text(path), inScope, t, ctx);
|
|
954
|
+
if (r)
|
|
955
|
+
view.only.push(r);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
for (const inc of body.getChildren("IncludeStmt")) {
|
|
960
|
+
if (inc.getChild("Star"))
|
|
961
|
+
view.includeStar = true;
|
|
962
|
+
for (const t of inc.getChild("TargetList")?.getChildren("Target") ?? []) {
|
|
963
|
+
const tag = t.getChild("Tag");
|
|
964
|
+
if (tag)
|
|
965
|
+
view.include.push({ tag: ctx.text(tag).slice(1) });
|
|
966
|
+
else {
|
|
967
|
+
const path = t.getChild("Path");
|
|
968
|
+
const r = path && resolve(ctx.text(path), inScope, t, ctx);
|
|
969
|
+
if (r)
|
|
970
|
+
view.include.push(r);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
for (const exc of body.getChildren("ExcludeStmt")) {
|
|
975
|
+
for (const t of exc.getChild("TargetList")?.getChildren("Target") ?? []) {
|
|
976
|
+
const tag = t.getChild("Tag");
|
|
977
|
+
if (tag)
|
|
978
|
+
view.exclude.push({ tag: ctx.text(tag).slice(1) });
|
|
979
|
+
else {
|
|
980
|
+
const path = t.getChild("Path");
|
|
981
|
+
const r = path && resolve(ctx.text(path), inScope, t, ctx);
|
|
982
|
+
if (r)
|
|
983
|
+
view.exclude.push(r);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
// `detail` carries what `include` used to smuggle: draw an outside element
|
|
988
|
+
// at its own depth rather than as its top-level context card.
|
|
989
|
+
for (const d of body.getChildren("DetailStmt")) {
|
|
990
|
+
const path = d.getChild("Path");
|
|
991
|
+
if (!path) {
|
|
992
|
+
error(ctx, d, "`detail` needs a path", "detail web.app");
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
const r = resolve(ctx.text(path), inScope, d, ctx);
|
|
996
|
+
if (r)
|
|
997
|
+
view.detail.push(r);
|
|
998
|
+
}
|
|
999
|
+
for (const ex of body.getChildren("ExpandStmt")) {
|
|
1000
|
+
if (ex.getChild("Star")) {
|
|
1001
|
+
view.expandStar = true;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const path = ex.getChild("Path");
|
|
1005
|
+
const r = path && resolve(ctx.text(path), inScope, ex, ctx);
|
|
1006
|
+
if (r)
|
|
1007
|
+
view.expand.push(r);
|
|
1008
|
+
}
|
|
1009
|
+
const ctxStmt = body.getChildren("ContextStmt")[0];
|
|
1010
|
+
if (ctxStmt)
|
|
1011
|
+
view.context = ctxStmt.getChild("off") ? "off" : "auto";
|
|
1012
|
+
for (const h of body.getChildren("HighlightStmt"))
|
|
1013
|
+
view.highlight.push(...h.getChildren("Tag").map((t) => ctx.text(t).slice(1)));
|
|
1014
|
+
for (const c of body.getChildren("ColorStmt")) {
|
|
1015
|
+
// `color #x` with the hue not yet typed is the editor's state on most
|
|
1016
|
+
// keystrokes — skip, never throw
|
|
1017
|
+
const tagNode = c.getChild("Tag");
|
|
1018
|
+
const hueNode = c.getChildren("Ident").pop();
|
|
1019
|
+
if (!tagNode || !hueNode)
|
|
1020
|
+
continue;
|
|
1021
|
+
const hue = checkHue(ctx, c, ctx.text(hueNode));
|
|
1022
|
+
if (!hue)
|
|
1023
|
+
continue;
|
|
1024
|
+
const tag = ctx.text(tagNode).slice(1);
|
|
1025
|
+
const prior = view.colors.find((x) => x.tag === tag);
|
|
1026
|
+
if (prior && prior.hue !== hue)
|
|
1027
|
+
warn(ctx, c, `color #${tag} is stated twice with different hues — the last wins`, `keep one \`color #${tag}\` line`);
|
|
1028
|
+
view.colors.push({ tag, hue, loc: ctx.loc(c) });
|
|
1029
|
+
}
|
|
1030
|
+
for (const show of body.getChildren("ShowStmt")) {
|
|
1031
|
+
const flowKw = show.getChild("flow");
|
|
1032
|
+
if (!flowKw) {
|
|
1033
|
+
view.showDescriptions = true;
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const idNode = show.getChildren("Ident").pop();
|
|
1037
|
+
if (!idNode)
|
|
1038
|
+
continue;
|
|
1039
|
+
const flowId = ctx.text(idNode);
|
|
1040
|
+
if (!model.flows.some((f) => f.id === flowId)) {
|
|
1041
|
+
const s = suggest(flowId, model.flows.map((f) => f.id));
|
|
1042
|
+
error(ctx, show, `unknown flow \`${flowId}\``, s ? `did you mean \`${s}\`?` : `declare it: flow ${flowId} { a -> b -> c }`);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
view.showFlow = flowId;
|
|
1046
|
+
}
|
|
1047
|
+
const legendStmt = body.getChildren("LegendStmt")[0];
|
|
1048
|
+
if (legendStmt)
|
|
1049
|
+
view.legend = !legendStmt.getChild("off");
|
|
1050
|
+
const tb = body.getChildren("TitleBlockStmt")[0];
|
|
1051
|
+
if (tb)
|
|
1052
|
+
view.titleblock = attrsOf(ctx, tb.getChild("AttrBlock")).attrs;
|
|
1053
|
+
for (const n of body.getChildren("NoteStmt")) {
|
|
1054
|
+
// half-typed `note` — anchor and text both arrive later
|
|
1055
|
+
const anchorNode = n.getChild("NoteAnchor");
|
|
1056
|
+
const textNode = n.getChild("String");
|
|
1057
|
+
if (!anchorNode || !textNode)
|
|
1058
|
+
continue;
|
|
1059
|
+
const noteText = ctx.str(textNode);
|
|
1060
|
+
const meta = attrsOf(ctx, n.getChild("AttrBlock"));
|
|
1061
|
+
let anchor;
|
|
1062
|
+
const relpos = anchorNode.getChild("RelPos");
|
|
1063
|
+
const corner = anchorNode.getChild("Corner");
|
|
1064
|
+
if (relpos) {
|
|
1065
|
+
const pathNode = anchorNode.getChild("Path");
|
|
1066
|
+
if (!pathNode)
|
|
1067
|
+
continue; // `note right-of` with the target unwritten
|
|
1068
|
+
const r = resolve(ctx.text(pathNode), inScope, anchorNode, ctx);
|
|
1069
|
+
if (r)
|
|
1070
|
+
anchor = { kind: "relpos", relpos: ctx.text(relpos), target: r };
|
|
1071
|
+
}
|
|
1072
|
+
else if (corner) {
|
|
1073
|
+
anchor = { kind: "corner", corner: ctx.text(corner) };
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
const [a, b] = anchorNode.getChildren("Path");
|
|
1077
|
+
const from = resolve(ctx.text(a), inScope, a, ctx);
|
|
1078
|
+
const to = resolve(ctx.text(b), inScope, b, ctx);
|
|
1079
|
+
if (from && to)
|
|
1080
|
+
anchor = { kind: "edge", from, to };
|
|
1081
|
+
}
|
|
1082
|
+
if (anchor)
|
|
1083
|
+
view.notes.push({ anchor, text: noteText, style: meta.attrs["style"], loc: ctx.loc(n) });
|
|
1084
|
+
}
|
|
1085
|
+
for (const lb of body.getChildren("LayoutBlock")) {
|
|
1086
|
+
const dir = lb.getChildren("DirectionStmt")[0];
|
|
1087
|
+
if (dir)
|
|
1088
|
+
view.layout.direction = ctx.text(dir.lastChild);
|
|
1089
|
+
const den = lb.getChildren("DensityStmt")[0];
|
|
1090
|
+
const denIdent = den?.getChild("Ident");
|
|
1091
|
+
if (denIdent) {
|
|
1092
|
+
const val = ctx.text(denIdent);
|
|
1093
|
+
if (val === "compact" || val === "comfortable" || val === "spacious")
|
|
1094
|
+
view.layout.density = val;
|
|
1095
|
+
else
|
|
1096
|
+
error(ctx, den, `unknown density \`${val}\``, "use compact | comfortable | spacious");
|
|
1097
|
+
}
|
|
1098
|
+
const lin = lb.getChildren("LinesStmt")[0];
|
|
1099
|
+
const linIdent = lin?.getChild("Ident");
|
|
1100
|
+
if (linIdent) {
|
|
1101
|
+
const val = ctx.text(linIdent);
|
|
1102
|
+
if (val === "orthogonal" || val === "curved" || val === "straight")
|
|
1103
|
+
view.layout.lines = val;
|
|
1104
|
+
else
|
|
1105
|
+
error(ctx, lin, `unknown lines style \`${val}\``, "use orthogonal | curved | straight");
|
|
1106
|
+
}
|
|
1107
|
+
for (const al of lb.getChildren("AlignStmt")) {
|
|
1108
|
+
const nodes = al.getChildren("Path")
|
|
1109
|
+
.map((pathNode) => resolve(ctx.text(pathNode), inScope, pathNode, ctx))
|
|
1110
|
+
.filter((r) => !!r);
|
|
1111
|
+
if (nodes.length >= 2)
|
|
1112
|
+
view.layout.align.push({ nodes, loc: ctx.loc(al) });
|
|
1113
|
+
else if (nodes.length === 1)
|
|
1114
|
+
warn(ctx, al, "`align` needs at least two elements", "align a b — b takes a's axis");
|
|
1115
|
+
}
|
|
1116
|
+
const rowsStmt = lb.getChildren("RowsStmt")[0];
|
|
1117
|
+
if (rowsStmt) {
|
|
1118
|
+
const rows = [];
|
|
1119
|
+
const placed = new Set();
|
|
1120
|
+
for (const rank of rowsStmt.getChildren("Rank")) {
|
|
1121
|
+
const row = [];
|
|
1122
|
+
for (const pathNode of rank.getChildren("Path")) {
|
|
1123
|
+
const r = resolve(ctx.text(pathNode), inScope, pathNode, ctx, { zones: true });
|
|
1124
|
+
if (!r)
|
|
1125
|
+
continue;
|
|
1126
|
+
if (placed.has(r)) {
|
|
1127
|
+
error(ctx, pathNode, `\`${ctx.text(pathNode)}\` appears in \`rows\` twice`, "a node can hold only one rank position; remove one occurrence");
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
placed.add(r);
|
|
1131
|
+
row.push(r);
|
|
1132
|
+
}
|
|
1133
|
+
rows.push(row);
|
|
1134
|
+
}
|
|
1135
|
+
view.layout.rows = rows;
|
|
1136
|
+
}
|
|
1137
|
+
const colsStmt = lb.getChildren("ColsStmt")[0];
|
|
1138
|
+
if (colsStmt) {
|
|
1139
|
+
const cols = [];
|
|
1140
|
+
const placed = new Set();
|
|
1141
|
+
for (const rank of colsStmt.getChildren("Rank")) {
|
|
1142
|
+
const col = [];
|
|
1143
|
+
for (const pathNode of rank.getChildren("Path")) {
|
|
1144
|
+
const r = resolve(ctx.text(pathNode), inScope, pathNode, ctx, { zones: true });
|
|
1145
|
+
if (!r)
|
|
1146
|
+
continue;
|
|
1147
|
+
if (placed.has(r)) {
|
|
1148
|
+
error(ctx, pathNode, `\`${ctx.text(pathNode)}\` appears in \`cols\` twice`, "a node can hold only one column position; remove one occurrence");
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
placed.add(r);
|
|
1152
|
+
col.push(r);
|
|
1153
|
+
}
|
|
1154
|
+
cols.push(col);
|
|
1155
|
+
}
|
|
1156
|
+
view.layout.cols = cols;
|
|
1157
|
+
}
|
|
1158
|
+
for (const ch of lb.getChildren("ChannelStmt")) {
|
|
1159
|
+
const list = ch.getChild("PathList");
|
|
1160
|
+
const targetNode = ch.getChildren("Path").at(-1);
|
|
1161
|
+
if (!list || !targetNode)
|
|
1162
|
+
continue; // partial node from error recovery
|
|
1163
|
+
const target = resolve(ctx.text(targetNode), inScope, targetNode, ctx);
|
|
1164
|
+
const sources = list.getChildren("Path")
|
|
1165
|
+
.filter((p) => p !== targetNode)
|
|
1166
|
+
.map((p) => resolve(ctx.text(p), inScope, p, ctx))
|
|
1167
|
+
.filter((r) => !!r);
|
|
1168
|
+
if (!target)
|
|
1169
|
+
continue;
|
|
1170
|
+
if (sources.length < 2) {
|
|
1171
|
+
warn(ctx, ch, "`channel` needs at least two sources", "one source is just an edge — use `route` to steer it");
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
view.layout.channels.push({ sources, target, loc: ctx.loc(ch) });
|
|
1175
|
+
}
|
|
1176
|
+
for (const pl of lb.getChildren("PlaceStmt")) {
|
|
1177
|
+
const [a, b] = pl.getChildren("Path");
|
|
1178
|
+
const node = resolve(ctx.text(a), inScope, a, ctx, { zones: true });
|
|
1179
|
+
const relposNode = pl.getChild("RelPos");
|
|
1180
|
+
const target = b ? resolve(ctx.text(b), inScope, b, ctx, { zones: true }) : undefined;
|
|
1181
|
+
if (node && target && relposNode)
|
|
1182
|
+
view.layout.place.push({
|
|
1183
|
+
node, target,
|
|
1184
|
+
relpos: ctx.text(relposNode),
|
|
1185
|
+
loc: ctx.loc(pl),
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
for (const rt of lb.getChildren("RouteStmt")) {
|
|
1189
|
+
const [a, b] = rt.getChildren("Path");
|
|
1190
|
+
const from = resolve(ctx.text(a), inScope, a, ctx);
|
|
1191
|
+
const to = resolve(ctx.text(b), inScope, b, ctx);
|
|
1192
|
+
const sidesNodes = rt.getChildren("Side");
|
|
1193
|
+
const labelNode = rt.getChild("String");
|
|
1194
|
+
if (from && to)
|
|
1195
|
+
view.layout.routes.push({
|
|
1196
|
+
from, to,
|
|
1197
|
+
label: labelNode ? ctx.str(labelNode) : undefined,
|
|
1198
|
+
fromSide: sidesNodes[0] ? ctx.text(sidesNodes[0]) : undefined,
|
|
1199
|
+
toSide: sidesNodes[1] ? ctx.text(sidesNodes[1]) : undefined,
|
|
1200
|
+
loc: ctx.loc(rt),
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
// hint-conflict checks (SPEC §6: contradictions are errors, never silent)
|
|
1205
|
+
for (const pl of view.layout.place) {
|
|
1206
|
+
const opposite = view.layout.place.find((o) => o !== pl && o.node === pl.target && o.target === pl.node);
|
|
1207
|
+
if (opposite)
|
|
1208
|
+
diagnostics.push({
|
|
1209
|
+
severity: "error",
|
|
1210
|
+
message: `contradictory place hints: \`${pl.node}\` vs \`${pl.target}\` reference each other`,
|
|
1211
|
+
fix: "remove one of the two place statements",
|
|
1212
|
+
loc: pl.loc, file: ctx.name,
|
|
1213
|
+
});
|
|
1214
|
+
// A node in a band already has a position, so a `place` on it is a second
|
|
1215
|
+
// opinion — but a second opinion is only a *conflict* when it disagrees.
|
|
1216
|
+
//
|
|
1217
|
+
// This check has been wrong twice, in opposite directions. It first read
|
|
1218
|
+
// `(rows && relpos === "right-of") || relpos === "left-of"`, which bound
|
|
1219
|
+
// the wrong way and listed only the horizontal directions, so `place x
|
|
1220
|
+
// above y` on a banded node sailed through with one hint silently
|
|
1221
|
+
// ignored. Widening it to "in a band at all" then went too far the other
|
|
1222
|
+
// way: four of twenty cold agents wrote `rows [db bus]` alongside `place
|
|
1223
|
+
// bus right-of db` and were refused, though the two say the same thing.
|
|
1224
|
+
// Restating a band's own order is how people reinforce intent, not how
|
|
1225
|
+
// they contradict it, and no amount of documentation stopped them — the
|
|
1226
|
+
// rate held across two rounds of skill fixes, because they were right.
|
|
1227
|
+
const bands = view.layout.rows
|
|
1228
|
+
? { kind: "rows", at: view.layout.rows }
|
|
1229
|
+
: view.layout.cols
|
|
1230
|
+
? { kind: "cols", at: view.layout.cols }
|
|
1231
|
+
: undefined;
|
|
1232
|
+
const where = (n) => {
|
|
1233
|
+
const b = bands?.at.findIndex((band) => band.includes(n)) ?? -1;
|
|
1234
|
+
return b < 0 ? undefined : { band: b, pos: bands.at[b].indexOf(n) };
|
|
1235
|
+
};
|
|
1236
|
+
const node = where(pl.node);
|
|
1237
|
+
if (bands && node) {
|
|
1238
|
+
// In `rows`, bands run top to bottom and members left to right; in
|
|
1239
|
+
// `cols` it is the transpose. So one axis is the band index and the
|
|
1240
|
+
// other the position within it, and which is which flips with `kind`.
|
|
1241
|
+
const target = where(pl.target);
|
|
1242
|
+
const along = bands.kind === "rows" ? ["right-of", "left-of"] : ["below", "above"];
|
|
1243
|
+
const agrees = target &&
|
|
1244
|
+
(along.includes(pl.relpos)
|
|
1245
|
+
? // same band, and immediately beside — `place` means adjacent
|
|
1246
|
+
node.band === target.band &&
|
|
1247
|
+
node.pos === target.pos + (pl.relpos === "right-of" || pl.relpos === "below" ? 1 : -1)
|
|
1248
|
+
: // the perpendicular axis: the neighbouring band, on the right side
|
|
1249
|
+
node.pos === target.pos &&
|
|
1250
|
+
node.band ===
|
|
1251
|
+
target.band + (pl.relpos === "below" || pl.relpos === "right-of" ? 1 : -1));
|
|
1252
|
+
if (!agrees)
|
|
1253
|
+
diagnostics.push({
|
|
1254
|
+
severity: "error",
|
|
1255
|
+
message: !target
|
|
1256
|
+
? `\`${pl.node}\` is listed in \`${bands.kind}\` but is placed relative to \`${pl.target}\`, which is not`
|
|
1257
|
+
: `\`${pl.node}\` is placed \`${pl.relpos} ${pl.target}\`, but \`${bands.kind}\` puts it somewhere else`,
|
|
1258
|
+
fix: !target
|
|
1259
|
+
? `add \`${pl.target}\` to ${bands.kind} too, or drop \`${pl.node}\` from ${bands.kind}`
|
|
1260
|
+
: `${bands.kind} already positions both; make them agree, or drop \`${pl.node}\` from ${bands.kind}`,
|
|
1261
|
+
loc: pl.loc, file: ctx.name,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (model.views.some((other) => other.name === view.name))
|
|
1266
|
+
error(ctx, view.loc, `duplicate view \`${view.name}\``, `\`render --view ${view.name}\` would pick one of them silently — rename or merge`);
|
|
1267
|
+
model.views.push(view);
|
|
1268
|
+
}
|
|
1269
|
+
// SPEC §5: every container gets a default view, so zoom navigation always has
|
|
1270
|
+
// somewhere to land. Declaring `view <path>` explicitly customizes that view.
|
|
1271
|
+
const scoped = new Set(model.views.map((v) => v.scope).filter(Boolean));
|
|
1272
|
+
for (const path of model.containers.keys()) {
|
|
1273
|
+
if (scoped.has(path))
|
|
1274
|
+
continue;
|
|
1275
|
+
model.views.push({
|
|
1276
|
+
name: path,
|
|
1277
|
+
scope: path,
|
|
1278
|
+
auto: true,
|
|
1279
|
+
only: [], include: [], includeStar: false, exclude: [], expand: [], expandStar: false, detail: [],
|
|
1280
|
+
context: "auto", highlight: [], colors: [], showDescriptions: false, legend: false, notes: [],
|
|
1281
|
+
layout: { place: [], routes: [], align: [], channels: [] },
|
|
1282
|
+
loc: model.containers.get(path).loc,
|
|
1283
|
+
file: model.containers.get(path).file,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
// One mistake, one diagnostic — the rule the syntax pass and the align/cols
|
|
1287
|
+
// collapse already follow, applied to the loudest class there is. Round 16:
|
|
1288
|
+
// an agent declared nine nodes inside `system warehouse` and then referenced
|
|
1289
|
+
// them unqualified from a zone and a layout, and `check` answered with
|
|
1290
|
+
// twenty-eight `unknown id`s. Each one named its own fix, so the agent did
|
|
1291
|
+
// recover — but a wall of twenty-eight errors hides that it is a single
|
|
1292
|
+
// misunderstanding about scope, which is the thing worth telling them.
|
|
1293
|
+
collapseUnknownIds(diagnostics);
|
|
1294
|
+
return {
|
|
1295
|
+
model,
|
|
1296
|
+
diagnostics,
|
|
1297
|
+
ok: !diagnostics.some((d) => d.severity === "error"),
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
/** Fold N `unknown id` errors that all want the same container prefix into one.
|
|
1301
|
+
* Conservative on purpose: only when every one of them agrees on the prefix,
|
|
1302
|
+
* and only from three up — two is not yet a pattern, and naming the ids
|
|
1303
|
+
* individually is more useful at that size. */
|
|
1304
|
+
function collapseUnknownIds(diagnostics) {
|
|
1305
|
+
const byPrefix = new Map();
|
|
1306
|
+
diagnostics.forEach((d, idx) => {
|
|
1307
|
+
if (d.severity !== "error")
|
|
1308
|
+
return;
|
|
1309
|
+
const ref = /^unknown id `([^`]+)`$/.exec(d.message)?.[1];
|
|
1310
|
+
const sug = d.fix && /^did you mean `([^`]+)`\?$/.exec(d.fix)?.[1];
|
|
1311
|
+
if (!ref || !sug || !sug.endsWith(`.${ref}`))
|
|
1312
|
+
return;
|
|
1313
|
+
const prefix = sug.slice(0, -(ref.length + 1));
|
|
1314
|
+
if (!byPrefix.has(prefix))
|
|
1315
|
+
byPrefix.set(prefix, []);
|
|
1316
|
+
byPrefix.get(prefix).push({ idx, ref });
|
|
1317
|
+
});
|
|
1318
|
+
for (const [prefix, hits] of byPrefix) {
|
|
1319
|
+
if (hits.length < 3)
|
|
1320
|
+
continue;
|
|
1321
|
+
const ids = [...new Set(hits.map((h) => h.ref))];
|
|
1322
|
+
const shown = ids.slice(0, 3).map((r) => `\`${r}\``).join(", ");
|
|
1323
|
+
const more = ids.length > 3 ? `, and ${ids.length - 3} more` : "";
|
|
1324
|
+
diagnostics[hits[0].idx] = {
|
|
1325
|
+
...diagnostics[hits[0].idx],
|
|
1326
|
+
message: `${ids.length} ids are missing their \`${prefix}\` prefix — ${shown}${more}`,
|
|
1327
|
+
fix: `ids declared inside \`${prefix}\` are written \`${prefix}.<id>\` from outside it`,
|
|
1328
|
+
};
|
|
1329
|
+
for (const h of hits.slice(1))
|
|
1330
|
+
diagnostics[h.idx] = null;
|
|
1331
|
+
}
|
|
1332
|
+
for (let i = diagnostics.length - 1; i >= 0; i--)
|
|
1333
|
+
if (!diagnostics[i])
|
|
1334
|
+
diagnostics.splice(i, 1);
|
|
1335
|
+
}
|
|
1336
|
+
export function formatDiagnostics(diags, file = "input") {
|
|
1337
|
+
return diags
|
|
1338
|
+
.map((d) => {
|
|
1339
|
+
const fix = d.fix ? `\n ${d.fix}` : "";
|
|
1340
|
+
return `${d.file ?? file}:${d.loc.line}:${d.loc.col} ${d.severity}: ${d.message}${fix}`;
|
|
1341
|
+
})
|
|
1342
|
+
.join("\n");
|
|
1343
|
+
}
|