@avijs/compiler 1.0.0-rc.3 → 1.0.0-rc.5

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/src/inject.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import ts from 'typescript';
3
- import { readProgram, type Diagnostic } from './check.js';
3
+ import { readProgram, unwrap, type Diagnostic } from './check.js';
4
4
 
5
5
  export interface GeneratedInjects {
6
6
  /** The generated file's contents. */
@@ -61,14 +61,19 @@ class InjectGenerator {
61
61
  }
62
62
 
63
63
  run(): GeneratedInjects {
64
+ const devOnly = devOnlyPackages(this.program);
64
65
  for (const source of this.program.getSourceFiles()) {
65
- if (!this.#inProject(source)) continue;
66
+ if (!this.#inProject(source) || path.resolve(source.fileName) === this.out) continue;
66
67
  // Test files are not loaded by the app, and the generated file must not import them.
67
68
  if (/\.(test|spec)\.[cm]?tsx?$/.test(source.fileName)) continue;
68
- if (path.resolve(source.fileName) === this.out) continue;
69
- for (const statement of source.statements) {
70
- if (ts.isClassDeclaration(statement)) this.#class(statement, source);
71
- }
69
+ const classes = source.statements.filter(ts.isClassDeclaration);
70
+ if (classes.length === 0) continue;
71
+ // Importing a file runs its top level. Only import files that just declare things: never an
72
+ // entry point, a worker or a seed script, and nothing a production install may lack.
73
+ const reason = runsCode(source)
74
+ ? 'runs code when imported (an entry point or a script)'
75
+ : importsDevOnly(source, devOnly);
76
+ for (const node of classes) this.#class(node, source, reason);
72
77
  }
73
78
 
74
79
  this.#entries.sort((a, b) => a.order.localeCompare(b.order));
@@ -92,10 +97,11 @@ class InjectGenerator {
92
97
  ([specifier, names]) => `import { ${names.join(', ')} } from '${specifier}';`,
93
98
  ),
94
99
  '',
100
+ // A function, read when the app starts: classes in an import cycle with this file exist by then.
95
101
  this.#entries.length === 0
96
- ? 'registerInjects([]);'
102
+ ? 'registerInjects(() => []);'
97
103
  : [
98
- 'registerInjects([',
104
+ 'registerInjects(() => [',
99
105
  ...this.#entries.map((e) => ` [${e.cls}, [${e.deps.join(', ')}]],`),
100
106
  ']);',
101
107
  ].join('\n'),
@@ -111,10 +117,8 @@ class InjectGenerator {
111
117
  };
112
118
  }
113
119
 
114
- #class(node: ts.ClassDeclaration, source: ts.SourceFile): void {
120
+ #class(node: ts.ClassDeclaration, source: ts.SourceFile, unsafe: string | undefined): void {
115
121
  if (!node.name) return;
116
- const modifiers = ts.getCombinedModifierFlags(node);
117
- if (modifiers & ts.ModifierFlags.Abstract) return;
118
122
  const hasList = node.members.some(
119
123
  (member) =>
120
124
  ts.isPropertyDeclaration(member) &&
@@ -126,14 +130,23 @@ class InjectGenerator {
126
130
  (member): member is ts.ConstructorDeclaration =>
127
131
  ts.isConstructorDeclaration(member) && member.body !== undefined,
128
132
  );
129
- if (!ctor || ctor.parameters.length === 0) return;
133
+ if (!ctor) return;
134
+ // Like constructor.length: optional, defaulted and rest parameters are left out.
135
+ const optional = ctor.parameters.findIndex(
136
+ (p) =>
137
+ p.questionToken !== undefined ||
138
+ p.initializer !== undefined ||
139
+ p.dotDotDotToken !== undefined,
140
+ );
141
+ const required = optional === -1 ? [...ctor.parameters] : ctor.parameters.slice(0, optional);
142
+ if (required.length === 0) return;
130
143
 
131
- const tokens = ctor.parameters.map((param) => this.#token(param, source));
144
+ const tokens = required.map((param) => this.#token(param, source));
132
145
  const resolved = tokens.filter((token): token is string => token !== undefined);
133
146
  if (resolved.length < tokens.length) {
134
147
  // A class none of whose parameters are injectable is not a DI class: say nothing.
135
148
  if (resolved.length === 0 && !isDecorated(node)) return;
136
- const param = ctor.parameters[tokens.findIndex((token) => token === undefined)];
149
+ const param = required[tokens.findIndex((token) => token === undefined)];
137
150
  const which = param ? `\`${param.name.getText(source)}\`` : 'a parameter';
138
151
  this.#report(
139
152
  source,
@@ -143,6 +156,15 @@ class InjectGenerator {
143
156
  return;
144
157
  }
145
158
 
159
+ if (unsafe) {
160
+ this.#report(
161
+ source,
162
+ node.name,
163
+ `${node.name.text} is in a file that ${unsafe}, so inject.gen.ts cannot import it: move the class to its own file, or add static inject`,
164
+ 'AVI203',
165
+ );
166
+ return;
167
+ }
146
168
  const cls = this.#importClass(node, source);
147
169
  if (!cls) return;
148
170
  this.#entries.push({
@@ -182,17 +204,22 @@ class InjectGenerator {
182
204
  return this.#importValue(symbol, declaration, param.type.typeName, source);
183
205
  }
184
206
 
185
- /** A class, a constructor, or an injection token (which has a `description`). */
207
+ /** A class, a constructor, or a token made by `createToken` or `defineConfig`. */
186
208
  #canBeToken(symbol: ts.Symbol): boolean {
187
209
  if (symbol.flags & ts.SymbolFlags.Class) return true;
188
210
  const declaration = symbol.valueDeclaration;
189
211
  if (!declaration) return false;
190
212
  const type = this.#checker.getTypeOfSymbolAtLocation(symbol, declaration);
191
213
  if (type.getConstructSignatures().length > 0) return true;
192
- const description = type.getProperty('description');
193
- if (!description) return false;
194
- const text = this.#checker.getTypeOfSymbolAtLocation(description, declaration);
195
- return (text.flags & ts.TypeFlags.StringLike) !== 0;
214
+ if (!ts.isVariableDeclaration(declaration) || !declaration.initializer) return false;
215
+ const call = unwrap(declaration.initializer);
216
+ if (!call || !ts.isCallExpression(call)) return false;
217
+ const callee = ts.isPropertyAccessExpression(call.expression)
218
+ ? call.expression.name.text
219
+ : ts.isIdentifier(call.expression)
220
+ ? call.expression.text
221
+ : undefined;
222
+ return callee === 'createToken' || callee === 'defineConfig';
196
223
  }
197
224
 
198
225
  #importClass(node: ts.ClassDeclaration, source: ts.SourceFile): string | undefined {
@@ -272,14 +299,14 @@ class InjectGenerator {
272
299
  return !source.isDeclarationFile && this.roots.has(path.resolve(source.fileName));
273
300
  }
274
301
 
275
- #report(source: ts.SourceFile, at: ts.Node, message: string): void {
302
+ #report(source: ts.SourceFile, at: ts.Node, message: string, code = 'AVI201'): void {
276
303
  const { line, character } = source.getLineAndCharacterOfPosition(at.getStart(source));
277
304
  this.#diagnostics.push({
278
305
  file: source.fileName,
279
306
  line: line + 1,
280
307
  column: character + 1,
281
308
  severity: 'warning',
282
- code: 'AVI201',
309
+ code,
283
310
  message,
284
311
  });
285
312
  }
@@ -297,3 +324,95 @@ function configTarget(type: ts.TypeNode): ts.EntityName | undefined {
297
324
  function isDecorated(node: ts.ClassDeclaration): boolean {
298
325
  return (ts.getDecorators(node) ?? []).length > 0;
299
326
  }
327
+
328
+ /** Whether a file does more than declare things when imported: a call, a loop, a top-level await. */
329
+ function runsCode(source: ts.SourceFile): boolean {
330
+ const awaits = (node: ts.Node): boolean =>
331
+ ts.isAwaitExpression(node) ||
332
+ (!ts.isFunctionLike(node) && !ts.isClassLike(node) && ts.forEachChild(node, awaits) === true);
333
+ return source.statements.some((statement) => {
334
+ if (
335
+ ts.isImportDeclaration(statement) ||
336
+ ts.isImportEqualsDeclaration(statement) ||
337
+ ts.isExportDeclaration(statement) ||
338
+ ts.isClassDeclaration(statement) ||
339
+ ts.isFunctionDeclaration(statement) ||
340
+ ts.isInterfaceDeclaration(statement) ||
341
+ ts.isTypeAliasDeclaration(statement) ||
342
+ ts.isEnumDeclaration(statement) ||
343
+ ts.isModuleDeclaration(statement) ||
344
+ ts.isEmptyStatement(statement)
345
+ ) {
346
+ return false;
347
+ }
348
+ if (ts.isVariableStatement(statement) || ts.isExportAssignment(statement)) {
349
+ return awaits(statement);
350
+ }
351
+ // 'use strict' and other directives.
352
+ if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression))
353
+ return false;
354
+ return true;
355
+ });
356
+ }
357
+
358
+ /** Packages the project only has as devDependencies, from the package.json next to its tsconfig. */
359
+ function devOnlyPackages(program: ts.Program): Set<string> {
360
+ const config = program.getCompilerOptions()['configFilePath'];
361
+ let dir = typeof config === 'string' ? path.dirname(config) : program.getCurrentDirectory();
362
+ for (;;) {
363
+ const file = path.join(dir, 'package.json');
364
+ if (ts.sys.fileExists(file)) {
365
+ try {
366
+ const pkg = JSON.parse(ts.sys.readFile(file) ?? '{}') as Record<
367
+ string,
368
+ Record<string, string> | undefined
369
+ >;
370
+ const runtime = new Set([
371
+ ...Object.keys(pkg['dependencies'] ?? {}),
372
+ ...Object.keys(pkg['peerDependencies'] ?? {}),
373
+ ...Object.keys(pkg['optionalDependencies'] ?? {}),
374
+ ]);
375
+ return new Set(
376
+ Object.keys(pkg['devDependencies'] ?? {}).filter((name) => !runtime.has(name)),
377
+ );
378
+ } catch {
379
+ return new Set();
380
+ }
381
+ }
382
+ const parent = path.dirname(dir);
383
+ if (parent === dir) return new Set();
384
+ dir = parent;
385
+ }
386
+ }
387
+
388
+ /** Why a file cannot be imported in production: it imports a package from devDependencies. */
389
+ function importsDevOnly(source: ts.SourceFile, devOnly: ReadonlySet<string>): string | undefined {
390
+ for (const statement of source.statements) {
391
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
392
+ continue;
393
+ const clause = statement.importClause;
394
+ // Type-only imports are erased.
395
+ if (clause && (clause.getText(source).startsWith('type ') || isTypeOnlyClause(clause)))
396
+ continue;
397
+ const specifier = statement.moduleSpecifier.text;
398
+ const name = specifier.startsWith('@')
399
+ ? specifier.split('/').slice(0, 2).join('/')
400
+ : specifier.split('/')[0];
401
+ if (name && devOnly.has(name)) return `imports ${name}, a devDependency`;
402
+ }
403
+ return undefined;
404
+ }
405
+
406
+ function isTypeOnlyClause(clause: ts.ImportClause): boolean {
407
+ const named = clause.namedBindings;
408
+ return (
409
+ clause.name === undefined &&
410
+ named !== undefined &&
411
+ ts.isNamedImports(named) &&
412
+ named.elements.length > 0 &&
413
+ named.elements.every((element) => element.isTypeOnly)
414
+ );
415
+ }
416
+
417
+ export const plural = (count: number, noun: string): string =>
418
+ `${String(count)} ${noun}${count === 1 ? '' : 's'}`;
package/src/outputs.ts ADDED
@@ -0,0 +1,143 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ rmdirSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import path from 'node:path';
12
+ import ts from 'typescript';
13
+
14
+ /** Lists the files `avi build` and `avi dev` compiled into an output folder. */
15
+ const MARKER = '.avi-build';
16
+ const OUTPUT = /\.(?:[cm]?js|d\.[cm]?ts|map)$/;
17
+
18
+ interface OutputFolder {
19
+ /** The real path. */
20
+ readonly out: string;
21
+ /** The configured path, as the compiler writes to it. */
22
+ readonly configured: string;
23
+ readonly key: (relative: string) => string;
24
+ }
25
+
26
+ /**
27
+ * The output folder, when it is safe to clean: a real folder inside the project, reached without
28
+ * links, not hidden or `node_modules`.
29
+ */
30
+ function outputFolder(outDir: string | undefined, projectDir: string): OutputFolder | undefined {
31
+ if (!outDir || !existsSync(outDir)) return undefined;
32
+ let out: string;
33
+ let root: string;
34
+ try {
35
+ out = realpathSync.native(outDir);
36
+ root = realpathSync.native(projectDir);
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ const ignoreCase = !ts.sys.useCaseSensitiveFileNames;
41
+ const key = (relative: string) => (ignoreCase ? relative.toLowerCase() : relative);
42
+ const inside = path.relative(root, out);
43
+ // A link between the project and the output folder changes the path inside the project. Links
44
+ // above the project (macOS /var, Windows short names) do not matter.
45
+ const configured = path.relative(path.resolve(projectDir), path.resolve(outDir));
46
+ if (key(inside) !== key(configured)) return undefined;
47
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) return undefined;
48
+ if (inside.split(path.sep).some((part) => part.startsWith('.') || part === 'node_modules')) {
49
+ return undefined;
50
+ }
51
+ return { out, configured: path.resolve(outDir), key };
52
+ }
53
+
54
+ /** Paths by their place in the output folder: the real and configured paths may differ above it. */
55
+ function relativeTo(folder: OutputFolder, written: Iterable<string>): string[] {
56
+ return [...written]
57
+ .map((file) => path.relative(folder.configured, file))
58
+ .filter((relative) => relative && !relative.startsWith('..') && !path.isAbsolute(relative))
59
+ .map((relative) => relative.split(path.sep).join('/'));
60
+ }
61
+
62
+ /**
63
+ * Deletes the files an earlier build wrote to `outDir` and this one did not, such as a deleted
64
+ * module's tests. Nothing else in the folder is touched: hand-written files, a bundler's assets.
65
+ */
66
+ export function removeStale(
67
+ outDir: string | undefined,
68
+ projectDir: string,
69
+ written: Iterable<string>,
70
+ ): void {
71
+ const folder = outputFolder(outDir, projectDir);
72
+ if (!folder) return;
73
+ const { out, key } = folder;
74
+ const current = relativeTo(folder, written).sort();
75
+ const keep = new Set(current.map(key));
76
+ for (const relative of readMarker(out)) {
77
+ if (keep.has(key(relative)) || !OUTPUT.test(relative)) continue;
78
+ const file = path.resolve(out, relative);
79
+ const within = path.relative(out, file);
80
+ if (!within || within.startsWith('..') || path.isAbsolute(within)) continue;
81
+ let stat;
82
+ try {
83
+ stat = lstatSync(file);
84
+ } catch {
85
+ continue;
86
+ }
87
+ // Only a plain file: never a link, and never through one.
88
+ const dir = path.dirname(file);
89
+ if (!stat.isFile() || key(realpathSync.native(dir)) !== key(dir)) continue;
90
+ rmSync(file);
91
+ removeEmptyDirs(path.dirname(file), out);
92
+ }
93
+ writeMarker(out, current);
94
+ }
95
+
96
+ /**
97
+ * Adds files a watch build wrote to the list, without removing anything: it only emits what
98
+ * changed. The next full build removes the ones whose source is gone.
99
+ */
100
+ export function recordOutputs(
101
+ outDir: string | undefined,
102
+ projectDir: string,
103
+ written: Iterable<string>,
104
+ ): void {
105
+ const folder = outputFolder(outDir, projectDir);
106
+ if (!folder) return;
107
+ const files = new Map(readMarker(folder.out).map((file) => [folder.key(file), file]));
108
+ const before = files.size;
109
+ for (const file of relativeTo(folder, written)) files.set(folder.key(file), file);
110
+ if (files.size === before && existsSync(path.join(folder.out, MARKER))) return;
111
+ writeMarker(folder.out, [...files.values()].sort());
112
+ }
113
+
114
+ function writeMarker(out: string, files: string[]): void {
115
+ writeFileSync(
116
+ path.join(out, MARKER),
117
+ `${JSON.stringify({ note: 'Written by avi build and avi dev: the files they compiled here, so the next build can remove the ones whose source is gone.', files }, null, 2)}\n`,
118
+ );
119
+ }
120
+
121
+ function readMarker(out: string): string[] {
122
+ try {
123
+ const parsed = JSON.parse(readFileSync(path.join(out, MARKER), 'utf8')) as {
124
+ files?: unknown;
125
+ };
126
+ return Array.isArray(parsed.files)
127
+ ? parsed.files.filter((file): file is string => typeof file === 'string')
128
+ : [];
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ function removeEmptyDirs(dir: string, stop: string): void {
135
+ for (
136
+ let current = dir;
137
+ current !== stop && current.startsWith(stop);
138
+ current = path.dirname(current)
139
+ ) {
140
+ if (readdirSync(current).length > 0) return;
141
+ rmdirSync(current);
142
+ }
143
+ }
package/src/watch.ts CHANGED
@@ -3,7 +3,8 @@ import path from 'node:path';
3
3
  import ts from 'typescript';
4
4
  import { formatDiagnostic, type Diagnostic } from './check.js';
5
5
  import { generateFrom, sameContent } from './client.js';
6
- import { INJECT_HEADER, generateInjectsFrom } from './inject.js';
6
+ import { INJECT_HEADER, generateInjectsFrom, plural } from './inject.js';
7
+ import { recordOutputs } from './outputs.js';
7
8
 
8
9
  export interface WatchProjectOptions {
9
10
  /** Path to tsconfig.json. */
@@ -31,10 +32,20 @@ export interface ProjectWatcher {
31
32
  */
32
33
  export function watchProject(options: WatchProjectOptions): ProjectWatcher {
33
34
  const emit = options.emit ?? true;
35
+ // What each build emits goes on avi build's list, so it can remove outputs of deleted files.
36
+ const projectDir = path.dirname(path.resolve(options.project));
37
+ const emitted = new Set<string>();
38
+ const system: ts.System = {
39
+ ...ts.sys,
40
+ writeFile: (file, text, bom) => {
41
+ ts.sys.writeFile(file, text, bom);
42
+ emitted.add(path.resolve(file));
43
+ },
44
+ };
34
45
  const host = ts.createWatchCompilerHost(
35
46
  path.resolve(options.project),
36
47
  emit ? { preserveWatchOutput: true } : { noEmit: true },
37
- ts.sys,
48
+ system,
38
49
  ts.createEmitAndSemanticDiagnosticsBuilderProgram,
39
50
  undefined,
40
51
  // Without emit the only output is the generated files'; tsc's status lines would be noise.
@@ -42,8 +53,11 @@ export function watchProject(options: WatchProjectOptions): ProjectWatcher {
42
53
  );
43
54
 
44
55
  let generatedErrors = 0;
56
+ // A file event can still start a build after close(); it must not write or report.
57
+ let closed = false;
45
58
  const status = host.onWatchStatusChange?.bind(host);
46
59
  host.onWatchStatusChange = (diagnostic, newLine, compilerOptions, errorCount) => {
60
+ if (closed) return;
47
61
  status?.(diagnostic, newLine, compilerOptions, errorCount);
48
62
  if (errorCount !== undefined) options.onBuild?.(errorCount + generatedErrors);
49
63
  };
@@ -51,6 +65,7 @@ export function watchProject(options: WatchProjectOptions): ProjectWatcher {
51
65
  const writers = generatedWriters(options);
52
66
  const build = host.afterProgramCreate?.bind(host);
53
67
  host.afterProgramCreate = (builder) => {
68
+ if (closed) return;
54
69
  const program = builder.getProgram();
55
70
  const results = writers.map((writer) => ({ out: writer.out, ...writer.write(program) }));
56
71
  generatedErrors = results.reduce((sum, result) => sum + result.errors, 0);
@@ -62,11 +77,16 @@ export function watchProject(options: WatchProjectOptions): ProjectWatcher {
62
77
  // report its stale errors. A file outside the program starts nothing, so build now.
63
78
  if (results.some((result) => result.written && program.getSourceFile(result.out))) return;
64
79
  build?.(builder);
80
+ if (emitted.size > 0) {
81
+ recordOutputs(program.getCompilerOptions().outDir, projectDir, emitted);
82
+ emitted.clear();
83
+ }
65
84
  };
66
85
 
67
86
  const watcher = ts.createWatchProgram(host);
68
87
  return {
69
88
  close: () => {
89
+ closed = true;
70
90
  watcher.close();
71
91
  },
72
92
  };
@@ -104,7 +124,7 @@ export function clientWriter(out: string, root: string | undefined) {
104
124
  export function injectWriter(out: string) {
105
125
  return fileWriter(out, INJECT_HEADER, (program, roots, file) => {
106
126
  const result = generateInjectsFrom(program, roots, { out: file });
107
- return { ...result, summary: `${String(result.classes.length)} dependency lists` };
127
+ return { ...result, summary: plural(result.classes.length, 'dependency list') };
108
128
  });
109
129
  }
110
130