@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/dist/build.d.ts.map +1 -1
- package/dist/build.js +16 -65
- package/dist/build.js.map +1 -1
- package/dist/cli.js +8 -4
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +5 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +26 -3
- package/dist/client.js.map +1 -1
- package/dist/inject.d.ts +1 -0
- package/dist/inject.d.ts.map +1 -1
- package/dist/inject.js +122 -23
- package/dist/inject.js.map +1 -1
- package/dist/outputs.d.ts +11 -0
- package/dist/outputs.d.ts.map +1 -0
- package/dist/outputs.js +116 -0
- package/dist/outputs.js.map +1 -0
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +25 -3
- package/dist/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +15 -66
- package/src/cli.ts +9 -4
- package/src/client.ts +30 -3
- package/src/inject.ts +140 -21
- package/src/outputs.ts +143 -0
- package/src/watch.ts +23 -3
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
127
|
+
return { ...result, summary: plural(result.classes.length, 'dependency list') };
|
|
108
128
|
});
|
|
109
129
|
}
|
|
110
130
|
|