prosebook 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +604 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +508 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config-D--RITk2.js +1208 -0
  7. package/dist/config-D--RITk2.js.map +1 -0
  8. package/dist/config-_4HfnJQu.d.ts +517 -0
  9. package/dist/config-_4HfnJQu.d.ts.map +1 -0
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +1 -0
  12. package/dist/copy-DXnzXsJa.d.ts +162 -0
  13. package/dist/copy-DXnzXsJa.d.ts.map +1 -0
  14. package/dist/helpers-BPuPGjQP.js +61 -0
  15. package/dist/helpers-BPuPGjQP.js.map +1 -0
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +188 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inspector.d.ts +37 -0
  21. package/dist/inspector.d.ts.map +1 -0
  22. package/dist/inspector.js +572 -0
  23. package/dist/inspector.js.map +1 -0
  24. package/dist/react/index.d.ts +37 -0
  25. package/dist/react/index.d.ts.map +1 -0
  26. package/dist/react/index.js +86 -0
  27. package/dist/react/index.js.map +1 -0
  28. package/dist/sarif-CI8HOokm.js +1033 -0
  29. package/dist/sarif-CI8HOokm.js.map +1 -0
  30. package/dist/scan-Ch2BfIUD.js +2561 -0
  31. package/dist/scan-Ch2BfIUD.js.map +1 -0
  32. package/dist/tools.d.ts +377 -0
  33. package/dist/tools.d.ts.map +1 -0
  34. package/dist/tools.js +4 -0
  35. package/dist/vite.d.ts +24 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +264 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +148 -15
  40. package/src/cli.ts +528 -0
  41. package/src/index.ts +31 -0
  42. package/src/inspector-dialog.ts +450 -0
  43. package/src/inspector.ts +228 -0
  44. package/src/react/index.tsx +112 -0
  45. package/src/runtime/copy.ts +221 -0
  46. package/src/runtime/helpers.ts +66 -0
  47. package/src/runtime/locale.ts +49 -0
  48. package/src/runtime/types.ts +106 -0
  49. package/src/tools/catalog.ts +449 -0
  50. package/src/tools/compile.ts +121 -0
  51. package/src/tools/config.ts +211 -0
  52. package/src/tools/diff.ts +84 -0
  53. package/src/tools/entry.ts +325 -0
  54. package/src/tools/extract.ts +694 -0
  55. package/src/tools/generate.ts +257 -0
  56. package/src/tools/icu.ts +211 -0
  57. package/src/tools/index.ts +91 -0
  58. package/src/tools/lint.ts +865 -0
  59. package/src/tools/lock.ts +95 -0
  60. package/src/tools/mf1.ts +246 -0
  61. package/src/tools/mf2.ts +752 -0
  62. package/src/tools/migrate.ts +138 -0
  63. package/src/tools/presets.ts +159 -0
  64. package/src/tools/pseudo.ts +40 -0
  65. package/src/tools/sarif.ts +80 -0
  66. package/src/tools/scan.ts +303 -0
  67. package/src/tools/syntax.ts +24 -0
  68. package/src/tools/types.ts +137 -0
  69. package/src/vite.ts +296 -0
  70. package/index.js +0 -1
@@ -0,0 +1,303 @@
1
+ /**
2
+ * What the code says: text a person reads that is still written in a component, and which catalog
3
+ * keys the code names.
4
+ *
5
+ * Key references are found by reading string literals, not by tracing calls, so a key kept in an
6
+ * array or a map still counts, and `nav.${id}` or `'nav.' + id` keeps every key under `nav.`. That
7
+ * errs towards keeping a key, which is the safe direction both for `unused` and for pruning a bundle.
8
+ */
9
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
10
+ import { extname, join, relative } from 'node:path';
11
+
12
+ import { parseSync } from 'oxc-parser';
13
+
14
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', '.git']);
15
+
16
+ export function sourceFiles(dirs: string[], extensions: string[], skip: string[] = []): string[] {
17
+ const files: string[] = [];
18
+ const visit = (dir: string) => {
19
+ let names: string[];
20
+ try {
21
+ names = readdirSync(dir);
22
+ } catch {
23
+ return;
24
+ }
25
+ for (const name of names) {
26
+ const path = join(dir, name);
27
+ if (skip.some((s) => path === s || path.startsWith(`${s}/`) || path.startsWith(`${s}\\`))) continue;
28
+ if (statSync(path).isDirectory()) {
29
+ if (!SKIP_DIRS.has(name) && !name.startsWith('.')) visit(path);
30
+ } else if (extensions.includes(extname(name)) && !name.endsWith('.d.ts')) {
31
+ files.push(path);
32
+ }
33
+ }
34
+ };
35
+ for (const dir of dirs) visit(dir);
36
+ return files.sort();
37
+ }
38
+
39
+ export interface KeyRefs {
40
+ exact: Set<string>;
41
+ /** `nav.` from `nav.${id}` or `'nav.' + id`: every key under it may be read. */
42
+ prefixes: Set<string>;
43
+ }
44
+
45
+ const LITERAL_KEY = /(['"`])([a-z0-9][\w-]*(?:\.[\w-]+)+)\1/g;
46
+ const KEY_PREFIX = /(['"`])([a-z0-9][\w-]*(?:\.[\w-]+)*\.)(?:\1|\$\{)/g;
47
+
48
+ export function keyRefs(code: string, into: KeyRefs = { exact: new Set(), prefixes: new Set() }): KeyRefs {
49
+ for (const m of code.matchAll(LITERAL_KEY)) into.exact.add(m[2]!);
50
+ for (const m of code.matchAll(KEY_PREFIX)) into.prefixes.add(m[2]!);
51
+ return into;
52
+ }
53
+
54
+ export function isReferenced(key: string, refs: KeyRefs): boolean {
55
+ if (refs.exact.has(key)) return true;
56
+ for (const prefix of refs.prefixes) if (key.startsWith(prefix)) return true;
57
+ return false;
58
+ }
59
+
60
+ export interface Hardcoded {
61
+ file: string;
62
+ line: number;
63
+ column: number;
64
+ text: string;
65
+ /** Where it sits: `text`, or the attribute it is the value of. */
66
+ where: string;
67
+ }
68
+
69
+ type Node = { type: string; start: number; end: number; [field: string]: unknown };
70
+
71
+ /**
72
+ * Text that reads as code rather than prose: one token holding a path, a call, an identifier with an
73
+ * underscore or a dotted name, as in `form.json()` or `src/index.ts`. Trailing punctuation doesn't
74
+ * count, so `Done.` is still prose.
75
+ */
76
+ export function looksLikeCode(text: string): boolean {
77
+ const token = text.trim().replace(/[.!?:,…]+$/, '');
78
+ return token !== '' && !/\s/.test(token) && /[/()_=<>{}[\].]|::/.test(token);
79
+ }
80
+
81
+ const NON_PROSE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp', 'script', 'style', 'svg', 'path']);
82
+ const LETTER = /\p{L}/u;
83
+
84
+ /** Offsets in `code` as lines and columns, and whether a line is marked `prosebook-ignore`, on it or the line above. */
85
+ function lineIndex(code: string) {
86
+ const lines = code.split('\n');
87
+ const lineStarts: number[] = [];
88
+ let offset = 0;
89
+ for (const line of lines) {
90
+ lineStarts.push(offset);
91
+ offset += line.length + 1;
92
+ }
93
+ const position = (at: number) => {
94
+ let lo = 0;
95
+ let hi = lineStarts.length - 1;
96
+ while (lo < hi) {
97
+ const mid = (lo + hi + 1) >> 1;
98
+ if (lineStarts[mid]! <= at) lo = mid;
99
+ else hi = mid - 1;
100
+ }
101
+ return { line: lo + 1, column: at - lineStarts[lo]! + 1 };
102
+ };
103
+ const ignored = (line: number) =>
104
+ (lines[line - 1] ?? '').includes('prosebook-ignore') || (lines[line - 2] ?? '').includes('prosebook-ignore');
105
+ return { position, ignored };
106
+ }
107
+
108
+ /** Literal text in JSX that a person reads, where it should be a catalog entry. */
109
+ export function hardcodedText(file: string, code: string, attributes: string[]): Hardcoded[] {
110
+ const { program } = parseSync(file, code);
111
+ const found: Hardcoded[] = [];
112
+ const { position, ignored } = lineIndex(code);
113
+ const report = (node: Node, text: string, where: string) => {
114
+ const trimmed = text.replace(/\s+/g, ' ').trim();
115
+ if (!LETTER.test(trimmed) || looksLikeCode(trimmed)) return;
116
+ const at = position(node.start + Math.max(0, code.slice(node.start, node.end).search(/\S/)));
117
+ if (ignored(at.line)) return;
118
+ found.push({ file, ...at, text: trimmed, where });
119
+ };
120
+
121
+ const visit = (node: Node, inProse: boolean) => {
122
+ let prose = inProse;
123
+ if (node.type === 'JSXElement') {
124
+ const opening = node.openingElement as Node & { name: Node & { name?: string } };
125
+ if (opening.name.type === 'JSXIdentifier' && NON_PROSE_ELEMENTS.has(opening.name.name ?? '')) prose = false;
126
+ }
127
+ if (node.type === 'JSXText' && prose) report(node, node.value as string, 'text');
128
+ if (node.type === 'JSXExpressionContainer' && prose) {
129
+ const expr = node.expression as Node;
130
+ if (expr.type === 'Literal' && typeof expr.value === 'string') report(expr, expr.value, 'text');
131
+ if (expr.type === 'TemplateLiteral' && (expr.expressions as Node[]).length === 0)
132
+ report(expr, String((expr.quasis as { value: { cooked: string } }[])[0]?.value.cooked), 'text');
133
+ }
134
+ if (node.type === 'JSXAttribute') {
135
+ const name = node.name as Node & { name?: string };
136
+ const value = node.value as Node | null;
137
+ const attr = name.type === 'JSXIdentifier' ? (name.name ?? '') : '';
138
+ if (attributes.includes(attr) && value !== null) {
139
+ if (value.type === 'Literal' && typeof value.value === 'string') report(value, value.value, attr);
140
+ if (value.type === 'JSXExpressionContainer') {
141
+ const expr = value.expression as Node;
142
+ if (expr.type === 'Literal' && typeof expr.value === 'string') report(expr, expr.value, attr);
143
+ }
144
+ }
145
+ return;
146
+ }
147
+ for (const value of Object.values(node)) {
148
+ if (Array.isArray(value)) {
149
+ for (const child of value) if (isNode(child)) visit(child, prose);
150
+ } else if (isNode(value)) {
151
+ visit(value, prose);
152
+ }
153
+ }
154
+ };
155
+ visit(program as unknown as Node, true);
156
+ return found;
157
+ }
158
+
159
+ function isNode(value: unknown): value is Node {
160
+ return typeof value === 'object' && value !== null && typeof (value as Node).type === 'string';
161
+ }
162
+
163
+ export interface ScanResult {
164
+ hardcoded: Hardcoded[];
165
+ refs: KeyRefs;
166
+ files: number;
167
+ }
168
+
169
+ export function scan(dirs: string[], extensions: string[], attributes: string[], root: string, skip: string[] = []): ScanResult {
170
+ const refs: KeyRefs = { exact: new Set(), prefixes: new Set() };
171
+ const hardcoded: Hardcoded[] = [];
172
+ const files = sourceFiles(dirs, extensions, skip);
173
+ for (const path of files) {
174
+ const code = readFileSync(path, 'utf8');
175
+ keyRefs(code, refs);
176
+ if (/\.[jt]sx$/.test(path)) hardcoded.push(...hardcodedText(relative(root, path).replaceAll('\\', '/'), code, attributes));
177
+ }
178
+ return { hardcoded, refs, files: files.length };
179
+ }
180
+
181
+ /** A copy's methods that take a key first. */
182
+ export const READERS = new Set(['t', 'rich']);
183
+
184
+ export interface UnseenRead {
185
+ file: string;
186
+ line: number;
187
+ column: number;
188
+ /** The read as written, on one line. */
189
+ code: string;
190
+ /** What it calls: a reader, the name it was imported by, or `Text`. */
191
+ name: string;
192
+ }
193
+
194
+ export interface Reads {
195
+ /** Reads whose key a build can't see, in calls that may be to a copy's `t` or `rich`. */
196
+ unseen: UnseenRead[];
197
+ /** Names a copy's readers are bound to here, as in `const { rich: tRich } = copy`. */
198
+ aliases: Set<string>;
199
+ }
200
+
201
+ const PREFIX = /^[a-z0-9][\w-]*(?:\.[\w-]+)*\.$/;
202
+ const WRAPPERS = new Set(['ParenthesizedExpression', 'TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion']);
203
+
204
+ /** Whether `keyRefs` finds every key an expression can be: a literal, or a template or sum after a key prefix. */
205
+ function keySeen(node: Node | undefined): boolean {
206
+ if (node === undefined) return false;
207
+ if (WRAPPERS.has(node.type)) return keySeen(node.expression as Node);
208
+ switch (node.type) {
209
+ case 'Literal':
210
+ return typeof node.value === 'string';
211
+ case 'TemplateLiteral': {
212
+ const [head] = node.quasis as { value: { cooked: string | null } }[];
213
+ return (node.expressions as Node[]).length === 0 || PREFIX.test(head?.value.cooked ?? '');
214
+ }
215
+ case 'BinaryExpression': {
216
+ const left = node.left as Node;
217
+ return node.operator === '+' && left.type === 'Literal' && typeof left.value === 'string' && PREFIX.test(left.value);
218
+ }
219
+ case 'ConditionalExpression':
220
+ return keySeen(node.consequent as Node) && keySeen(node.alternate as Node);
221
+ case 'LogicalExpression':
222
+ return keySeen(node.left as Node) && keySeen(node.right as Node);
223
+ default:
224
+ return false;
225
+ }
226
+ }
227
+
228
+ const nameOf = (node: Node | undefined): string | undefined =>
229
+ node?.type === 'Identifier' || node?.type === 'JSXIdentifier'
230
+ ? (node.name as string)
231
+ : node?.type === 'Literal'
232
+ ? String(node.value)
233
+ : undefined;
234
+
235
+ /**
236
+ * The reads in a module whose key a pruned build can't see, as in `t(item.key)` or `<Text id={id} />`:
237
+ * the build would leave the message out, and the key would show in its place. A build has no types,
238
+ * so readers are known by name: `t` and `rich` called alone or as methods, and any name imported here,
239
+ * which may be bound to one elsewhere (`aliases`); the caller keeps those whose name turns out to be a
240
+ * reader. `<Text>` counts where it is imported from `prosebook/react`. Lines marked `prosebook-ignore`
241
+ * are skipped.
242
+ */
243
+ export function unseenReads(file: string, code: string): Reads {
244
+ const { program } = parseSync(file, code);
245
+ const { position, ignored } = lineIndex(code);
246
+ const unseen: UnseenRead[] = [];
247
+ const aliases = new Set<string>();
248
+ /** Local name to the name it was imported by. */
249
+ const imported = new Map<string, string>();
250
+ const texts = new Set<string>();
251
+ for (const statement of (program as unknown as Node).body as Node[]) {
252
+ if (statement.type !== 'ImportDeclaration') continue;
253
+ for (const s of statement.specifiers as Node[]) {
254
+ const name = s.type === 'ImportSpecifier' ? nameOf(s.imported as Node) : undefined;
255
+ const local = nameOf(s.local as Node);
256
+ if (name === undefined || local === undefined) continue;
257
+ if (name === 'Text' && (statement.source as Node).value === 'prosebook/react') texts.add(local);
258
+ else imported.set(local, name);
259
+ }
260
+ }
261
+ const report = (node: Node, name: string) => {
262
+ const at = position(node.start);
263
+ if (ignored(at.line)) return;
264
+ const text = code.slice(node.start, node.end).replace(/\s+/g, ' ');
265
+ unseen.push({ file, ...at, name, code: text.length > 80 ? `${text.slice(0, 77)}...` : text });
266
+ };
267
+ // Calls by a plain name, kept until every alias in the module is known.
268
+ const calls: [Node, string][] = [];
269
+ const visit = (node: Node) => {
270
+ if (node.type === 'ObjectPattern') {
271
+ for (const p of node.properties as Node[]) {
272
+ if (p.type === 'Property' && READERS.has(nameOf(p.key as Node) ?? '') && (p.value as Node).type === 'Identifier')
273
+ aliases.add(nameOf(p.value as Node)!);
274
+ }
275
+ }
276
+ if (node.type === 'CallExpression' && !keySeen((node.arguments as Node[])[0])) {
277
+ const callee = node.callee as Node;
278
+ if (callee.type === 'Identifier') calls.push([node, callee.name as string]);
279
+ else if (callee.type === 'MemberExpression' && !callee.computed && READERS.has(nameOf(callee.property as Node) ?? ''))
280
+ report(node, nameOf(callee.property as Node)!);
281
+ }
282
+ if (node.type === 'JSXOpeningElement' && texts.has(nameOf(node.name as Node) ?? '')) {
283
+ const id = (node.attributes as Node[]).find((a) => a.type === 'JSXAttribute' && nameOf(a.name as Node) === 'id');
284
+ const value = id?.value as Node | null | undefined;
285
+ if (value !== null && value !== undefined && !keySeen(value.type === 'JSXExpressionContainer' ? (value.expression as Node) : value))
286
+ report(node, 'Text');
287
+ }
288
+ for (const value of Object.values(node)) {
289
+ if (Array.isArray(value)) {
290
+ for (const child of value) if (isNode(child)) visit(child);
291
+ } else if (isNode(value)) {
292
+ visit(value);
293
+ }
294
+ }
295
+ };
296
+ visit(program as unknown as Node);
297
+ for (const [node, local] of calls) {
298
+ if (READERS.has(local) || aliases.has(local)) report(node, local);
299
+ else if (imported.has(local)) report(node, imported.get(local)!);
300
+ }
301
+ unseen.sort((a, b) => (a.line === b.line ? a.column - b.column : a.line - b.line));
302
+ return { unseen, aliases };
303
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The two syntaxes a catalog can be written in, behind one pair of calls: read a message into the
3
+ * element tree, and write a tree back out. Everything past reading works on the tree alone.
4
+ */
5
+ import { parseMessage, type MessageFormatElement } from './icu.ts';
6
+ import { toMf1 } from './mf1.ts';
7
+ import { parseMf2, toMf2 } from './mf2.ts';
8
+
9
+ export type MessageFormatVersion = 1 | 2;
10
+
11
+ export const SYNTAX_NAME: Record<MessageFormatVersion, string> = { 1: 'ICU MessageFormat', 2: 'MessageFormat 2' };
12
+
13
+ /**
14
+ * A message as the element tree. MF2 is read per locale: which variants a value can reach depends
15
+ * on the locale's plural rules (see `matchTree` in mf2.ts).
16
+ */
17
+ export function readMessage(version: MessageFormatVersion, text: string, locale: string): MessageFormatElement[] {
18
+ return version === 2 ? parseMf2(text, locale) : parseMessage(text);
19
+ }
20
+
21
+ /** A tree as text in the given syntax. Throws, saying what the syntax can't express, where it can't. */
22
+ export function writeMessage(version: MessageFormatVersion, ast: MessageFormatElement[], locale: string): string {
23
+ return version === 2 ? toMf2(ast, locale) : toMf1(ast);
24
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * What kind of value an expression in JSX holds, so `extract` can write `{n, number}` for a number and
3
+ * `{d, date, medium}` for a date instead of a bare `{x}`, which is text and prints a number unformatted.
4
+ *
5
+ * The project's own TypeScript (7 or later, through its `unstable/async` API) answers where it can; the
6
+ * syntax of the expression answers where it can't: a JavaScript project, a file outside any tsconfig,
7
+ * or no TypeScript installed. Not the `unstable/sync` API: on Linux and macOS it reads the compiler's
8
+ * pipes through a Node internal (`stdout._handle.fd`) that Bun doesn't have, so under Bun it throws.
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { dirname, join, resolve } from 'node:path';
12
+
13
+ import type * as TsAsync from 'typescript/unstable/async';
14
+ import type { Checker, Type } from 'typescript/unstable/async';
15
+
16
+ // oxlint-disable-next-line typescript/no-explicit-any -- a loose ESTree node: the walk reads whichever fields each node type has
17
+ type Node = { type: string; start: number; end: number; [field: string]: any };
18
+
19
+ type TsNode = { pos: number; end: number; forEachChild(visit: (child: TsNode) => unknown): unknown };
20
+
21
+ /** `node` is markup or an object, which a message can't hold as text. */
22
+ export type ValueKind = 'string' | 'number' | 'date' | 'node' | 'unknown';
23
+
24
+ export interface Span {
25
+ start: number;
26
+ end: number;
27
+ }
28
+
29
+ export interface TypeOracle {
30
+ /** Where the answers come from, for the report. */
31
+ readonly source: string;
32
+ /** The kind of each span's expression in `file`, or nothing where the oracle can't say. */
33
+ kinds(file: string, spans: readonly Span[]): Promise<(ValueKind | undefined)[]>;
34
+ close(): Promise<void>;
35
+ }
36
+
37
+ /** The nearest `tsconfig.json` at or above the file's folder. */
38
+ function tsconfigFor(file: string): string | undefined {
39
+ for (let dir = dirname(resolve(file)); ; dir = dirname(dir)) {
40
+ const candidate = join(dir, 'tsconfig.json');
41
+ if (existsSync(candidate)) return candidate;
42
+ if (dirname(dir) === dir) return undefined;
43
+ }
44
+ }
45
+
46
+ // TypeFlags, as the TypeScript 7 API numbers them.
47
+ const ANY_OR_UNKNOWN = 3;
48
+ const NUMBER_LIKE = 67648 | 4224;
49
+ const STRING_LIKE = 12583968;
50
+ const UNION = 134217728;
51
+ const OBJECT = 1048576;
52
+
53
+ /**
54
+ * An oracle over the projects the files belong to, or nothing where TypeScript 7 isn't installed or
55
+ * none of the files has a tsconfig. One compiler process serves every project and is closed by `close`.
56
+ */
57
+ export async function typescriptOracle(root: string, files: readonly string[]): Promise<TypeOracle | undefined> {
58
+ const configs = [...new Set(files.map(tsconfigFor).filter((c) => c !== undefined))];
59
+ if (configs.length === 0) return undefined;
60
+ let ts: typeof TsAsync;
61
+ try {
62
+ ts = await import('typescript/unstable/async');
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ let api: InstanceType<typeof ts.API>;
67
+ let snapshot: Awaited<ReturnType<InstanceType<typeof ts.API>['updateSnapshot']>>;
68
+ try {
69
+ api = new ts.API({ cwd: root });
70
+ snapshot = await api.updateSnapshot({ openProjects: configs });
71
+ } catch {
72
+ return undefined;
73
+ }
74
+
75
+ const classify = async (checker: Checker, type: Type | undefined): Promise<ValueKind | undefined> => {
76
+ if (type === undefined) return undefined;
77
+ const t = (await checker.getNonNullableType(type)) ?? type;
78
+ if ((t.flags & ANY_OR_UNKNOWN) !== 0) return 'unknown';
79
+ if ((t.flags & UNION) !== 0) {
80
+ const members = (await (t as Type & { getTypes(): Promise<readonly Type[] | undefined> }).getTypes()) ?? [];
81
+ const kinds = new Set(await Promise.all(members.map((m) => classify(checker, m))));
82
+ if (kinds.size === 1) return [...kinds][0];
83
+ return kinds.has('node') ? 'node' : 'unknown';
84
+ }
85
+ if ((t.flags & NUMBER_LIKE) !== 0) return 'number';
86
+ if ((t.flags & STRING_LIKE) !== 0) return 'string';
87
+ if ((await checker.typeToString(t)) === 'Date') return 'date';
88
+ if ((t.flags & OBJECT) !== 0) return 'node';
89
+ return 'unknown';
90
+ };
91
+
92
+ return {
93
+ source: 'TypeScript',
94
+ async kinds(file, spans) {
95
+ const project = await snapshot.getDefaultProjectForFile(resolve(file));
96
+ const sourceFile = await project?.program.getSourceFile(resolve(file));
97
+ if (project === undefined || sourceFile === undefined) return spans.map(() => undefined);
98
+ // The smallest node covering each span is the expression itself.
99
+ const nodes = spans.map(({ start, end }) => {
100
+ let node: TsNode = sourceFile;
101
+ for (;;) {
102
+ let next: TsNode | undefined;
103
+ node.forEachChild((child) => {
104
+ if (next === undefined && child.pos <= start && child.end >= end) next = child;
105
+ });
106
+ if (next === undefined) return node;
107
+ node = next;
108
+ }
109
+ });
110
+ const types = await project.checker.getTypeAtLocation(nodes as never[]);
111
+ return Promise.all(types.map((type) => classify(project.checker, type)));
112
+ },
113
+ close() {
114
+ return api.close();
115
+ },
116
+ };
117
+ }
118
+
119
+ /** The kind an expression's syntax gives away, where it does: `items.length`, `a * b`, `new Date()`. */
120
+ export function syntaxKind(expr: Node): ValueKind | undefined {
121
+ switch (expr.type) {
122
+ case 'Literal':
123
+ return typeof expr.value === 'number' ? 'number' : typeof expr.value === 'string' ? 'string' : undefined;
124
+ case 'TemplateLiteral':
125
+ return 'string';
126
+ case 'MemberExpression':
127
+ return !expr.computed && ['length', 'size', 'count'].includes(expr.property?.name as string) ? 'number' : undefined;
128
+ case 'BinaryExpression':
129
+ return ['-', '*', '/', '%', '**'].includes(expr.operator as string) ? 'number' : undefined;
130
+ case 'NewExpression':
131
+ return expr.callee?.type === 'Identifier' && expr.callee.name === 'Date' ? 'date' : undefined;
132
+ case 'CallExpression':
133
+ return expr.callee?.type === 'MemberExpression' && expr.callee.object?.name === 'Math' ? 'number' : undefined;
134
+ default:
135
+ return undefined;
136
+ }
137
+ }