@jigs.design/compiler 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +28 -2
- package/dist/ast.d.ts +78 -0
- package/dist/block-hoist.js +237 -0
- package/dist/check-code.d.ts +46 -0
- package/dist/check-code.js +700 -0
- package/dist/codegen.d.ts +8 -0
- package/dist/codegen.js +219 -0
- package/dist/compile.d.ts +177 -0
- package/dist/compile.js +950 -0
- package/dist/hoist.d.ts +20 -0
- package/dist/hoist.js +76 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/offsets.d.ts +18 -0
- package/dist/offsets.js +40 -0
- package/dist/parse.d.ts +16 -0
- package/dist/parse.js +690 -0
- package/dist/patch.d.ts +55 -0
- package/dist/patch.js +140 -0
- package/dist/script.d.ts +14 -0
- package/dist/script.js +695 -0
- package/dist/signature.d.ts +5 -0
- package/dist/signature.js +25 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +14 -0
- package/dist/suggest.d.ts +8 -0
- package/dist/suggest.js +53 -0
- package/package.json +27 -3
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { analyze, lineOf } from "./script.js";
|
|
2
|
+
import { statementProblem } from "./codegen.js";
|
|
3
|
+
import { sourceRange } from "./offsets.js";
|
|
4
|
+
import { nearest } from "./suggest.js";
|
|
5
|
+
//#region src/check-code.ts
|
|
6
|
+
/** A step of `DataRead.path` into each element of an array, as an `<each>` item takes. */
|
|
7
|
+
const ELEMENT = "[]";
|
|
8
|
+
/**
|
|
9
|
+
* Names that do not throw ReferenceError in the sandbox: the language's
|
|
10
|
+
* built-ins, the worker's own globals that `LOCKED_GLOBALS` in
|
|
11
|
+
* `@jigs.design/runtime/frame` leaves in place, and Pickle's bindings. Kept by
|
|
12
|
+
* hand, since the compiler runs on the server; `checks.test.ts` fails if a
|
|
13
|
+
* name here is one the worker locks, or exists in no JavaScript runtime.
|
|
14
|
+
* `Function`, `Proxy`, `Reflect`, `WeakRef`, `FinalizationRegistry` and
|
|
15
|
+
* `Atomics` are left out and locked: no UI needs them.
|
|
16
|
+
*/
|
|
17
|
+
const KNOWN_GLOBALS = /* @__PURE__ */ new Set([
|
|
18
|
+
"AbortController",
|
|
19
|
+
"AbortSignal",
|
|
20
|
+
"AggregateError",
|
|
21
|
+
"Array",
|
|
22
|
+
"ArrayBuffer",
|
|
23
|
+
"BigInt",
|
|
24
|
+
"BigInt64Array",
|
|
25
|
+
"BigUint64Array",
|
|
26
|
+
"Blob",
|
|
27
|
+
"Boolean",
|
|
28
|
+
"DOMException",
|
|
29
|
+
"DataView",
|
|
30
|
+
"Date",
|
|
31
|
+
"Error",
|
|
32
|
+
"EvalError",
|
|
33
|
+
"Float16Array",
|
|
34
|
+
"Float32Array",
|
|
35
|
+
"Float64Array",
|
|
36
|
+
"Infinity",
|
|
37
|
+
"Int16Array",
|
|
38
|
+
"Int32Array",
|
|
39
|
+
"Int8Array",
|
|
40
|
+
"Intl",
|
|
41
|
+
"Iterator",
|
|
42
|
+
"JSON",
|
|
43
|
+
"Map",
|
|
44
|
+
"Math",
|
|
45
|
+
"NaN",
|
|
46
|
+
"Number",
|
|
47
|
+
"Object",
|
|
48
|
+
"Pickle",
|
|
49
|
+
"Promise",
|
|
50
|
+
"RangeError",
|
|
51
|
+
"ReferenceError",
|
|
52
|
+
"RegExp",
|
|
53
|
+
"Set",
|
|
54
|
+
"String",
|
|
55
|
+
"Symbol",
|
|
56
|
+
"SyntaxError",
|
|
57
|
+
"TextDecoder",
|
|
58
|
+
"TextEncoder",
|
|
59
|
+
"TypeError",
|
|
60
|
+
"URIError",
|
|
61
|
+
"URL",
|
|
62
|
+
"URLSearchParams",
|
|
63
|
+
"Uint16Array",
|
|
64
|
+
"Uint32Array",
|
|
65
|
+
"Uint8Array",
|
|
66
|
+
"Uint8ClampedArray",
|
|
67
|
+
"WeakMap",
|
|
68
|
+
"WeakSet",
|
|
69
|
+
"__pickle",
|
|
70
|
+
"__pickleConstants",
|
|
71
|
+
"__pickleSafe",
|
|
72
|
+
"arguments",
|
|
73
|
+
"atob",
|
|
74
|
+
"btoa",
|
|
75
|
+
"console",
|
|
76
|
+
"crypto",
|
|
77
|
+
"decodeURI",
|
|
78
|
+
"decodeURIComponent",
|
|
79
|
+
"encodeURI",
|
|
80
|
+
"encodeURIComponent",
|
|
81
|
+
"escape",
|
|
82
|
+
"globalThis",
|
|
83
|
+
"isFinite",
|
|
84
|
+
"isNaN",
|
|
85
|
+
"parseFloat",
|
|
86
|
+
"parseInt",
|
|
87
|
+
"structuredClone",
|
|
88
|
+
"undefined",
|
|
89
|
+
"unescape",
|
|
90
|
+
"useMutation",
|
|
91
|
+
"useQuery",
|
|
92
|
+
"useState"
|
|
93
|
+
]);
|
|
94
|
+
/** Undefined names the server reports with its own advice, from the host's procedures. */
|
|
95
|
+
const NAMED_BY_SERVER = /* @__PURE__ */ new Set(["useAction"]);
|
|
96
|
+
/** Browser globals a model reaches for; the sandbox has none of them. */
|
|
97
|
+
const BROWSER_GLOBALS = /* @__PURE__ */ new Set([
|
|
98
|
+
"alert",
|
|
99
|
+
"clearInterval",
|
|
100
|
+
"clearTimeout",
|
|
101
|
+
"confirm",
|
|
102
|
+
"document",
|
|
103
|
+
"fetch",
|
|
104
|
+
"localStorage",
|
|
105
|
+
"location",
|
|
106
|
+
"navigator",
|
|
107
|
+
"performance",
|
|
108
|
+
"prompt",
|
|
109
|
+
"queueMicrotask",
|
|
110
|
+
"requestAnimationFrame",
|
|
111
|
+
"sessionStorage",
|
|
112
|
+
"setInterval",
|
|
113
|
+
"setTimeout",
|
|
114
|
+
"window"
|
|
115
|
+
]);
|
|
116
|
+
const HANDLE_FIELDS = {
|
|
117
|
+
useMutation: {
|
|
118
|
+
fields: /* @__PURE__ */ new Set([
|
|
119
|
+
"data",
|
|
120
|
+
"error",
|
|
121
|
+
"input",
|
|
122
|
+
"mutate",
|
|
123
|
+
"status"
|
|
124
|
+
]),
|
|
125
|
+
instead: /* @__PURE__ */ new Map([
|
|
126
|
+
["isError", "compare `NAME.status === \"error\"`"],
|
|
127
|
+
["isIdle", "compare `NAME.status === \"idle\"`"],
|
|
128
|
+
["isLoading", "compare `NAME.status === \"pending\"`"],
|
|
129
|
+
["isPending", "compare `NAME.status === \"pending\"`"],
|
|
130
|
+
["isSuccess", "compare `NAME.status === \"success\"`"],
|
|
131
|
+
["mutateAsync", "call `NAME.mutate(input)` from a handler; `status` and `data` update when it settles"],
|
|
132
|
+
["reset", "the handle resets when `mutate` is called again"]
|
|
133
|
+
]),
|
|
134
|
+
written: "{ status, data, error, input, mutate }"
|
|
135
|
+
},
|
|
136
|
+
useQuery: {
|
|
137
|
+
fields: /* @__PURE__ */ new Set([
|
|
138
|
+
"data",
|
|
139
|
+
"error",
|
|
140
|
+
"status"
|
|
141
|
+
]),
|
|
142
|
+
instead: /* @__PURE__ */ new Map([
|
|
143
|
+
["isError", "compare `NAME.status === \"error\"`"],
|
|
144
|
+
["isFetching", "compare `NAME.status === \"pending\"`"],
|
|
145
|
+
["isLoading", "compare `NAME.status === \"pending\"`"],
|
|
146
|
+
["isPending", "compare `NAME.status === \"pending\"`"],
|
|
147
|
+
["isSuccess", "compare `NAME.status === \"success\"`"],
|
|
148
|
+
["refetch", "a query reloads by itself when its input changes, or when a mutation that lists it under Refreshes succeeds"]
|
|
149
|
+
]),
|
|
150
|
+
written: "{ status, data, error }"
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* TanStack's status flags, and the status each one compares. Each has one
|
|
155
|
+
* way to write it, so the compiler rewrites it. `refetch` and `mutateAsync`
|
|
156
|
+
* have none: a query refetches by itself, and code may await what
|
|
157
|
+
* `mutateAsync` returns, which `mutate` does not.
|
|
158
|
+
*/
|
|
159
|
+
const STATUS_FLAGS = /* @__PURE__ */ new Map([
|
|
160
|
+
["isError", "error"],
|
|
161
|
+
["isFetching", "pending"],
|
|
162
|
+
["isIdle", "idle"],
|
|
163
|
+
["isLoading", "pending"],
|
|
164
|
+
["isPending", "pending"],
|
|
165
|
+
["isSuccess", "success"]
|
|
166
|
+
]);
|
|
167
|
+
function quoteCode(snippet) {
|
|
168
|
+
switch (snippet.place.type) {
|
|
169
|
+
case "attribute": return `<${snippet.place.tag} ${snippet.place.attribute}={${snippet.code}}>`;
|
|
170
|
+
case "statement": return "This {@body} line";
|
|
171
|
+
case "text": return `"{${snippet.code}}"`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The chain an `<each of>` is, when it is one alone or with `?? []` or
|
|
176
|
+
* `|| []` after it: `orders.data`, `data.items ?? []`. A bare name is a
|
|
177
|
+
* chain with no fields.
|
|
178
|
+
*/
|
|
179
|
+
function listChain(code) {
|
|
180
|
+
const analysis = analyze(code, "expression");
|
|
181
|
+
if (!analysis.ok || analysis.chains.length > 1) return null;
|
|
182
|
+
const [chain] = analysis.chains;
|
|
183
|
+
const name = /^\s*([A-Za-z_$][\w$]*)/.exec(code);
|
|
184
|
+
if (chain === void 0) return name?.[1] === void 0 || !FALLBACK.test(code.slice(name[0].length)) ? null : {
|
|
185
|
+
object: name[1],
|
|
186
|
+
path: []
|
|
187
|
+
};
|
|
188
|
+
const end = chain.ends.at(-1) ?? chain.offset;
|
|
189
|
+
return code.slice(0, chain.offset).trim() === "" && FALLBACK.test(code.slice(end)) ? {
|
|
190
|
+
object: chain.object,
|
|
191
|
+
path: chain.path
|
|
192
|
+
} : null;
|
|
193
|
+
}
|
|
194
|
+
/** What may follow a list's chain in `<each of>`: nothing, or an empty fallback. */
|
|
195
|
+
const FALLBACK = /^\s*(?:(?:\?\?|\|\|)\s*\[\s*\]\s*)?$/;
|
|
196
|
+
/** The procedure data an `<each of>` lists, when its code is a chain into it. */
|
|
197
|
+
function itemSource(code, body, scope) {
|
|
198
|
+
const chain = listChain(code);
|
|
199
|
+
if (chain === null) return null;
|
|
200
|
+
const list = [chain.object, ...chain.path].join(".");
|
|
201
|
+
const outer = scope.items.get(chain.object);
|
|
202
|
+
if (outer !== void 0) return {
|
|
203
|
+
data: outer.data,
|
|
204
|
+
list,
|
|
205
|
+
path: [
|
|
206
|
+
...outer.path,
|
|
207
|
+
...chain.path,
|
|
208
|
+
"[]"
|
|
209
|
+
],
|
|
210
|
+
procedure: outer.procedure
|
|
211
|
+
};
|
|
212
|
+
const source = scope.inner.has(chain.object) ? void 0 : body.sources.get(chain.object);
|
|
213
|
+
const skip = source?.field === "handle" ? 1 : 0;
|
|
214
|
+
if (source === void 0 || skip === 1 && chain.path[0] !== "data" || chain.path.length < skip) return null;
|
|
215
|
+
return {
|
|
216
|
+
data: skip === 1 ? `${chain.object}.data` : chain.object,
|
|
217
|
+
list,
|
|
218
|
+
path: [...chain.path.slice(skip), "[]"],
|
|
219
|
+
procedure: source.procedure
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function factsOf(snippet) {
|
|
223
|
+
if (snippet.kind === "statement" && statementProblem(snippet.code) !== null) return null;
|
|
224
|
+
const analysis = analyze(snippet.code, snippet.kind);
|
|
225
|
+
return analysis.ok ? analysis : null;
|
|
226
|
+
}
|
|
227
|
+
/** The names a body's top-level statements declare, and its query and mutation handles. */
|
|
228
|
+
function readBody(statements, define, outer) {
|
|
229
|
+
const declaredAt = /* @__PURE__ */ new Map();
|
|
230
|
+
const handles = /* @__PURE__ */ new Map();
|
|
231
|
+
const sources = /* @__PURE__ */ new Map();
|
|
232
|
+
const mutateFns = /* @__PURE__ */ new Set();
|
|
233
|
+
const maybeUndefined = /* @__PURE__ */ new Set();
|
|
234
|
+
for (const statement of statements) {
|
|
235
|
+
const facts = factsOf({
|
|
236
|
+
at: statement.at,
|
|
237
|
+
code: statement.code,
|
|
238
|
+
kind: "statement",
|
|
239
|
+
line: statement.line,
|
|
240
|
+
place: {
|
|
241
|
+
nested: false,
|
|
242
|
+
type: "statement"
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
for (const name of facts?.declared ?? []) if (!declaredAt.has(name)) declaredAt.set(name, statement.line);
|
|
246
|
+
for (const name of facts?.maybeUndefined ?? []) maybeUndefined.add(name);
|
|
247
|
+
for (const binding of facts?.hooks ?? []) {
|
|
248
|
+
if (binding.hook === "useState") continue;
|
|
249
|
+
if (binding.property === null) {
|
|
250
|
+
handles.set(binding.name, binding.hook);
|
|
251
|
+
if (binding.procedure !== null) sources.set(binding.name, {
|
|
252
|
+
field: "handle",
|
|
253
|
+
procedure: binding.procedure
|
|
254
|
+
});
|
|
255
|
+
} else if (binding.hook === "useMutation" && binding.property === "mutate") mutateFns.add(binding.name);
|
|
256
|
+
else if (binding.property === "data" && binding.procedure !== null) sources.set(binding.name, {
|
|
257
|
+
field: "data",
|
|
258
|
+
procedure: binding.procedure
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
declaredAt,
|
|
264
|
+
define,
|
|
265
|
+
early: [],
|
|
266
|
+
handles,
|
|
267
|
+
late: [],
|
|
268
|
+
maybeUndefined,
|
|
269
|
+
missing: [],
|
|
270
|
+
mutateFns,
|
|
271
|
+
outer,
|
|
272
|
+
reported: /* @__PURE__ */ new Set(),
|
|
273
|
+
sources
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Checks every piece of code in the page and its defines, adding to
|
|
278
|
+
* `diagnostics`, and returns the procedure calls it saw, in source order.
|
|
279
|
+
*/
|
|
280
|
+
function checkCode(tree, defines, pageStatements, diagnostics, hoists, dropped = /* @__PURE__ */ new Set()) {
|
|
281
|
+
const procedures = [];
|
|
282
|
+
const reads = [];
|
|
283
|
+
const report = (place, code, message, fix) => {
|
|
284
|
+
const diagnostic = {
|
|
285
|
+
code,
|
|
286
|
+
...place,
|
|
287
|
+
message,
|
|
288
|
+
severity: "warning"
|
|
289
|
+
};
|
|
290
|
+
if (fix !== void 0) diagnostic.fix = fix;
|
|
291
|
+
diagnostics.push(diagnostic);
|
|
292
|
+
};
|
|
293
|
+
const checkSnippet = (snippet, body, scope) => {
|
|
294
|
+
const facts = factsOf(snippet);
|
|
295
|
+
if (facts === null) return;
|
|
296
|
+
/** The place of `[offset, end)` in the snippet's code. */
|
|
297
|
+
const placeAt = (offset, end) => {
|
|
298
|
+
const line = lineOf(snippet.code, snippet.line, offset);
|
|
299
|
+
return snippet.at === void 0 ? { line } : {
|
|
300
|
+
line,
|
|
301
|
+
...sourceRange(snippet.at, offset, end)
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
for (const { end, offset, ...call } of facts.procedures) procedures.push({
|
|
305
|
+
...call,
|
|
306
|
+
...placeAt(offset, end)
|
|
307
|
+
});
|
|
308
|
+
const once = (key) => {
|
|
309
|
+
if (body.reported.has(key)) return false;
|
|
310
|
+
body.reported.add(key);
|
|
311
|
+
return true;
|
|
312
|
+
};
|
|
313
|
+
const shadowed = (name) => scope.inner.has(name);
|
|
314
|
+
for (const compare of facts.nullCompares) {
|
|
315
|
+
if (!body.maybeUndefined.has(compare.name) || shadowed(compare.name)) continue;
|
|
316
|
+
const loose = compare.operator === "!==" ? "!=" : "==";
|
|
317
|
+
const operator = placeAt(compare.operatorOffset, compare.operatorEnd);
|
|
318
|
+
report(placeAt(compare.offset, compare.end), "undefined_compare", `\`${compare.name} ${compare.operator} null\` misses undefined, which \`${compare.name}\` is when \`find\`, \`at\` or an index finds nothing: compare \`${compare.name} ${loose} null\``, operator.start === void 0 || operator.end === void 0 ? void 0 : {
|
|
319
|
+
edits: [{
|
|
320
|
+
end: operator.end,
|
|
321
|
+
start: operator.start,
|
|
322
|
+
text: loose
|
|
323
|
+
}],
|
|
324
|
+
title: `Replace with \`${compare.name} ${loose} null\``
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
for (const chain of facts.chains) {
|
|
328
|
+
const item = scope.items.get(chain.object);
|
|
329
|
+
if (item !== void 0) {
|
|
330
|
+
reads.push({
|
|
331
|
+
data: item.data,
|
|
332
|
+
item: {
|
|
333
|
+
list: item.list,
|
|
334
|
+
name: chain.object,
|
|
335
|
+
steps: item.path.length
|
|
336
|
+
},
|
|
337
|
+
path: [...item.path, ...chain.path],
|
|
338
|
+
places: [...item.path.map(() => null), ...chain.ends.map((end) => placeAt(chain.offset, end))],
|
|
339
|
+
procedure: item.procedure
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const source = shadowed(chain.object) ? void 0 : body.sources.get(chain.object);
|
|
344
|
+
const skip = source?.field === "handle" ? 1 : 0;
|
|
345
|
+
if (source === void 0 || skip === 1 && chain.path[0] !== "data" || chain.path.length <= skip) continue;
|
|
346
|
+
reads.push({
|
|
347
|
+
data: skip === 1 ? `${chain.object}.data` : chain.object,
|
|
348
|
+
path: chain.path.slice(skip),
|
|
349
|
+
places: chain.ends.slice(skip).map((end) => placeAt(chain.offset, end)),
|
|
350
|
+
procedure: source.procedure
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
for (const issue of facts.issues) report(placeAt(issue.offset, issue.end), issue.code, issue.message);
|
|
354
|
+
const seen = /* @__PURE__ */ new Set();
|
|
355
|
+
const callsFetch = facts.calls.some((call) => call.callee === "fetch");
|
|
356
|
+
for (const reference of facts.references) {
|
|
357
|
+
const { name } = reference;
|
|
358
|
+
if (scope.names.has(name) || KNOWN_GLOBALS.has(name)) {
|
|
359
|
+
const declared = body.declaredAt.get(name);
|
|
360
|
+
if (declared !== void 0 && declared > snippet.line && !reference.deferred && !shadowed(name) && once(`order:${name}`)) {
|
|
361
|
+
if (snippet.place.type === "statement" && !snippet.place.nested) body.late.push({
|
|
362
|
+
message: `This {@body} line reads "${name}" before line ${declared} declares it, so it sees undefined. Move the declaration up`,
|
|
363
|
+
place: placeAt(reference.offset, reference.end)
|
|
364
|
+
});
|
|
365
|
+
else body.early.push({
|
|
366
|
+
declared,
|
|
367
|
+
name,
|
|
368
|
+
place: placeAt(reference.offset, reference.end)
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (seen.has(name)) continue;
|
|
374
|
+
seen.add(name);
|
|
375
|
+
if (body.outer.has(name)) {
|
|
376
|
+
if (once(`outer:${name}`)) report(placeAt(reference.offset, reference.end), "outer_reference", `<define name="${body.define ?? ""}"> uses "${name}" from the page; components can't see page state. Pass it in as a prop`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (name === "fetch" && callsFetch || NAMED_BY_SERVER.has(name) || dropped.has(name)) continue;
|
|
380
|
+
const inBlocks = hoists.byName.get(name) ?? [];
|
|
381
|
+
const [hoist] = inBlocks;
|
|
382
|
+
if (hoist !== void 0) {
|
|
383
|
+
const entry = {
|
|
384
|
+
message: blockMessage(snippet, name, inBlocks),
|
|
385
|
+
name,
|
|
386
|
+
own: true,
|
|
387
|
+
place: placeAt(reference.offset, reference.end)
|
|
388
|
+
};
|
|
389
|
+
if (inBlocks.length === 1 && hoist.fix !== null) entry.fix = hoist.fix;
|
|
390
|
+
body.missing.push(entry);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
body.missing.push({
|
|
394
|
+
message: undefinedMessage(snippet, name, scope.names),
|
|
395
|
+
name,
|
|
396
|
+
own: BROWSER_GLOBALS.has(name) || nearest(name, scope.names, 1).length > 0,
|
|
397
|
+
place: placeAt(reference.offset, reference.end)
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
for (const member of facts.members) {
|
|
401
|
+
const hook = shadowed(member.object) ? void 0 : body.handles.get(member.object);
|
|
402
|
+
if (hook === void 0) continue;
|
|
403
|
+
const written = `${member.object}.${member.property}`;
|
|
404
|
+
if (hook === "useMutation" && member.property === "mutate" && member.called && !member.deferred) {
|
|
405
|
+
report(placeAt(member.offset, member.end), "mutate_in_render", mutateMessage(written));
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const fields = HANDLE_FIELDS[hook];
|
|
409
|
+
if (fields.fields.has(member.property)) continue;
|
|
410
|
+
const place = placeAt(member.offset, member.end);
|
|
411
|
+
const status = STATUS_FLAGS.get(member.property);
|
|
412
|
+
const fix = status !== void 0 && fields.instead.has(member.property) && !member.called && place.start !== void 0 && place.end !== void 0 && snippet.code.slice(member.offset, member.end) === written ? statusFix(member.object, status, place.start, place.end) : void 0;
|
|
413
|
+
report(place, "handle_field", handleFieldMessage(hook, member.object, member.property, member.called), fix);
|
|
414
|
+
}
|
|
415
|
+
for (const call of facts.calls) if (body.mutateFns.has(call.callee) && !shadowed(call.callee) && !call.deferred) report(placeAt(call.offset, call.end), "mutate_in_render", mutateMessage(call.callee));
|
|
416
|
+
for (const binding of facts.hooks) {
|
|
417
|
+
if (binding.hook === "useState" || binding.property === null) continue;
|
|
418
|
+
if (!HANDLE_FIELDS[binding.hook].fields.has(binding.property)) report(placeAt(0, snippet.code.length), "handle_field", handleFieldMessage(binding.hook, "handle", binding.property, false));
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const walk = (nodes, body, scope) => {
|
|
422
|
+
const attribute = (tag, name, code, line, at) => {
|
|
423
|
+
checkSnippet({
|
|
424
|
+
at,
|
|
425
|
+
code,
|
|
426
|
+
kind: "expression",
|
|
427
|
+
line,
|
|
428
|
+
place: {
|
|
429
|
+
attribute: name,
|
|
430
|
+
tag,
|
|
431
|
+
type: "attribute"
|
|
432
|
+
}
|
|
433
|
+
}, body, scope);
|
|
434
|
+
};
|
|
435
|
+
for (const node of nodes) switch (node.type) {
|
|
436
|
+
case "expr":
|
|
437
|
+
checkSnippet({
|
|
438
|
+
at: node.at,
|
|
439
|
+
code: node.code,
|
|
440
|
+
kind: "expression",
|
|
441
|
+
line: node.line,
|
|
442
|
+
place: { type: "text" }
|
|
443
|
+
}, body, scope);
|
|
444
|
+
break;
|
|
445
|
+
case "element":
|
|
446
|
+
case "call": {
|
|
447
|
+
const tag = node.type === "element" ? node.component : node.name;
|
|
448
|
+
for (const [name, value] of Object.entries(node.props)) if (value.kind === "expr") attribute(tag, name, value.code, node.line, value.at);
|
|
449
|
+
if (node.type === "call" && node.key?.kind === "expr") attribute(tag, "key", node.key.code, node.line, node.key.at);
|
|
450
|
+
walk(node.children, body, scope);
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
case "if":
|
|
454
|
+
attribute("if", "test", node.test, node.line, node.testAt);
|
|
455
|
+
block(node.statements, node.children, body, scope, []);
|
|
456
|
+
break;
|
|
457
|
+
case "each": {
|
|
458
|
+
attribute("each", "of", node.items, node.line, node.itemsAt);
|
|
459
|
+
const item = itemSource(node.items, body, scope);
|
|
460
|
+
block(node.statements, node.children, body, scope, node.index === null ? [node.item] : [node.item, node.index], item === null ? void 0 : {
|
|
461
|
+
name: node.item,
|
|
462
|
+
source: item
|
|
463
|
+
});
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
const block = (statements, children, body, scope, bound, item) => {
|
|
469
|
+
const lines = [];
|
|
470
|
+
for (const statement of statements) {
|
|
471
|
+
const facts = factsOf({
|
|
472
|
+
at: statement.at,
|
|
473
|
+
code: statement.code,
|
|
474
|
+
kind: "statement",
|
|
475
|
+
line: statement.line,
|
|
476
|
+
place: {
|
|
477
|
+
nested: true,
|
|
478
|
+
type: "statement"
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
lines.push(...facts?.declared ?? []);
|
|
482
|
+
}
|
|
483
|
+
const declared = [...bound, ...lines];
|
|
484
|
+
const items = new Map(scope.items);
|
|
485
|
+
for (const name of declared) items.delete(name);
|
|
486
|
+
if (item !== void 0 && !lines.includes(item.name)) items.set(item.name, item.source);
|
|
487
|
+
const inner = {
|
|
488
|
+
inner: /* @__PURE__ */ new Set([...scope.inner, ...declared]),
|
|
489
|
+
items,
|
|
490
|
+
names: /* @__PURE__ */ new Set([...scope.names, ...declared])
|
|
491
|
+
};
|
|
492
|
+
for (const statement of statements) checkSnippet({
|
|
493
|
+
at: statement.at,
|
|
494
|
+
code: statement.code,
|
|
495
|
+
kind: "statement",
|
|
496
|
+
line: statement.line,
|
|
497
|
+
place: {
|
|
498
|
+
nested: true,
|
|
499
|
+
type: "statement"
|
|
500
|
+
}
|
|
501
|
+
}, body, inner);
|
|
502
|
+
walk(children, body, inner);
|
|
503
|
+
};
|
|
504
|
+
const checkBody = (statements, children, body, names) => {
|
|
505
|
+
const scope = {
|
|
506
|
+
inner: /* @__PURE__ */ new Set(),
|
|
507
|
+
items: /* @__PURE__ */ new Map(),
|
|
508
|
+
names: /* @__PURE__ */ new Set([...names, ...body.declaredAt.keys()])
|
|
509
|
+
};
|
|
510
|
+
for (const statement of statements) checkSnippet({
|
|
511
|
+
at: statement.at,
|
|
512
|
+
code: statement.code,
|
|
513
|
+
kind: "statement",
|
|
514
|
+
line: statement.line,
|
|
515
|
+
place: {
|
|
516
|
+
nested: false,
|
|
517
|
+
type: "statement"
|
|
518
|
+
}
|
|
519
|
+
}, body, scope);
|
|
520
|
+
const reorder = body.late.length > 0 ? reorderFix(statements) : void 0;
|
|
521
|
+
for (const entry of body.late) report(entry.place, "use_before_declare", entry.message, reorder);
|
|
522
|
+
walk(children, body, scope);
|
|
523
|
+
reportMissing(body);
|
|
524
|
+
const [first] = body.early;
|
|
525
|
+
if (first !== void 0) {
|
|
526
|
+
const quoted = body.early.map((entry) => `"${entry.name}"`);
|
|
527
|
+
const listed = quoted.length > 4 ? `${quoted.slice(0, 3).join(", ")} and ${quoted.length - 3} more` : quoted.join(", ");
|
|
528
|
+
const from = Math.min(...body.early.map((entry) => entry.declared));
|
|
529
|
+
const subject = quoted.length === 1 ? `${listed} is used above its {@body} declaration on line ${from}` : `${listed} are used above their {@body} declarations, the first on line ${from}`;
|
|
530
|
+
report(first.place, "use_before_declare", `${subject}. Declare state, queries and values at the top, above the markup that uses them: the page renders while it streams in, and until a declaration arrives its name is undefined`);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
/**
|
|
534
|
+
* Reports the names `body` reads and never declares. A page that declares
|
|
535
|
+
* fewer names than it misses, at least `MISSING_AT_ONCE` of them, was
|
|
536
|
+
* written as markup alone: it gets one diagnostic that lists them all,
|
|
537
|
+
* rather than one per name and snippet. Browser globals and names close
|
|
538
|
+
* to a declared one keep their own messages, which say more.
|
|
539
|
+
*/
|
|
540
|
+
const reportMissing = (body) => {
|
|
541
|
+
const grouped = [...new Set(body.missing.flatMap((entry) => entry.own ? [] : [entry.name]))];
|
|
542
|
+
const group = body.define === null && grouped.length >= MISSING_AT_ONCE && body.declaredAt.size < grouped.length;
|
|
543
|
+
const first = body.missing.find((entry) => !entry.own);
|
|
544
|
+
if (group && first !== void 0) report(first.place, "missing_declarations", `This page reads ${grouped.length} names it never declares: ${grouped.map((name) => `"${name}"`).join(", ")}. Declare state, queries and helpers in {@body} lines at the top, above the markup that uses them`);
|
|
545
|
+
for (const entry of body.missing) if (!group || entry.own) report(entry.place, "undefined_reference", entry.message, entry.fix);
|
|
546
|
+
};
|
|
547
|
+
const page = readBody(pageStatements, null, /* @__PURE__ */ new Set());
|
|
548
|
+
checkBody(pageStatements, tree, page, /* @__PURE__ */ new Set());
|
|
549
|
+
const pageNames = new Set(page.declaredAt.keys());
|
|
550
|
+
for (const def of defines) {
|
|
551
|
+
if (def.origin !== "doc") continue;
|
|
552
|
+
const body = readBody(def.statements, def.name, pageNames);
|
|
553
|
+
const props = new Set(def.props.map((prop) => prop.name));
|
|
554
|
+
const scope = {
|
|
555
|
+
inner: /* @__PURE__ */ new Set(),
|
|
556
|
+
items: /* @__PURE__ */ new Map(),
|
|
557
|
+
names: /* @__PURE__ */ new Set([...props, ...body.declaredAt.keys()])
|
|
558
|
+
};
|
|
559
|
+
for (const prop of def.props) if (prop.defaultValue?.kind === "expr") checkSnippet({
|
|
560
|
+
at: prop.defaultValue.at,
|
|
561
|
+
code: prop.defaultValue.code,
|
|
562
|
+
kind: "expression",
|
|
563
|
+
line: def.line,
|
|
564
|
+
place: {
|
|
565
|
+
attribute: prop.name,
|
|
566
|
+
tag: "define",
|
|
567
|
+
type: "attribute"
|
|
568
|
+
}
|
|
569
|
+
}, body, scope);
|
|
570
|
+
checkBody(def.statements, def.body, body, props);
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
procedures: procedures.toSorted((a, b) => a.line - b.line),
|
|
574
|
+
reads
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* An order for `{@body}` lines in which each runs after the lines whose names
|
|
579
|
+
* it reads: a stable topological sort, which keeps the written order
|
|
580
|
+
* wherever it can, as indexes into `statements`. Every read counts, a read
|
|
581
|
+
* inside a callback too, since `rows.map(...)` calls it at once. Lines that
|
|
582
|
+
* read a name in common keep their order, as one may change what the other
|
|
583
|
+
* reads (`items.push(1)`), and so do lines with a hook, whose state slots go
|
|
584
|
+
* by call order; a line that declares nothing stays where it is, with
|
|
585
|
+
* nothing moving across it. Undefined when that leaves no order, or a line
|
|
586
|
+
* cannot be read, or a name is declared twice.
|
|
587
|
+
*/
|
|
588
|
+
function statementOrder(statements) {
|
|
589
|
+
const facts = statements.flatMap((statement) => {
|
|
590
|
+
const fact = factsOf({
|
|
591
|
+
at: statement.at,
|
|
592
|
+
code: statement.code,
|
|
593
|
+
kind: "statement",
|
|
594
|
+
line: statement.line,
|
|
595
|
+
place: {
|
|
596
|
+
nested: false,
|
|
597
|
+
type: "statement"
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
return fact === null ? [] : [fact];
|
|
601
|
+
});
|
|
602
|
+
if (facts.length !== statements.length) return;
|
|
603
|
+
const declaredBy = /* @__PURE__ */ new Map();
|
|
604
|
+
const reads = [];
|
|
605
|
+
for (const [index, fact] of facts.entries()) {
|
|
606
|
+
for (const name of fact.declared) {
|
|
607
|
+
if (declaredBy.has(name)) return;
|
|
608
|
+
declaredBy.set(name, index);
|
|
609
|
+
}
|
|
610
|
+
reads.push(new Set(fact.references.map((reference) => reference.name).filter((name) => !KNOWN_GLOBALS.has(name))));
|
|
611
|
+
}
|
|
612
|
+
/** `after[i]`: the lines that must run before line `i`. */
|
|
613
|
+
const after = facts.map(() => /* @__PURE__ */ new Set());
|
|
614
|
+
for (const [index, names] of reads.entries()) for (const name of names) {
|
|
615
|
+
const by = declaredBy.get(name);
|
|
616
|
+
if (by !== void 0 && by !== index) after[index]?.add(by);
|
|
617
|
+
}
|
|
618
|
+
/** Whether lines `a` and `b`, in written order, must stay in it. */
|
|
619
|
+
const keepsOrder = (a, b) => {
|
|
620
|
+
const first = facts[a];
|
|
621
|
+
const second = facts[b];
|
|
622
|
+
if (first === void 0 || second === void 0) return true;
|
|
623
|
+
return first.declared.length === 0 || second.declared.length === 0 || first.hookCalls.length > 0 && second.hookCalls.length > 0 || [...reads[a] ?? []].some((name) => reads[b]?.has(name));
|
|
624
|
+
};
|
|
625
|
+
for (let b = 0; b < statements.length; b++) for (let a = 0; a < b; a++) if (keepsOrder(a, b)) after[b]?.add(a);
|
|
626
|
+
const placed = [];
|
|
627
|
+
const done = /* @__PURE__ */ new Set();
|
|
628
|
+
while (placed.length < statements.length) {
|
|
629
|
+
const next = after.findIndex((needs, index) => !done.has(index) && [...needs].every((need) => done.has(need)));
|
|
630
|
+
if (next === -1) return;
|
|
631
|
+
placed.push(next);
|
|
632
|
+
done.add(next);
|
|
633
|
+
}
|
|
634
|
+
return placed;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Moves top-level `{@body}` lines into `statementOrder`, each into the place
|
|
638
|
+
* of another, so markup stays where it is. Undefined when there is no such
|
|
639
|
+
* order or the lines are in it already.
|
|
640
|
+
*/
|
|
641
|
+
function reorderFix(statements) {
|
|
642
|
+
const edits = (statementOrder(statements) ?? []).flatMap((from, slot) => {
|
|
643
|
+
const moved = statements[from];
|
|
644
|
+
const place = statements[slot];
|
|
645
|
+
return from === slot || moved === void 0 || place === void 0 ? [] : [{
|
|
646
|
+
end: place.end,
|
|
647
|
+
start: place.start,
|
|
648
|
+
text: `{@body ${moved.code}}`
|
|
649
|
+
}];
|
|
650
|
+
});
|
|
651
|
+
return edits.length === 0 ? void 0 : {
|
|
652
|
+
edits,
|
|
653
|
+
title: "Reorder the {@body} lines so each comes after the lines it reads"
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
/** How many undeclared names make a page that declares fewer than that one problem. */
|
|
657
|
+
const MISSING_AT_ONCE = 2;
|
|
658
|
+
function undefinedMessage(snippet, name, names) {
|
|
659
|
+
if (BROWSER_GLOBALS.has(name)) return `${quoteCode(snippet)} reads "${name}", which doesn't exist in a spec: there are no browser globals. Keep values in state, and read data with useQuery`;
|
|
660
|
+
const near = nearest(name, names, 1)[0];
|
|
661
|
+
const hint = near === void 0 ? "" : ` Did you mean "${near}"?`;
|
|
662
|
+
if (snippet.place.type === "text") return `${quoteCode(snippet)} reads "${name}", which isn't defined.${hint} Braces always run code, even inside backticks: declare "${name}" in a {@body} line, or write a formula without braces, e.g. x^(n) instead of x^{n}`;
|
|
663
|
+
return `${quoteCode(snippet)} reads "${name}", which isn't defined.${hint} Declare it in a {@body} line`;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* The message for a name that `{@body}` lines inside blocks declare: one
|
|
667
|
+
* line that can move above its block, one that can't and why, or several.
|
|
668
|
+
*/
|
|
669
|
+
function blockMessage(snippet, name, hoists) {
|
|
670
|
+
const [first] = hoists;
|
|
671
|
+
if (first === void 0 || hoists.length > 1) {
|
|
672
|
+
const lines = hoists.map((hoist) => hoist.line).join(" and ");
|
|
673
|
+
return `${quoteCode(snippet)} reads "${name}", which lines ${lines} declare inside blocks, where only those blocks see it. Declare it once, in a {@body} line above them`;
|
|
674
|
+
}
|
|
675
|
+
const where = `which line ${first.line} declares inside <${first.block}>, where only that block sees it`;
|
|
676
|
+
return first.reason === null ? `${quoteCode(snippet)} reads "${name}", ${where}. Declare it in a {@body} line above the block` : `${quoteCode(snippet)} reads "${name}", ${where}, and it can't move out by itself, as it ${first.reason}. Declare it above the block, or read it inside`;
|
|
677
|
+
}
|
|
678
|
+
function mutateMessage(written) {
|
|
679
|
+
return `\`${written}(…)\` runs while the page renders, where it does nothing. Call it from an event handler, e.g. \`onClick={() => ${written}(…)}\``;
|
|
680
|
+
}
|
|
681
|
+
/** The rewrite of a status flag at `[start, end)` to the comparison it stands for. */
|
|
682
|
+
function statusFix(object, status, start, end) {
|
|
683
|
+
const text = `${object}.status === "${status}"`;
|
|
684
|
+
return {
|
|
685
|
+
edits: [{
|
|
686
|
+
end,
|
|
687
|
+
start,
|
|
688
|
+
text: `(${text})`
|
|
689
|
+
}],
|
|
690
|
+
title: `Replace with \`${text}\``
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
function handleFieldMessage(hook, object, property, called) {
|
|
694
|
+
const written = `\`${object}.${property}${called ? "()" : ""}\``;
|
|
695
|
+
const instead = HANDLE_FIELDS[hook].instead.get(property);
|
|
696
|
+
if (instead !== void 0) return `${written} does not exist: ${instead.replaceAll("NAME", object)}`;
|
|
697
|
+
return hook === "useQuery" ? `${written} is undefined: \`${object}\` is a useQuery handle, ${HANDLE_FIELDS.useQuery.written}. Read \`${object}.data.${property}\` once \`${object}.status === "success"\`` : `${written} is undefined: \`${object}\` is a useMutation handle, ${HANDLE_FIELDS.useMutation.written}`;
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
700
|
+
export { ELEMENT, KNOWN_GLOBALS, checkCode, statementOrder };
|