@jigs.design/compiler 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,950 @@
1
+ import { analyze } from "./script.js";
2
+ import { generateProgram, isBindingName } from "./codegen.js";
3
+ import { columnAt, lineAt, sourceRange } from "./offsets.js";
4
+ import { closest, nearest } from "./suggest.js";
5
+ import { checkCode } from "./check-code.js";
6
+ import { parsePickle } from "./parse.js";
7
+ import { isValidValue } from "./spec.js";
8
+ import { blockHoists } from "./block-hoist.js";
9
+ //#region src/compile.ts
10
+ const RESERVED_TAGS = /* @__PURE__ */ new Set([
11
+ "define",
12
+ "each",
13
+ "if",
14
+ "slot"
15
+ ]);
16
+ const HOOK_MESSAGE = {
17
+ useMutation: {
18
+ each: "inside <each> is not supported; declare it at the top and compare its input to find the busy item",
19
+ if: "inside <if> is not supported; declare it at the top"
20
+ },
21
+ useQuery: {
22
+ each: "inside <each> is not supported; declare it at the top of the document or of a <define>",
23
+ if: "inside <if> is not supported; declare it at the top"
24
+ },
25
+ useState: {
26
+ each: "inside <each> is not supported; keep per-item state in one object at the top",
27
+ if: "inside <if> is not supported; declare state at the top"
28
+ }
29
+ };
30
+ /** How to write each hook, for a message that names the form to write. */
31
+ const HOOK_FORM = {
32
+ useMutation: "const archive = useMutation(\"name\")",
33
+ useQuery: "const orders = useQuery(\"name\")",
34
+ useState: "const [open, setOpen] = useState(false)"
35
+ };
36
+ /**
37
+ * A hook used as a value, written `Pickle.useX`, called inside a handler
38
+ * or anywhere but as a top-level declaration's initializer, or declared
39
+ * inside `<each>` or `<if>`. Null when the code parses and uses hooks only
40
+ * as it may, or does not parse (the parse error is reported elsewhere).
41
+ */
42
+ function hookMisuse(code, kind, block) {
43
+ const facts = analyze(code, kind);
44
+ if (!facts.ok) return null;
45
+ const message = (text) => ({
46
+ declared: facts.declared,
47
+ message: text
48
+ });
49
+ const [value] = facts.hookValues;
50
+ if (value !== void 0) return message(`\`${value.hook}\` is not a value: call it in a {@body} line at the top of the document or of a <define>, \`${HOOK_FORM[value.hook]}\``);
51
+ const [call] = facts.hookCalls;
52
+ if (call === void 0) return null;
53
+ const written = call.prefixed ? `Pickle.${call.hook}` : call.hook;
54
+ if (block === "each" || block === "if") return message(`${written} ${HOOK_MESSAGE[call.hook][block]}`);
55
+ if (call.prefixed) return message(`\`${written}\` is not supported: write \`${call.hook}\` without the prefix, \`${HOOK_FORM[call.hook]}\``);
56
+ if (call.place === "handler") return message(call.hook === "useMutation" ? `\`useMutation(...)\` inside a handler is not supported: declare \`${HOOK_FORM.useMutation}\` in a {@body} line at the top of the document or of a <define>, and call \`archive.mutate(input)\` from the handler` : `\`${call.hook}(...)\` inside a handler is not supported: declare it in a {@body} line at the top of the document or of a <define>, \`${HOOK_FORM[call.hook]}\`, and read it from the handler`);
57
+ if (call.place === "nested") return message(`\`${call.hook}(...)\` must be the whole initializer of a {@body} line at the top of the document or of a <define>, \`${HOOK_FORM[call.hook]}\`, not part of a condition, a call or another expression`);
58
+ return null;
59
+ }
60
+ const NO_LIBRARY = /* @__PURE__ */ new Map();
61
+ /** Libraries by catalog, then by recipe list. */
62
+ const libraryCache = /* @__PURE__ */ new WeakMap();
63
+ function propSpec(spec, name) {
64
+ const props = spec.props;
65
+ return Object.hasOwn(props, name) ? props[name] : void 0;
66
+ }
67
+ function componentSpec(components, name) {
68
+ return Object.hasOwn(components, name) ? components[name] : void 0;
69
+ }
70
+ const controlProps = {
71
+ each: [
72
+ "of",
73
+ "as",
74
+ "index"
75
+ ],
76
+ if: ["test"]
77
+ };
78
+ const asCode = (value) => value === void 0 ? null : value.kind === "expr" ? value.code : JSON.stringify(value.value);
79
+ function literalKind(value) {
80
+ if (typeof value === "boolean") return "boolean";
81
+ if (typeof value === "number") return "number";
82
+ return "string";
83
+ }
84
+ function propSignature(prop) {
85
+ if (prop.shorthand === true) return {
86
+ defaultValue: null,
87
+ kind: null,
88
+ name: prop.name,
89
+ required: true
90
+ };
91
+ if (prop.value.kind === "literal") return {
92
+ defaultValue: prop.value,
93
+ kind: literalKind(prop.value.value),
94
+ name: prop.name,
95
+ required: false
96
+ };
97
+ return {
98
+ defaultValue: prop.value,
99
+ kind: null,
100
+ name: prop.name,
101
+ required: false
102
+ };
103
+ }
104
+ /** A `<slot/>` anywhere in the define's source, ignoring nested defines. */
105
+ function treeHasSlot(nodes) {
106
+ for (const node of nodes) {
107
+ if (node.type !== "element" || node.name === "define") continue;
108
+ if (node.name === "slot" || treeHasSlot(node.children)) return true;
109
+ }
110
+ return false;
111
+ }
112
+ function readDefine(node, diagnostics, libraryNames, components) {
113
+ const nameProp = node.props.find((prop) => prop.name === "name");
114
+ const name = nameProp !== void 0 && nameProp.shorthand !== true && nameProp.value.kind === "literal" && typeof nameProp.value.value === "string" ? nameProp.value.value : null;
115
+ if (name === null) {
116
+ diagnostics.push({
117
+ code: "invalid_define",
118
+ ...placeOf(node),
119
+ message: `<define> needs name="componentName"; it was removed`,
120
+ severity: "warning"
121
+ });
122
+ return null;
123
+ }
124
+ if (componentSpec(components, name) !== void 0 || RESERVED_TAGS.has(name)) {
125
+ diagnostics.push({
126
+ code: "invalid_define",
127
+ ...placeOf(node),
128
+ message: `<define name="${name}"> clashes with <${name}>; it was removed`,
129
+ severity: "warning"
130
+ });
131
+ return null;
132
+ }
133
+ if (!isBindingName(name)) {
134
+ diagnostics.push({
135
+ code: "invalid_define",
136
+ ...placeOf(node),
137
+ message: `<define name="${name}"> is not a usable component name; it was removed`,
138
+ severity: "warning"
139
+ });
140
+ return null;
141
+ }
142
+ if (libraryNames.has(name)) diagnostics.push({
143
+ code: "shadows_library",
144
+ ...placeOf(node),
145
+ message: `<define name="${name}"> overrides the library component`,
146
+ severity: "info"
147
+ });
148
+ const props = [];
149
+ const seen = /* @__PURE__ */ new Set();
150
+ for (const prop of node.props) {
151
+ if (prop === nameProp) continue;
152
+ if (prop.name === "key") {
153
+ diagnostics.push({
154
+ code: "invalid_define",
155
+ ...placeOf(node, prop),
156
+ message: `<define name="${name}"> prop "key" is reserved for the instance; it was removed`,
157
+ severity: "warning"
158
+ });
159
+ continue;
160
+ }
161
+ if (prop.name === "name") {
162
+ diagnostics.push({
163
+ code: "invalid_define",
164
+ ...placeOf(node, prop),
165
+ message: `<define name="${name}"> cannot take a prop called "name", which names the define itself; call it "title" or "label". It was removed`,
166
+ severity: "warning"
167
+ });
168
+ continue;
169
+ }
170
+ if (prop.name === "children" || !isBindingName(prop.name)) {
171
+ diagnostics.push({
172
+ code: "invalid_define",
173
+ ...placeOf(node, prop),
174
+ message: `<define name="${name}"> prop "${prop.name}" is not a usable name; it was removed`,
175
+ severity: "warning"
176
+ });
177
+ continue;
178
+ }
179
+ if (seen.has(prop.name)) {
180
+ diagnostics.push({
181
+ code: "invalid_define",
182
+ ...placeOf(node, prop),
183
+ message: `<define name="${name}"> prop "${prop.name}" is duplicated; the later one was removed`,
184
+ severity: "warning"
185
+ });
186
+ continue;
187
+ }
188
+ seen.add(prop.name);
189
+ props.push(propSignature(prop));
190
+ }
191
+ return {
192
+ body: [],
193
+ hasSlot: treeHasSlot(node.children),
194
+ line: node.line,
195
+ name,
196
+ origin: "doc",
197
+ procedures: [],
198
+ props,
199
+ statements: []
200
+ };
201
+ }
202
+ function lookupHint(name, candidates) {
203
+ const lower = name.toLowerCase();
204
+ for (const candidate of candidates) if (candidate !== name && candidate.toLowerCase() === lower) return candidate;
205
+ }
206
+ /** HTML and other UI-library tags, and the catalog tag that does the job. */
207
+ const TAG_ALIASES = /* @__PURE__ */ new Map([
208
+ ["a", "link"],
209
+ ["b", "bold"],
210
+ ["div", "box"],
211
+ ["em", "italic"],
212
+ ["h1", "title"],
213
+ ["h2", "title"],
214
+ ["h3", "title"],
215
+ ["h4", "title"],
216
+ ["hr", "divider"],
217
+ ["i", "italic"],
218
+ ["img", "image"],
219
+ ["li", "item"],
220
+ ["ol", "list"],
221
+ ["p", "text"],
222
+ ["section", "box"],
223
+ ["span", "text"],
224
+ ["strong", "bold"],
225
+ ["ul", "list"],
226
+ ["view", "box"]
227
+ ]);
228
+ /** Tags a model writes for control flow that Pickle spells differently. */
229
+ const CONTROL_HINTS = /* @__PURE__ */ new Map([
230
+ ["elif", "There is no <elif>: add another <if> with its own test"],
231
+ ["else", "There is no <else>: add a second <if> with the opposite test"],
232
+ ["elseif", "There is no <elseif>: add another <if> with its own test"],
233
+ ["for", "There is no <for>: repeat children with <each of={list}>"],
234
+ ["foreach", "There is no <foreach>: repeat children with <each of={list}>"],
235
+ ["map", "There is no <map>: repeat children with <each of={list}>"],
236
+ ["switch", "There is no <switch>: add one <if> per case"]
237
+ ]);
238
+ function unknownComponentMessage(name, defines, components) {
239
+ const control = CONTROL_HINTS.get(name.toLowerCase());
240
+ if (control !== void 0) return `${control}. Rendering its children only`;
241
+ const known = [...Object.keys(components), ...defines.keys()];
242
+ const alias = TAG_ALIASES.get(name.toLowerCase());
243
+ const hint = lookupHint(name, known) ?? (alias !== void 0 && known.includes(alias) ? alias : void 0) ?? nearest(name, known, 1)[0];
244
+ return hint === void 0 ? `<${name}> is not in the catalog; rendering its children only` : `<${name}> is not in the catalog; did you mean <${hint}>? Rendering its children only`;
245
+ }
246
+ /** Props other UI libraries use, and the name Pickle uses for the same thing. */
247
+ const PROP_ALIASES = /* @__PURE__ */ new Map([
248
+ ["checked", "value"],
249
+ ["onChangeText", "onChange"],
250
+ ["onInput", "onChange"],
251
+ ["onPress", "onClick"],
252
+ ["onSelect", "onChange"],
253
+ ["onTap", "onClick"],
254
+ ["onValueChange", "onChange"],
255
+ ["selected", "value"]
256
+ ]);
257
+ const STYLE_PROPS = /* @__PURE__ */ new Set([
258
+ "class",
259
+ "className",
260
+ "style",
261
+ "sx"
262
+ ]);
263
+ function unknownPropMessage(component, name, props) {
264
+ const base = `<${component}> has no prop "${name}"`;
265
+ if (STYLE_PROPS.has(name)) return `${base}; style only through the component's props`;
266
+ for (const [prop, spec] of Object.entries(props)) if (spec.kind === "enum" && spec.values.includes(name)) return `${base}; write ${prop}="${name}"`;
267
+ const alias = PROP_ALIASES.get(name);
268
+ const hint = lookupHint(name, Object.keys(props)) ?? (alias !== void 0 && Object.hasOwn(props, alias) ? alias : void 0) ?? nearest(name, Object.keys(props), 1)[0];
269
+ if (hint !== void 0) return `${base}; did you mean ${hint}?`;
270
+ const names = Object.keys(props);
271
+ return names.length > 0 && names.length <= 12 ? `${base}; it has ${names.join(", ")}` : base;
272
+ }
273
+ /**
274
+ * `flex` on a component that has none, as `<text flex={1}>` in a row: the
275
+ * element wrapped in a `<box>` that takes it, which is what makes it
276
+ * stretch. Null when the catalog's `<box>` has no `flex`, or the parser
277
+ * cannot place the element's closing tag.
278
+ */
279
+ function flexWrap(node, prop, catalog) {
280
+ if (prop.name !== "flex" || prop.written === void 0 || prop.start === void 0 || prop.end === void 0 || node.closeEnd === void 0 || componentSpec(catalog, "box")?.props["flex"] === void 0) return null;
281
+ return {
282
+ fix: {
283
+ edits: [
284
+ {
285
+ end: node.start,
286
+ start: node.start,
287
+ text: `<box ${prop.written}>`
288
+ },
289
+ {
290
+ end: prop.end,
291
+ start: prop.start,
292
+ text: ""
293
+ },
294
+ {
295
+ end: node.closeEnd,
296
+ start: node.closeEnd,
297
+ text: "</box>"
298
+ }
299
+ ],
300
+ title: `Wrap <${node.name}> in <box ${prop.written}>`
301
+ },
302
+ written: prop.written
303
+ };
304
+ }
305
+ /**
306
+ * Deletes the element's prop at `index` with the whitespace in front of it,
307
+ * which runs from the end of the prop or tag name before it. Null when the
308
+ * parser made up either end.
309
+ */
310
+ function dropAttribute(node, index) {
311
+ const prop = node.props[index];
312
+ const before = index === 0 ? node.start + 1 + node.name.length : node.props[index - 1]?.end;
313
+ if (prop?.end === void 0 || before === void 0) return null;
314
+ return {
315
+ edits: [{
316
+ end: prop.end,
317
+ start: before,
318
+ text: ""
319
+ }],
320
+ title: `Remove ${writtenProp(prop)}`
321
+ };
322
+ }
323
+ const NUMERIC = /^\s*-?\d+(?:\.\d+)?\s*$/;
324
+ /** A quoted number on a number prop, such as `gap="3"`, read as the number. */
325
+ function coerceNumber(spec, value) {
326
+ if (spec.kind !== "number" || value.kind !== "literal" || typeof value.value !== "string" || !NUMERIC.test(value.value)) return null;
327
+ const number = Number(value.value);
328
+ return isValidValue(spec, number) ? {
329
+ kind: "literal",
330
+ value: number
331
+ } : null;
332
+ }
333
+ /** The attribute as the source wrote it. */
334
+ const writtenProp = (prop) => prop.written ?? (prop.value.kind === "literal" ? `${prop.name}={${JSON.stringify(prop.value.value)}}` : `${prop.name}={${prop.value.code}}`);
335
+ /**
336
+ * Where a diagnostic about `node` points: its line, and its range in the
337
+ * source when it has one, or `prop`'s when the diagnostic is about one prop.
338
+ */
339
+ function placeOf(node, prop) {
340
+ const range = prop?.start === void 0 ? node : prop;
341
+ return range.start === void 0 || range.end === void 0 ? { line: node.line } : {
342
+ end: range.end,
343
+ line: node.line,
344
+ start: range.start
345
+ };
346
+ }
347
+ /** Where a diagnostic about a piece of code points. */
348
+ function placeOfCode(code) {
349
+ if (code.at === void 0) return { line: code.line };
350
+ const range = sourceRange(code.at, 0, code.code.length);
351
+ return {
352
+ end: range.end,
353
+ line: code.line,
354
+ start: range.start
355
+ };
356
+ }
357
+ const codeAt = (value) => value?.kind === "expr" ? value.at : void 0;
358
+ function validateControl(node, name, children, statements, diagnostics) {
359
+ const warn = (code, message, prop) => {
360
+ diagnostics.push({
361
+ code,
362
+ ...placeOf(node, prop),
363
+ message,
364
+ severity: "warning"
365
+ });
366
+ };
367
+ const props = new Map(node.props.map((prop) => [prop.name, prop.value]));
368
+ for (const [index, prop] of node.props.entries()) {
369
+ if (controlProps[name].some((allowed) => allowed === prop.name)) continue;
370
+ const fix = name === "each" && prop.name === "key" ? dropAttribute(node, index) : null;
371
+ if (fix === null) {
372
+ warn("unknown_prop", `<${name}> has no prop "${prop.name}"`, prop);
373
+ continue;
374
+ }
375
+ diagnostics.push({
376
+ code: "unknown_prop",
377
+ ...placeOf(node, prop),
378
+ fix,
379
+ message: "<each> has no prop \"key\": items are keyed by their own `id` or `key`",
380
+ severity: "warning"
381
+ });
382
+ }
383
+ if (name === "if") {
384
+ const test = asCode(props.get("test"));
385
+ if (test === null) {
386
+ warn("invalid_control", "<if> needs test={condition}; it was removed");
387
+ return [];
388
+ }
389
+ const testAt = codeAt(props.get("test"));
390
+ return [{
391
+ children,
392
+ line: node.line,
393
+ statements,
394
+ test,
395
+ testAt,
396
+ type: "if"
397
+ }];
398
+ }
399
+ const items = asCode(props.get("of"));
400
+ const itemsAt = codeAt(props.get("of"));
401
+ if (items === null) {
402
+ warn("invalid_control", "<each> needs of={list}; it was removed");
403
+ return [];
404
+ }
405
+ const binding = (prop, fallback) => {
406
+ const value = props.get(prop);
407
+ if (value === void 0) return fallback;
408
+ if (value.kind === "literal" && isBindingName(String(value.value))) return String(value.value);
409
+ warn("invalid_control", `<each ${prop}> must be a plain variable name like "item"; using ${fallback === null ? "none" : `"${fallback}"`}`);
410
+ return fallback;
411
+ };
412
+ return [{
413
+ children,
414
+ index: binding("index", null),
415
+ item: binding("as", "item") ?? "item",
416
+ items,
417
+ itemsAt,
418
+ line: node.line,
419
+ statements,
420
+ type: "each"
421
+ }];
422
+ }
423
+ function validateSlot(node, state) {
424
+ if (!state.inDefine) {
425
+ state.diagnostics.push({
426
+ code: "invalid_define",
427
+ ...placeOf(node),
428
+ message: "<slot> is only allowed inside <define>; it was removed",
429
+ severity: "warning"
430
+ });
431
+ return [];
432
+ }
433
+ for (const prop of node.props) state.diagnostics.push({
434
+ code: "unknown_prop",
435
+ ...placeOf(node, prop),
436
+ message: `<slot> has no prop "${prop.name}"`,
437
+ severity: "warning"
438
+ });
439
+ if (node.children.length > 0) state.diagnostics.push({
440
+ code: "unexpected_children",
441
+ ...placeOf(node),
442
+ message: "<slot> takes no children; they were dropped",
443
+ severity: "warning"
444
+ });
445
+ return [{
446
+ line: node.line,
447
+ type: "slot"
448
+ }];
449
+ }
450
+ /** Keeps the first `<slot/>` that survived validation and drops the rest. */
451
+ function trimExtraSlots(nodes, defineName, diagnostics, seen) {
452
+ return nodes.flatMap((node) => {
453
+ if (node.type === "slot") {
454
+ if (seen.count > 0) {
455
+ diagnostics.push({
456
+ code: "invalid_define",
457
+ ...placeOf(node),
458
+ message: `<${defineName}> already has a <slot>; this one was removed`,
459
+ severity: "warning"
460
+ });
461
+ return [];
462
+ }
463
+ seen.count++;
464
+ return [node];
465
+ }
466
+ if (node.type === "call" || node.type === "each" || node.type === "element" || node.type === "if") return [{
467
+ ...node,
468
+ children: trimExtraSlots(node.children, defineName, diagnostics, seen)
469
+ }];
470
+ return [node];
471
+ });
472
+ }
473
+ function validateCall(node, def, state, scope) {
474
+ const ordinal = state.ordinals.get(def.name) ?? 0;
475
+ state.ordinals.set(def.name, ordinal + 1);
476
+ const children = validateNodes(node.children, state, scope);
477
+ const known = new Map(def.props.map((prop) => [prop.name, prop]));
478
+ const props = {};
479
+ let key = null;
480
+ for (const prop of node.props) {
481
+ if (prop.name === "key") {
482
+ if (prop.value.kind === "literal" && typeof prop.value.value !== "string" && typeof prop.value.value !== "number") {
483
+ state.diagnostics.push({
484
+ code: "invalid_literal",
485
+ ...placeOf(node, prop),
486
+ message: `<${def.name} key={${JSON.stringify(prop.value.value)}}> must be a string or number`,
487
+ severity: "warning"
488
+ });
489
+ continue;
490
+ }
491
+ key = prop.value;
492
+ continue;
493
+ }
494
+ const spec = known.get(prop.name);
495
+ if (spec === void 0) {
496
+ const near = nearest(prop.name, known.keys(), 1)[0];
497
+ state.diagnostics.push({
498
+ code: "unknown_prop",
499
+ ...placeOf(node, prop),
500
+ message: near === void 0 ? `<${def.name}> has no prop "${prop.name}"` : `<${def.name}> has no prop "${prop.name}"; did you mean ${near}?`,
501
+ severity: "warning"
502
+ });
503
+ continue;
504
+ }
505
+ if (spec.kind === "number" && prop.value.kind === "literal" && typeof prop.value.value === "string" && NUMERIC.test(prop.value.value)) {
506
+ const value = Number(prop.value.value);
507
+ state.diagnostics.push({
508
+ code: "coerced",
509
+ ...placeOf(node, prop),
510
+ message: `<${def.name} ${writtenProp(prop)}> is text; it was read as ${prop.name}={${String(value)}}. Write numbers in braces`,
511
+ severity: "info"
512
+ });
513
+ props[prop.name] = {
514
+ kind: "literal",
515
+ value
516
+ };
517
+ continue;
518
+ }
519
+ if (prop.value.kind === "literal" && spec.kind !== null && typeof prop.value.value !== spec.kind) {
520
+ state.diagnostics.push({
521
+ code: "invalid_literal",
522
+ ...placeOf(node, prop),
523
+ message: `<${def.name} ${writtenProp(prop)}> does not match a ${spec.kind} value`,
524
+ severity: "warning"
525
+ });
526
+ continue;
527
+ }
528
+ props[prop.name] = prop.value;
529
+ }
530
+ for (const spec of def.props) if (spec.required && !Object.hasOwn(props, spec.name)) state.diagnostics.push({
531
+ code: "missing_prop",
532
+ ...placeOf(node),
533
+ message: `<${def.name}> is missing required prop "${spec.name}"`,
534
+ severity: "warning"
535
+ });
536
+ if (!def.hasSlot && def.incomplete !== true && children.length > 0) state.diagnostics.push({
537
+ code: "unexpected_children",
538
+ ...placeOf(node),
539
+ message: `<${def.name}> has no <slot>, so its children were dropped`,
540
+ severity: "warning"
541
+ });
542
+ return [{
543
+ children: def.hasSlot ? children : [],
544
+ key,
545
+ line: node.line,
546
+ name: def.name,
547
+ ordinal,
548
+ origin: def.origin,
549
+ props,
550
+ type: "call"
551
+ }];
552
+ }
553
+ /**
554
+ * Validates `nodes` against the catalog and in-scope defines. `{@body}` lines
555
+ * move into `scope`: the statements of the nearest enclosing `<define>`,
556
+ * `<each>` or `<if>`, or of the whole program.
557
+ */
558
+ function validateNodes(nodes, state, scope) {
559
+ return nodes.flatMap((node) => {
560
+ if (node.type === "statement") {
561
+ const misuse = hookMisuse(node.code, "statement", scope.block);
562
+ if (misuse !== null) {
563
+ const inBlock = scope.block === "each" || scope.block === "if";
564
+ const hoist = inBlock ? state.hoists.byStart.get(node.start) : void 0;
565
+ const diagnostic = {
566
+ code: inBlock ? "invalid_statement" : "hook_placement",
567
+ ...placeOfCode(node),
568
+ message: `${misuse.message}${hoist?.reason === null || hoist?.reason === void 0 ? "" : `; it can't move out by itself, as it ${hoist.reason}`}. The statement was removed`,
569
+ severity: "warning"
570
+ };
571
+ if (hoist?.fix) diagnostic.fix = hoist.fix;
572
+ state.diagnostics.push(diagnostic);
573
+ for (const name of misuse.declared) state.dropped.add(name);
574
+ return [];
575
+ }
576
+ scope.statements.push({
577
+ at: node.at,
578
+ code: node.code,
579
+ end: node.end,
580
+ line: node.line,
581
+ start: node.start
582
+ });
583
+ return [];
584
+ }
585
+ if (node.type === "expr") {
586
+ const misuse = hookMisuse(node.code, "expression", scope.block);
587
+ if (misuse !== null) {
588
+ state.diagnostics.push({
589
+ code: "hook_placement",
590
+ ...placeOfCode(node),
591
+ message: `${misuse.message}. The expression was removed`,
592
+ severity: "warning"
593
+ });
594
+ return [];
595
+ }
596
+ return [node];
597
+ }
598
+ if (node.type !== "element") return [node];
599
+ if (node.name === "define") {
600
+ state.diagnostics.push({
601
+ code: "invalid_define",
602
+ ...placeOf(node),
603
+ message: "<define> is only allowed at the top level; it was removed",
604
+ severity: "warning"
605
+ });
606
+ return [];
607
+ }
608
+ if (node.name === "slot") return validateSlot(node, state);
609
+ if (node.name === "each" || node.name === "if") {
610
+ const statements = [];
611
+ const children = validateNodes(node.children, state, {
612
+ block: node.name,
613
+ statements
614
+ });
615
+ return validateControl(node, node.name, children, statements, state.diagnostics);
616
+ }
617
+ const defined = state.defines.get(node.name);
618
+ if (defined !== void 0) return validateCall(node, defined, state, scope);
619
+ const children = validateNodes(node.children, state, scope);
620
+ const spec = componentSpec(state.catalog, node.name);
621
+ if (spec === void 0) {
622
+ state.diagnostics.push({
623
+ code: "unknown_component",
624
+ ...placeOf(node),
625
+ message: unknownComponentMessage(node.name, state.defines, state.catalog),
626
+ severity: "warning"
627
+ });
628
+ return children;
629
+ }
630
+ const component = node.name;
631
+ const props = {};
632
+ for (const prop of node.props) {
633
+ if (prop.name === "key") continue;
634
+ const propDefinition = propSpec(spec, prop.name);
635
+ if (propDefinition === void 0) {
636
+ const wrap = flexWrap(node, prop, state.catalog);
637
+ const diagnostic = {
638
+ code: "unknown_prop",
639
+ ...placeOf(node, prop),
640
+ message: wrap === null ? unknownPropMessage(component, prop.name, spec.props) : `<${component}> has no prop "flex"; wrap it in <box ${wrap.written}>`,
641
+ severity: "warning"
642
+ };
643
+ if (wrap !== null) diagnostic.fix = wrap.fix;
644
+ state.diagnostics.push(diagnostic);
645
+ continue;
646
+ }
647
+ const misuse = prop.value.kind === "expr" ? hookMisuse(prop.value.code, "expression", scope.block) : null;
648
+ if (misuse !== null) {
649
+ state.diagnostics.push({
650
+ code: "hook_placement",
651
+ ...placeOf(node, prop),
652
+ message: `<${component} ${prop.name}={…}>: ${misuse.message}. The prop was removed`,
653
+ severity: "warning"
654
+ });
655
+ continue;
656
+ }
657
+ const coerced = coerceNumber(propDefinition, prop.value);
658
+ if (coerced !== null && coerced.kind === "literal") {
659
+ state.diagnostics.push({
660
+ code: "coerced",
661
+ ...placeOf(node, prop),
662
+ message: `<${component} ${writtenProp(prop)}> is text; it was read as ${prop.name}={${String(coerced.value)}}. Write numbers in braces`,
663
+ severity: "info"
664
+ });
665
+ props[prop.name] = coerced;
666
+ continue;
667
+ }
668
+ if (prop.value.kind === "literal" && !isValidValue(propDefinition, prop.value.value)) {
669
+ state.diagnostics.push({
670
+ code: "invalid_literal",
671
+ ...placeOf(node, prop),
672
+ message: `<${component} ${writtenProp(prop)}> does not match ${describe(propDefinition, prop.value.value)}`,
673
+ severity: "warning"
674
+ });
675
+ continue;
676
+ }
677
+ props[prop.name] = prop.value;
678
+ }
679
+ const allowsChildren = spec.children !== "none";
680
+ if (!allowsChildren && children.length > 0) state.diagnostics.push({
681
+ code: "unexpected_children",
682
+ ...placeOf(node),
683
+ message: `<${component}> takes no children; they were dropped`,
684
+ severity: "warning"
685
+ });
686
+ return [{
687
+ children: allowsChildren ? children : [],
688
+ component,
689
+ line: node.line,
690
+ props,
691
+ type: "element"
692
+ }];
693
+ });
694
+ }
695
+ /** Enums longer than this list only the values nearest to what was written. */
696
+ const MAX_LISTED_VALUES = 8;
697
+ function describe(spec, written) {
698
+ if (spec.kind === "enum" && spec.values.length > MAX_LISTED_VALUES) {
699
+ const near = closest(String(written), spec.values, 5);
700
+ return `any of its ${spec.values.length} values; the closest are ${near.join(" | ")}. Use a name from the prompt's list`;
701
+ }
702
+ if (spec.kind === "enum") return `one of ${spec.values.join(" | ")}`;
703
+ if (spec.kind === "number" && typeof written === "string") return `${describeNumber(spec)}, and a number goes in braces: {3}, not "3"`;
704
+ return describeNumber(spec);
705
+ }
706
+ function describeNumber(spec) {
707
+ if (spec.kind === "number" && spec.min !== void 0 && spec.max !== void 0) return `a number from ${spec.min} to ${spec.max}`;
708
+ return `a ${spec.kind} value`;
709
+ }
710
+ function visitRender(nodes, visit) {
711
+ for (const node of nodes) if (node.type === "call") {
712
+ visit(node);
713
+ visitRender(node.children, visit);
714
+ } else if (node.type === "each" || node.type === "element" || node.type === "if") visitRender(node.children, visit);
715
+ }
716
+ /** Document defines, plus library defines reached from the tree or those defines. */
717
+ function emittedDefines(tree, docDefines, available) {
718
+ const library = [];
719
+ const seen = new Set(docDefines.map((def) => def.name));
720
+ const consider = (name) => {
721
+ if (seen.has(name)) return;
722
+ const def = available.get(name);
723
+ if (def?.origin !== "library") return;
724
+ seen.add(def.name);
725
+ library.push(def);
726
+ visitRender(def.body, (call) => {
727
+ consider(call.name);
728
+ });
729
+ };
730
+ const walk = (nodes) => {
731
+ visitRender(nodes, (call) => {
732
+ consider(call.name);
733
+ });
734
+ };
735
+ walk(tree);
736
+ for (const def of docDefines) walk(def.body);
737
+ return [...docDefines, ...library.toSorted((a, b) => a.name.localeCompare(b.name))];
738
+ }
739
+ /**
740
+ * Static call sites in the document tree and in document-local define bodies.
741
+ * A tag inside a library recipe is not counted again.
742
+ */
743
+ function countUses(tree, docDefines) {
744
+ const counts = /* @__PURE__ */ new Map();
745
+ const visit = (node) => {
746
+ const current = counts.get(node.name);
747
+ counts.set(node.name, {
748
+ instances: (current?.instances ?? 0) + 1,
749
+ name: node.name,
750
+ origin: node.origin
751
+ });
752
+ };
753
+ visitRender(tree, visit);
754
+ for (const def of docDefines) visitRender(def.body, visit);
755
+ return [...counts.values()].toSorted((a, b) => a.name.localeCompare(b.name));
756
+ }
757
+ function compileDocument(source, library, components) {
758
+ const document = parsePickle(source);
759
+ const diagnostics = [...document.repairs.map((repair) => ({
760
+ code: "repaired",
761
+ ...placeOf(repair),
762
+ message: repair.message,
763
+ severity: "info"
764
+ })), ...document.errors.map((error) => ({
765
+ code: "unclosed",
766
+ ...placeOf(error),
767
+ message: error.message,
768
+ severity: "warning"
769
+ }))];
770
+ const libraryNames = new Set(library.keys());
771
+ const docDefines = [];
772
+ const rawBodies = /* @__PURE__ */ new Map();
773
+ const topLevel = [];
774
+ const docNames = /* @__PURE__ */ new Set();
775
+ for (const node of document.children) {
776
+ if (node.type === "element" && node.name === "define") {
777
+ const def = readDefine(node, diagnostics, libraryNames, components);
778
+ if (def === null) continue;
779
+ if (docNames.has(def.name)) {
780
+ diagnostics.push({
781
+ code: "invalid_define",
782
+ ...placeOf(node),
783
+ message: `<define name="${def.name}"> is already defined; this one was removed`,
784
+ severity: "warning"
785
+ });
786
+ continue;
787
+ }
788
+ docNames.add(def.name);
789
+ def.incomplete = document.repairs.some((repair) => repair.line === node.line && repair.message === "Closed unclosed <define>");
790
+ docDefines.push(def);
791
+ rawBodies.set(def.name, node.children);
792
+ continue;
793
+ }
794
+ topLevel.push(node);
795
+ }
796
+ const available = new Map(library);
797
+ for (const def of docDefines) available.set(def.name, def);
798
+ const dropped = /* @__PURE__ */ new Set();
799
+ const hoists = blockHoists(document);
800
+ const state = {
801
+ catalog: components,
802
+ defineName: null,
803
+ defines: available,
804
+ diagnostics,
805
+ dropped,
806
+ hoists,
807
+ inDefine: false,
808
+ ordinals: /* @__PURE__ */ new Map()
809
+ };
810
+ for (const def of docDefines) {
811
+ const statements = [];
812
+ def.body = trimExtraSlots(validateNodes(rawBodies.get(def.name) ?? [], {
813
+ catalog: components,
814
+ defineName: def.name,
815
+ defines: available,
816
+ diagnostics,
817
+ dropped,
818
+ hoists,
819
+ inDefine: true,
820
+ ordinals: /* @__PURE__ */ new Map()
821
+ }, {
822
+ block: "define",
823
+ statements
824
+ }), def.name, diagnostics, { count: 0 });
825
+ def.statements = statements;
826
+ }
827
+ const statements = [];
828
+ for (const statement of document.statements) {
829
+ const misuse = hookMisuse(statement.code, "statement", null);
830
+ if (misuse === null) {
831
+ statements.push(statement);
832
+ continue;
833
+ }
834
+ diagnostics.push({
835
+ code: "hook_placement",
836
+ ...placeOfCode(statement),
837
+ message: `${misuse.message}. The statement was removed`,
838
+ severity: "warning"
839
+ });
840
+ for (const name of misuse.declared) dropped.add(name);
841
+ }
842
+ const tree = validateNodes(topLevel, state, {
843
+ block: null,
844
+ statements
845
+ });
846
+ const uses = checkCode(tree, docDefines, statements, diagnostics, hoists, dropped);
847
+ const generated = generateProgram(statements, tree, emittedDefines(tree, docDefines, available));
848
+ diagnostics.push(...generated.diagnostics);
849
+ for (const diagnostic of diagnostics) if (diagnostic.start !== void 0) {
850
+ diagnostic.line = lineAt(source, diagnostic.start);
851
+ diagnostic.column = columnAt(source, diagnostic.start);
852
+ }
853
+ diagnostics.sort((a, b) => a.line - b.line);
854
+ return {
855
+ defines: docDefines,
856
+ diagnostics,
857
+ library,
858
+ output: generated.output,
859
+ procedures: uses.procedures,
860
+ program: {
861
+ defines: countUses(tree, docDefines),
862
+ statements,
863
+ tree
864
+ },
865
+ reads: uses.reads,
866
+ source
867
+ };
868
+ }
869
+ function buildLibrary(list, components) {
870
+ const built = /* @__PURE__ */ new Map();
871
+ for (const recipe of list) {
872
+ if (built.has(recipe.name)) throw new Error(`Recipe name "${recipe.name}" is duplicated`);
873
+ const result = compileDocument(recipe.source, built, components);
874
+ const problem = result.diagnostics[0];
875
+ if (problem !== void 0) throw new Error(`Recipe "${recipe.name}" failed to compile: line ${problem.line}: ${problem.message}`);
876
+ const def = result.defines[0];
877
+ if (result.defines.length !== 1 || def === void 0) throw new Error(`Recipe "${recipe.name}" must contain exactly one <define>`);
878
+ if (def.name !== recipe.name) throw new Error(`Recipe "${recipe.name}" defines <${def.name}> instead`);
879
+ built.set(def.name, {
880
+ ...def,
881
+ origin: "library",
882
+ procedures: result.procedures
883
+ });
884
+ }
885
+ return built;
886
+ }
887
+ /**
888
+ * Compiles `recipes` against `catalog`, in order: a recipe may use the ones
889
+ * before it. Throws when a recipe has any diagnostic. Cached per catalog and
890
+ * recipe list.
891
+ */
892
+ function compileLibrary(recipes, catalog) {
893
+ const byRecipes = libraryCache.get(catalog) ?? /* @__PURE__ */ new WeakMap();
894
+ libraryCache.set(catalog, byRecipes);
895
+ const existing = byRecipes.get(recipes);
896
+ if (existing !== void 0) return existing;
897
+ const built = buildLibrary(recipes, catalog);
898
+ byRecipes.set(recipes, built);
899
+ return built;
900
+ }
901
+ /** `source` with the fixes' edits applied. */
902
+ function applyFixes(source, fixes) {
903
+ const applied = /* @__PURE__ */ new Set();
904
+ const taken = [];
905
+ for (const fix of fixes) if (!fix.edits.some((edit) => taken.some((other) => edit.start < other.end && other.start < edit.end))) {
906
+ applied.add(fix);
907
+ taken.push(...fix.edits);
908
+ }
909
+ let text = source;
910
+ const order = taken.map((edit, index) => ({
911
+ edit,
912
+ index
913
+ })).toSorted((a, b) => b.edit.start - a.edit.start || b.index - a.index);
914
+ for (const { edit } of order) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
915
+ return {
916
+ applied,
917
+ text
918
+ };
919
+ }
920
+ const problems = (diagnostics) => diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
921
+ /**
922
+ * Compiles a document. A warning that carries a fix is applied rather than
923
+ * sent to the model: the fixed source is compiled again, and when it has
924
+ * exactly the problems left that the fixes did not cover, its program is
925
+ * used and those warnings become `info`, as a quoted number on a number prop
926
+ * already is. Diagnostics, procedures and their spans stay those of `source`,
927
+ * which is what the model wrote and what the session keeps.
928
+ */
929
+ function compilePickle(source, options) {
930
+ const library = options.library ?? NO_LIBRARY;
931
+ const result = compileDocument(source, library, options.catalog);
932
+ const fixes = new Set(result.diagnostics.flatMap((diagnostic) => diagnostic.severity === "warning" && diagnostic.fix !== void 0 ? [diagnostic.fix] : []));
933
+ if (fixes.size === 0) return result;
934
+ const patched = applyFixes(source, [...fixes]);
935
+ const fixed = compileDocument(patched.text, library, options.catalog);
936
+ const covered = result.diagnostics.filter((diagnostic) => diagnostic.fix !== void 0 && patched.applied.has(diagnostic.fix)).length;
937
+ if (problems(fixed.diagnostics) !== problems(result.diagnostics) - covered) return result;
938
+ return {
939
+ ...fixed,
940
+ diagnostics: result.diagnostics.map((diagnostic) => diagnostic.fix !== void 0 && patched.applied.has(diagnostic.fix) ? {
941
+ ...diagnostic,
942
+ message: `${diagnostic.message}; the compiler made that change`,
943
+ severity: "info"
944
+ } : diagnostic),
945
+ procedures: result.procedures,
946
+ reads: result.reads
947
+ };
948
+ }
949
+ //#endregion
950
+ export { compileLibrary, compilePickle };