@moku-labs/game 0.5.0 → 0.6.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/README.md +3 -1
- package/bin/moku-game-index.mjs +4 -0
- package/dist/project.d.mts +267 -0
- package/dist/project.mjs +2744 -0
- package/llms.txt +22 -1
- package/package.json +12 -3
package/dist/project.mjs
ADDED
|
@@ -0,0 +1,2744 @@
|
|
|
1
|
+
import { createBrandConsole } from "@moku-labs/common/cli";
|
|
2
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { watch } from "node:fs";
|
|
6
|
+
/** The definers whose calls make keys. */
|
|
7
|
+
const DEFINERS = [
|
|
8
|
+
"defineFlow",
|
|
9
|
+
"defineFeature",
|
|
10
|
+
"defineScene",
|
|
11
|
+
"projection",
|
|
12
|
+
"defineEmitter",
|
|
13
|
+
"defineTextStyles",
|
|
14
|
+
"defineStyle"
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Whether a name is a definer whose calls make keys.
|
|
18
|
+
*
|
|
19
|
+
* @param name - A name.
|
|
20
|
+
* @returns True for the definers of `DEFINERS`.
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* isDefiner("defineScene"); // true
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
function isDefiner(name) {
|
|
27
|
+
return DEFINERS.includes(name);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether a statement carries a modifier.
|
|
31
|
+
*
|
|
32
|
+
* @param typescript - The TypeScript module.
|
|
33
|
+
* @param node - The statement.
|
|
34
|
+
* @param kind - `ExportKeyword` or `DefaultKeyword`.
|
|
35
|
+
* @returns True when the modifier is there.
|
|
36
|
+
*/
|
|
37
|
+
function hasModifier(typescript, node, kind) {
|
|
38
|
+
if (!typescript.canHaveModifiers(node)) return false;
|
|
39
|
+
return typescript.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The names a binding declares: the identifier, or every name of a destructure.
|
|
43
|
+
*
|
|
44
|
+
* @param typescript - The TypeScript module.
|
|
45
|
+
* @param name - The binding name.
|
|
46
|
+
* @returns The declared names.
|
|
47
|
+
*/
|
|
48
|
+
function namesOf(typescript, name) {
|
|
49
|
+
if (typescript.isIdentifier(name)) return [name.text];
|
|
50
|
+
return name.elements.flatMap((element) => typescript.isBindingElement(element) ? namesOf(typescript, element.name) : []);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Records the value imports of one import statement.
|
|
54
|
+
*
|
|
55
|
+
* @param typescript - The TypeScript module.
|
|
56
|
+
* @param statement - The import statement.
|
|
57
|
+
* @param draft - The facts so far.
|
|
58
|
+
*/
|
|
59
|
+
function readImport(typescript, statement, draft) {
|
|
60
|
+
const clause = statement.importClause;
|
|
61
|
+
if (clause === void 0 || clause.isTypeOnly) return;
|
|
62
|
+
if (!typescript.isStringLiteral(statement.moduleSpecifier)) return;
|
|
63
|
+
const specifier = statement.moduleSpecifier.text;
|
|
64
|
+
const bindings = clause.namedBindings;
|
|
65
|
+
if (clause.name !== void 0) draft.imports.set(clause.name.text, {
|
|
66
|
+
specifier,
|
|
67
|
+
imported: "default"
|
|
68
|
+
});
|
|
69
|
+
if (bindings === void 0) return;
|
|
70
|
+
if (typescript.isNamespaceImport(bindings)) {
|
|
71
|
+
draft.imports.set(bindings.name.text, {
|
|
72
|
+
specifier,
|
|
73
|
+
imported: "*"
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const element of bindings.elements) {
|
|
78
|
+
if (element.isTypeOnly) continue;
|
|
79
|
+
const imported = element.propertyName?.text ?? element.name.text;
|
|
80
|
+
draft.imports.set(element.name.text, {
|
|
81
|
+
specifier,
|
|
82
|
+
imported
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Records one export statement: a re-export from another module, a star, or a list of local
|
|
88
|
+
* bindings.
|
|
89
|
+
*
|
|
90
|
+
* @param typescript - The TypeScript module.
|
|
91
|
+
* @param statement - The export statement.
|
|
92
|
+
* @param draft - The facts so far.
|
|
93
|
+
*/
|
|
94
|
+
function readExport(typescript, statement, draft) {
|
|
95
|
+
const source = statement.moduleSpecifier;
|
|
96
|
+
const specifier = source !== void 0 && typescript.isStringLiteral(source) ? source.text : "";
|
|
97
|
+
const clause = statement.exportClause;
|
|
98
|
+
if (statement.isTypeOnly) return;
|
|
99
|
+
if (clause === void 0) {
|
|
100
|
+
if (specifier !== "") draft.stars.push(specifier);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (typescript.isNamespaceExport(clause)) {
|
|
104
|
+
draft.exports.set(clause.name.text, {
|
|
105
|
+
specifier,
|
|
106
|
+
imported: "*"
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const element of clause.elements) {
|
|
111
|
+
if (element.isTypeOnly) continue;
|
|
112
|
+
const inner = element.propertyName?.text ?? element.name.text;
|
|
113
|
+
const target = specifier === "" ? { local: inner } : {
|
|
114
|
+
specifier,
|
|
115
|
+
imported: inner
|
|
116
|
+
};
|
|
117
|
+
draft.exports.set(element.name.text, target);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether an import brings a name of the engine package.
|
|
122
|
+
*
|
|
123
|
+
* @param ref - The import, if any.
|
|
124
|
+
* @param name - The name taken from the package, `*` for a namespace.
|
|
125
|
+
* @returns True for that import of `@moku-labs/game`.
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* isGameImport({ specifier: "@moku-labs/game", imported: "defineGame" }, "defineGame"); // true
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
function isGameImport(ref, name) {
|
|
132
|
+
return ref?.specifier === "@moku-labs/game" && ref.imported === name;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether an expression calls `defineGame` of the engine: by a named import, renamed or not, or
|
|
136
|
+
* through a namespace import of the package.
|
|
137
|
+
*
|
|
138
|
+
* @param typescript - The TypeScript module.
|
|
139
|
+
* @param expression - The initializer of a destructure.
|
|
140
|
+
* @param imports - The value imports of the file.
|
|
141
|
+
* @returns True for the kit call.
|
|
142
|
+
*/
|
|
143
|
+
function callsDefineGame(typescript, expression, imports) {
|
|
144
|
+
if (expression === void 0 || !typescript.isCallExpression(expression)) return false;
|
|
145
|
+
const callee = expression.expression;
|
|
146
|
+
if (typescript.isIdentifier(callee)) return isGameImport(imports.get(callee.text), "defineGame");
|
|
147
|
+
return typescript.isPropertyAccessExpression(callee) && typescript.isIdentifier(callee.expression) && callee.name.text === "defineGame" && isGameImport(imports.get(callee.expression.text), "*");
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Records the definers a `defineGame` destructure hands out, under their local names.
|
|
151
|
+
*
|
|
152
|
+
* @param typescript - The TypeScript module.
|
|
153
|
+
* @param pattern - The destructure.
|
|
154
|
+
* @param draft - The facts so far.
|
|
155
|
+
*/
|
|
156
|
+
function readKit(typescript, pattern, draft) {
|
|
157
|
+
for (const element of pattern.elements) {
|
|
158
|
+
if (!typescript.isIdentifier(element.name)) continue;
|
|
159
|
+
const property = element.propertyName;
|
|
160
|
+
const handed = property !== void 0 && typescript.isIdentifier(property) ? property.text : element.name.text;
|
|
161
|
+
if (isDefiner(handed)) draft.kit.set(element.name.text, handed);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Records one top-level variable statement: its names, its exports and a kit destructure.
|
|
166
|
+
*
|
|
167
|
+
* @param typescript - The TypeScript module.
|
|
168
|
+
* @param statement - The statement.
|
|
169
|
+
* @param draft - The facts so far.
|
|
170
|
+
*/
|
|
171
|
+
function readVariables(typescript, statement, draft) {
|
|
172
|
+
const exported = hasModifier(typescript, statement, typescript.SyntaxKind.ExportKeyword);
|
|
173
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
174
|
+
for (const name of namesOf(typescript, declaration.name)) {
|
|
175
|
+
draft.declared.add(name);
|
|
176
|
+
if (exported) draft.exports.set(name, { local: name });
|
|
177
|
+
}
|
|
178
|
+
const pattern = declaration.name;
|
|
179
|
+
if (typescript.isObjectBindingPattern(pattern) && callsDefineGame(typescript, declaration.initializer, draft.imports)) readKit(typescript, pattern, draft);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Records a top-level function or class: its name, and its export.
|
|
184
|
+
*
|
|
185
|
+
* @param typescript - The TypeScript module.
|
|
186
|
+
* @param statement - The declaration.
|
|
187
|
+
* @param draft - The facts so far.
|
|
188
|
+
*/
|
|
189
|
+
function readNamed(typescript, statement, draft) {
|
|
190
|
+
const name = statement.name?.text;
|
|
191
|
+
if (name === void 0) return;
|
|
192
|
+
draft.declared.add(name);
|
|
193
|
+
if (!hasModifier(typescript, statement, typescript.SyntaxKind.ExportKeyword)) return;
|
|
194
|
+
const isDefault = hasModifier(typescript, statement, typescript.SyntaxKind.DefaultKeyword);
|
|
195
|
+
draft.exports.set(isDefault ? "default" : name, { local: name });
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Reads the module facts of one parsed file. Imports come first, so a kit destructure anywhere
|
|
199
|
+
* in the file knows where its `defineGame` comes from.
|
|
200
|
+
*
|
|
201
|
+
* @param typescript - The TypeScript module.
|
|
202
|
+
* @param source - The parsed file.
|
|
203
|
+
* @returns The facts.
|
|
204
|
+
*/
|
|
205
|
+
function readModule(typescript, source) {
|
|
206
|
+
const draft = {
|
|
207
|
+
imports: /* @__PURE__ */ new Map(),
|
|
208
|
+
exports: /* @__PURE__ */ new Map(),
|
|
209
|
+
stars: [],
|
|
210
|
+
declared: /* @__PURE__ */ new Set(),
|
|
211
|
+
kit: /* @__PURE__ */ new Map()
|
|
212
|
+
};
|
|
213
|
+
for (const statement of source.statements) if (typescript.isImportDeclaration(statement)) readImport(typescript, statement, draft);
|
|
214
|
+
for (const statement of source.statements) {
|
|
215
|
+
if (typescript.isVariableStatement(statement)) readVariables(typescript, statement, draft);
|
|
216
|
+
if (typescript.isFunctionDeclaration(statement) || typescript.isClassDeclaration(statement)) readNamed(typescript, statement, draft);
|
|
217
|
+
if (typescript.isExportDeclaration(statement)) readExport(typescript, statement, draft);
|
|
218
|
+
if (typescript.isExportAssignment(statement) && typescript.isIdentifier(statement.expression)) draft.exports.set("default", { local: statement.expression.text });
|
|
219
|
+
}
|
|
220
|
+
return draft;
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/project/extract/resolve.ts
|
|
224
|
+
/**
|
|
225
|
+
* @file project, extract — following a name across files with the module facts only: an import
|
|
226
|
+
* to the module it names, a re-export to the module behind it, a star export to the first module
|
|
227
|
+
* that has the name, until the name is declared, handed out by the kit, or comes from a package.
|
|
228
|
+
* This is how a definer is recognised by binding (a renamed import still is the definer) and how
|
|
229
|
+
* a node of a flow table reaches the file that declares it.
|
|
230
|
+
*/
|
|
231
|
+
/** How many imports and re-exports a name is followed through before giving up. */
|
|
232
|
+
const MAX_HOPS = 12;
|
|
233
|
+
/** The extensions a TypeScript import may name instead of the source file. */
|
|
234
|
+
const SCRIPT_EXTENSION = /\.(?:[cm]?js|jsx)$/;
|
|
235
|
+
/**
|
|
236
|
+
* Whether a module specifier is relative to the importing file.
|
|
237
|
+
*
|
|
238
|
+
* @param specifier - The module specifier.
|
|
239
|
+
* @returns True for `./…` and `../…`.
|
|
240
|
+
* @example
|
|
241
|
+
* ```ts
|
|
242
|
+
* isRelative("../kit"); // true
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
function isRelative(specifier) {
|
|
246
|
+
return specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../");
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Resolves a relative module specifier to an indexed file, the way a bundler does: the path as
|
|
250
|
+
* written, then with `.ts`, `.tsx`, `/index.ts` and `/index.tsx`.
|
|
251
|
+
*
|
|
252
|
+
* @param modules - The module facts by path.
|
|
253
|
+
* @param from - The importing file.
|
|
254
|
+
* @param specifier - The module specifier.
|
|
255
|
+
* @returns The root-relative path, or `undefined` for a package or a file the index does not hold.
|
|
256
|
+
*/
|
|
257
|
+
function resolveModulePath(modules, from, specifier) {
|
|
258
|
+
if (!isRelative(specifier)) return void 0;
|
|
259
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
|
|
260
|
+
const stem = base.replace(SCRIPT_EXTENSION, "");
|
|
261
|
+
return [
|
|
262
|
+
base,
|
|
263
|
+
`${stem}.ts`,
|
|
264
|
+
`${stem}.tsx`,
|
|
265
|
+
`${stem}/index.ts`,
|
|
266
|
+
`${stem}/index.tsx`
|
|
267
|
+
].find((candidate) => modules.has(candidate));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Follows an imported name into the module that the specifier names.
|
|
271
|
+
*
|
|
272
|
+
* @param modules - The module facts by path.
|
|
273
|
+
* @param from - The importing file.
|
|
274
|
+
* @param ref - The specifier and the name.
|
|
275
|
+
* @param hops - How many hops were taken so far.
|
|
276
|
+
* @returns Where the name ends up.
|
|
277
|
+
*/
|
|
278
|
+
function followImport(modules, from, ref, hops) {
|
|
279
|
+
if (!isRelative(ref.specifier)) return {
|
|
280
|
+
kind: "package",
|
|
281
|
+
...ref
|
|
282
|
+
};
|
|
283
|
+
if (ref.imported === "*") return void 0;
|
|
284
|
+
const target = resolveModulePath(modules, from, ref.specifier);
|
|
285
|
+
return target === void 0 ? void 0 : resolveExport(modules, target, ref.imported, hops + 1);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Follows an exported name of a module: a local binding, a re-export, or a star export.
|
|
289
|
+
*
|
|
290
|
+
* @param modules - The module facts by path.
|
|
291
|
+
* @param file - The module.
|
|
292
|
+
* @param name - The exported name.
|
|
293
|
+
* @param hops - How many hops were taken so far.
|
|
294
|
+
* @returns Where the name ends up.
|
|
295
|
+
*/
|
|
296
|
+
function resolveExport(modules, file, name, hops = 0) {
|
|
297
|
+
const facts = modules.get(file);
|
|
298
|
+
if (facts === void 0 || hops > MAX_HOPS) return void 0;
|
|
299
|
+
const ref = facts.exports.get(name);
|
|
300
|
+
if (ref !== void 0 && "local" in ref) return resolveBinding(modules, file, ref.local, hops + 1);
|
|
301
|
+
if (ref !== void 0) return followImport(modules, file, ref, hops);
|
|
302
|
+
for (const specifier of facts.stars) {
|
|
303
|
+
const found = followImport(modules, file, {
|
|
304
|
+
specifier,
|
|
305
|
+
imported: name
|
|
306
|
+
}, hops);
|
|
307
|
+
if (found !== void 0) return found;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Follows a name used in a file to where it ends up: the kit hands it out, the file declares it,
|
|
312
|
+
* or an import brings it.
|
|
313
|
+
*
|
|
314
|
+
* @param modules - The module facts by path.
|
|
315
|
+
* @param file - The file that uses the name.
|
|
316
|
+
* @param local - The name as the file writes it.
|
|
317
|
+
* @param hops - How many hops were taken so far.
|
|
318
|
+
* @returns Where the name ends up, or `undefined` when it cannot be followed.
|
|
319
|
+
*/
|
|
320
|
+
function resolveBinding(modules, file, local, hops = 0) {
|
|
321
|
+
const facts = modules.get(file);
|
|
322
|
+
if (facts === void 0 || hops > MAX_HOPS) return void 0;
|
|
323
|
+
const definer = facts.kit.get(local);
|
|
324
|
+
if (definer !== void 0) return {
|
|
325
|
+
kind: "definer",
|
|
326
|
+
definer
|
|
327
|
+
};
|
|
328
|
+
if (facts.declared.has(local)) return {
|
|
329
|
+
kind: "declared",
|
|
330
|
+
path: file,
|
|
331
|
+
binding: local
|
|
332
|
+
};
|
|
333
|
+
const ref = facts.imports.get(local);
|
|
334
|
+
return ref === void 0 ? void 0 : followImport(modules, file, ref, hops);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The definer a target is, if any: one the kit hands out, or one imported from the engine.
|
|
338
|
+
*
|
|
339
|
+
* @param target - Where a name ends up.
|
|
340
|
+
* @returns The definer, or `undefined`.
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* definerOf({ kind: "package", specifier: "@moku-labs/game", imported: "defineStyle" }); // "defineStyle"
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
function definerOf(target) {
|
|
347
|
+
if (target?.kind === "definer") return target.definer;
|
|
348
|
+
if (target?.kind !== "package" || target.specifier !== "@moku-labs/game") return void 0;
|
|
349
|
+
return isDefiner(target.imported) ? target.imported : void 0;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Adds the definers a namespace import reaches as `ns.name`.
|
|
353
|
+
*
|
|
354
|
+
* @param modules - The module facts by path.
|
|
355
|
+
* @param file - The importing file.
|
|
356
|
+
* @param namespace - The local name of the namespace.
|
|
357
|
+
* @param specifier - The module it imports.
|
|
358
|
+
* @param definers - The map to add to.
|
|
359
|
+
*/
|
|
360
|
+
function addNamespace(modules, file, namespace, specifier, definers) {
|
|
361
|
+
if (specifier === "@moku-labs/game") {
|
|
362
|
+
for (const definer of DEFINERS) definers.set(`${namespace}.${definer}`, definer);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const target = resolveModulePath(modules, file, specifier);
|
|
366
|
+
const names = target === void 0 ? [] : [...modules.get(target)?.exports.keys() ?? []];
|
|
367
|
+
for (const name of names) {
|
|
368
|
+
const definer = definerOf(resolveExport(modules, target ?? "", name));
|
|
369
|
+
if (definer !== void 0) definers.set(`${namespace}.${name}`, definer);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The definers a file can call, by the callee as the file writes it: `defineFlow`, a renamed
|
|
374
|
+
* `scene`, or `kit.defineScene` through a namespace.
|
|
375
|
+
*
|
|
376
|
+
* @param modules - The module facts by path.
|
|
377
|
+
* @param file - The file.
|
|
378
|
+
* @returns The callee text to the definer.
|
|
379
|
+
*/
|
|
380
|
+
function definersOf(modules, file) {
|
|
381
|
+
const facts = modules.get(file);
|
|
382
|
+
const definers = new Map(facts?.kit);
|
|
383
|
+
for (const [local, ref] of facts?.imports ?? []) {
|
|
384
|
+
if (ref.imported === "*") {
|
|
385
|
+
addNamespace(modules, file, local, ref.specifier, definers);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const definer = definerOf(resolveBinding(modules, file, local));
|
|
389
|
+
if (definer !== void 0) definers.set(local, definer);
|
|
390
|
+
}
|
|
391
|
+
return definers;
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/project/hash.ts
|
|
395
|
+
/**
|
|
396
|
+
* @file project — the two hashes of the index: the sha1 of a file's bytes, the same value the
|
|
397
|
+
* editor uses as a file version, and the revision over every file of the index.
|
|
398
|
+
*/
|
|
399
|
+
/**
|
|
400
|
+
* Hashes bytes or text with sha1.
|
|
401
|
+
*
|
|
402
|
+
* @param bytes - The file bytes, or a text.
|
|
403
|
+
* @returns The hex digest.
|
|
404
|
+
* @example
|
|
405
|
+
* ```ts
|
|
406
|
+
* sha1(""); // "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
|
407
|
+
* ```
|
|
408
|
+
*/
|
|
409
|
+
function sha1(bytes) {
|
|
410
|
+
return createHash("sha1").update(bytes).digest("hex");
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The revision of an index: the sha1 of the lines `${path}\0${hash}\n`, sorted by path.
|
|
414
|
+
*
|
|
415
|
+
* @param files - Every indexed file with its hash.
|
|
416
|
+
* @returns The hex digest.
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* revisionOf({}); // "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
|
420
|
+
* ```
|
|
421
|
+
*/
|
|
422
|
+
function revisionOf(files) {
|
|
423
|
+
return sha1(Object.entries(files).map(([file, entry]) => `${file}\0${entry.hash}\n`).toSorted().join(""));
|
|
424
|
+
}
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/project/assemble.ts
|
|
427
|
+
/**
|
|
428
|
+
* The entry of a key, made on first use.
|
|
429
|
+
*
|
|
430
|
+
* @param symbols - The keys so far.
|
|
431
|
+
* @param key - The key.
|
|
432
|
+
* @returns Its entry.
|
|
433
|
+
*/
|
|
434
|
+
function entryOf(symbols, key) {
|
|
435
|
+
const existing = symbols.get(key);
|
|
436
|
+
if (existing !== void 0) return existing;
|
|
437
|
+
const entry = {
|
|
438
|
+
def: [],
|
|
439
|
+
uses: []
|
|
440
|
+
};
|
|
441
|
+
symbols.set(key, entry);
|
|
442
|
+
return entry;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Adds the `node:` keys of one flow table. A plain name is followed to the file that declares
|
|
446
|
+
* it, and the table is its use; a slot or an inline node is anchored at the table. A name that
|
|
447
|
+
* cannot be followed is anchored at the table and listed as unresolved.
|
|
448
|
+
*
|
|
449
|
+
* @param assembly - The records and module facts.
|
|
450
|
+
* @param symbols - The keys so far.
|
|
451
|
+
* @param unresolved - The unresolved list so far.
|
|
452
|
+
* @param file - The flow file.
|
|
453
|
+
* @param table - The table.
|
|
454
|
+
*/
|
|
455
|
+
function addTable(assembly, symbols, unresolved, file, table) {
|
|
456
|
+
const use = table.binding === void 0 ? { path: file } : {
|
|
457
|
+
path: file,
|
|
458
|
+
binding: table.binding
|
|
459
|
+
};
|
|
460
|
+
for (const { name, ref } of table.entries) {
|
|
461
|
+
const entry = entryOf(symbols, `node:${table.flow}/${name}`);
|
|
462
|
+
const atTable = {
|
|
463
|
+
...use,
|
|
464
|
+
key: name
|
|
465
|
+
};
|
|
466
|
+
const target = ref === void 0 ? void 0 : resolveBinding(assembly.modules, file, ref);
|
|
467
|
+
if (target?.kind === "declared") {
|
|
468
|
+
entry.def.push({
|
|
469
|
+
path: target.path,
|
|
470
|
+
binding: target.binding
|
|
471
|
+
});
|
|
472
|
+
entry.uses.push(atTable);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
entry.def.push(atTable);
|
|
476
|
+
if (ref !== void 0) {
|
|
477
|
+
const reason = `node "${table.flow}/${name}": "${ref}" is not declared in a file of the root`;
|
|
478
|
+
unresolved.push({
|
|
479
|
+
path: file,
|
|
480
|
+
reason
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Adds, to every style key, the files that import its binding by name.
|
|
487
|
+
*
|
|
488
|
+
* @param assembly - The records and module facts.
|
|
489
|
+
* @param symbols - The keys so far.
|
|
490
|
+
*/
|
|
491
|
+
function addStyleUses(assembly, symbols) {
|
|
492
|
+
for (const [file, record] of assembly.files) for (const [local, ref] of record.good?.module.imports ?? []) {
|
|
493
|
+
const target = ref.imported === "*" ? void 0 : resolveBinding(assembly.modules, file, local);
|
|
494
|
+
(target?.kind === "declared" ? symbols.get(`style:${target.path}#${target.binding}`) : void 0)?.uses.push({
|
|
495
|
+
path: file,
|
|
496
|
+
binding: local
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* The symbols as the index holds them: sorted by key, `uses` only when there are some, and
|
|
502
|
+
* `conflict` on a key other than `jsx:` defined twice.
|
|
503
|
+
*
|
|
504
|
+
* @param symbols - The keys as collected.
|
|
505
|
+
* @returns The symbols of the index.
|
|
506
|
+
*/
|
|
507
|
+
function finishSymbols(symbols) {
|
|
508
|
+
const finished = {};
|
|
509
|
+
const sorted = [...symbols].toSorted(([first], [second]) => first < second ? -1 : 1);
|
|
510
|
+
for (const [key, { def, uses }] of sorted) {
|
|
511
|
+
const isConflict = !key.startsWith("jsx:") && def.length > 1;
|
|
512
|
+
finished[key] = {
|
|
513
|
+
def,
|
|
514
|
+
...uses.length === 0 ? {} : { uses },
|
|
515
|
+
...isConflict ? { conflict: true } : {}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
return finished;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Freezes a value and everything it holds.
|
|
522
|
+
*
|
|
523
|
+
* @param value - A plain value.
|
|
524
|
+
* @returns The same value, frozen.
|
|
525
|
+
*/
|
|
526
|
+
function deepFreeze(value) {
|
|
527
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
|
528
|
+
Object.freeze(value);
|
|
529
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
530
|
+
return value;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Assembles the index of a catalog.
|
|
534
|
+
*
|
|
535
|
+
* @param assembly - The records sorted by path, and the module facts of the good files.
|
|
536
|
+
* @param manifest - The root-relative manifest path when the file exists.
|
|
537
|
+
* @returns The index, frozen.
|
|
538
|
+
*/
|
|
539
|
+
function assembleIndex(assembly, manifest) {
|
|
540
|
+
const symbols = /* @__PURE__ */ new Map();
|
|
541
|
+
const files = {};
|
|
542
|
+
const unresolved = [];
|
|
543
|
+
for (const [file, record] of assembly.files) {
|
|
544
|
+
files[file] = {
|
|
545
|
+
hash: record.hash,
|
|
546
|
+
state: record.state,
|
|
547
|
+
...record.error === void 0 ? {} : { error: record.error }
|
|
548
|
+
};
|
|
549
|
+
const extracted = record.extracted?.result;
|
|
550
|
+
if (record.good === void 0 || extracted === void 0) {
|
|
551
|
+
unresolved.push({
|
|
552
|
+
path: file,
|
|
553
|
+
reason: record.error ?? "the file never parsed"
|
|
554
|
+
});
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
for (const { key, anchor } of extracted.definitions) entryOf(symbols, key).def.push(anchor);
|
|
558
|
+
unresolved.push(...extracted.unresolved);
|
|
559
|
+
for (const table of extracted.tables) addTable(assembly, symbols, unresolved, file, table);
|
|
560
|
+
}
|
|
561
|
+
addStyleUses(assembly, symbols);
|
|
562
|
+
return deepFreeze({
|
|
563
|
+
schemaVersion: 1,
|
|
564
|
+
revision: revisionOf(files),
|
|
565
|
+
...manifest === void 0 ? {} : { manifest },
|
|
566
|
+
symbols: finishSymbols(symbols),
|
|
567
|
+
files,
|
|
568
|
+
unresolved
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/project/extract/scope.ts
|
|
573
|
+
/**
|
|
574
|
+
* Unwraps the expressions that change no value: parentheses, `as`, `satisfies`, `!` and `<T>`.
|
|
575
|
+
*
|
|
576
|
+
* @param typescript - The TypeScript module.
|
|
577
|
+
* @param node - An expression.
|
|
578
|
+
* @returns The expression inside.
|
|
579
|
+
*/
|
|
580
|
+
function unwrap(typescript, node) {
|
|
581
|
+
let inner = node;
|
|
582
|
+
while (typescript.isParenthesizedExpression(inner) || typescript.isAsExpression(inner) || typescript.isSatisfiesExpression(inner) || typescript.isNonNullExpression(inner) || typescript.isTypeAssertionExpression(inner)) inner = inner.expression;
|
|
583
|
+
return inner;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Whether a node holds statements: a block, a source file, a module block or a case clause.
|
|
587
|
+
*
|
|
588
|
+
* @param node - A node.
|
|
589
|
+
* @returns True when it has a `statements` list.
|
|
590
|
+
*/
|
|
591
|
+
function holdsStatements(node) {
|
|
592
|
+
return "statements" in node && Array.isArray(node.statements);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Whether a node is a function whose parameters a name can be.
|
|
596
|
+
*
|
|
597
|
+
* @param typescript - The TypeScript module.
|
|
598
|
+
* @param node - A node.
|
|
599
|
+
* @returns True for function declarations, expressions, arrows and methods.
|
|
600
|
+
*/
|
|
601
|
+
function isFunctionScope(typescript, node) {
|
|
602
|
+
return typescript.isFunctionDeclaration(node) || typescript.isFunctionExpression(node) || typescript.isArrowFunction(node) || typescript.isMethodDeclaration(node);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* The meaning of a `const` declarator: a function when its initializer is one.
|
|
606
|
+
*
|
|
607
|
+
* @param typescript - The TypeScript module.
|
|
608
|
+
* @param initializer - The initializer of the const.
|
|
609
|
+
* @returns The meaning.
|
|
610
|
+
*/
|
|
611
|
+
function constMeaning(typescript, initializer) {
|
|
612
|
+
const inner = unwrap(typescript, initializer);
|
|
613
|
+
return typescript.isArrowFunction(inner) || typescript.isFunctionExpression(inner) ? {
|
|
614
|
+
kind: "function",
|
|
615
|
+
node: inner
|
|
616
|
+
} : {
|
|
617
|
+
kind: "const",
|
|
618
|
+
initializer
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Looks for a name among the statements of one block.
|
|
623
|
+
*
|
|
624
|
+
* @param typescript - The TypeScript module.
|
|
625
|
+
* @param holder - The block.
|
|
626
|
+
* @param name - The name.
|
|
627
|
+
* @returns What the name means there, or `undefined`.
|
|
628
|
+
*/
|
|
629
|
+
function declaredIn(typescript, holder, name) {
|
|
630
|
+
for (const statement of holder.statements) {
|
|
631
|
+
if (typescript.isFunctionDeclaration(statement) && statement.name?.text === name) return {
|
|
632
|
+
kind: "function",
|
|
633
|
+
node: statement
|
|
634
|
+
};
|
|
635
|
+
if (!typescript.isVariableStatement(statement)) continue;
|
|
636
|
+
const isConst = (statement.declarationList.flags & typescript.NodeFlags.Const) !== 0;
|
|
637
|
+
const declaration = statement.declarationList.declarations.find((item) => typescript.isIdentifier(item.name) && item.name.text === name);
|
|
638
|
+
if (isConst && declaration?.initializer !== void 0) return constMeaning(typescript, declaration.initializer);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Looks for a name among the names of a destructured parameter (`({ id }) => …`).
|
|
643
|
+
*
|
|
644
|
+
* @param typescript - The TypeScript module.
|
|
645
|
+
* @param pattern - The destructure.
|
|
646
|
+
* @param name - The name.
|
|
647
|
+
* @returns The parameter meaning with the property it takes, or `undefined`.
|
|
648
|
+
*/
|
|
649
|
+
function destructuredIn(typescript, pattern, name) {
|
|
650
|
+
for (const element of pattern.elements) {
|
|
651
|
+
if (!(typescript.isBindingElement(element) && typescript.isIdentifier(element.name) && element.name.text === name)) continue;
|
|
652
|
+
const property = element.propertyName;
|
|
653
|
+
return {
|
|
654
|
+
kind: "param",
|
|
655
|
+
property: property !== void 0 && typescript.isIdentifier(property) ? property.text : name
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Looks for a name among the parameters of one function: a plain parameter, or a property of a
|
|
661
|
+
* destructured one.
|
|
662
|
+
*
|
|
663
|
+
* @param typescript - The TypeScript module.
|
|
664
|
+
* @param scope - The function.
|
|
665
|
+
* @param name - The name.
|
|
666
|
+
* @returns The parameter meaning, or `undefined`.
|
|
667
|
+
*/
|
|
668
|
+
function parameterIn(typescript, scope, name) {
|
|
669
|
+
for (const parameter of scope.parameters) {
|
|
670
|
+
const binding = parameter.name;
|
|
671
|
+
if (typescript.isIdentifier(binding) && binding.text === name) return { kind: "param" };
|
|
672
|
+
if (typescript.isIdentifier(binding)) continue;
|
|
673
|
+
const meaning = destructuredIn(typescript, binding, name);
|
|
674
|
+
if (meaning !== void 0) return meaning;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Says what a name means at the place it is used, walking up from there.
|
|
679
|
+
*
|
|
680
|
+
* @param typescript - The TypeScript module.
|
|
681
|
+
* @param identifier - The name where it is used.
|
|
682
|
+
* @returns What it means, or `undefined` when the file does not declare it.
|
|
683
|
+
*/
|
|
684
|
+
function meaningOf(typescript, identifier) {
|
|
685
|
+
const name = identifier.text;
|
|
686
|
+
for (let node = identifier.parent; node !== void 0; node = node.parent) {
|
|
687
|
+
const parameter = isFunctionScope(typescript, node) ? parameterIn(typescript, node, name) : void 0;
|
|
688
|
+
if (parameter !== void 0) return parameter;
|
|
689
|
+
const declared = holdsStatements(node) ? declaredIn(typescript, node, name) : void 0;
|
|
690
|
+
if (declared !== void 0) return declared;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The name of the top-level declaration a node sits in: the function, class or first const of
|
|
695
|
+
* the statement directly under the file.
|
|
696
|
+
*
|
|
697
|
+
* @param typescript - The TypeScript module.
|
|
698
|
+
* @param node - Any node.
|
|
699
|
+
* @returns The declared name, or `undefined` for a statement that declares none.
|
|
700
|
+
*/
|
|
701
|
+
function enclosingName(typescript, node) {
|
|
702
|
+
let statement = node;
|
|
703
|
+
while (statement.parent !== void 0 && !typescript.isSourceFile(statement.parent)) statement = statement.parent;
|
|
704
|
+
if (typescript.isFunctionDeclaration(statement) || typescript.isClassDeclaration(statement)) return statement.name?.text;
|
|
705
|
+
if (!typescript.isVariableStatement(statement)) return void 0;
|
|
706
|
+
const [first] = statement.declarationList.declarations;
|
|
707
|
+
return first !== void 0 && typescript.isIdentifier(first.name) ? first.name.text : void 0;
|
|
708
|
+
}
|
|
709
|
+
/** A hole filled by the `id` prop of the component, at run time. */
|
|
710
|
+
const ID = "{id}";
|
|
711
|
+
/** How deep names and calls are followed before a hole is given up on. */
|
|
712
|
+
const MAX_DEPTH = 8;
|
|
713
|
+
/**
|
|
714
|
+
* The one expression a function returns, when it has exactly one.
|
|
715
|
+
*
|
|
716
|
+
* @param typescript - The TypeScript module.
|
|
717
|
+
* @param node - The function.
|
|
718
|
+
* @returns The returned expression, or `undefined`.
|
|
719
|
+
*/
|
|
720
|
+
function returnedBy(typescript, node) {
|
|
721
|
+
const body = node.body;
|
|
722
|
+
if (body === void 0) return void 0;
|
|
723
|
+
if (!typescript.isBlock(body)) return body;
|
|
724
|
+
const returns = [];
|
|
725
|
+
const collect = (child) => {
|
|
726
|
+
if (typescript.isReturnStatement(child)) returns.push(child);
|
|
727
|
+
if (!typescript.isFunctionLike(child)) typescript.forEachChild(child, collect);
|
|
728
|
+
};
|
|
729
|
+
typescript.forEachChild(body, collect);
|
|
730
|
+
return returns.length === 1 ? returns[0]?.expression : void 0;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Reads a name: a bound parameter, a const, or a destructured `id` prop.
|
|
734
|
+
*
|
|
735
|
+
* @param typescript - The TypeScript module.
|
|
736
|
+
* @param identifier - The name.
|
|
737
|
+
* @param bindings - The parameters bound by the call being read.
|
|
738
|
+
* @param depth - How deep the reading is.
|
|
739
|
+
* @returns The pattern.
|
|
740
|
+
*/
|
|
741
|
+
function readName(typescript, identifier, bindings, depth) {
|
|
742
|
+
const bound = bindings.get(identifier.text);
|
|
743
|
+
if (bound !== void 0) return bound;
|
|
744
|
+
const meaning = meaningOf(typescript, identifier);
|
|
745
|
+
if (meaning?.kind === "const") return read(typescript, meaning.initializer, bindings, depth + 1);
|
|
746
|
+
if (meaning?.kind === "param" && meaning.property === "id") return ID;
|
|
747
|
+
return "*";
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Reads `props.id`: the `id` of a parameter object becomes `{id}`.
|
|
751
|
+
*
|
|
752
|
+
* @param typescript - The TypeScript module.
|
|
753
|
+
* @param access - The property access.
|
|
754
|
+
* @param bindings - The parameters bound by the call being read.
|
|
755
|
+
* @returns The pattern.
|
|
756
|
+
*/
|
|
757
|
+
function readAccess(typescript, access, bindings) {
|
|
758
|
+
const owner = access.expression;
|
|
759
|
+
return access.name.text === "id" && typescript.isIdentifier(owner) && !bindings.has(owner.text) && meaningOf(typescript, owner)?.kind === "param" ? ID : "*";
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Reads a call of a same-file function: its one returned expression, with each parameter bound
|
|
763
|
+
* to the pattern of its argument.
|
|
764
|
+
*
|
|
765
|
+
* @param typescript - The TypeScript module.
|
|
766
|
+
* @param call - The call.
|
|
767
|
+
* @param bindings - The parameters bound by the call being read.
|
|
768
|
+
* @param depth - How deep the reading is.
|
|
769
|
+
* @returns The pattern.
|
|
770
|
+
*/
|
|
771
|
+
function readCall$1(typescript, call, bindings, depth) {
|
|
772
|
+
const callee = call.expression;
|
|
773
|
+
const meaning = typescript.isIdentifier(callee) ? meaningOf(typescript, callee) : void 0;
|
|
774
|
+
if (meaning?.kind !== "function") return "*";
|
|
775
|
+
const returned = returnedBy(typescript, meaning.node);
|
|
776
|
+
if (returned === void 0) return "*";
|
|
777
|
+
const inner = /* @__PURE__ */ new Map();
|
|
778
|
+
for (const [index, parameter] of meaning.node.parameters.entries()) {
|
|
779
|
+
const argument = call.arguments[index];
|
|
780
|
+
if (!typescript.isIdentifier(parameter.name)) continue;
|
|
781
|
+
inner.set(parameter.name.text, argument === void 0 ? "*" : read(typescript, argument, bindings, depth + 1));
|
|
782
|
+
}
|
|
783
|
+
return read(typescript, returned, inner, depth + 1);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Reads a template: its literal parts stay, each span is read into a pattern.
|
|
787
|
+
*
|
|
788
|
+
* @param typescript - The TypeScript module.
|
|
789
|
+
* @param template - The template expression.
|
|
790
|
+
* @param bindings - The parameters bound by the call being read.
|
|
791
|
+
* @param depth - How deep the reading is.
|
|
792
|
+
* @returns The pattern.
|
|
793
|
+
*/
|
|
794
|
+
function readTemplate(typescript, template, bindings, depth) {
|
|
795
|
+
const spans = template.templateSpans.map((span) => read(typescript, span.expression, bindings, depth + 1) + span.literal.text);
|
|
796
|
+
return template.head.text + spans.join("");
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Reads one expression into a pattern.
|
|
800
|
+
*
|
|
801
|
+
* @param typescript - The TypeScript module.
|
|
802
|
+
* @param node - The expression.
|
|
803
|
+
* @param bindings - The parameters bound by the call being read.
|
|
804
|
+
* @param depth - How deep the reading is.
|
|
805
|
+
* @returns The pattern, holes not yet merged.
|
|
806
|
+
*/
|
|
807
|
+
function read(typescript, node, bindings, depth) {
|
|
808
|
+
const expression = unwrap(typescript, node);
|
|
809
|
+
if (depth > MAX_DEPTH) return "*";
|
|
810
|
+
if (typescript.isStringLiteralLike(expression) || typescript.isNumericLiteral(expression)) return expression.text;
|
|
811
|
+
if (typescript.isTemplateExpression(expression)) return readTemplate(typescript, expression, bindings, depth);
|
|
812
|
+
if (typescript.isBinaryExpression(expression) && expression.operatorToken.kind === typescript.SyntaxKind.PlusToken) return read(typescript, expression.left, bindings, depth + 1) + read(typescript, expression.right, bindings, depth + 1);
|
|
813
|
+
if (typescript.isIdentifier(expression)) return readName(typescript, expression, bindings, depth);
|
|
814
|
+
if (typescript.isPropertyAccessExpression(expression)) return readAccess(typescript, expression, bindings);
|
|
815
|
+
if (typescript.isCallExpression(expression)) return readCall$1(typescript, expression, bindings, depth);
|
|
816
|
+
return "*";
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Reduces a key expression to its pattern, with neighbouring `*` holes merged into one.
|
|
820
|
+
*
|
|
821
|
+
* @param typescript - The TypeScript module.
|
|
822
|
+
* @param expression - The expression of a JSX `key` attribute.
|
|
823
|
+
* @returns The pattern: `card*Picture`, `{id}Close`, `hudRow`, or `*` when nothing is literal.
|
|
824
|
+
*/
|
|
825
|
+
function patternOf(typescript, expression) {
|
|
826
|
+
return read(typescript, expression, /* @__PURE__ */ new Map(), 0).replaceAll(/\*+/g, "*");
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* The literal head of a pattern: the text before its first hole.
|
|
830
|
+
*
|
|
831
|
+
* @param pattern - A pattern.
|
|
832
|
+
* @returns The head, empty when the pattern starts with a hole.
|
|
833
|
+
* @example
|
|
834
|
+
* ```ts
|
|
835
|
+
* stemOf("card*Picture"); // "card"
|
|
836
|
+
* ```
|
|
837
|
+
*/
|
|
838
|
+
function stemOf(pattern) {
|
|
839
|
+
const holes = [pattern.indexOf("*"), pattern.indexOf(ID)].filter((at) => at >= 0);
|
|
840
|
+
return pattern.slice(0, holes.length === 0 ? void 0 : Math.min(...holes));
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/project/extract/jsx.ts
|
|
844
|
+
/** The longest key expression an unresolved reason quotes. */
|
|
845
|
+
const QUOTE_LENGTH = 60;
|
|
846
|
+
/** A component tag: its name starts upper-case. */
|
|
847
|
+
const COMPONENT_TAG = /^[A-Z]/;
|
|
848
|
+
/**
|
|
849
|
+
* The expression a JSX attribute holds: the string of `key="x"` or the code of `key={x}`.
|
|
850
|
+
*
|
|
851
|
+
* @param typescript - The TypeScript module.
|
|
852
|
+
* @param attribute - The attribute.
|
|
853
|
+
* @returns The expression, or `undefined` for `<a key />` and `key={}`.
|
|
854
|
+
*/
|
|
855
|
+
function attributeValue(typescript, attribute) {
|
|
856
|
+
const initializer = attribute.initializer;
|
|
857
|
+
if (initializer === void 0) return void 0;
|
|
858
|
+
if (typescript.isStringLiteral(initializer)) return initializer;
|
|
859
|
+
if (typescript.isJsxExpression(initializer)) return initializer.expression;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* How a key expression is written.
|
|
863
|
+
*
|
|
864
|
+
* @param typescript - The TypeScript module.
|
|
865
|
+
* @param expression - The key expression.
|
|
866
|
+
* @returns `literal` for a string, `template` for a template with holes, `ident` for the rest.
|
|
867
|
+
*/
|
|
868
|
+
function kindOf(typescript, expression) {
|
|
869
|
+
const inner = unwrap(typescript, expression);
|
|
870
|
+
if (typescript.isStringLiteralLike(inner)) return "literal";
|
|
871
|
+
return typescript.isTemplateExpression(inner) ? "template" : "ident";
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* The upper-case top-level declaration a node sits in: the component an `{id}` pattern belongs to.
|
|
875
|
+
*
|
|
876
|
+
* @param typescript - The TypeScript module.
|
|
877
|
+
* @param node - The attribute.
|
|
878
|
+
* @returns The component name, or `undefined` inside a lower-case helper.
|
|
879
|
+
*/
|
|
880
|
+
function componentAround(typescript, node) {
|
|
881
|
+
const name = enclosingName(typescript, node);
|
|
882
|
+
return name !== void 0 && COMPONENT_TAG.test(name) ? name : void 0;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Quotes a key expression for an unresolved reason: one line, cut to a readable length.
|
|
886
|
+
*
|
|
887
|
+
* @param text - The expression as written.
|
|
888
|
+
* @returns The quote.
|
|
889
|
+
* @example
|
|
890
|
+
* ```ts
|
|
891
|
+
* quoteOf("card.key"); // "card.key"
|
|
892
|
+
* ```
|
|
893
|
+
*/
|
|
894
|
+
function quoteOf(text) {
|
|
895
|
+
const line = text.replaceAll(/\s+/g, " ");
|
|
896
|
+
return line.length > QUOTE_LENGTH ? `${line.slice(0, QUOTE_LENGTH)}…` : line;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* The hit of a `key` attribute.
|
|
900
|
+
*
|
|
901
|
+
* @param typescript - The TypeScript module.
|
|
902
|
+
* @param attribute - The attribute.
|
|
903
|
+
* @param place - The element and its tag.
|
|
904
|
+
* @param place.element - The whole element.
|
|
905
|
+
* @param place.tag - The tag as written.
|
|
906
|
+
* @returns The hit, or `undefined` for a key without a value.
|
|
907
|
+
*/
|
|
908
|
+
function keyHit(typescript, attribute, place) {
|
|
909
|
+
const expression = attributeValue(typescript, attribute);
|
|
910
|
+
if (expression === void 0) return void 0;
|
|
911
|
+
const kind = kindOf(typescript, expression);
|
|
912
|
+
const key = patternOf(typescript, expression);
|
|
913
|
+
const stem = kind === "literal" ? "" : stemOf(key);
|
|
914
|
+
const component = key.includes("{id}") ? componentAround(typescript, attribute) : void 0;
|
|
915
|
+
const written = quoteOf(expression.getText());
|
|
916
|
+
return {
|
|
917
|
+
key,
|
|
918
|
+
kind,
|
|
919
|
+
attribute,
|
|
920
|
+
...place,
|
|
921
|
+
written,
|
|
922
|
+
...stem === "" ? {} : { stem },
|
|
923
|
+
...component === void 0 ? {} : { component }
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The hit of a literal `id` prop on a component.
|
|
928
|
+
*
|
|
929
|
+
* @param typescript - The TypeScript module.
|
|
930
|
+
* @param attribute - The attribute.
|
|
931
|
+
* @param place - The element and its tag.
|
|
932
|
+
* @param place.element - The whole element.
|
|
933
|
+
* @param place.tag - The tag as written.
|
|
934
|
+
* @returns The hit, or `undefined` when the value is not a string literal.
|
|
935
|
+
*/
|
|
936
|
+
function idHit(typescript, attribute, place) {
|
|
937
|
+
const expression = attributeValue(typescript, attribute);
|
|
938
|
+
const value = expression === void 0 ? void 0 : unwrap(typescript, expression);
|
|
939
|
+
if (value === void 0 || !typescript.isStringLiteralLike(value)) return void 0;
|
|
940
|
+
return {
|
|
941
|
+
key: value.text,
|
|
942
|
+
kind: "idProp",
|
|
943
|
+
component: place.tag,
|
|
944
|
+
attribute,
|
|
945
|
+
...place,
|
|
946
|
+
written: value.text
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* The hits of the attributes of one opening or self-closing element.
|
|
951
|
+
*
|
|
952
|
+
* @param typescript - The TypeScript module.
|
|
953
|
+
* @param opening - The opening or self-closing element.
|
|
954
|
+
* @returns Its hits.
|
|
955
|
+
*/
|
|
956
|
+
function hitsOf(typescript, opening) {
|
|
957
|
+
const place = {
|
|
958
|
+
element: typescript.isJsxOpeningElement(opening) ? opening.parent : opening,
|
|
959
|
+
tag: opening.tagName.getText()
|
|
960
|
+
};
|
|
961
|
+
const isComponent = COMPONENT_TAG.test(place.tag);
|
|
962
|
+
const hits = [];
|
|
963
|
+
for (const attribute of opening.attributes.properties) {
|
|
964
|
+
if (!typescript.isJsxAttribute(attribute) || !typescript.isIdentifier(attribute.name)) continue;
|
|
965
|
+
const name = attribute.name.text;
|
|
966
|
+
const keyEntry = name === "key" ? keyHit(typescript, attribute, place) : void 0;
|
|
967
|
+
const idEntry = name === "id" && isComponent ? idHit(typescript, attribute, place) : void 0;
|
|
968
|
+
if (keyEntry !== void 0) hits.push(keyEntry);
|
|
969
|
+
if (idEntry !== void 0) hits.push(idEntry);
|
|
970
|
+
}
|
|
971
|
+
return hits;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Collects every JSX key of a file, in source order.
|
|
975
|
+
*
|
|
976
|
+
* @param typescript - The TypeScript module.
|
|
977
|
+
* @param source - The parsed file.
|
|
978
|
+
* @returns The hits; a key that reads `*` is among them.
|
|
979
|
+
*/
|
|
980
|
+
function collectJsx(typescript, source) {
|
|
981
|
+
const hits = [];
|
|
982
|
+
const visit = (node) => {
|
|
983
|
+
if (typescript.isJsxOpeningElement(node) || typescript.isJsxSelfClosingElement(node)) hits.push(...hitsOf(typescript, node));
|
|
984
|
+
typescript.forEachChild(node, visit);
|
|
985
|
+
};
|
|
986
|
+
if (source.languageVariant === typescript.LanguageVariant.JSX) visit(source);
|
|
987
|
+
return hits;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Whether a hit holds nothing literal: its key reads `*` alone.
|
|
991
|
+
*
|
|
992
|
+
* @param hit - A hit.
|
|
993
|
+
* @returns True for a key nothing in the file can read.
|
|
994
|
+
*/
|
|
995
|
+
function isOpaque(hit) {
|
|
996
|
+
return hit.key === "*";
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* The anchor of a hit.
|
|
1000
|
+
*
|
|
1001
|
+
* @param file - The root-relative path of the file.
|
|
1002
|
+
* @param hit - The hit.
|
|
1003
|
+
* @returns The anchor: path, key, kind, and the stem and component when there are.
|
|
1004
|
+
*/
|
|
1005
|
+
function anchorOfHit(file, hit) {
|
|
1006
|
+
return {
|
|
1007
|
+
path: file,
|
|
1008
|
+
key: hit.key,
|
|
1009
|
+
kind: hit.kind,
|
|
1010
|
+
...hit.stem === void 0 ? {} : { stem: hit.stem },
|
|
1011
|
+
...hit.component === void 0 ? {} : { component: hit.component }
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* The unresolved reason of a key that reads `*`.
|
|
1016
|
+
*
|
|
1017
|
+
* @param hit - The hit.
|
|
1018
|
+
* @returns The reason: `JSX key "card.key" on <row> resolves to "*"`.
|
|
1019
|
+
*/
|
|
1020
|
+
function opaqueReason(hit) {
|
|
1021
|
+
return `JSX key "${hit.written}" on <${hit.tag}> resolves to "*"`;
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/project/extract/definitions.ts
|
|
1025
|
+
/** The key prefix of each definer that takes its id as the first argument. */
|
|
1026
|
+
const ID_PREFIXES = {
|
|
1027
|
+
defineFlow: "flow",
|
|
1028
|
+
defineFeature: "feature",
|
|
1029
|
+
defineScene: "scene",
|
|
1030
|
+
defineEmitter: "emitter"
|
|
1031
|
+
};
|
|
1032
|
+
/**
|
|
1033
|
+
* The text of a string literal or a template without holes.
|
|
1034
|
+
*
|
|
1035
|
+
* @param typescript - The TypeScript module.
|
|
1036
|
+
* @param node - An expression, if any.
|
|
1037
|
+
* @returns The text, or `undefined` for anything else.
|
|
1038
|
+
*/
|
|
1039
|
+
function literalOf(typescript, node) {
|
|
1040
|
+
const inner = node === void 0 ? void 0 : unwrap(typescript, node);
|
|
1041
|
+
return inner !== void 0 && typescript.isStringLiteralLike(inner) ? inner.text : void 0;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* The name of an object property when it is written literally.
|
|
1045
|
+
*
|
|
1046
|
+
* @param typescript - The TypeScript module.
|
|
1047
|
+
* @param property - The property.
|
|
1048
|
+
* @returns The name, or `undefined` for a spread or a computed name.
|
|
1049
|
+
*/
|
|
1050
|
+
function propertyName(typescript, property) {
|
|
1051
|
+
const name = property.name;
|
|
1052
|
+
if (name === void 0) return void 0;
|
|
1053
|
+
return typescript.isIdentifier(name) || typescript.isStringLiteral(name) || typescript.isNumericLiteral(name) ? name.text : void 0;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* The value of a named property of an object literal; for a shorthand, the name itself.
|
|
1057
|
+
*
|
|
1058
|
+
* @param typescript - The TypeScript module.
|
|
1059
|
+
* @param object - The object literal.
|
|
1060
|
+
* @param name - The property name.
|
|
1061
|
+
* @returns The value, or `undefined` when the object has no such property.
|
|
1062
|
+
*/
|
|
1063
|
+
function propertyValue(typescript, object, name) {
|
|
1064
|
+
for (const property of object.properties) {
|
|
1065
|
+
if (propertyName(typescript, property) !== name) continue;
|
|
1066
|
+
if (typescript.isPropertyAssignment(property)) return property.initializer;
|
|
1067
|
+
if (typescript.isShorthandPropertyAssignment(property)) return property.name;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* The object literal an expression is, directly or through a same-file const.
|
|
1072
|
+
*
|
|
1073
|
+
* @param typescript - The TypeScript module.
|
|
1074
|
+
* @param node - An expression, if any.
|
|
1075
|
+
* @returns The object literal, or `undefined`.
|
|
1076
|
+
*/
|
|
1077
|
+
function objectOf(typescript, node) {
|
|
1078
|
+
const inner = node === void 0 ? void 0 : unwrap(typescript, node);
|
|
1079
|
+
if (inner === void 0) return void 0;
|
|
1080
|
+
if (typescript.isObjectLiteralExpression(inner)) return inner;
|
|
1081
|
+
if (!typescript.isIdentifier(inner)) return void 0;
|
|
1082
|
+
const meaning = meaningOf(typescript, inner);
|
|
1083
|
+
const value = meaning?.kind === "const" ? unwrap(typescript, meaning.initializer) : void 0;
|
|
1084
|
+
return value !== void 0 && typescript.isObjectLiteralExpression(value) ? value : void 0;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* The const a call is bound to: `export const boardFlow = defineFlow(…)` at the top of the file.
|
|
1088
|
+
*
|
|
1089
|
+
* @param typescript - The TypeScript module.
|
|
1090
|
+
* @param call - The definer call.
|
|
1091
|
+
* @returns The binding, or `undefined` when the call is not the initializer of a module const.
|
|
1092
|
+
*/
|
|
1093
|
+
function bindingOf(typescript, call) {
|
|
1094
|
+
let node = call.parent;
|
|
1095
|
+
while (typescript.isExpression(node)) node = node.parent;
|
|
1096
|
+
if (!typescript.isVariableDeclaration(node) || !typescript.isIdentifier(node.name)) return void 0;
|
|
1097
|
+
if (node.initializer === void 0 || unwrap(typescript, node.initializer) !== call) return void 0;
|
|
1098
|
+
const list = node.parent;
|
|
1099
|
+
return (list.flags & typescript.NodeFlags.Const) !== 0 && typescript.isVariableStatement(list.parent) && typescript.isSourceFile(list.parent.parent) ? node.name.text : void 0;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* The definer a call calls, by its callee as written: `defineFlow(…)` or `kit.defineFlow(…)`.
|
|
1103
|
+
*
|
|
1104
|
+
* @param typescript - The TypeScript module.
|
|
1105
|
+
* @param call - The call.
|
|
1106
|
+
* @param definers - The definers of the file.
|
|
1107
|
+
* @returns The definer, or `undefined` for any other call.
|
|
1108
|
+
*/
|
|
1109
|
+
function definerOfCall(typescript, call, definers) {
|
|
1110
|
+
const callee = call.expression;
|
|
1111
|
+
if (typescript.isIdentifier(callee)) return definers.get(callee.text);
|
|
1112
|
+
if (!typescript.isPropertyAccessExpression(callee) || !typescript.isIdentifier(callee.expression)) return;
|
|
1113
|
+
return definers.get(`${callee.expression.text}.${callee.name.text}`);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Adds a key bound to the const of a call, or anchored by its id when the call has no const.
|
|
1117
|
+
*
|
|
1118
|
+
* @param reading - The file being read.
|
|
1119
|
+
* @param call - The definer call.
|
|
1120
|
+
* @param key - The key.
|
|
1121
|
+
* @param id - The literal id, the anchor key when there is no binding.
|
|
1122
|
+
* @param bound - What the anchor adds to the binding: the `name` key of a projection.
|
|
1123
|
+
*/
|
|
1124
|
+
function define(reading, call, key, id, bound = {}) {
|
|
1125
|
+
const binding = bindingOf(reading.typescript, call);
|
|
1126
|
+
const anchor = binding === void 0 ? {
|
|
1127
|
+
path: reading.path,
|
|
1128
|
+
key: id
|
|
1129
|
+
} : {
|
|
1130
|
+
path: reading.path,
|
|
1131
|
+
binding,
|
|
1132
|
+
...bound
|
|
1133
|
+
};
|
|
1134
|
+
reading.definitions.push({
|
|
1135
|
+
key,
|
|
1136
|
+
anchor
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Notes something the file holds that cannot be read.
|
|
1141
|
+
*
|
|
1142
|
+
* @param reading - The file being read.
|
|
1143
|
+
* @param reason - What and why.
|
|
1144
|
+
*/
|
|
1145
|
+
function unresolve(reading, reason) {
|
|
1146
|
+
reading.unresolved.push({
|
|
1147
|
+
path: reading.path,
|
|
1148
|
+
reason
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* The plain name a table entry's value is: `merge` for `{ merge }` and for `{ merge: merge }`.
|
|
1153
|
+
*
|
|
1154
|
+
* @param typescript - The TypeScript module.
|
|
1155
|
+
* @param property - The table entry.
|
|
1156
|
+
* @returns The name, or `undefined` for a slot, an inline node or anything else.
|
|
1157
|
+
*/
|
|
1158
|
+
function refOf(typescript, property) {
|
|
1159
|
+
if (typescript.isShorthandPropertyAssignment(property)) return property.name.text;
|
|
1160
|
+
if (!typescript.isPropertyAssignment(property)) return void 0;
|
|
1161
|
+
const value = unwrap(typescript, property.initializer);
|
|
1162
|
+
return typescript.isIdentifier(value) ? value.text : void 0;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Reads the `nodes` table of a flow into its entries.
|
|
1166
|
+
*
|
|
1167
|
+
* @param reading - The file being read.
|
|
1168
|
+
* @param call - The `defineFlow` call.
|
|
1169
|
+
* @param flow - The flow id.
|
|
1170
|
+
*/
|
|
1171
|
+
function readTable(reading, call, flow) {
|
|
1172
|
+
const { typescript } = reading;
|
|
1173
|
+
const spec = objectOf(typescript, call.arguments[1]);
|
|
1174
|
+
const nodes = spec === void 0 ? void 0 : objectOf(typescript, propertyValue(typescript, spec, "nodes"));
|
|
1175
|
+
if (nodes === void 0) {
|
|
1176
|
+
unresolve(reading, `defineFlow "${flow}" nodes is not an object literal`);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const entries = [];
|
|
1180
|
+
for (const property of nodes.properties) {
|
|
1181
|
+
if (typescript.isSpreadAssignment(property)) {
|
|
1182
|
+
unresolve(reading, `defineFlow "${flow}" nodes has a spread`);
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
const name = propertyName(typescript, property);
|
|
1186
|
+
if (name === void 0) {
|
|
1187
|
+
unresolve(reading, `defineFlow "${flow}" node name is not a literal`);
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
const ref = refOf(typescript, property);
|
|
1191
|
+
entries.push(ref === void 0 ? { name } : {
|
|
1192
|
+
name,
|
|
1193
|
+
ref
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
const binding = bindingOf(typescript, call);
|
|
1197
|
+
reading.tables.push(binding === void 0 ? {
|
|
1198
|
+
flow,
|
|
1199
|
+
entries
|
|
1200
|
+
} : {
|
|
1201
|
+
flow,
|
|
1202
|
+
binding,
|
|
1203
|
+
entries
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Reads a definer that takes its id as the first argument; a flow also brings its table.
|
|
1208
|
+
*
|
|
1209
|
+
* @param reading - The file being read.
|
|
1210
|
+
* @param call - The call.
|
|
1211
|
+
* @param definer - The definer.
|
|
1212
|
+
* @param prefix - The key prefix.
|
|
1213
|
+
*/
|
|
1214
|
+
function readIdCall(reading, call, definer, prefix) {
|
|
1215
|
+
const id = literalOf(reading.typescript, call.arguments[0]);
|
|
1216
|
+
if (id === void 0) {
|
|
1217
|
+
unresolve(reading, `${definer} id is not a string literal`);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
define(reading, call, `${prefix}:${id}`, id);
|
|
1221
|
+
if (definer === "defineFlow") readTable(reading, call, id);
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Reads a projection: its key is its `name`, its line the `name` property.
|
|
1225
|
+
*
|
|
1226
|
+
* @param reading - The file being read.
|
|
1227
|
+
* @param call - The call.
|
|
1228
|
+
*/
|
|
1229
|
+
function readProjection(reading, call) {
|
|
1230
|
+
const { typescript } = reading;
|
|
1231
|
+
const spec = objectOf(typescript, call.arguments[0]);
|
|
1232
|
+
const name = spec === void 0 ? void 0 : literalOf(typescript, propertyValue(typescript, spec, "name"));
|
|
1233
|
+
if (name === void 0) {
|
|
1234
|
+
unresolve(reading, "projection name is not a string literal");
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
define(reading, call, `projection:${name}`, name, { key: "name" });
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Reads a text style table: every literal key is a key.
|
|
1241
|
+
*
|
|
1242
|
+
* @param reading - The file being read.
|
|
1243
|
+
* @param call - The call.
|
|
1244
|
+
*/
|
|
1245
|
+
function readTextStyles(reading, call) {
|
|
1246
|
+
const { typescript } = reading;
|
|
1247
|
+
const table = call.arguments[0] === void 0 ? void 0 : unwrap(typescript, call.arguments[0]);
|
|
1248
|
+
if (table === void 0 || !typescript.isObjectLiteralExpression(table)) {
|
|
1249
|
+
unresolve(reading, "defineTextStyles argument is not an object literal");
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const binding = bindingOf(typescript, call);
|
|
1253
|
+
for (const property of table.properties) {
|
|
1254
|
+
const key = propertyName(typescript, property);
|
|
1255
|
+
if (key === void 0) {
|
|
1256
|
+
unresolve(reading, "defineTextStyles key is not a literal");
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
const anchor = binding === void 0 ? {
|
|
1260
|
+
path: reading.path,
|
|
1261
|
+
key
|
|
1262
|
+
} : {
|
|
1263
|
+
path: reading.path,
|
|
1264
|
+
binding,
|
|
1265
|
+
key
|
|
1266
|
+
};
|
|
1267
|
+
reading.definitions.push({
|
|
1268
|
+
key: `textStyle:${key}`,
|
|
1269
|
+
anchor
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Reads a style: only one bound to a module const has a key, `style:<path>#<binding>`.
|
|
1275
|
+
*
|
|
1276
|
+
* @param reading - The file being read.
|
|
1277
|
+
* @param call - The call.
|
|
1278
|
+
*/
|
|
1279
|
+
function readStyle(reading, call) {
|
|
1280
|
+
const binding = bindingOf(reading.typescript, call);
|
|
1281
|
+
if (binding !== void 0) {
|
|
1282
|
+
reading.definitions.push({
|
|
1283
|
+
key: `style:${reading.path}#${binding}`,
|
|
1284
|
+
anchor: {
|
|
1285
|
+
path: reading.path,
|
|
1286
|
+
binding
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const around = enclosingName(reading.typescript, call);
|
|
1292
|
+
unresolve(reading, `defineStyle${around === void 0 ? "" : ` in "${around}"`} is not bound to a module-level const`);
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Reads one definer call.
|
|
1296
|
+
*
|
|
1297
|
+
* @param reading - The file being read.
|
|
1298
|
+
* @param call - The call.
|
|
1299
|
+
* @param definer - The definer it calls.
|
|
1300
|
+
*/
|
|
1301
|
+
function readCall(reading, call, definer) {
|
|
1302
|
+
const prefix = ID_PREFIXES[definer];
|
|
1303
|
+
if (prefix !== void 0) readIdCall(reading, call, definer, prefix);
|
|
1304
|
+
if (definer === "projection") readProjection(reading, call);
|
|
1305
|
+
if (definer === "defineTextStyles") readTextStyles(reading, call);
|
|
1306
|
+
if (definer === "defineStyle") readStyle(reading, call);
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Adds the JSX keys of the file; the same key twice in one file is one anchor, since `find`
|
|
1310
|
+
* answers every place of an anchor in its file.
|
|
1311
|
+
*
|
|
1312
|
+
* @param reading - The file being read.
|
|
1313
|
+
* @param source - The parsed file.
|
|
1314
|
+
*/
|
|
1315
|
+
function readJsx(reading, source) {
|
|
1316
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1317
|
+
for (const hit of collectJsx(reading.typescript, source)) {
|
|
1318
|
+
if (isOpaque(hit)) {
|
|
1319
|
+
unresolve(reading, opaqueReason(hit));
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const anchor = anchorOfHit(reading.path, hit);
|
|
1323
|
+
const identity = JSON.stringify(anchor);
|
|
1324
|
+
if (seen.has(identity)) continue;
|
|
1325
|
+
seen.add(identity);
|
|
1326
|
+
reading.definitions.push({
|
|
1327
|
+
key: `jsx:${hit.key}`,
|
|
1328
|
+
anchor
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Reads the keys one file defines.
|
|
1334
|
+
*
|
|
1335
|
+
* @param typescript - The TypeScript module.
|
|
1336
|
+
* @param source - The parsed file.
|
|
1337
|
+
* @param path - The root-relative path of the file.
|
|
1338
|
+
* @param definers - The definers the file can call, from `definersOf`.
|
|
1339
|
+
* @returns The definitions, the node tables and the unresolved items, in source order.
|
|
1340
|
+
*/
|
|
1341
|
+
function extractFile(typescript, source, path, definers) {
|
|
1342
|
+
const reading = {
|
|
1343
|
+
typescript,
|
|
1344
|
+
path,
|
|
1345
|
+
definers,
|
|
1346
|
+
definitions: [],
|
|
1347
|
+
tables: [],
|
|
1348
|
+
unresolved: []
|
|
1349
|
+
};
|
|
1350
|
+
const visit = (node) => {
|
|
1351
|
+
const definer = typescript.isCallExpression(node) ? definerOfCall(typescript, node, definers) : void 0;
|
|
1352
|
+
if (definer !== void 0 && typescript.isCallExpression(node)) readCall(reading, node, definer);
|
|
1353
|
+
typescript.forEachChild(node, visit);
|
|
1354
|
+
};
|
|
1355
|
+
if (definers.size > 0) visit(source);
|
|
1356
|
+
readJsx(reading, source);
|
|
1357
|
+
return {
|
|
1358
|
+
definitions: reading.definitions,
|
|
1359
|
+
tables: reading.tables,
|
|
1360
|
+
unresolved: reading.unresolved
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
//#endregion
|
|
1364
|
+
//#region src/project/typescript.ts
|
|
1365
|
+
/**
|
|
1366
|
+
* Loads the optional peer `typescript`.
|
|
1367
|
+
*
|
|
1368
|
+
* @param load - The import; the real dynamic import by default.
|
|
1369
|
+
* @returns The TypeScript module.
|
|
1370
|
+
* @throws {Error} When the package is not installed.
|
|
1371
|
+
*/
|
|
1372
|
+
async function loadTypeScript(load = () => import("typescript")) {
|
|
1373
|
+
try {
|
|
1374
|
+
const loaded = await load();
|
|
1375
|
+
return loaded.default ?? loaded;
|
|
1376
|
+
} catch {
|
|
1377
|
+
throw new Error("[game] The project index needs the \"typescript\" package.\n Install it as a dev dependency of the game.");
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Formats the first parse error of a file as `path:line:col message`, 1-based.
|
|
1382
|
+
*
|
|
1383
|
+
* @param typescript - The TypeScript module.
|
|
1384
|
+
* @param source - The parsed file.
|
|
1385
|
+
* @param diagnostic - The parse error.
|
|
1386
|
+
* @returns The message.
|
|
1387
|
+
*/
|
|
1388
|
+
function describeError(typescript, source, diagnostic) {
|
|
1389
|
+
const { line, character } = source.getLineAndCharacterOfPosition(diagnostic.start);
|
|
1390
|
+
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, " ");
|
|
1391
|
+
return `${source.fileName}:${line + 1}:${character + 1} ${message}`;
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Parses one file with parent links, as TSX for a `.tsx` path and as TS otherwise.
|
|
1395
|
+
*
|
|
1396
|
+
* @param typescript - The TypeScript module.
|
|
1397
|
+
* @param path - The root-relative path; it names the file in the error.
|
|
1398
|
+
* @param text - The source text.
|
|
1399
|
+
* @returns The tree and the first parse error.
|
|
1400
|
+
*/
|
|
1401
|
+
function parseFile(typescript, path, text) {
|
|
1402
|
+
const kind = path.endsWith(".tsx") ? typescript.ScriptKind.TSX : typescript.ScriptKind.TS;
|
|
1403
|
+
const source = typescript.createSourceFile(path, text, typescript.ScriptTarget.Latest, true, kind);
|
|
1404
|
+
const [first] = source.parseDiagnostics ?? [];
|
|
1405
|
+
if (first === void 0) return { source };
|
|
1406
|
+
return {
|
|
1407
|
+
source,
|
|
1408
|
+
error: describeError(typescript, source, first)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
//#endregion
|
|
1412
|
+
//#region src/project/catalog.ts
|
|
1413
|
+
/**
|
|
1414
|
+
* @file project — the catalog: one record per indexed file and a cache of parses. A file is
|
|
1415
|
+
* parsed once per version of its bytes; a file that stops parsing keeps its last good parse, so
|
|
1416
|
+
* its keys stay in the index while it is broken. Building the index reads the module facts of
|
|
1417
|
+
* every good file, re-extracts only the files whose bytes or reachable definers changed, and
|
|
1418
|
+
* assembles the result.
|
|
1419
|
+
*/
|
|
1420
|
+
/** How many parses the cache keeps; the oldest goes first. */
|
|
1421
|
+
const PARSE_CACHE_SIZE = 512;
|
|
1422
|
+
/**
|
|
1423
|
+
* Creates an empty catalog.
|
|
1424
|
+
*
|
|
1425
|
+
* @param typescript - The TypeScript module.
|
|
1426
|
+
* @returns The catalog.
|
|
1427
|
+
*/
|
|
1428
|
+
function createCatalog(typescript) {
|
|
1429
|
+
return {
|
|
1430
|
+
typescript,
|
|
1431
|
+
records: /* @__PURE__ */ new Map(),
|
|
1432
|
+
parses: /* @__PURE__ */ new Map()
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Parses one version of a file, or takes it from the cache.
|
|
1437
|
+
*
|
|
1438
|
+
* @param catalog - The catalog.
|
|
1439
|
+
* @param path - The root-relative path.
|
|
1440
|
+
* @param hash - The sha1 of the bytes.
|
|
1441
|
+
* @param text - The text of the bytes.
|
|
1442
|
+
* @returns The parse.
|
|
1443
|
+
*/
|
|
1444
|
+
function parseCached(catalog, path, hash, text) {
|
|
1445
|
+
const id = `${path}\0${hash}`;
|
|
1446
|
+
const cached = catalog.parses.get(id);
|
|
1447
|
+
catalog.parses.delete(id);
|
|
1448
|
+
const parsed = cached ?? parseFile(catalog.typescript, path, text);
|
|
1449
|
+
catalog.parses.set(id, parsed);
|
|
1450
|
+
for (const old of catalog.parses.keys()) {
|
|
1451
|
+
if (catalog.parses.size <= PARSE_CACHE_SIZE) break;
|
|
1452
|
+
catalog.parses.delete(old);
|
|
1453
|
+
}
|
|
1454
|
+
return parsed;
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Takes in the bytes of a file. Unchanged bytes change nothing; bytes that parse replace the
|
|
1458
|
+
* good version; bytes that do not parse mark the file broken and keep the good version.
|
|
1459
|
+
*
|
|
1460
|
+
* @param catalog - The catalog.
|
|
1461
|
+
* @param path - The root-relative path.
|
|
1462
|
+
* @param bytes - The bytes on disk.
|
|
1463
|
+
* @returns True when the bytes differ from the ones last read.
|
|
1464
|
+
*/
|
|
1465
|
+
function putFile(catalog, path, bytes) {
|
|
1466
|
+
const hash = sha1(bytes);
|
|
1467
|
+
const record = catalog.records.get(path);
|
|
1468
|
+
if (record?.hash === hash) return false;
|
|
1469
|
+
const text = new TextDecoder().decode(bytes);
|
|
1470
|
+
const parsed = parseCached(catalog, path, hash, text);
|
|
1471
|
+
if (parsed.error === void 0) {
|
|
1472
|
+
const good = {
|
|
1473
|
+
hash,
|
|
1474
|
+
text,
|
|
1475
|
+
module: readModule(catalog.typescript, parsed.source)
|
|
1476
|
+
};
|
|
1477
|
+
catalog.records.set(path, {
|
|
1478
|
+
hash,
|
|
1479
|
+
state: "ok",
|
|
1480
|
+
good
|
|
1481
|
+
});
|
|
1482
|
+
return true;
|
|
1483
|
+
}
|
|
1484
|
+
catalog.records.set(path, {
|
|
1485
|
+
hash,
|
|
1486
|
+
state: "broken",
|
|
1487
|
+
error: parsed.error,
|
|
1488
|
+
...record?.good === void 0 ? {} : { good: record.good },
|
|
1489
|
+
...record?.extracted === void 0 ? {} : { extracted: record.extracted }
|
|
1490
|
+
});
|
|
1491
|
+
return true;
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Drops a file that is gone.
|
|
1495
|
+
*
|
|
1496
|
+
* @param catalog - The catalog.
|
|
1497
|
+
* @param path - The root-relative path.
|
|
1498
|
+
* @returns True when the catalog held the file.
|
|
1499
|
+
*/
|
|
1500
|
+
function dropFile(catalog, path) {
|
|
1501
|
+
return catalog.records.delete(path);
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Brings the keys of every good file up to date: a file is read again when its good version or
|
|
1505
|
+
* the definers it reaches changed since it was last read.
|
|
1506
|
+
*
|
|
1507
|
+
* @param catalog - The catalog.
|
|
1508
|
+
* @param modules - The module facts of every good file.
|
|
1509
|
+
*/
|
|
1510
|
+
function refreshExtractions(catalog, modules) {
|
|
1511
|
+
for (const [path, record] of catalog.records) {
|
|
1512
|
+
const good = record.good;
|
|
1513
|
+
if (good === void 0) continue;
|
|
1514
|
+
const definers = definersOf(modules, path);
|
|
1515
|
+
const signature = [...definers].map(([callee, definer]) => `${callee}=${definer}`).join(";");
|
|
1516
|
+
const stamp = `${good.hash}|${signature}`;
|
|
1517
|
+
if (record.extracted?.definers === stamp) continue;
|
|
1518
|
+
const { source } = parseCached(catalog, path, good.hash, good.text);
|
|
1519
|
+
record.extracted = {
|
|
1520
|
+
definers: stamp,
|
|
1521
|
+
result: extractFile(catalog.typescript, source, path, definers)
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Builds the index of everything the catalog holds.
|
|
1527
|
+
*
|
|
1528
|
+
* @param catalog - The catalog.
|
|
1529
|
+
* @param manifest - The root-relative manifest path when the file exists.
|
|
1530
|
+
* @returns The index, frozen.
|
|
1531
|
+
*/
|
|
1532
|
+
function buildIndex(catalog, manifest) {
|
|
1533
|
+
const files = [...catalog.records].toSorted(([first], [second]) => first < second ? -1 : 1);
|
|
1534
|
+
const modules = /* @__PURE__ */ new Map();
|
|
1535
|
+
for (const [path, record] of files) if (record.good !== void 0) modules.set(path, record.good.module);
|
|
1536
|
+
refreshExtractions(catalog, modules);
|
|
1537
|
+
return assembleIndex({
|
|
1538
|
+
files,
|
|
1539
|
+
modules
|
|
1540
|
+
}, manifest);
|
|
1541
|
+
}
|
|
1542
|
+
//#endregion
|
|
1543
|
+
//#region src/project/locate.ts
|
|
1544
|
+
/**
|
|
1545
|
+
* The place of a node: the line of one node and the range of another.
|
|
1546
|
+
*
|
|
1547
|
+
* @param source - The parsed file.
|
|
1548
|
+
* @param lineNode - The node whose start line is the line.
|
|
1549
|
+
* @param rangeNode - The node whose span is the range.
|
|
1550
|
+
* @returns The place.
|
|
1551
|
+
*/
|
|
1552
|
+
function placeOf(source, lineNode, rangeNode) {
|
|
1553
|
+
const line = source.getLineAndCharacterOfPosition(lineNode.getStart(source)).line + 1;
|
|
1554
|
+
const start = source.getLineAndCharacterOfPosition(rangeNode.getStart(source));
|
|
1555
|
+
const end = source.getLineAndCharacterOfPosition(rangeNode.getEnd());
|
|
1556
|
+
return {
|
|
1557
|
+
line,
|
|
1558
|
+
range: [
|
|
1559
|
+
start.line + 1,
|
|
1560
|
+
start.character + 1,
|
|
1561
|
+
end.line + 1,
|
|
1562
|
+
end.character + 1
|
|
1563
|
+
]
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Whether a top-level statement declares a name.
|
|
1568
|
+
*
|
|
1569
|
+
* @param typescript - The TypeScript module.
|
|
1570
|
+
* @param statement - The statement.
|
|
1571
|
+
* @param binding - The name.
|
|
1572
|
+
* @returns True for a const, a destructured const, a function or a class of that name.
|
|
1573
|
+
*/
|
|
1574
|
+
function declares(typescript, statement, binding) {
|
|
1575
|
+
if (typescript.isFunctionDeclaration(statement) || typescript.isClassDeclaration(statement)) return statement.name?.text === binding;
|
|
1576
|
+
if (!typescript.isVariableStatement(statement)) return false;
|
|
1577
|
+
return statement.declarationList.declarations.some((declaration) => {
|
|
1578
|
+
const name = declaration.name;
|
|
1579
|
+
if (typescript.isIdentifier(name)) return name.text === binding;
|
|
1580
|
+
return name.elements.some((element) => typescript.isBindingElement(element) && element.name.getText() === binding);
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* The name of a property-like node, when it is written literally.
|
|
1585
|
+
*
|
|
1586
|
+
* @param typescript - The TypeScript module.
|
|
1587
|
+
* @param node - Any node.
|
|
1588
|
+
* @returns The name of a property, a shorthand or a method; `undefined` for anything else.
|
|
1589
|
+
*/
|
|
1590
|
+
function propertyNameOf(typescript, node) {
|
|
1591
|
+
if (!(typescript.isPropertyAssignment(node) || typescript.isShorthandPropertyAssignment(node) || typescript.isMethodDeclaration(node))) return void 0;
|
|
1592
|
+
const name = node.name;
|
|
1593
|
+
return typescript.isIdentifier(name) || typescript.isStringLiteral(name) || typescript.isNumericLiteral(name) ? name.text : void 0;
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* The shallowest property of a key inside a declaration, the first one at that depth.
|
|
1597
|
+
*
|
|
1598
|
+
* @param typescript - The TypeScript module.
|
|
1599
|
+
* @param root - The declaration statement.
|
|
1600
|
+
* @param key - The property name.
|
|
1601
|
+
* @returns The property, or `undefined`.
|
|
1602
|
+
*/
|
|
1603
|
+
function propertyIn(typescript, root, key) {
|
|
1604
|
+
const queue = [root];
|
|
1605
|
+
for (let next = queue.shift(); next !== void 0; next = queue.shift()) {
|
|
1606
|
+
if (propertyNameOf(typescript, next) === key) return next;
|
|
1607
|
+
typescript.forEachChild(next, (child) => {
|
|
1608
|
+
queue.push(child);
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* The first place a key is written: a string literal of that text, or a property of that name.
|
|
1614
|
+
*
|
|
1615
|
+
* @param typescript - The TypeScript module.
|
|
1616
|
+
* @param source - The parsed file.
|
|
1617
|
+
* @param key - The key.
|
|
1618
|
+
* @returns The place, or `undefined`.
|
|
1619
|
+
*/
|
|
1620
|
+
function writtenAt(typescript, source, key) {
|
|
1621
|
+
const visit = (node) => {
|
|
1622
|
+
if (typescript.isStringLiteralLike(node) && node.text === key || propertyNameOf(typescript, node) === key) return node;
|
|
1623
|
+
return typescript.forEachChild(node, visit);
|
|
1624
|
+
};
|
|
1625
|
+
const found = visit(source);
|
|
1626
|
+
if (found === void 0) return void 0;
|
|
1627
|
+
const parent = found.parent;
|
|
1628
|
+
return placeOf(source, found, typescript.isPropertyAssignment(parent) || typescript.isCallExpression(parent) ? parent : found);
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Reads an anchor on one parse of its file.
|
|
1632
|
+
*
|
|
1633
|
+
* @param typescript - The TypeScript module.
|
|
1634
|
+
* @param source - The parsed file.
|
|
1635
|
+
* @param anchor - The anchor.
|
|
1636
|
+
* @param jsxHits - The JSX hits of the parse; collected on this call by default.
|
|
1637
|
+
* @returns Every place of the anchor in the file; none when the file no longer holds it.
|
|
1638
|
+
*/
|
|
1639
|
+
function locate(typescript, source, anchor, jsxHits = () => collectJsx(typescript, source)) {
|
|
1640
|
+
if (anchor.kind !== void 0) return jsxHits().filter((hit) => hit.key === anchor.key && hit.kind === anchor.kind && hit.component === anchor.component).map((hit) => placeOf(source, hit.attribute, hit.element));
|
|
1641
|
+
if (anchor.binding === void 0) {
|
|
1642
|
+
const place = anchor.key === void 0 ? void 0 : writtenAt(typescript, source, anchor.key);
|
|
1643
|
+
return place === void 0 ? [] : [place];
|
|
1644
|
+
}
|
|
1645
|
+
const binding = anchor.binding;
|
|
1646
|
+
const statement = source.statements.find((item) => declares(typescript, item, binding));
|
|
1647
|
+
if (statement === void 0) return [];
|
|
1648
|
+
const node = (anchor.key === void 0 ? void 0 : propertyIn(typescript, statement, anchor.key)) ?? statement;
|
|
1649
|
+
return [placeOf(source, node, node)];
|
|
1650
|
+
}
|
|
1651
|
+
//#endregion
|
|
1652
|
+
//#region src/project/paths.ts
|
|
1653
|
+
/**
|
|
1654
|
+
* @file project — which files the index reads, and the guard on every path it reads. Paths in and
|
|
1655
|
+
* out are root-relative POSIX. A read resolves the path against the real root with `realpath`, so
|
|
1656
|
+
* a `..` or a symlink that leaves the root is refused by name instead of read.
|
|
1657
|
+
*/
|
|
1658
|
+
/** The folders a game never edits by hand: dependencies, builds, generated code and tests. */
|
|
1659
|
+
const SKIPPED_FOLDER = /^(?:node_modules|dist|generated|\.moku|\.git|__tests__|tests)$/;
|
|
1660
|
+
/** The source files the index reads. */
|
|
1661
|
+
const SOURCE_FILE = /\.tsx?$/;
|
|
1662
|
+
/** Test files, skipped wherever they sit. */
|
|
1663
|
+
const TEST_FILE = /\.(?:test|spec)\.tsx?$/;
|
|
1664
|
+
/** The hint under every root error. */
|
|
1665
|
+
const ROOT_HINT = "Pass the folder of the game as root.";
|
|
1666
|
+
/**
|
|
1667
|
+
* Whether a folder name is one the index never walks into.
|
|
1668
|
+
*
|
|
1669
|
+
* @param name - One folder name.
|
|
1670
|
+
* @returns True for `node_modules`, `dist`, `generated`, `.moku`, `.git`, `__tests__` and `tests`.
|
|
1671
|
+
* @example
|
|
1672
|
+
* ```ts
|
|
1673
|
+
* isSkippedFolder("generated"); // true
|
|
1674
|
+
* ```
|
|
1675
|
+
*/
|
|
1676
|
+
function isSkippedFolder(name) {
|
|
1677
|
+
return SKIPPED_FOLDER.test(name);
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Whether a root-relative path sits under a skipped folder.
|
|
1681
|
+
*
|
|
1682
|
+
* @param file - A path with `/` or `\` separators.
|
|
1683
|
+
* @returns True when one of its folders is skipped.
|
|
1684
|
+
* @example
|
|
1685
|
+
* ```ts
|
|
1686
|
+
* isSkippedPath("node_modules/pixi.js/index.ts"); // true
|
|
1687
|
+
* ```
|
|
1688
|
+
*/
|
|
1689
|
+
function isSkippedPath(file) {
|
|
1690
|
+
return file.split(/[/\\]/).slice(0, -1).some((folder) => isSkippedFolder(folder));
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Whether the index reads a root-relative path: a `.ts` or `.tsx` file outside the skipped
|
|
1694
|
+
* folders that is not a test.
|
|
1695
|
+
*
|
|
1696
|
+
* @param file - A root-relative POSIX path.
|
|
1697
|
+
* @returns True for a source file of the index.
|
|
1698
|
+
* @example
|
|
1699
|
+
* ```ts
|
|
1700
|
+
* isIndexedFile("features/ui/kit.tsx"); // true
|
|
1701
|
+
* ```
|
|
1702
|
+
*/
|
|
1703
|
+
function isIndexedFile(file) {
|
|
1704
|
+
return SOURCE_FILE.test(file) && !TEST_FILE.test(file) && !isSkippedPath(file);
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Turns a path into its root-relative POSIX form.
|
|
1708
|
+
*
|
|
1709
|
+
* @param file - A path with any separators.
|
|
1710
|
+
* @returns The path with `/` separators, `.` segments resolved.
|
|
1711
|
+
* @example
|
|
1712
|
+
* ```ts
|
|
1713
|
+
* toPosix("./features\\ui\\kit.tsx"); // "features/ui/kit.tsx"
|
|
1714
|
+
* ```
|
|
1715
|
+
*/
|
|
1716
|
+
function toPosix(file) {
|
|
1717
|
+
return path.posix.normalize(file.replaceAll("\\", "/"));
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Whether a file system error says nothing is at the path.
|
|
1721
|
+
*
|
|
1722
|
+
* @param error - What was thrown.
|
|
1723
|
+
* @returns True for `ENOENT` and `ENOTDIR`.
|
|
1724
|
+
*/
|
|
1725
|
+
function isMissing(error) {
|
|
1726
|
+
const code = error instanceof Error && "code" in error ? error.code : void 0;
|
|
1727
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Whether an absolute path lies inside a folder.
|
|
1731
|
+
*
|
|
1732
|
+
* @param folder - The absolute folder.
|
|
1733
|
+
* @param file - The absolute path.
|
|
1734
|
+
* @returns True for the folder itself and anything below it.
|
|
1735
|
+
*/
|
|
1736
|
+
function isInside(folder, file) {
|
|
1737
|
+
const relative = path.relative(folder, file);
|
|
1738
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* The error of a path that leaves the root.
|
|
1742
|
+
*
|
|
1743
|
+
* @param file - The path as it was given.
|
|
1744
|
+
* @param root - The real root.
|
|
1745
|
+
* @returns The error to throw.
|
|
1746
|
+
*/
|
|
1747
|
+
function leavesRoot(file, root) {
|
|
1748
|
+
return /* @__PURE__ */ new Error(`[game] The path "${file}" leaves the project root.\n Pass a path inside "${root}", relative to it.`);
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Checks the root of a project and resolves it: it exists, it is a directory, and its real path
|
|
1752
|
+
* is the one every read is held to.
|
|
1753
|
+
*
|
|
1754
|
+
* @param root - The root as given, absolute or relative to the working directory.
|
|
1755
|
+
* @returns The real absolute path of the root.
|
|
1756
|
+
* @throws {Error} When the root does not exist or is not a directory.
|
|
1757
|
+
*/
|
|
1758
|
+
async function openRoot(root) {
|
|
1759
|
+
const absolute = path.resolve(root);
|
|
1760
|
+
if (!(await stat(absolute).catch((error) => {
|
|
1761
|
+
if (!isMissing(error)) throw error;
|
|
1762
|
+
throw new Error(`[game] The project root "${absolute}" does not exist.\n ${ROOT_HINT}`);
|
|
1763
|
+
})).isDirectory()) throw new Error(`[game] The project root "${absolute}" is not a directory.\n ${ROOT_HINT}`);
|
|
1764
|
+
return realpath(absolute);
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Resolves a root-relative path to the real file, refusing any path that leaves the root.
|
|
1768
|
+
*
|
|
1769
|
+
* @param root - The real root.
|
|
1770
|
+
* @param file - The root-relative path.
|
|
1771
|
+
* @returns The real absolute path, or `undefined` when nothing is there.
|
|
1772
|
+
* @throws {Error} When the path leaves the root, by `..`, as an absolute path or by a symlink.
|
|
1773
|
+
*/
|
|
1774
|
+
async function resolveInside(root, file) {
|
|
1775
|
+
const absolute = path.resolve(root, file);
|
|
1776
|
+
if (!isInside(root, absolute)) throw leavesRoot(file, root);
|
|
1777
|
+
let real;
|
|
1778
|
+
try {
|
|
1779
|
+
real = await realpath(absolute);
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
if (isMissing(error)) return;
|
|
1782
|
+
throw error;
|
|
1783
|
+
}
|
|
1784
|
+
if (!isInside(root, real)) throw leavesRoot(file, root);
|
|
1785
|
+
return real;
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Reads the bytes of a root-relative file through the guard. A path that leaves the root reads as
|
|
1789
|
+
* nothing here; `changed` refuses it by name with `resolveInside` first.
|
|
1790
|
+
*
|
|
1791
|
+
* @param root - The real root.
|
|
1792
|
+
* @param file - The root-relative path.
|
|
1793
|
+
* @returns The bytes, or `undefined` when the file is gone or lies outside the root.
|
|
1794
|
+
*/
|
|
1795
|
+
async function readInside(root, file) {
|
|
1796
|
+
try {
|
|
1797
|
+
const real = await resolveInside(root, file);
|
|
1798
|
+
if (real !== void 0) return await readFile(real);
|
|
1799
|
+
} catch {}
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* The anchors of the JSX patterns and of the literal `id=` props of an index.
|
|
1803
|
+
*
|
|
1804
|
+
* @param index - The index.
|
|
1805
|
+
* @returns The `{id}` patterns, the `*` patterns and the `id=` props.
|
|
1806
|
+
*/
|
|
1807
|
+
function jsxShapes(index) {
|
|
1808
|
+
const shapes = {
|
|
1809
|
+
idPatterns: [],
|
|
1810
|
+
wildPatterns: [],
|
|
1811
|
+
idAttributes: []
|
|
1812
|
+
};
|
|
1813
|
+
for (const [key, entry] of Object.entries(index.symbols)) {
|
|
1814
|
+
if (!key.startsWith("jsx:")) continue;
|
|
1815
|
+
for (const anchor of entry.def) {
|
|
1816
|
+
const pattern = anchor.key ?? "";
|
|
1817
|
+
if (anchor.kind === "idProp") shapes.idAttributes.push(anchor);
|
|
1818
|
+
else if (pattern.includes("{id}")) shapes.idPatterns.push(anchor);
|
|
1819
|
+
else if (pattern.includes("*")) shapes.wildPatterns.push(anchor);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return shapes;
|
|
1823
|
+
}
|
|
1824
|
+
//#endregion
|
|
1825
|
+
//#region src/project/find.ts
|
|
1826
|
+
/**
|
|
1827
|
+
* Whether a key matches a pattern whose `*` holes stand for any text.
|
|
1828
|
+
*
|
|
1829
|
+
* @param pattern - A pattern, `{id}` already filled.
|
|
1830
|
+
* @param key - The key without its prefix.
|
|
1831
|
+
* @returns True when the key reads as the pattern.
|
|
1832
|
+
* @example
|
|
1833
|
+
* ```ts
|
|
1834
|
+
* matchesPattern("card*Picture", "card2Picture"); // true
|
|
1835
|
+
* ```
|
|
1836
|
+
*/
|
|
1837
|
+
function matchesPattern(pattern, key) {
|
|
1838
|
+
if (!pattern.includes("*")) return pattern === key;
|
|
1839
|
+
const parts = pattern.split("*").map((part) => part.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`));
|
|
1840
|
+
return new RegExp(`^${parts.join(".*")}$`).test(key);
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* The JSX shapes of the current index, read once per index: an index is frozen, and an update
|
|
1844
|
+
* replaces it with a new object.
|
|
1845
|
+
*
|
|
1846
|
+
* @param session - The open project.
|
|
1847
|
+
* @returns The shapes of `session.index`.
|
|
1848
|
+
*/
|
|
1849
|
+
function shapesOf(session) {
|
|
1850
|
+
const cached = session.shapes.get(session.index);
|
|
1851
|
+
if (cached !== void 0) return cached;
|
|
1852
|
+
const shapes = jsxShapes(session.index);
|
|
1853
|
+
session.shapes.set(session.index, shapes);
|
|
1854
|
+
return shapes;
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Whether an `id=` prop fills an `{id}` pattern into the wanted key. A pattern written in a
|
|
1858
|
+
* component takes only the props of that component; one written in a helper takes any.
|
|
1859
|
+
*
|
|
1860
|
+
* @param pattern - The anchor of the `{id}` pattern.
|
|
1861
|
+
* @param idAttribute - The anchor of the `id=` prop.
|
|
1862
|
+
* @param wanted - The runtime key, without its prefix.
|
|
1863
|
+
* @returns True when the filled pattern reads as the key.
|
|
1864
|
+
*/
|
|
1865
|
+
function fills(pattern, idAttribute, wanted) {
|
|
1866
|
+
const isSameComponent = pattern.component === void 0 || pattern.component === idAttribute.component;
|
|
1867
|
+
const filled = (pattern.key ?? "").replaceAll(ID, idAttribute.key ?? "");
|
|
1868
|
+
return isSameComponent && matchesPattern(filled, wanted);
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* How much literal text a pattern holds: a pattern with more of it says more about a key.
|
|
1872
|
+
*
|
|
1873
|
+
* @param anchor - The anchor of a pattern.
|
|
1874
|
+
* @returns The length of the pattern without its holes.
|
|
1875
|
+
* @example
|
|
1876
|
+
* ```ts
|
|
1877
|
+
* specificityOf({ path: "strip.tsx", key: "card*Picture" }); // 11
|
|
1878
|
+
* ```
|
|
1879
|
+
*/
|
|
1880
|
+
function specificityOf(anchor) {
|
|
1881
|
+
return (anchor.key ?? "").replaceAll(ID, "").replaceAll("*", "").length;
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* Orders patterns from the most specific to the least, keeping their order otherwise.
|
|
1885
|
+
*
|
|
1886
|
+
* @param first - One pattern anchor.
|
|
1887
|
+
* @param second - The other.
|
|
1888
|
+
* @returns A negative number when the first holds more literal text.
|
|
1889
|
+
*/
|
|
1890
|
+
function bySpecificity(first, second) {
|
|
1891
|
+
return specificityOf(second) - specificityOf(first);
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* The anchors a runtime JSX key reaches through patterns: each `{id}` pattern filled with an `id=`
|
|
1895
|
+
* prop (the pattern, then the prop), then each `*` pattern as a wildcard. Within each tier the
|
|
1896
|
+
* pattern with more literal text comes first: `card*Picture` before `card*`.
|
|
1897
|
+
*
|
|
1898
|
+
* @param shapes - The JSX shapes of the index.
|
|
1899
|
+
* @param wanted - The runtime key, without its prefix.
|
|
1900
|
+
* @returns The anchors, in that order.
|
|
1901
|
+
*/
|
|
1902
|
+
function patternAnchors(shapes, wanted) {
|
|
1903
|
+
const { idPatterns, wildPatterns, idAttributes } = shapes;
|
|
1904
|
+
const reached = [];
|
|
1905
|
+
for (const pattern of idPatterns.toSorted(bySpecificity)) for (const idAttribute of idAttributes.filter((item) => fills(pattern, item, wanted))) reached.push(pattern, idAttribute);
|
|
1906
|
+
const wild = wildPatterns.filter((pattern) => matchesPattern(pattern.key ?? "", wanted));
|
|
1907
|
+
reached.push(...wild.toSorted(bySpecificity));
|
|
1908
|
+
return reached;
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* The anchors of a key, in the order `find` answers them: the exact key, then, for a JSX key as
|
|
1912
|
+
* the game reports it (no `*`, no `{id}`), the patterns that read as it.
|
|
1913
|
+
*
|
|
1914
|
+
* @param session - The open project.
|
|
1915
|
+
* @param key - The key.
|
|
1916
|
+
* @returns The anchors, each once.
|
|
1917
|
+
*/
|
|
1918
|
+
function anchorsOf(session, key) {
|
|
1919
|
+
const exact = session.index.symbols[key]?.def ?? [];
|
|
1920
|
+
const reached = key.startsWith("jsx:") && !key.includes("*") && !key.includes("{id}") ? patternAnchors(shapesOf(session), key.slice(4)) : [];
|
|
1921
|
+
return [.../* @__PURE__ */ new Set([...exact, ...reached])];
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* The JSX hits of one parse, collected on the first call and kept for the next ones.
|
|
1925
|
+
*
|
|
1926
|
+
* @param typescript - The TypeScript module.
|
|
1927
|
+
* @param source - The parse.
|
|
1928
|
+
* @returns A function that answers the hits.
|
|
1929
|
+
*/
|
|
1930
|
+
function jsxHitsOnce(typescript, source) {
|
|
1931
|
+
let hits;
|
|
1932
|
+
return () => {
|
|
1933
|
+
hits ??= collectJsx(typescript, source);
|
|
1934
|
+
return hits;
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* The parse a file answers from now: the bytes on disk when they parse, else the last good parse
|
|
1939
|
+
* of the file, marked broken.
|
|
1940
|
+
*
|
|
1941
|
+
* @param session - The open project.
|
|
1942
|
+
* @param file - The root-relative path.
|
|
1943
|
+
* @returns The view, or `undefined` when the file is gone or never parsed.
|
|
1944
|
+
*/
|
|
1945
|
+
async function viewOf(session, file) {
|
|
1946
|
+
const { catalog } = session;
|
|
1947
|
+
const good = catalog.records.get(file)?.good;
|
|
1948
|
+
const bytes = await readInside(session.root, file);
|
|
1949
|
+
if (bytes === void 0) return void 0;
|
|
1950
|
+
const hash = sha1(bytes);
|
|
1951
|
+
const fresh = parseCached(catalog, file, hash, hash === good?.hash ? good.text : new TextDecoder().decode(bytes));
|
|
1952
|
+
if (fresh.error === void 0) return {
|
|
1953
|
+
source: fresh.source,
|
|
1954
|
+
hash,
|
|
1955
|
+
broken: false,
|
|
1956
|
+
jsx: jsxHitsOnce(catalog.typescript, fresh.source)
|
|
1957
|
+
};
|
|
1958
|
+
if (good === void 0) return void 0;
|
|
1959
|
+
const { source } = parseCached(catalog, file, good.hash, good.text);
|
|
1960
|
+
return {
|
|
1961
|
+
source,
|
|
1962
|
+
hash: good.hash,
|
|
1963
|
+
broken: true,
|
|
1964
|
+
jsx: jsxHitsOnce(catalog.typescript, source)
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* The identity of an answer: two anchors that land on the same element with the same key answer
|
|
1969
|
+
* once.
|
|
1970
|
+
*
|
|
1971
|
+
* @param answer - One answer of `find`.
|
|
1972
|
+
* @returns The path, the range, the kind and the key, joined.
|
|
1973
|
+
* @example
|
|
1974
|
+
* ```ts
|
|
1975
|
+
* identityOf({ path: "a.tsx", key: "row", kind: "literal", line: 3, range: [3, 1, 3, 9], hash: "h" }); // "a.tsx|3,1,3,9|literal|row"
|
|
1976
|
+
* ```
|
|
1977
|
+
*/
|
|
1978
|
+
function identityOf(answer) {
|
|
1979
|
+
return `${answer.path}|${answer.range.join(",")}|${answer.kind ?? ""}|${answer.key ?? ""}`;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Answers where a key is defined, with lines read from the files on disk now.
|
|
1983
|
+
*
|
|
1984
|
+
* @param session - The open project.
|
|
1985
|
+
* @param key - The key.
|
|
1986
|
+
* @returns One entry per place; `[]` for an unknown key.
|
|
1987
|
+
*/
|
|
1988
|
+
async function findKey(session, key) {
|
|
1989
|
+
const views = /* @__PURE__ */ new Map();
|
|
1990
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1991
|
+
const found = [];
|
|
1992
|
+
for (const anchor of anchorsOf(session, key)) {
|
|
1993
|
+
if (!views.has(anchor.path)) views.set(anchor.path, await viewOf(session, anchor.path));
|
|
1994
|
+
const view = views.get(anchor.path);
|
|
1995
|
+
if (view === void 0) continue;
|
|
1996
|
+
const answers = locate(session.catalog.typescript, view.source, anchor, view.jsx).map((place) => ({
|
|
1997
|
+
...anchor,
|
|
1998
|
+
...place,
|
|
1999
|
+
hash: view.hash,
|
|
2000
|
+
...view.broken ? { broken: true } : {}
|
|
2001
|
+
}));
|
|
2002
|
+
for (const answer of answers) {
|
|
2003
|
+
const identity = identityOf(answer);
|
|
2004
|
+
if (seen.has(identity)) continue;
|
|
2005
|
+
seen.add(identity);
|
|
2006
|
+
found.push(answer);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return found;
|
|
2010
|
+
}
|
|
2011
|
+
//#endregion
|
|
2012
|
+
//#region src/project/change.ts
|
|
2013
|
+
/**
|
|
2014
|
+
* The files that define a key, each once, in the order of its anchors.
|
|
2015
|
+
*
|
|
2016
|
+
* @param entry - The symbol entry.
|
|
2017
|
+
* @param entry.def - Its definitions.
|
|
2018
|
+
* @returns The paths.
|
|
2019
|
+
*/
|
|
2020
|
+
function pathsOf(entry) {
|
|
2021
|
+
return [...new Set(entry.def.map((anchor) => anchor.path))];
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Compares the index before a batch with the index after it.
|
|
2025
|
+
*
|
|
2026
|
+
* @param before - The index before the batch.
|
|
2027
|
+
* @param after - The index after the batch.
|
|
2028
|
+
* @param files - The paths whose bytes changed, appeared or vanished.
|
|
2029
|
+
* @returns The change: the new revision, the files sorted, the moves and the removed keys.
|
|
2030
|
+
*/
|
|
2031
|
+
function diffIndexes(before, after, files) {
|
|
2032
|
+
const moved = [];
|
|
2033
|
+
const removed = [];
|
|
2034
|
+
for (const [key, entry] of Object.entries(before.symbols)) {
|
|
2035
|
+
const now = after.symbols[key];
|
|
2036
|
+
if (now === void 0) {
|
|
2037
|
+
removed.push(key);
|
|
2038
|
+
continue;
|
|
2039
|
+
}
|
|
2040
|
+
const was = pathsOf(entry);
|
|
2041
|
+
const is = pathsOf(now);
|
|
2042
|
+
const left = was.filter((file) => !is.includes(file));
|
|
2043
|
+
const arrived = is.filter((file) => !was.includes(file));
|
|
2044
|
+
for (const [index, from] of left.entries()) {
|
|
2045
|
+
const to = arrived[index];
|
|
2046
|
+
if (to !== void 0) moved.push({
|
|
2047
|
+
key,
|
|
2048
|
+
from,
|
|
2049
|
+
to
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return {
|
|
2054
|
+
revision: after.revision,
|
|
2055
|
+
files: files.toSorted(),
|
|
2056
|
+
moved,
|
|
2057
|
+
removed
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
//#endregion
|
|
2061
|
+
//#region src/project/walk.ts
|
|
2062
|
+
/**
|
|
2063
|
+
* Reads the stamp of a file.
|
|
2064
|
+
*
|
|
2065
|
+
* @param file - The absolute path.
|
|
2066
|
+
* @returns The stamp, or `undefined` when the file is gone.
|
|
2067
|
+
*/
|
|
2068
|
+
async function stampOf(file) {
|
|
2069
|
+
try {
|
|
2070
|
+
const stats = await stat(file, { bigint: true });
|
|
2071
|
+
return {
|
|
2072
|
+
ino: stats.ino,
|
|
2073
|
+
size: stats.size,
|
|
2074
|
+
mtimeNs: stats.mtimeNs,
|
|
2075
|
+
ctimeNs: stats.ctimeNs
|
|
2076
|
+
};
|
|
2077
|
+
} catch {
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Whether two stamps describe the same write.
|
|
2083
|
+
*
|
|
2084
|
+
* @param first - One stamp.
|
|
2085
|
+
* @param second - The other.
|
|
2086
|
+
* @returns True when inode, size and both time stamps are equal.
|
|
2087
|
+
* @example
|
|
2088
|
+
* ```ts
|
|
2089
|
+
* const stamp = { ino: 1n, size: 10n, mtimeNs: 5n, ctimeNs: 5n };
|
|
2090
|
+
* sameStamp(stamp, { ...stamp, size: 11n }); // false
|
|
2091
|
+
* ```
|
|
2092
|
+
*/
|
|
2093
|
+
function sameStamp(first, second) {
|
|
2094
|
+
return first.ino === second.ino && first.size === second.size && first.mtimeNs === second.mtimeNs && first.ctimeNs === second.ctimeNs;
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Lists one folder, empty when it vanished during the walk.
|
|
2098
|
+
*
|
|
2099
|
+
* @param folder - The absolute folder.
|
|
2100
|
+
* @returns Its entries.
|
|
2101
|
+
*/
|
|
2102
|
+
async function entriesOf(folder) {
|
|
2103
|
+
return readdir(folder, { withFileTypes: true }).catch(() => []);
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* Reads one folder of the walk: its sub-folders join the queue, its source files join the list
|
|
2107
|
+
* with their stamps.
|
|
2108
|
+
*
|
|
2109
|
+
* @param root - The real root.
|
|
2110
|
+
* @param folder - The folder, root-relative; `""` is the root.
|
|
2111
|
+
* @param walk - The files so far, and the queue of folders still to read.
|
|
2112
|
+
* @param walk.files - The files so far.
|
|
2113
|
+
* @param walk.pending - The folders still to read.
|
|
2114
|
+
*/
|
|
2115
|
+
async function readFolder(root, folder, walk) {
|
|
2116
|
+
for (const entry of await entriesOf(path.join(root, folder))) {
|
|
2117
|
+
const relative = folder === "" ? entry.name : `${folder}/${entry.name}`;
|
|
2118
|
+
if (entry.isDirectory() && !isSkippedFolder(entry.name)) walk.pending.push(relative);
|
|
2119
|
+
if (!entry.isFile() || !isIndexedFile(relative)) continue;
|
|
2120
|
+
const stamp = await stampOf(path.join(root, relative));
|
|
2121
|
+
if (stamp !== void 0) walk.files.set(relative, stamp);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Walks a root: every indexed source file with its stamp and every folder outside the skipped
|
|
2126
|
+
* ones. Symlinks are skipped, files and folders alike.
|
|
2127
|
+
*
|
|
2128
|
+
* @param root - The real root.
|
|
2129
|
+
* @returns The files and the folders, root-relative POSIX.
|
|
2130
|
+
*/
|
|
2131
|
+
async function walkRoot(root) {
|
|
2132
|
+
const files = /* @__PURE__ */ new Map();
|
|
2133
|
+
const folders = [];
|
|
2134
|
+
const pending = [""];
|
|
2135
|
+
for (let folder = pending.pop(); folder !== void 0; folder = pending.pop()) {
|
|
2136
|
+
folders.push(folder);
|
|
2137
|
+
await readFolder(root, folder, {
|
|
2138
|
+
files,
|
|
2139
|
+
pending
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
return {
|
|
2143
|
+
files,
|
|
2144
|
+
folders
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
//#endregion
|
|
2148
|
+
//#region src/project/session.ts
|
|
2149
|
+
/**
|
|
2150
|
+
* @file project — an open project: the real root, the catalog of its files, the stamps of the last
|
|
2151
|
+
* walk, the current index and a queue that runs one update at a time. `syncWithDisk` is the batch
|
|
2152
|
+
* walk of a watch batch: it walks the root, hashes the files whose stamp moved and every new one,
|
|
2153
|
+
* drops the vanished ones and rebuilds the index when bytes changed. `reindexPath` reads one path
|
|
2154
|
+
* now for `changed`.
|
|
2155
|
+
*/
|
|
2156
|
+
/** The manifest the asset scanner writes by default. */
|
|
2157
|
+
const MANIFEST_FILE = "manifest.json";
|
|
2158
|
+
/**
|
|
2159
|
+
* The manifest path to report, when the file exists.
|
|
2160
|
+
*
|
|
2161
|
+
* @param root - The real root.
|
|
2162
|
+
* @param manifest - The root-relative manifest path.
|
|
2163
|
+
* @returns The path, or `undefined` when nothing is there.
|
|
2164
|
+
*/
|
|
2165
|
+
async function presentManifest(root, manifest) {
|
|
2166
|
+
try {
|
|
2167
|
+
if (await resolveInside(root, manifest) !== void 0) return manifest;
|
|
2168
|
+
} catch {}
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Reads one file into the catalog, or drops it when it is gone or cannot be read.
|
|
2172
|
+
*
|
|
2173
|
+
* @param session - The project.
|
|
2174
|
+
* @param file - The root-relative path.
|
|
2175
|
+
* @returns True when the catalog changed.
|
|
2176
|
+
*/
|
|
2177
|
+
async function readIntoCatalog(session, file) {
|
|
2178
|
+
const bytes = await readInside(session.root, file);
|
|
2179
|
+
if (bytes === void 0) {
|
|
2180
|
+
session.stamps.delete(file);
|
|
2181
|
+
return dropFile(session.catalog, file);
|
|
2182
|
+
}
|
|
2183
|
+
return putFile(session.catalog, file, bytes);
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Rebuilds the index after files changed and says what changed.
|
|
2187
|
+
*
|
|
2188
|
+
* @param session - The project.
|
|
2189
|
+
* @param files - The paths whose bytes changed, appeared or vanished.
|
|
2190
|
+
* @returns The change.
|
|
2191
|
+
*/
|
|
2192
|
+
async function rebuild(session, files) {
|
|
2193
|
+
const before = session.index;
|
|
2194
|
+
const manifest = await presentManifest(session.root, session.manifest);
|
|
2195
|
+
session.index = buildIndex(session.catalog, manifest);
|
|
2196
|
+
return diffIndexes(before, session.index, files);
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Opens a project: checks the root, loads TypeScript, reads every source file and builds the
|
|
2200
|
+
* index.
|
|
2201
|
+
*
|
|
2202
|
+
* @param options - The root, the manifest and the debounce.
|
|
2203
|
+
* @returns The open project.
|
|
2204
|
+
* @throws {Error} When the root is missing or not a directory, the manifest path leaves the root,
|
|
2205
|
+
* or TypeScript is not installed.
|
|
2206
|
+
*/
|
|
2207
|
+
async function openSession(options) {
|
|
2208
|
+
const root = await openRoot(options.root);
|
|
2209
|
+
const typescript = await loadTypeScript();
|
|
2210
|
+
const manifest = toPosix(options.manifest ?? MANIFEST_FILE);
|
|
2211
|
+
await resolveInside(root, manifest);
|
|
2212
|
+
const walk = await walkRoot(root);
|
|
2213
|
+
const catalog = createCatalog(typescript);
|
|
2214
|
+
const session = {
|
|
2215
|
+
root,
|
|
2216
|
+
manifest,
|
|
2217
|
+
catalog,
|
|
2218
|
+
stamps: walk.files,
|
|
2219
|
+
folders: walk.folders,
|
|
2220
|
+
index: buildIndex(catalog),
|
|
2221
|
+
shapes: /* @__PURE__ */ new WeakMap(),
|
|
2222
|
+
queue: Promise.resolve()
|
|
2223
|
+
};
|
|
2224
|
+
for (const file of [...walk.files.keys()].toSorted()) await readIntoCatalog(session, file);
|
|
2225
|
+
session.index = buildIndex(catalog, await presentManifest(root, manifest));
|
|
2226
|
+
return session;
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Runs an update after the ones before it, so two never interleave.
|
|
2230
|
+
*
|
|
2231
|
+
* @param session - The project.
|
|
2232
|
+
* @param task - The update.
|
|
2233
|
+
* @returns What the update returns.
|
|
2234
|
+
*/
|
|
2235
|
+
function serialize(session, task) {
|
|
2236
|
+
const result = session.queue.then(task);
|
|
2237
|
+
session.queue = result.catch(() => {});
|
|
2238
|
+
return result;
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* The batch walk: walks the root, hashes each file whose stamp moved and each new one, drops the
|
|
2242
|
+
* vanished ones, and rebuilds the index when bytes changed. A save with the same bytes changes
|
|
2243
|
+
* nothing.
|
|
2244
|
+
*
|
|
2245
|
+
* @param session - The project.
|
|
2246
|
+
* @returns The change, or `undefined` when the index stayed the same.
|
|
2247
|
+
*/
|
|
2248
|
+
async function syncWithDisk(session) {
|
|
2249
|
+
const walk = await walkRoot(session.root);
|
|
2250
|
+
const files = [];
|
|
2251
|
+
for (const file of session.catalog.records.keys()) if (!walk.files.has(file) && dropFile(session.catalog, file)) files.push(file);
|
|
2252
|
+
const previous = session.stamps;
|
|
2253
|
+
session.stamps = walk.files;
|
|
2254
|
+
session.folders = walk.folders;
|
|
2255
|
+
for (const [file, stamp] of walk.files) {
|
|
2256
|
+
const known = previous.get(file);
|
|
2257
|
+
if (!(known !== void 0 && sameStamp(known, stamp)) && await readIntoCatalog(session, file)) files.push(file);
|
|
2258
|
+
}
|
|
2259
|
+
const manifest = await presentManifest(session.root, session.manifest);
|
|
2260
|
+
if (files.length === 0 && manifest === session.index.manifest) return void 0;
|
|
2261
|
+
return rebuild(session, files);
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Reads one root-relative path now: a changed or new source file is parsed, a deleted one is
|
|
2265
|
+
* dropped, and anything the index does not read changes nothing.
|
|
2266
|
+
*
|
|
2267
|
+
* @param session - The project.
|
|
2268
|
+
* @param file - The root-relative path.
|
|
2269
|
+
* @returns The index after the read.
|
|
2270
|
+
* @throws {Error} When the path leaves the root.
|
|
2271
|
+
*/
|
|
2272
|
+
async function reindexPath(session, file) {
|
|
2273
|
+
const relative = toPosix(file);
|
|
2274
|
+
const real = await resolveInside(session.root, relative);
|
|
2275
|
+
const isLinked = real !== void 0 && real !== path.join(session.root, relative);
|
|
2276
|
+
if (path.isAbsolute(relative) || isLinked || !isIndexedFile(relative)) return session.index;
|
|
2277
|
+
const stamp = real === void 0 ? void 0 : await stampOf(real);
|
|
2278
|
+
if (stamp !== void 0) session.stamps.set(relative, stamp);
|
|
2279
|
+
if (!await readIntoCatalog(session, relative)) return session.index;
|
|
2280
|
+
await rebuild(session, [relative]);
|
|
2281
|
+
return session.index;
|
|
2282
|
+
}
|
|
2283
|
+
//#endregion
|
|
2284
|
+
//#region src/project/watch.ts
|
|
2285
|
+
/**
|
|
2286
|
+
* @file project — the watcher of an open project. A platform event is only a trigger: Bun on macOS
|
|
2287
|
+
* reports every edit as `rename`, drops the destination of a move and names the `.tmp` of an
|
|
2288
|
+
* atomic save instead of the file. So events only poke a batcher; when the quiet period ends, the
|
|
2289
|
+
* batch walks the root (`syncWithDisk`) and finds what really changed. The watcher can also miss a
|
|
2290
|
+
* write made as it starts, so the handle walks once when watching starts and then every two
|
|
2291
|
+
* seconds as a backstop. The root is watched recursively; where the platform cannot, every folder
|
|
2292
|
+
* of the last walk gets its own watcher, re-armed after each walk. Every caller of `watch` shares
|
|
2293
|
+
* the one watcher of the handle.
|
|
2294
|
+
*/
|
|
2295
|
+
/** The longest a burst of events holds a batch back, at the least. */
|
|
2296
|
+
const LONGEST_WAIT_MS = 1e3;
|
|
2297
|
+
/** The longest wait as a multiple of the quiet period, when that is longer. */
|
|
2298
|
+
const LONGEST_WAIT_FACTOR = 10;
|
|
2299
|
+
/**
|
|
2300
|
+
* How often a watching handle walks the root with no event: the platform watcher can miss a write
|
|
2301
|
+
* made as it starts, and drop events under load. A walk that finds the same bytes calls nobody.
|
|
2302
|
+
*/
|
|
2303
|
+
const BACKSTOP_MS = 2e3;
|
|
2304
|
+
/** The error `watch` throws on a closed handle. */
|
|
2305
|
+
const CLOSED = "[game] The project is closed.\n Open it again with openProject().";
|
|
2306
|
+
/**
|
|
2307
|
+
* Watches every folder of the last walk with its own watcher, for a platform without recursive
|
|
2308
|
+
* watching.
|
|
2309
|
+
*
|
|
2310
|
+
* @param root - The real root.
|
|
2311
|
+
* @param listener - Gets every event.
|
|
2312
|
+
* @param onError - Called when a folder watcher errors.
|
|
2313
|
+
* @param watchFunction - `fs.watch`.
|
|
2314
|
+
* @returns The tree watcher.
|
|
2315
|
+
*/
|
|
2316
|
+
function watchFolders(root, listener, onError, watchFunction) {
|
|
2317
|
+
const watchers = /* @__PURE__ */ new Map();
|
|
2318
|
+
const arm = (folder) => {
|
|
2319
|
+
try {
|
|
2320
|
+
const handle = watchFunction(path.join(root, folder), { recursive: false }, listener);
|
|
2321
|
+
handle.on("error", () => {
|
|
2322
|
+
handle.close();
|
|
2323
|
+
if (watchers.get(folder) === handle) watchers.delete(folder);
|
|
2324
|
+
onError();
|
|
2325
|
+
});
|
|
2326
|
+
watchers.set(folder, handle);
|
|
2327
|
+
} catch {}
|
|
2328
|
+
};
|
|
2329
|
+
return {
|
|
2330
|
+
rearm: (folders) => {
|
|
2331
|
+
for (const [folder, handle] of watchers) {
|
|
2332
|
+
if (folders.includes(folder)) continue;
|
|
2333
|
+
handle.close();
|
|
2334
|
+
watchers.delete(folder);
|
|
2335
|
+
}
|
|
2336
|
+
for (const folder of folders) if (!watchers.has(folder)) arm(folder);
|
|
2337
|
+
},
|
|
2338
|
+
close: () => {
|
|
2339
|
+
for (const handle of watchers.values()) handle.close();
|
|
2340
|
+
watchers.clear();
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Watches a root: recursively when the platform can, else one watcher per folder. Events under
|
|
2346
|
+
* the skipped folders are ignored.
|
|
2347
|
+
*
|
|
2348
|
+
* @param root - The real root.
|
|
2349
|
+
* @param onEvent - Called on every event that may matter.
|
|
2350
|
+
* @param watchFunction - `fs.watch` by default.
|
|
2351
|
+
* @returns The tree watcher.
|
|
2352
|
+
*/
|
|
2353
|
+
function watchTree(root, onEvent, watchFunction = watch) {
|
|
2354
|
+
const listener = (_event, file) => {
|
|
2355
|
+
if (file === null || !isSkippedPath(`${file}/`)) onEvent();
|
|
2356
|
+
};
|
|
2357
|
+
try {
|
|
2358
|
+
const handle = watchFunction(root, { recursive: true }, listener);
|
|
2359
|
+
handle.on("error", () => onEvent());
|
|
2360
|
+
return {
|
|
2361
|
+
rearm: () => {},
|
|
2362
|
+
close: () => handle.close()
|
|
2363
|
+
};
|
|
2364
|
+
} catch {
|
|
2365
|
+
return watchFolders(root, listener, onEvent, watchFunction);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Creates the debounce of a watcher: a batch runs once the events stop for `quietMs`, or after the
|
|
2370
|
+
* longest wait during an endless burst. One batch runs at a time; events during a batch queue one
|
|
2371
|
+
* more.
|
|
2372
|
+
*
|
|
2373
|
+
* @param quietMs - The quiet period that closes a batch.
|
|
2374
|
+
* @param run - The batch.
|
|
2375
|
+
* @returns The batcher.
|
|
2376
|
+
*/
|
|
2377
|
+
function createBatcher(quietMs, run) {
|
|
2378
|
+
const longestMs = Math.max(LONGEST_WAIT_MS, quietMs * LONGEST_WAIT_FACTOR);
|
|
2379
|
+
let quiet;
|
|
2380
|
+
let longest;
|
|
2381
|
+
let running = false;
|
|
2382
|
+
let pending = false;
|
|
2383
|
+
let cancelled = false;
|
|
2384
|
+
const clear = () => {
|
|
2385
|
+
clearTimeout(quiet);
|
|
2386
|
+
clearTimeout(longest);
|
|
2387
|
+
quiet = void 0;
|
|
2388
|
+
longest = void 0;
|
|
2389
|
+
};
|
|
2390
|
+
const fire = () => {
|
|
2391
|
+
clear();
|
|
2392
|
+
if (running) {
|
|
2393
|
+
pending = true;
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const settle = () => {
|
|
2397
|
+
running = false;
|
|
2398
|
+
if (pending && !cancelled) poke();
|
|
2399
|
+
pending = false;
|
|
2400
|
+
};
|
|
2401
|
+
running = true;
|
|
2402
|
+
run().then(settle, settle);
|
|
2403
|
+
};
|
|
2404
|
+
const poke = () => {
|
|
2405
|
+
if (cancelled) return;
|
|
2406
|
+
clearTimeout(quiet);
|
|
2407
|
+
quiet = setTimeout(fire, quietMs);
|
|
2408
|
+
longest ??= setTimeout(fire, longestMs);
|
|
2409
|
+
};
|
|
2410
|
+
return {
|
|
2411
|
+
poke,
|
|
2412
|
+
cancel: () => {
|
|
2413
|
+
cancelled = true;
|
|
2414
|
+
clear();
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* Creates the callers' hub of one handle: the watcher starts with the first caller and stops with
|
|
2420
|
+
* the last; each batch that changed the index calls every caller once.
|
|
2421
|
+
*
|
|
2422
|
+
* @param session - The open project.
|
|
2423
|
+
* @param debounceMs - The quiet period.
|
|
2424
|
+
* @param watchFunction - `fs.watch` by default.
|
|
2425
|
+
* @param backstopMs - The period of the walk with no event, two seconds by default.
|
|
2426
|
+
* @returns The hub.
|
|
2427
|
+
*/
|
|
2428
|
+
function createHub(session, debounceMs, watchFunction = watch, backstopMs = BACKSTOP_MS) {
|
|
2429
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2430
|
+
let tree;
|
|
2431
|
+
let batcher;
|
|
2432
|
+
let backstop;
|
|
2433
|
+
let closed = false;
|
|
2434
|
+
const batch = async () => {
|
|
2435
|
+
const change = await serialize(session, () => syncWithDisk(session));
|
|
2436
|
+
tree?.rearm(session.folders);
|
|
2437
|
+
if (change === void 0 || closed) return;
|
|
2438
|
+
for (const { listener } of [...listeners]) try {
|
|
2439
|
+
listener(session.index, change);
|
|
2440
|
+
} catch {}
|
|
2441
|
+
};
|
|
2442
|
+
const stop = () => {
|
|
2443
|
+
clearInterval(backstop);
|
|
2444
|
+
tree?.close();
|
|
2445
|
+
batcher?.cancel();
|
|
2446
|
+
backstop = void 0;
|
|
2447
|
+
tree = void 0;
|
|
2448
|
+
batcher = void 0;
|
|
2449
|
+
};
|
|
2450
|
+
const start = () => {
|
|
2451
|
+
const started = createBatcher(debounceMs, batch);
|
|
2452
|
+
batcher = started;
|
|
2453
|
+
tree = watchTree(session.root, () => started.poke(), watchFunction);
|
|
2454
|
+
tree.rearm(session.folders);
|
|
2455
|
+
started.poke();
|
|
2456
|
+
backstop = setInterval(() => started.poke(), backstopMs);
|
|
2457
|
+
};
|
|
2458
|
+
return {
|
|
2459
|
+
add: (listener) => {
|
|
2460
|
+
if (closed) throw new Error(CLOSED);
|
|
2461
|
+
const entry = { listener };
|
|
2462
|
+
listeners.add(entry);
|
|
2463
|
+
if (tree === void 0) start();
|
|
2464
|
+
return () => {
|
|
2465
|
+
listeners.delete(entry);
|
|
2466
|
+
if (listeners.size === 0) stop();
|
|
2467
|
+
};
|
|
2468
|
+
},
|
|
2469
|
+
close: () => {
|
|
2470
|
+
closed = true;
|
|
2471
|
+
listeners.clear();
|
|
2472
|
+
stop();
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
//#endregion
|
|
2477
|
+
//#region src/project/open.ts
|
|
2478
|
+
/**
|
|
2479
|
+
* @file project — `openProject`, the one function of the project index a caller starts from. It
|
|
2480
|
+
* opens the root, builds the index and hands back the handle: the current index, `find`, `watch`,
|
|
2481
|
+
* `changed` and `close`.
|
|
2482
|
+
*/
|
|
2483
|
+
/** The quiet period that closes a watch batch, when the options name none. */
|
|
2484
|
+
const DEFAULT_DEBOUNCE_MS = 75;
|
|
2485
|
+
/**
|
|
2486
|
+
* Opens the project index of a game: checks the root (it exists, it is a directory, its real
|
|
2487
|
+
* path), loads TypeScript, parses every source file and builds the index. Nothing is written to
|
|
2488
|
+
* disk and nothing is inferred from the working directory but the root itself.
|
|
2489
|
+
*
|
|
2490
|
+
* @param options - The game root, the manifest path and the quiet period of `watch`.
|
|
2491
|
+
* @returns The handle; close it when the game closes.
|
|
2492
|
+
* @throws {Error} When the root is missing or not a directory, or the `typescript` package is not
|
|
2493
|
+
* installed: `[game] The project index needs the "typescript" package.`
|
|
2494
|
+
* @example
|
|
2495
|
+
* ```ts
|
|
2496
|
+
* // The editor opens the fixture game and answers where a node and a text style live.
|
|
2497
|
+
* const project = await openProject({ root: "tests/integration/merge-game" });
|
|
2498
|
+
* Object.keys(project.index.files).length; // 111
|
|
2499
|
+
* (await project.find("textStyle:ui.title"))[0]?.line; // 39, in "features/ui/styles.ts"
|
|
2500
|
+
* project.close();
|
|
2501
|
+
* ```
|
|
2502
|
+
*/
|
|
2503
|
+
async function openProject(options) {
|
|
2504
|
+
const session = await openSession(options);
|
|
2505
|
+
const hub = createHub(session, options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
|
|
2506
|
+
return {
|
|
2507
|
+
/**
|
|
2508
|
+
* The current index of the session; a batch or `changed` replaces it.
|
|
2509
|
+
*
|
|
2510
|
+
* @returns The index.
|
|
2511
|
+
*/
|
|
2512
|
+
get index() {
|
|
2513
|
+
return session.index;
|
|
2514
|
+
},
|
|
2515
|
+
find: (key) => findKey(session, key),
|
|
2516
|
+
watch: (onIndex) => hub.add(onIndex),
|
|
2517
|
+
changed: (file) => serialize(session, () => reindexPath(session, file)),
|
|
2518
|
+
close: () => hub.close()
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
//#endregion
|
|
2522
|
+
//#region src/project/cli.ts
|
|
2523
|
+
/**
|
|
2524
|
+
* @file project — the command line `moku-game-index`. It opens the project of `--root` and runs one
|
|
2525
|
+
* command: `--json` prints the index, `where <key>` prints one `path:line` per place, `--check`
|
|
2526
|
+
* reports broken files and conflicts (exit 1) and lists the unresolved items as info. Output goes
|
|
2527
|
+
* through the branded console of `@moku-labs/common`; `runCli` returns the exit code instead of
|
|
2528
|
+
* taking it.
|
|
2529
|
+
*/
|
|
2530
|
+
/** The usage line under every flag error. */
|
|
2531
|
+
const USAGE = "Run: moku-game-index --root <dir> [--manifest <path>] --json | --check | where <key>.";
|
|
2532
|
+
/** Pretty JSON indent of `--json`. */
|
|
2533
|
+
const JSON_INDENT = 2;
|
|
2534
|
+
/**
|
|
2535
|
+
* Wraps a command line problem in the message shape of the framework.
|
|
2536
|
+
*
|
|
2537
|
+
* @param message - One sentence naming the flag.
|
|
2538
|
+
* @returns The error to throw.
|
|
2539
|
+
*/
|
|
2540
|
+
function problem(message) {
|
|
2541
|
+
return /* @__PURE__ */ new Error(`[game] index: ${message}\n ${USAGE}`);
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Reads the message of anything that was thrown.
|
|
2545
|
+
*
|
|
2546
|
+
* @param failure - What the `catch` caught.
|
|
2547
|
+
* @returns The message.
|
|
2548
|
+
*/
|
|
2549
|
+
function messageOf(failure) {
|
|
2550
|
+
return failure instanceof Error ? failure.message : String(failure);
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* The value after a flag that takes one.
|
|
2554
|
+
*
|
|
2555
|
+
* @param argv - The arguments.
|
|
2556
|
+
* @param at - The index of the flag.
|
|
2557
|
+
* @param what - What the flag needs, for the error.
|
|
2558
|
+
* @returns The value.
|
|
2559
|
+
* @throws {Error} When the value is missing.
|
|
2560
|
+
*/
|
|
2561
|
+
function valueAfter(argv, at, what) {
|
|
2562
|
+
const flag = argv[at] ?? "";
|
|
2563
|
+
const value = argv[at + 1];
|
|
2564
|
+
if (value === void 0 || value.startsWith("--")) throw problem(`"${flag}" needs ${what}.`);
|
|
2565
|
+
return value;
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Reads one flag or command into the flags.
|
|
2569
|
+
*
|
|
2570
|
+
* @param argv - The arguments.
|
|
2571
|
+
* @param at - The index of the flag.
|
|
2572
|
+
* @param flags - The flags so far.
|
|
2573
|
+
* @returns How many arguments the flag took.
|
|
2574
|
+
* @throws {Error} When the flag is unknown or misses its value.
|
|
2575
|
+
*/
|
|
2576
|
+
function readFlag(argv, at, flags) {
|
|
2577
|
+
const flag = argv[at];
|
|
2578
|
+
switch (flag) {
|
|
2579
|
+
case "--json":
|
|
2580
|
+
flags.commands.push({ kind: "json" });
|
|
2581
|
+
return 1;
|
|
2582
|
+
case "--check":
|
|
2583
|
+
flags.commands.push({ kind: "check" });
|
|
2584
|
+
return 1;
|
|
2585
|
+
case "where":
|
|
2586
|
+
flags.commands.push({
|
|
2587
|
+
kind: "where",
|
|
2588
|
+
key: valueAfter(argv, at, "a key, such as node:board/merge")
|
|
2589
|
+
});
|
|
2590
|
+
return 2;
|
|
2591
|
+
case "--root":
|
|
2592
|
+
flags.root = valueAfter(argv, at, "a path");
|
|
2593
|
+
return 2;
|
|
2594
|
+
case "--manifest":
|
|
2595
|
+
flags.manifest = valueAfter(argv, at, "a path");
|
|
2596
|
+
return 2;
|
|
2597
|
+
default: throw problem(`unknown option "${String(flag)}".`);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* Reads the arguments.
|
|
2602
|
+
*
|
|
2603
|
+
* @param argv - The arguments after the script name.
|
|
2604
|
+
* @returns The root, the manifest and the one command.
|
|
2605
|
+
* @throws {Error} When a flag is unknown, a value is missing, `--root` is missing or the commands
|
|
2606
|
+
* are not exactly one.
|
|
2607
|
+
*/
|
|
2608
|
+
function parseArgv(argv) {
|
|
2609
|
+
const flags = { commands: [] };
|
|
2610
|
+
for (let at = 0; at < argv.length;) at += readFlag(argv, at, flags);
|
|
2611
|
+
if (flags.root === void 0) throw new Error("[game] index: \"--root\" is required.\n Name the game folder: moku-game-index --root <dir> where <key>.");
|
|
2612
|
+
const [command] = flags.commands;
|
|
2613
|
+
if (command === void 0 || flags.commands.length > 1) throw problem("name one command: --json, --check or where <key>.");
|
|
2614
|
+
return {
|
|
2615
|
+
root: flags.root,
|
|
2616
|
+
command,
|
|
2617
|
+
...flags.manifest === void 0 ? {} : { manifest: flags.manifest }
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* One line of a `where` answer.
|
|
2622
|
+
*
|
|
2623
|
+
* @param found - One place of the key.
|
|
2624
|
+
* @returns `path:line`, with ` (broken)` when the line comes from the last good parse.
|
|
2625
|
+
* @example
|
|
2626
|
+
* ```ts
|
|
2627
|
+
* whereLine({ path: "nodes/merge.ts", line: 17, range: [17, 1, 38, 4], hash: "ab" }); // "nodes/merge.ts:17"
|
|
2628
|
+
* ```
|
|
2629
|
+
*/
|
|
2630
|
+
function whereLine(found) {
|
|
2631
|
+
return `${found.path}:${found.line}${found.broken === true ? " (broken)" : ""}`;
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Counts a thing in words, so the summary reads as a sentence.
|
|
2635
|
+
*
|
|
2636
|
+
* @param count - How many.
|
|
2637
|
+
* @param word - The singular.
|
|
2638
|
+
* @returns The count and the word.
|
|
2639
|
+
* @example
|
|
2640
|
+
* ```ts
|
|
2641
|
+
* plural(1, "key"); // "1 key"
|
|
2642
|
+
* ```
|
|
2643
|
+
*/
|
|
2644
|
+
function plural(count, word) {
|
|
2645
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* The `--check` report: a summary, every broken file and conflict as an error, every unresolved
|
|
2649
|
+
* item as info.
|
|
2650
|
+
*
|
|
2651
|
+
* @param index - The index.
|
|
2652
|
+
* @param ui - Where the lines go.
|
|
2653
|
+
* @returns `1` when a file is broken or a key is in conflict, else `0`.
|
|
2654
|
+
*/
|
|
2655
|
+
function printCheck(index, ui) {
|
|
2656
|
+
const files = Object.entries(index.files);
|
|
2657
|
+
const symbols = Object.entries(index.symbols);
|
|
2658
|
+
const broken = files.filter(([, file]) => file.state === "broken");
|
|
2659
|
+
const conflicts = symbols.filter(([, symbol]) => symbol.conflict === true);
|
|
2660
|
+
ui.info(`${plural(files.length, "file")}, ${plural(symbols.length, "key")}: ${broken.length} broken, ${conflicts.length} in conflict, ${index.unresolved.length} unresolved.`);
|
|
2661
|
+
for (const [file, entry] of broken) ui.error(`broken ${file}: ${entry.error ?? ""}`);
|
|
2662
|
+
for (const [key, symbol] of conflicts) ui.error(`conflict ${key}: ${symbol.def.map((anchor) => anchor.path).join(", ")}`);
|
|
2663
|
+
for (const item of index.unresolved) ui.info(`unresolved ${item.path}: ${item.reason}`);
|
|
2664
|
+
return broken.length + conflicts.length > 0 ? 1 : 0;
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* The `where` answer: one line per place of the key.
|
|
2668
|
+
*
|
|
2669
|
+
* @param project - The open project.
|
|
2670
|
+
* @param key - The key.
|
|
2671
|
+
* @param root - The root as given, for the message.
|
|
2672
|
+
* @param ui - Where the lines go.
|
|
2673
|
+
* @returns `1` when the key is unknown, else `0`.
|
|
2674
|
+
*/
|
|
2675
|
+
async function printWhere(project, key, root, ui) {
|
|
2676
|
+
const found = await project.find(key);
|
|
2677
|
+
if (found.length === 0) {
|
|
2678
|
+
ui.error(`[game] The key "${key}" is not in the index of "${root}".\n Run "moku-game-index --root ${root} --json" to list the keys.`);
|
|
2679
|
+
return 1;
|
|
2680
|
+
}
|
|
2681
|
+
for (const place of found) ui.line(whereLine(place));
|
|
2682
|
+
return 0;
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* Runs the one command of the line on an open project.
|
|
2686
|
+
*
|
|
2687
|
+
* @param project - The open project.
|
|
2688
|
+
* @param options - What the flags asked for.
|
|
2689
|
+
* @param ui - Where the lines go.
|
|
2690
|
+
* @returns The exit code.
|
|
2691
|
+
*/
|
|
2692
|
+
async function runCommand(project, options, ui) {
|
|
2693
|
+
const { command } = options;
|
|
2694
|
+
if (command.kind === "where") return printWhere(project, command.key, options.root, ui);
|
|
2695
|
+
if (command.kind === "check") return printCheck(project.index, ui);
|
|
2696
|
+
ui.line(JSON.stringify(project.index, void 0, JSON_INDENT));
|
|
2697
|
+
return 0;
|
|
2698
|
+
}
|
|
2699
|
+
/**
|
|
2700
|
+
* Runs `moku-game-index`: opens the project of `--root` and runs one command. Nothing here calls
|
|
2701
|
+
* `process.exit`; the caller does.
|
|
2702
|
+
*
|
|
2703
|
+
* @param argv - The arguments after the script name.
|
|
2704
|
+
* @param ui - Where the lines go. The branded console by default.
|
|
2705
|
+
* @returns The exit code: `1` for a bad flag, a root that cannot be opened, an unknown key, or a
|
|
2706
|
+
* `--check` that found a broken file or a conflict; else `0`.
|
|
2707
|
+
* @example
|
|
2708
|
+
* ```ts
|
|
2709
|
+
* // An agent asks where a node of the fixture game lives.
|
|
2710
|
+
* await runCli(["--root", "tests/integration/merge-game", "where", "node:board/merge"]);
|
|
2711
|
+
* // 0, and the console printed "nodes/merge.ts:17"
|
|
2712
|
+
* ```
|
|
2713
|
+
*/
|
|
2714
|
+
async function runCli(argv, ui = createBrandConsole()) {
|
|
2715
|
+
try {
|
|
2716
|
+
const options = parseArgv(argv);
|
|
2717
|
+
const project = await openProject({
|
|
2718
|
+
root: options.root,
|
|
2719
|
+
...options.manifest === void 0 ? {} : { manifest: options.manifest }
|
|
2720
|
+
});
|
|
2721
|
+
try {
|
|
2722
|
+
return await runCommand(project, options, ui);
|
|
2723
|
+
} finally {
|
|
2724
|
+
project.close();
|
|
2725
|
+
}
|
|
2726
|
+
} catch (error) {
|
|
2727
|
+
ui.error(messageOf(error));
|
|
2728
|
+
return 1;
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
//#endregion
|
|
2732
|
+
//#region src/project.ts
|
|
2733
|
+
/**
|
|
2734
|
+
* @file The project door of the engine (subpath `./project`, node and bun only): the static index
|
|
2735
|
+
* of a game's sources. `openProject` parses the game with TypeScript (an optional peer, loaded on
|
|
2736
|
+
* the first open) and maps every engine id (flows, nodes, features, scenes, projections, emitters,
|
|
2737
|
+
* text styles, styles and JSX keys) to anchors; `find` reads the line of a key from the file on
|
|
2738
|
+
* disk at the call, `watch` keeps the index fresh while agents edit files. The package bin
|
|
2739
|
+
* `moku-game-index` runs `runCli`. Re-exports only: the code lives in `project/`, and this is the
|
|
2740
|
+
* one door to it in `src/`.
|
|
2741
|
+
*/
|
|
2742
|
+
if (import.meta.main) process.exitCode = await runCli(process.argv.slice(2));
|
|
2743
|
+
//#endregion
|
|
2744
|
+
export { openProject, runCli };
|