ttsc 0.20.1 → 0.22.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/cmd/ttscserver/main.go +79 -3
- package/cmd/ttscserver/run_lsp_uses_native_plugin_manifest_source_test.go +96 -16
- package/driver/lsp.go +4 -0
- package/internal/lspserver/lsp_document_diagnostics_omits_cached_project_when_current_result_has_none_test.go +48 -0
- package/internal/lspserver/lsp_executable_project_input_change_requests_launcher_restart_test.go +98 -0
- package/internal/lspserver/lsp_external_watched_change_retains_program_test.go +53 -0
- package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +9 -0
- package/internal/lspserver/lsp_native_plugin_source.go +171 -55
- package/internal/lspserver/lsp_native_resident.go +110 -9
- package/internal/lspserver/lsp_plugin.go +6 -0
- package/internal/lspserver/lsp_project_context_transport_identity_test.go +43 -0
- package/internal/lspserver/lsp_project_diagnostics.go +235 -0
- package/internal/lspserver/lsp_project_diagnostics_capability_is_independent_test.go +67 -0
- package/internal/lspserver/lsp_project_diagnostics_program_input_overlap_refreshes_all_producers_test.go +78 -0
- package/internal/lspserver/lsp_project_diagnostics_refresh_accumulates_owner_freshness_test.go +65 -0
- package/internal/lspserver/lsp_project_diagnostics_refresh_keeps_each_producer_last_good_test.go +90 -0
- package/internal/lspserver/lsp_project_diagnostics_refreshes_only_input_owners_test.go +62 -0
- package/internal/lspserver/lsp_project_diagnostics_resident_unsupported_falls_back_test.go +123 -0
- package/internal/lspserver/lsp_project_diagnostics_resume_preserves_generation_test.go +52 -0
- package/internal/lspserver/lsp_project_diagnostics_translates_relative_client_project_test.go +90 -0
- package/internal/lspserver/lsp_project_input_ownership_resolves_declared_spellings_test.go +95 -0
- package/internal/lspserver/lsp_project_input_registration.go +504 -0
- package/internal/lspserver/lsp_project_input_registration_rechecks_selection_baseline_test.go +71 -0
- package/internal/lspserver/lsp_project_input_registration_reconciles_rejected_pending_request_test.go +268 -0
- package/internal/lspserver/lsp_project_input_registration_shapes_patterns_test.go +94 -0
- package/internal/lspserver/lsp_project_input_watchers_anchor_missing_paths_test.go +48 -0
- package/internal/lspserver/lsp_project_inputs.go +944 -0
- package/internal/lspserver/lsp_project_inputs_match_missing_exact_and_glob_test.go +129 -0
- package/internal/lspserver/lsp_project_inputs_reject_invalid_and_foreign_snapshots_test.go +110 -0
- package/internal/lspserver/lsp_proxy.go +591 -28
- package/internal/lspserver/lsp_reload_directories_compare_immediate_topology_test.go +188 -0
- package/internal/lspserver/lsp_reload_file_fingerprint_preserves_raw_symlink_target_test.go +47 -0
- package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +1 -1
- package/internal/lspserver/lsp_resident_invalidation_broadcasts_program_input_overlap_test.go +60 -0
- package/internal/lspserver/lsp_resident_invalidation_targets_project_input_owners_test.go +51 -0
- package/internal/lspserver/lsp_same_transport_entries_are_deduplicated_test.go +66 -0
- package/lib/compiler/internal/project/readProjectConfig.d.ts +5 -3
- package/lib/compiler/internal/project/readProjectConfig.js +46 -14
- package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
- package/lib/compiler/internal/residentCheckProcess.d.ts +46 -0
- package/lib/compiler/internal/residentCheckProcess.js +227 -0
- package/lib/compiler/internal/residentCheckProcess.js.map +1 -0
- package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -1
- package/lib/compiler/internal/resolveEmittedJavaScript.js +35 -18
- package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
- package/lib/compiler/internal/runBuild.d.ts +64 -1
- package/lib/compiler/internal/runBuild.js +484 -7
- package/lib/compiler/internal/runBuild.js.map +1 -1
- package/lib/compiler/internal/runSingleFileEmit.js +1 -0
- package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
- package/lib/flags/parser.js +13 -17
- package/lib/flags/parser.js.map +1 -1
- package/lib/flags/schema.d.ts +10 -3
- package/lib/flags/schema.js +99 -0
- package/lib/flags/schema.js.map +1 -1
- package/lib/internal/projectInputPathIdentity.d.ts +23 -0
- package/lib/internal/projectInputPathIdentity.js +222 -0
- package/lib/internal/projectInputPathIdentity.js.map +1 -0
- package/lib/launcher/internal/runTtsc.d.ts +17 -0
- package/lib/launcher/internal/runTtsc.js +121 -88
- package/lib/launcher/internal/runTtsc.js.map +1 -1
- package/lib/launcher/internal/runTtscserver.d.ts +13 -1
- package/lib/launcher/internal/runTtscserver.js +415 -45
- package/lib/launcher/internal/runTtscserver.js.map +1 -1
- package/lib/launcher/internal/singleFileOutput.d.ts +16 -0
- package/lib/launcher/internal/singleFileOutput.js +103 -0
- package/lib/launcher/internal/singleFileOutput.js.map +1 -0
- package/lib/launcher/internal/watchTopology.d.ts +214 -2
- package/lib/launcher/internal/watchTopology.js +1560 -41
- package/lib/launcher/internal/watchTopology.js.map +1 -1
- package/lib/plugin/internal/loadProjectPlugins.d.ts +1 -0
- package/lib/plugin/internal/loadProjectPlugins.js +5 -0
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/lib/structures/ITtscPluginCapabilities.d.ts +31 -0
- package/lib/structures/internal/ITtscProjectInputSnapshot.d.ts +29 -0
- package/lib/structures/internal/ITtscProjectInputSnapshot.js +3 -0
- package/lib/structures/internal/ITtscProjectInputSnapshot.js.map +1 -0
- package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -0
- package/package.json +8 -8
- package/src/compiler/internal/project/readProjectConfig.ts +62 -8
- package/src/compiler/internal/residentCheckProcess.ts +279 -0
- package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -21
- package/src/compiler/internal/runBuild.ts +728 -12
- package/src/compiler/internal/runSingleFileEmit.ts +1 -0
- package/src/flags/parser.ts +17 -17
- package/src/flags/schema.ts +111 -3
- package/src/internal/projectInputPathIdentity.ts +273 -0
- package/src/launcher/internal/runTtsc.ts +141 -117
- package/src/launcher/internal/runTtscserver.ts +561 -54
- package/src/launcher/internal/singleFileOutput.ts +133 -0
- package/src/launcher/internal/watchTopology.ts +2008 -59
- package/src/plugin/internal/loadProjectPlugins.ts +8 -0
- package/src/structures/ITtscPluginCapabilities.ts +34 -0
- package/src/structures/internal/ITtscProjectInputSnapshot.ts +29 -0
- package/src/structures/internal/TtscSingleFileEmitOptions.ts +5 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
@@ -5,12 +6,28 @@ import { readJsoncFile } from "../../compiler/internal/project/readConfigJson";
|
|
|
5
6
|
import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig";
|
|
6
7
|
import { resolveTsgo } from "../../compiler/internal/resolveTsgo";
|
|
7
8
|
import { outputText, spawnNative } from "../../compiler/internal/spawnNative";
|
|
9
|
+
import { resolveFlagSpec } from "../../flags/schema";
|
|
10
|
+
import {
|
|
11
|
+
type ProjectInputPathIdentity,
|
|
12
|
+
type ProjectInputPathIdentityContext,
|
|
13
|
+
createProjectInputPathIdentityContext,
|
|
14
|
+
isProjectInputPathIdentityWithin,
|
|
15
|
+
resolveProjectInputPath,
|
|
16
|
+
} from "../../internal/projectInputPathIdentity";
|
|
8
17
|
import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscParsedProjectConfig";
|
|
18
|
+
import type { ITtscProjectInputSnapshot } from "../../structures/internal/ITtscProjectInputSnapshot";
|
|
9
19
|
import type { TtscBuildOptions } from "../../structures/internal/TtscBuildOptions";
|
|
20
|
+
import { resolveSingleFileOutput } from "./singleFileOutput";
|
|
10
21
|
|
|
11
22
|
type WatchTopologyOptions = Pick<
|
|
12
23
|
TtscBuildOptions,
|
|
13
|
-
|
|
24
|
+
| "binary"
|
|
25
|
+
| "emit"
|
|
26
|
+
| "env"
|
|
27
|
+
| "outDir"
|
|
28
|
+
| "passthrough"
|
|
29
|
+
| "projectRoot"
|
|
30
|
+
| "tsconfig"
|
|
14
31
|
> & {
|
|
15
32
|
cwd: string;
|
|
16
33
|
files: readonly string[];
|
|
@@ -18,14 +35,31 @@ type WatchTopologyOptions = Pick<
|
|
|
18
35
|
|
|
19
36
|
type WatchTopologyCallbacks = {
|
|
20
37
|
onError(location: string, error: unknown): void;
|
|
21
|
-
onInputChange(): void;
|
|
38
|
+
onInputChange(change: WatchInputChange): void;
|
|
39
|
+
onProjectInputWatchRoots?(roots: readonly string[]): void;
|
|
22
40
|
onTopologyChange(): void;
|
|
23
41
|
};
|
|
24
42
|
|
|
43
|
+
export type WatchInputChange = {
|
|
44
|
+
/** Keep the resident process but cold-load its compiler Program. */
|
|
45
|
+
invalidate?: boolean;
|
|
46
|
+
kind: "compiler" | "config" | "plugin" | "project";
|
|
47
|
+
path?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
25
50
|
type ResolvedWatchTopology = {
|
|
51
|
+
analysisOnly: boolean;
|
|
26
52
|
directories: Map<string, string>;
|
|
27
53
|
files: Map<string, string>;
|
|
54
|
+
outputFiles: Map<string, string>;
|
|
28
55
|
outputs: Map<string, string>;
|
|
56
|
+
reloadFiles: Map<string, string>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type CompilerDirectoryWatchEventPlan = {
|
|
60
|
+
changes: string[];
|
|
61
|
+
rearm: string[];
|
|
62
|
+
refresh: boolean;
|
|
29
63
|
};
|
|
30
64
|
|
|
31
65
|
/**
|
|
@@ -37,14 +71,39 @@ type ResolvedWatchTopology = {
|
|
|
37
71
|
* outputs are filtered before any watcher is installed.
|
|
38
72
|
*/
|
|
39
73
|
export class WatchTopology {
|
|
74
|
+
private analysisOnly = false;
|
|
75
|
+
private closed = false;
|
|
40
76
|
private directories = new Map<string, string>();
|
|
41
77
|
private directoryWatchers = new Map<string, fs.FSWatcher>();
|
|
42
78
|
private extraInputs: readonly string[] = [];
|
|
43
79
|
private extraWatchers = new Map<string, fs.FSWatcher>();
|
|
80
|
+
private compilerFileStamps = new Map<string, string>();
|
|
44
81
|
private files = new Map<string, string>();
|
|
45
82
|
private fileWatchers = new Map<string, fs.FSWatcher>();
|
|
46
83
|
private observedDirectories = new Map<string, string>();
|
|
84
|
+
private outputFiles = new Map<string, string>();
|
|
47
85
|
private outputs = new Map<string, string>();
|
|
86
|
+
private projectInputFingerprints = new Map<string, string>();
|
|
87
|
+
private projectInputMatches = new Map<string, string>();
|
|
88
|
+
private projectInputs: ITtscProjectInputSnapshot = {
|
|
89
|
+
files: [],
|
|
90
|
+
globs: [],
|
|
91
|
+
reloadDirectories: [],
|
|
92
|
+
reloadFiles: [],
|
|
93
|
+
root: "",
|
|
94
|
+
};
|
|
95
|
+
private declaredProjectInputs: ITtscProjectInputSnapshot = {
|
|
96
|
+
files: [],
|
|
97
|
+
globs: [],
|
|
98
|
+
reloadDirectories: [],
|
|
99
|
+
reloadFiles: [],
|
|
100
|
+
root: "",
|
|
101
|
+
};
|
|
102
|
+
private projectInputRejectedWatchRoots = new Set<string>();
|
|
103
|
+
private projectInputWatchRoots = new Map<string, string>();
|
|
104
|
+
private projectInputWatchers = new Map<string, fs.FSWatcher>();
|
|
105
|
+
private projectInputLinkWatchers = new Map<string, fs.FSWatcher>();
|
|
106
|
+
private reloadFiles = new Map<string, string>();
|
|
48
107
|
|
|
49
108
|
public constructor(
|
|
50
109
|
private readonly options: WatchTopologyOptions,
|
|
@@ -54,18 +113,61 @@ export class WatchTopology {
|
|
|
54
113
|
/** Re-resolve compiler inputs and notify only when their membership changed. */
|
|
55
114
|
public refresh(notify: boolean): void {
|
|
56
115
|
const next = resolveWatchTopology(this.options, this.extraInputs);
|
|
116
|
+
const projectInputProgramChange =
|
|
117
|
+
next.analysisOnly &&
|
|
118
|
+
mapsEqual(this.reloadFiles, next.reloadFiles) &&
|
|
119
|
+
mapsEqual(this.outputFiles, next.outputFiles) &&
|
|
120
|
+
mapsEqual(this.outputs, next.outputs)
|
|
121
|
+
? projectInputCompilerMembershipChange(
|
|
122
|
+
this.projectInputs,
|
|
123
|
+
this.files,
|
|
124
|
+
next.files,
|
|
125
|
+
)
|
|
126
|
+
: undefined;
|
|
57
127
|
const changed =
|
|
128
|
+
this.analysisOnly !== next.analysisOnly ||
|
|
58
129
|
mapsEqual(this.files, next.files) === false ||
|
|
59
130
|
mapsEqual(this.directories, next.directories) === false ||
|
|
60
|
-
mapsEqual(this.
|
|
131
|
+
mapsEqual(this.outputFiles, next.outputFiles) === false ||
|
|
132
|
+
mapsEqual(this.outputs, next.outputs) === false ||
|
|
133
|
+
mapsEqual(this.reloadFiles, next.reloadFiles) === false;
|
|
134
|
+
this.analysisOnly = next.analysisOnly;
|
|
61
135
|
this.files = next.files;
|
|
136
|
+
// Stamp the tracked set as it is resolved, so the first event that cannot
|
|
137
|
+
// name what changed compares against the state the compiler just saw rather
|
|
138
|
+
// than against nothing, which would make it nominate everything once.
|
|
139
|
+
for (const key of [...this.compilerFileStamps.keys()]) {
|
|
140
|
+
if (!next.files.has(key)) this.compilerFileStamps.delete(key);
|
|
141
|
+
}
|
|
142
|
+
for (const [key, file] of next.files) {
|
|
143
|
+
// Only a file with no stamp yet is seeded. Restamping one that already
|
|
144
|
+
// has a baseline would advance it past a change nobody reported, and the
|
|
145
|
+
// next unnamed event would then read that change as no change at all.
|
|
146
|
+
if (!this.compilerFileStamps.has(key)) {
|
|
147
|
+
this.compilerFileStamps.set(key, compilerFileStamp(file));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
62
150
|
this.directories = next.directories;
|
|
151
|
+
this.outputFiles = next.outputFiles;
|
|
63
152
|
this.outputs = next.outputs;
|
|
153
|
+
this.reloadFiles = next.reloadFiles;
|
|
64
154
|
this.syncFileWatchers();
|
|
65
155
|
this.syncDirectoryWatchers();
|
|
66
156
|
this.syncExtraWatchers();
|
|
157
|
+
this.syncProjectInputWatchers();
|
|
67
158
|
if (notify && changed) {
|
|
68
|
-
|
|
159
|
+
if (projectInputProgramChange !== undefined) {
|
|
160
|
+
this.callbacks.onInputChange({
|
|
161
|
+
invalidate: true,
|
|
162
|
+
kind: "project",
|
|
163
|
+
path:
|
|
164
|
+
projectInputProgramChange.length === 1
|
|
165
|
+
? projectInputProgramChange[0]
|
|
166
|
+
: undefined,
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
this.callbacks.onTopologyChange();
|
|
170
|
+
}
|
|
69
171
|
}
|
|
70
172
|
}
|
|
71
173
|
|
|
@@ -77,62 +179,220 @@ export class WatchTopology {
|
|
|
77
179
|
this.refresh(false);
|
|
78
180
|
}
|
|
79
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Reconcile project-rule dependencies, retaining absent files and empty glob
|
|
184
|
+
* populations as live topology.
|
|
185
|
+
*/
|
|
186
|
+
public setProjectInputs(inputs: ITtscProjectInputSnapshot): void {
|
|
187
|
+
const next = normalizeProjectInputSnapshot(inputs);
|
|
188
|
+
// The declared spellings are recorded even when the normalized snapshot did
|
|
189
|
+
// not move, because a republication can carry a new alias for identities
|
|
190
|
+
// that already matched, and anchoring the spelling that was retired would
|
|
191
|
+
// leave the live one unwatched.
|
|
192
|
+
this.declaredProjectInputs =
|
|
193
|
+
inputs.declared === undefined
|
|
194
|
+
? inputs
|
|
195
|
+
: { ...inputs.declared, root: inputs.root };
|
|
196
|
+
if (projectInputSnapshotsEqual(this.projectInputs, next)) {
|
|
197
|
+
this.syncProjectInputWatchers();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.projectInputs = next;
|
|
201
|
+
this.projectInputRejectedWatchRoots.clear();
|
|
202
|
+
const declarations = new Set(
|
|
203
|
+
[
|
|
204
|
+
next,
|
|
205
|
+
inputs,
|
|
206
|
+
{ ...(inputs.declared ?? inputs), root: inputs.root },
|
|
207
|
+
].flatMap((snapshot) => [
|
|
208
|
+
...snapshot.files.map((file) =>
|
|
209
|
+
projectInputDeclarationKey("file", file),
|
|
210
|
+
),
|
|
211
|
+
...snapshot.globs.map((glob) =>
|
|
212
|
+
projectInputDeclarationKey("glob", glob),
|
|
213
|
+
),
|
|
214
|
+
...(snapshot.reloadFiles ?? []).map((file) =>
|
|
215
|
+
projectInputDeclarationKey("reload", file),
|
|
216
|
+
),
|
|
217
|
+
...(snapshot.reloadDirectories ?? []).map((directory) =>
|
|
218
|
+
projectInputDeclarationKey("reload-directory", directory),
|
|
219
|
+
),
|
|
220
|
+
]),
|
|
221
|
+
);
|
|
222
|
+
for (const key of this.projectInputWatchRoots.keys()) {
|
|
223
|
+
if (!declarations.has(key)) this.projectInputWatchRoots.delete(key);
|
|
224
|
+
}
|
|
225
|
+
this.projectInputMatches = this.collectProjectInputMatches();
|
|
226
|
+
this.projectInputFingerprints = fingerprintProjectInputMatches(
|
|
227
|
+
this.projectInputMatches,
|
|
228
|
+
);
|
|
229
|
+
this.syncProjectInputWatchers();
|
|
230
|
+
}
|
|
231
|
+
|
|
80
232
|
/** Close every watcher so SIGINT/SIGTERM can drain the event loop. */
|
|
81
233
|
public close(): void {
|
|
234
|
+
this.closed = true;
|
|
82
235
|
closeWatchers(this.fileWatchers);
|
|
83
236
|
closeWatchers(this.directoryWatchers);
|
|
84
237
|
closeWatchers(this.extraWatchers);
|
|
238
|
+
closeWatchers(this.projectInputWatchers);
|
|
239
|
+
closeWatchers(this.projectInputLinkWatchers);
|
|
85
240
|
}
|
|
86
241
|
|
|
87
242
|
private syncFileWatchers(): void {
|
|
243
|
+
const files =
|
|
244
|
+
process.platform === "win32" ? new Map<string, string>() : this.files;
|
|
88
245
|
syncWatchers(
|
|
89
246
|
this.fileWatchers,
|
|
90
|
-
|
|
247
|
+
files,
|
|
91
248
|
(location) =>
|
|
92
|
-
fs.watch(
|
|
93
|
-
|
|
94
|
-
|
|
249
|
+
fs.watch(
|
|
250
|
+
watcherRegistrationPath(location),
|
|
251
|
+
{ persistent: true },
|
|
252
|
+
() => {
|
|
253
|
+
// A per-file watcher fires on any filesystem attention its target
|
|
254
|
+
// receives, and it carries no filename to distinguish an edit from
|
|
255
|
+
// a touch. It answers the same question the unnamed directory event
|
|
256
|
+
// answers, so it answers it the same way: from the bytes.
|
|
257
|
+
if (!this.compilerFileMoved(location)) return;
|
|
258
|
+
this.callbacks.onInputChange({
|
|
259
|
+
kind: this.classifyCompilerInput(location),
|
|
260
|
+
path: location,
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
),
|
|
95
264
|
(location, error) => this.callbacks.onError(location, error),
|
|
96
265
|
);
|
|
97
266
|
}
|
|
98
267
|
|
|
268
|
+
/** Whether a tracked file's bytes differ from the last stamp taken. */
|
|
269
|
+
private compilerFileMoved(location: string): boolean {
|
|
270
|
+
const key = pathKey(location);
|
|
271
|
+
const stamp = compilerFileStamp(location);
|
|
272
|
+
if (this.compilerFileStamps.get(key) === stamp) return false;
|
|
273
|
+
this.compilerFileStamps.set(key, stamp);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
99
277
|
private syncDirectoryWatchers(): void {
|
|
100
278
|
const desired = new Map(this.directories);
|
|
101
279
|
for (const [key, location] of this.observedDirectories) {
|
|
102
280
|
if (
|
|
103
281
|
isDirectory(location) === false ||
|
|
104
|
-
this.isCompilerOutputDirectory(location)
|
|
282
|
+
this.isCompilerOutputDirectory(location) ||
|
|
283
|
+
this.isProjectInputDirectory(location)
|
|
105
284
|
) {
|
|
106
285
|
this.observedDirectories.delete(key);
|
|
107
286
|
continue;
|
|
108
287
|
}
|
|
109
288
|
desired.set(key, location);
|
|
110
289
|
}
|
|
290
|
+
if (process.platform === "win32") {
|
|
291
|
+
for (const [key, location] of desired) {
|
|
292
|
+
if (
|
|
293
|
+
[...desired].some(
|
|
294
|
+
([candidateKey, candidate]) =>
|
|
295
|
+
candidateKey !== key && isPathWithin(candidate, location),
|
|
296
|
+
)
|
|
297
|
+
) {
|
|
298
|
+
desired.delete(key);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
111
302
|
syncWatchers(
|
|
112
303
|
this.directoryWatchers,
|
|
113
304
|
desired,
|
|
114
305
|
(location) =>
|
|
115
|
-
fs.watch(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
306
|
+
fs.watch(
|
|
307
|
+
watcherRegistrationPath(location),
|
|
308
|
+
{
|
|
309
|
+
persistent: true,
|
|
310
|
+
recursive: process.platform === "win32",
|
|
311
|
+
},
|
|
312
|
+
(event, filename) => {
|
|
313
|
+
const changed =
|
|
314
|
+
filename === null
|
|
315
|
+
? undefined
|
|
316
|
+
: path.resolve(location, filename.toString());
|
|
317
|
+
const pluginInput = changed ?? location;
|
|
318
|
+
if (this.isPluginInput(pluginInput)) {
|
|
319
|
+
this.callbacks.onInputChange({
|
|
320
|
+
kind: "plugin",
|
|
321
|
+
path: pluginInput,
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const plan = planCompilerDirectoryWatchEvent({
|
|
326
|
+
changed,
|
|
327
|
+
event,
|
|
328
|
+
exists: fs.existsSync,
|
|
329
|
+
location,
|
|
330
|
+
platform: process.platform,
|
|
331
|
+
trackedFiles: this.files,
|
|
332
|
+
});
|
|
333
|
+
this.rearmFileWatchers(plan.rearm);
|
|
334
|
+
for (const file of this.compilerChangesToReport(
|
|
335
|
+
plan.changes,
|
|
336
|
+
changed,
|
|
337
|
+
event,
|
|
338
|
+
)) {
|
|
339
|
+
this.callbacks.onInputChange({
|
|
340
|
+
kind: this.classifyCompilerInput(file),
|
|
341
|
+
path: file,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (plan.refresh) this.refreshFromDirectory(location, changed);
|
|
345
|
+
},
|
|
346
|
+
),
|
|
132
347
|
(location, error) => this.callbacks.onError(location, error),
|
|
133
348
|
);
|
|
134
349
|
}
|
|
135
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Narrow a plan's changes to the tracked files that actually moved.
|
|
353
|
+
*
|
|
354
|
+
* A backend that cannot name what changed forces the plan to nominate every
|
|
355
|
+
* tracked file under the watched directory, which is the only safe answer it
|
|
356
|
+
* can give from an event carrying no filename. macOS delivers such events for
|
|
357
|
+
* ordinary activity elsewhere in the project, so the compiler lane would wake
|
|
358
|
+
* for sources nobody touched. Only a content notification passes through: it
|
|
359
|
+
* is the one event that claims the bytes moved. A rename claims the directory
|
|
360
|
+
* entry was rewritten and an unnamed event claims nothing, so both are
|
|
361
|
+
* decided from the bytes, which is the question neither of them answered.
|
|
362
|
+
*/
|
|
363
|
+
private compilerChangesToReport(
|
|
364
|
+
changes: readonly string[],
|
|
365
|
+
changed: string | undefined,
|
|
366
|
+
event: string,
|
|
367
|
+
): string[] {
|
|
368
|
+
// A content notification is taken at its word: the backend is telling us
|
|
369
|
+
// these bytes changed, and second-guessing it would lose an edit that
|
|
370
|
+
// landed inside the clock's resolution. A rename says the directory entry
|
|
371
|
+
// was rewritten, which is a different claim — a file can be moved back, or
|
|
372
|
+
// replaced by an identical copy, without its content moving at all — and an
|
|
373
|
+
// event that cannot name anything makes no claim about content either.
|
|
374
|
+
// Those two are decided from the bytes, and the rearm they drive is
|
|
375
|
+
// unaffected, because rebinding is about the inode and not the content.
|
|
376
|
+
if (changed !== undefined && event !== "rename") {
|
|
377
|
+
for (const file of changes) this.recordCompilerFileStamp(file);
|
|
378
|
+
return [...changes];
|
|
379
|
+
}
|
|
380
|
+
return changes.filter((file) => this.compilerFileMoved(file));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private recordCompilerFileStamp(file: string): void {
|
|
384
|
+
this.compilerFileStamps.set(pathKey(file), compilerFileStamp(file));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private rearmFileWatchers(files: readonly string[]): void {
|
|
388
|
+
for (const file of files) {
|
|
389
|
+
const key = pathKey(file);
|
|
390
|
+
this.fileWatchers.get(key)?.close();
|
|
391
|
+
this.fileWatchers.delete(key);
|
|
392
|
+
}
|
|
393
|
+
this.syncFileWatchers();
|
|
394
|
+
}
|
|
395
|
+
|
|
136
396
|
private syncExtraWatchers(): void {
|
|
137
397
|
const directories = new Map<string, string>();
|
|
138
398
|
for (const input of this.extraInputs) {
|
|
@@ -144,24 +404,474 @@ export class WatchTopology {
|
|
|
144
404
|
this.extraWatchers,
|
|
145
405
|
directories,
|
|
146
406
|
(location) =>
|
|
147
|
-
fs.watch(
|
|
148
|
-
|
|
149
|
-
|
|
407
|
+
fs.watch(
|
|
408
|
+
watcherRegistrationPath(location),
|
|
409
|
+
{ persistent: true },
|
|
410
|
+
(_event, filename) => {
|
|
411
|
+
const changed =
|
|
412
|
+
filename === null
|
|
413
|
+
? undefined
|
|
414
|
+
: path.resolve(location, filename.toString());
|
|
415
|
+
this.callbacks.onInputChange({
|
|
416
|
+
kind: "plugin",
|
|
417
|
+
path: changed ?? location,
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
),
|
|
150
421
|
(location, error) => this.callbacks.onError(location, error),
|
|
151
422
|
);
|
|
152
423
|
}
|
|
153
424
|
|
|
425
|
+
private syncProjectInputWatchers(): void {
|
|
426
|
+
if (this.closed) return;
|
|
427
|
+
const identities = createProjectInputPathIdentityContext();
|
|
428
|
+
const desired = new Map<string, string>();
|
|
429
|
+
for (const file of this.projectInputDeclarations("file")) {
|
|
430
|
+
if (this.isProjectInputCompilerOutput(file, identities)) continue;
|
|
431
|
+
const location = this.projectInputWatchRoot(
|
|
432
|
+
"file",
|
|
433
|
+
file,
|
|
434
|
+
path.dirname(file),
|
|
435
|
+
);
|
|
436
|
+
if (location !== undefined) {
|
|
437
|
+
this.retainProjectInputWatchRoot(desired, identities, location);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const glob of this.projectInputDeclarations("glob")) {
|
|
441
|
+
const root = literalGlobRoot(glob);
|
|
442
|
+
if (this.isProjectInputCompilerOutputDirectory(root, identities)) {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const location = this.projectInputWatchRoot("glob", glob, root);
|
|
446
|
+
if (location !== undefined) {
|
|
447
|
+
this.retainProjectInputWatchRoot(desired, identities, location);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const file of this.projectInputDeclarations("reload")) {
|
|
451
|
+
if (this.isProjectInputCompilerOutput(file, identities)) continue;
|
|
452
|
+
const location = this.projectInputWatchRoot(
|
|
453
|
+
"reload",
|
|
454
|
+
file,
|
|
455
|
+
path.dirname(file),
|
|
456
|
+
);
|
|
457
|
+
if (location !== undefined) {
|
|
458
|
+
this.retainProjectInputWatchRoot(desired, identities, location);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const directory of this.projectInputDeclarations("reload-directory")) {
|
|
462
|
+
if (this.isProjectInputCompilerOutputDirectory(directory, identities)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const location = this.projectInputWatchRoot(
|
|
466
|
+
"reload-directory",
|
|
467
|
+
directory,
|
|
468
|
+
directory,
|
|
469
|
+
);
|
|
470
|
+
if (location !== undefined) {
|
|
471
|
+
this.retainProjectInputWatchRoot(desired, identities, location);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const active = new Map<string, string>();
|
|
475
|
+
for (const location of projectInputActiveWatchDirectories(
|
|
476
|
+
desired.values(),
|
|
477
|
+
identities,
|
|
478
|
+
)) {
|
|
479
|
+
const identity = identities.resolve(location);
|
|
480
|
+
active.set(identity.key, identity.path);
|
|
481
|
+
}
|
|
482
|
+
syncWatchers(
|
|
483
|
+
this.projectInputWatchers,
|
|
484
|
+
active,
|
|
485
|
+
(location) =>
|
|
486
|
+
fs.watch(
|
|
487
|
+
watcherRegistrationPath(location),
|
|
488
|
+
{ persistent: true, recursive: true },
|
|
489
|
+
(_event, filename) => {
|
|
490
|
+
const changed =
|
|
491
|
+
filename === null
|
|
492
|
+
? undefined
|
|
493
|
+
: path.resolve(location, filename.toString());
|
|
494
|
+
this.refreshProjectInputs(location, changed);
|
|
495
|
+
},
|
|
496
|
+
),
|
|
497
|
+
(location, error) => {
|
|
498
|
+
const key = identities.resolve(location).key;
|
|
499
|
+
const firstFailure = !this.projectInputRejectedWatchRoots.has(key);
|
|
500
|
+
this.projectInputRejectedWatchRoots.add(key);
|
|
501
|
+
this.callbacks.onError(location, error);
|
|
502
|
+
if (firstFailure) {
|
|
503
|
+
queueMicrotask(() => this.syncProjectInputWatchers());
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
this.syncProjectInputLinkWatchers(identities);
|
|
508
|
+
this.callbacks.onProjectInputWatchRoots?.(
|
|
509
|
+
[...this.projectInputWatchers.keys()].sort(),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Watch the directory that holds a declaration which is itself a link.
|
|
515
|
+
*
|
|
516
|
+
* A recursive watcher cannot report the link being replaced. The backend that
|
|
517
|
+
* keys its handles by path skips an entry it already knows, and the handle it
|
|
518
|
+
* put on the entry followed the link to the target's inode, which unlinking
|
|
519
|
+
* and recreating the link never touches. A plain directory watch has neither
|
|
520
|
+
* property: it reports the entry by name the moment it moves. These are kept
|
|
521
|
+
* apart from the recursive roots because they are not roots — they observe
|
|
522
|
+
* one directory, they are never reported as watch roots, and an ancestor
|
|
523
|
+
* covering them does not make them redundant.
|
|
524
|
+
*/
|
|
525
|
+
private syncProjectInputLinkWatchers(
|
|
526
|
+
identities: ProjectInputPathIdentityContext,
|
|
527
|
+
): void {
|
|
528
|
+
const desired = new Map<string, string>();
|
|
529
|
+
for (const kind of ["file", "reload"] as const) {
|
|
530
|
+
for (const declaration of this.projectInputDeclarations(kind)) {
|
|
531
|
+
const declared = path.resolve(declaration);
|
|
532
|
+
// The test is whether the declaration is itself a link, not whether its
|
|
533
|
+
// spelling is canonical. Comparing against the resolved identity would
|
|
534
|
+
// admit every declaration whose ancestor is aliased — which on macOS is
|
|
535
|
+
// every declaration under the system temporary directory — and it would
|
|
536
|
+
// still miss the retarget, because the watcher goes below the link.
|
|
537
|
+
if (!isSymbolicLink(declared)) continue;
|
|
538
|
+
if (this.isProjectInputCompilerOutput(declared, identities)) continue;
|
|
539
|
+
const parent = nearestExistingDirectory(path.dirname(declared));
|
|
540
|
+
if (parent === undefined) continue;
|
|
541
|
+
desired.set(identities.resolve(parent).key, parent);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
syncWatchers(
|
|
545
|
+
this.projectInputLinkWatchers,
|
|
546
|
+
desired,
|
|
547
|
+
(location) =>
|
|
548
|
+
fs.watch(
|
|
549
|
+
watcherRegistrationPath(location),
|
|
550
|
+
{ persistent: true },
|
|
551
|
+
(_event, filename) => {
|
|
552
|
+
const changed =
|
|
553
|
+
filename === null
|
|
554
|
+
? undefined
|
|
555
|
+
: path.resolve(location, filename.toString());
|
|
556
|
+
this.refreshProjectInputs(location, changed);
|
|
557
|
+
},
|
|
558
|
+
),
|
|
559
|
+
(location, error) => this.callbacks.onError(location, error),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Drop the watcher that just reported a directory replacement. */
|
|
564
|
+
private retireProjectInputWatcher(
|
|
565
|
+
location: string,
|
|
566
|
+
identities: ProjectInputPathIdentityContext,
|
|
567
|
+
): void {
|
|
568
|
+
const key = identities.resolve(location).key;
|
|
569
|
+
// A plain directory watcher binds an inode, so a replacement strands it
|
|
570
|
+
// exactly as it strands a recursive root. Both maps are keyed the same way,
|
|
571
|
+
// so both are retired together and the next sync reinstalls whichever the
|
|
572
|
+
// declarations still call for.
|
|
573
|
+
for (const watchers of [
|
|
574
|
+
this.projectInputWatchers,
|
|
575
|
+
this.projectInputLinkWatchers,
|
|
576
|
+
]) {
|
|
577
|
+
const watcher = watchers.get(key);
|
|
578
|
+
if (watcher === undefined) continue;
|
|
579
|
+
watcher.close();
|
|
580
|
+
watchers.delete(key);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private retainProjectInputWatchRoot(
|
|
585
|
+
desired: Map<string, string>,
|
|
586
|
+
identities: ProjectInputPathIdentityContext,
|
|
587
|
+
location: string,
|
|
588
|
+
): void {
|
|
589
|
+
const available = projectInputAvailableWatchDirectory(
|
|
590
|
+
location,
|
|
591
|
+
this.projectInputRejectedWatchRoots,
|
|
592
|
+
identities,
|
|
593
|
+
this.projectInputs.root,
|
|
594
|
+
);
|
|
595
|
+
if (available === undefined) return;
|
|
596
|
+
const identity = identities.resolve(available);
|
|
597
|
+
desired.set(identity.key, identity.path);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* One snapshot holding every spelling of every declaration.
|
|
602
|
+
*
|
|
603
|
+
* Consumers that decide from a population rather than from a single path have
|
|
604
|
+
* to see both, or half of them answer from the file a link pointed at when
|
|
605
|
+
* the snapshot was published while the event they are judging resolved to the
|
|
606
|
+
* file it points at now.
|
|
607
|
+
*/
|
|
608
|
+
private projectInputPopulation(): ITtscProjectInputSnapshot {
|
|
609
|
+
return {
|
|
610
|
+
files: this.projectInputDeclarations("file"),
|
|
611
|
+
globs: this.projectInputDeclarations("glob"),
|
|
612
|
+
reloadDirectories: this.projectInputDeclarations("reload-directory"),
|
|
613
|
+
reloadFiles: this.projectInputDeclarations("reload"),
|
|
614
|
+
root: this.projectInputs.root,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Every spelling of one declaration that has to be anchored separately.
|
|
620
|
+
*
|
|
621
|
+
* The retained snapshot is normalized to physical identities, which is what
|
|
622
|
+
* every comparison needs but not what every watcher needs: a declaration
|
|
623
|
+
* reached through a symlink resolves to its target's directory, so anchoring
|
|
624
|
+
* the normalized form alone watches the bytes and never the link. Retargeting
|
|
625
|
+
* or replacing the link then goes unobserved, even though it is exactly what
|
|
626
|
+
* decides which bytes the declaration names next. Both spellings are planned
|
|
627
|
+
* through the same root selection, so the project-root hoist and the
|
|
628
|
+
* nearest-existing-ancestor boundary still bound each of them, and the active
|
|
629
|
+
* set drops one again whenever they coincide or share an ancestor.
|
|
630
|
+
*/
|
|
631
|
+
private projectInputDeclarations(
|
|
632
|
+
kind: "file" | "glob" | "reload" | "reload-directory",
|
|
633
|
+
): string[] {
|
|
634
|
+
const select = (snapshot: ITtscProjectInputSnapshot): readonly string[] =>
|
|
635
|
+
kind === "file"
|
|
636
|
+
? snapshot.files
|
|
637
|
+
: kind === "glob"
|
|
638
|
+
? snapshot.globs
|
|
639
|
+
: kind === "reload"
|
|
640
|
+
? (snapshot.reloadFiles ?? [])
|
|
641
|
+
: (snapshot.reloadDirectories ?? []);
|
|
642
|
+
const seen = new Set<string>();
|
|
643
|
+
const declarations: string[] = [];
|
|
644
|
+
for (const entry of [
|
|
645
|
+
...select(this.projectInputs),
|
|
646
|
+
...select(this.declaredProjectInputs),
|
|
647
|
+
]) {
|
|
648
|
+
const key = resolveProjectInputPath(entry);
|
|
649
|
+
if (seen.has(key)) continue;
|
|
650
|
+
seen.add(key);
|
|
651
|
+
declarations.push(entry);
|
|
652
|
+
}
|
|
653
|
+
return declarations;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private projectInputWatchRoot(
|
|
657
|
+
kind: "file" | "glob" | "reload" | "reload-directory",
|
|
658
|
+
declaration: string,
|
|
659
|
+
target: string,
|
|
660
|
+
): string | undefined {
|
|
661
|
+
const key = projectInputDeclarationKey(kind, declaration);
|
|
662
|
+
const retained = this.projectInputWatchRoots.get(key);
|
|
663
|
+
if (retained !== undefined && isDirectory(retained)) return retained;
|
|
664
|
+
const resolved = projectInputRecursiveWatchRoot(
|
|
665
|
+
target,
|
|
666
|
+
this.projectInputs.root,
|
|
667
|
+
);
|
|
668
|
+
if (resolved !== undefined) this.projectInputWatchRoots.set(key, resolved);
|
|
669
|
+
return resolved;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private refreshProjectInputs(location: string, changed?: string): void {
|
|
673
|
+
try {
|
|
674
|
+
const previous = this.projectInputMatches;
|
|
675
|
+
const identities = createProjectInputPathIdentityContext();
|
|
676
|
+
// One population for the whole decision. Every question below is asked of
|
|
677
|
+
// the same declarations, and rebuilding it per question would both cost
|
|
678
|
+
// more and let two answers disagree about what was declared.
|
|
679
|
+
const population = this.projectInputPopulation();
|
|
680
|
+
const directlyMatched =
|
|
681
|
+
changed !== undefined &&
|
|
682
|
+
(previous.has(identities.resolve(changed).key) ||
|
|
683
|
+
matchesProjectInput(population, changed, identities));
|
|
684
|
+
const topologyMatched =
|
|
685
|
+
changed !== undefined &&
|
|
686
|
+
projectInputTopologyMayAffect(
|
|
687
|
+
population,
|
|
688
|
+
changed,
|
|
689
|
+
previous,
|
|
690
|
+
identities,
|
|
691
|
+
);
|
|
692
|
+
if (
|
|
693
|
+
changed !== undefined &&
|
|
694
|
+
(this.isProjectInputCompilerOutput(changed, identities) ||
|
|
695
|
+
(directlyMatched === false && topologyMatched === false))
|
|
696
|
+
) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// Rearm before snapshotting. A watcher that has to be replaced stops
|
|
700
|
+
// delivering the moment it is closed, so a scan taken first would become
|
|
701
|
+
// the baseline for a window in which nothing was watched, and anything
|
|
702
|
+
// written there would never be announced again. Reinstalling first makes
|
|
703
|
+
// the scan below observe whatever the gap swallowed.
|
|
704
|
+
if (
|
|
705
|
+
changed !== undefined &&
|
|
706
|
+
projectInputReplacementStrandsWatchers(population, changed, identities)
|
|
707
|
+
) {
|
|
708
|
+
this.retireProjectInputWatcher(location, identities);
|
|
709
|
+
this.syncProjectInputWatchers();
|
|
710
|
+
}
|
|
711
|
+
const next = this.collectProjectInputMatches();
|
|
712
|
+
const membershipChanged = mapsEqual(previous, next) === false;
|
|
713
|
+
const nextFingerprints =
|
|
714
|
+
changed === undefined ||
|
|
715
|
+
membershipChanged ||
|
|
716
|
+
directlyMatched ||
|
|
717
|
+
topologyMatched
|
|
718
|
+
? fingerprintProjectInputMatches(next)
|
|
719
|
+
: this.projectInputFingerprints;
|
|
720
|
+
const contentChanged =
|
|
721
|
+
mapsEqual(this.projectInputFingerprints, nextFingerprints) === false;
|
|
722
|
+
const changedInputs = projectInputChangedPaths({
|
|
723
|
+
next,
|
|
724
|
+
nextFingerprints,
|
|
725
|
+
previous,
|
|
726
|
+
previousFingerprints: this.projectInputFingerprints,
|
|
727
|
+
});
|
|
728
|
+
// Both spellings classify the event. The normalized form names the file a
|
|
729
|
+
// link pointed at when the snapshot was published, so after a retarget it
|
|
730
|
+
// names the wrong one; only the declared form resolves to what the link
|
|
731
|
+
// points at now, which is the selection this lane exists to protect.
|
|
732
|
+
const reload = projectInputReloadEventShouldNotify({
|
|
733
|
+
changed,
|
|
734
|
+
changedInputs,
|
|
735
|
+
globs: population.globs,
|
|
736
|
+
reloadDirectories: population.reloadDirectories ?? [],
|
|
737
|
+
reloadFiles: population.reloadFiles ?? [],
|
|
738
|
+
});
|
|
739
|
+
const invalidate = projectInputMembershipInvalidatesProgram({
|
|
740
|
+
changed,
|
|
741
|
+
changedInputs,
|
|
742
|
+
contentChanged,
|
|
743
|
+
next,
|
|
744
|
+
previous,
|
|
745
|
+
});
|
|
746
|
+
this.projectInputMatches = next;
|
|
747
|
+
this.projectInputFingerprints = nextFingerprints;
|
|
748
|
+
this.syncProjectInputWatchers();
|
|
749
|
+
// A JSON/TS/JS project-input member can simultaneously enter or leave
|
|
750
|
+
// the compiler Program. Reconcile the compiler watch snapshot before
|
|
751
|
+
// scheduling its resident invalidation, so runWatch's post-cycle refresh
|
|
752
|
+
// does not rediscover the same delta as a broader execution reload.
|
|
753
|
+
if (invalidate) this.refresh(false);
|
|
754
|
+
if (
|
|
755
|
+
projectInputEventShouldNotify({
|
|
756
|
+
contentChanged,
|
|
757
|
+
directlyMatched,
|
|
758
|
+
membershipChanged,
|
|
759
|
+
}) &&
|
|
760
|
+
(changed === undefined ||
|
|
761
|
+
this.isProjectInputCompilerOutput(changed, identities) === false)
|
|
762
|
+
) {
|
|
763
|
+
this.callbacks.onInputChange(
|
|
764
|
+
reload
|
|
765
|
+
? { kind: "config", path: changed }
|
|
766
|
+
: {
|
|
767
|
+
...(invalidate ? { invalidate: true } : {}),
|
|
768
|
+
kind: "project",
|
|
769
|
+
path: changed,
|
|
770
|
+
},
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
} catch (error) {
|
|
774
|
+
// A rename can invalidate the old filesystem object before the
|
|
775
|
+
// replacement is readable. Rebind ancestor ownership even when the
|
|
776
|
+
// population scan races that transient gap, so a later create cannot be
|
|
777
|
+
// stranded without a watcher.
|
|
778
|
+
this.syncProjectInputWatchers();
|
|
779
|
+
this.callbacks.onError(location, error);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private collectProjectInputMatches(): Map<string, string> {
|
|
784
|
+
const identities = createProjectInputPathIdentityContext();
|
|
785
|
+
const matches = new Map<string, string>();
|
|
786
|
+
// Both spellings are scanned, and each is resolved here rather than when
|
|
787
|
+
// the snapshot arrived. A declaration reached through a symlink otherwise
|
|
788
|
+
// keeps the identity it had when it was published, so retargeting the link
|
|
789
|
+
// moves no key, changes no fingerprint, and the cycle never learns that the
|
|
790
|
+
// bytes it depends on are now a different file.
|
|
791
|
+
for (const file of this.projectInputDeclarations("file")) {
|
|
792
|
+
if (
|
|
793
|
+
fs.existsSync(file) &&
|
|
794
|
+
this.isProjectInputCompilerOutput(file, identities) === false
|
|
795
|
+
) {
|
|
796
|
+
const identity = identities.resolve(file);
|
|
797
|
+
matches.set(identity.key, identity.path);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
for (const file of this.projectInputDeclarations("reload")) {
|
|
801
|
+
if (
|
|
802
|
+
fs.existsSync(file) &&
|
|
803
|
+
this.isProjectInputCompilerOutput(file, identities) === false
|
|
804
|
+
) {
|
|
805
|
+
const identity = identities.resolve(file);
|
|
806
|
+
matches.set(identity.key, identity.path);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
for (const directory of this.projectInputDeclarations("reload-directory")) {
|
|
810
|
+
if (
|
|
811
|
+
isDirectory(directory) &&
|
|
812
|
+
this.isProjectInputCompilerOutputDirectory(directory, identities) ===
|
|
813
|
+
false
|
|
814
|
+
) {
|
|
815
|
+
const identity = identities.resolve(directory);
|
|
816
|
+
matches.set(identity.key, identity.path);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
for (const glob of this.projectInputDeclarations("glob")) {
|
|
820
|
+
const root = literalGlobRoot(glob);
|
|
821
|
+
if (
|
|
822
|
+
isDirectory(root) === false ||
|
|
823
|
+
this.isProjectInputCompilerOutputDirectory(root, identities)
|
|
824
|
+
) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const stack = [root];
|
|
828
|
+
while (stack.length !== 0) {
|
|
829
|
+
const current = stack.pop()!;
|
|
830
|
+
let entries: fs.Dirent[];
|
|
831
|
+
try {
|
|
832
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
833
|
+
} catch (error) {
|
|
834
|
+
if (isVanishedFilesystemEntry(error)) continue;
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
837
|
+
for (const entry of entries) {
|
|
838
|
+
const location = path.join(current, entry.name);
|
|
839
|
+
if (this.isProjectInputCompilerOutput(location, identities)) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (entry.isDirectory()) {
|
|
843
|
+
stack.push(location);
|
|
844
|
+
} else if (
|
|
845
|
+
entry.isFile() &&
|
|
846
|
+
matchesProjectInputGlob(glob, location, identities)
|
|
847
|
+
) {
|
|
848
|
+
const identity = identities.resolve(location);
|
|
849
|
+
matches.set(identity.key, identity.path);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return matches;
|
|
855
|
+
}
|
|
856
|
+
|
|
154
857
|
private refreshFromDirectory(location: string, changed?: string): void {
|
|
155
858
|
if (
|
|
156
859
|
changed !== undefined &&
|
|
157
860
|
isDirectory(changed) &&
|
|
158
|
-
this.isCompilerOutputDirectory(changed) === false
|
|
861
|
+
this.isCompilerOutputDirectory(changed) === false &&
|
|
862
|
+
this.isProjectInputDirectory(changed) === false
|
|
159
863
|
) {
|
|
160
864
|
this.observedDirectories.set(pathKey(changed), changed);
|
|
161
865
|
}
|
|
162
866
|
try {
|
|
163
867
|
this.refresh(true);
|
|
164
868
|
} catch (error) {
|
|
869
|
+
for (const reload of reloadInputsForFailedTopologyRefresh(
|
|
870
|
+
this.reloadFiles.values(),
|
|
871
|
+
changed,
|
|
872
|
+
)) {
|
|
873
|
+
this.callbacks.onInputChange({ kind: "config", path: reload });
|
|
874
|
+
}
|
|
165
875
|
this.callbacks.onError(location, error);
|
|
166
876
|
}
|
|
167
877
|
}
|
|
@@ -171,14 +881,117 @@ export class WatchTopology {
|
|
|
171
881
|
isPathWithin(output, location),
|
|
172
882
|
);
|
|
173
883
|
}
|
|
884
|
+
|
|
885
|
+
private isCompilerOutput(location: string): boolean {
|
|
886
|
+
return (
|
|
887
|
+
this.outputFiles.has(pathKey(location)) ||
|
|
888
|
+
this.isCompilerOutputDirectory(location)
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private isProjectInputCompilerOutputDirectory(
|
|
893
|
+
location: string,
|
|
894
|
+
identities: ProjectInputPathIdentityContext,
|
|
895
|
+
): boolean {
|
|
896
|
+
const root = this.projectInputs.root;
|
|
897
|
+
return [...this.outputs.values()].some((output) => {
|
|
898
|
+
// An output directory that is the project itself, or holds it, is not a
|
|
899
|
+
// place where only build products live -- the sources are there too. A
|
|
900
|
+
// project emitting in place declares exactly that, and honouring it
|
|
901
|
+
// literally would classify every declared input as a product and leave
|
|
902
|
+
// the whole lane unwatched without anything failing to say so. Watching
|
|
903
|
+
// is the safe side here: a product that gets watched costs an extra
|
|
904
|
+
// rebuild, while an input that does not is never seen again.
|
|
905
|
+
if (root !== "" && identities.isWithin(output, root)) return false;
|
|
906
|
+
return identities.isWithin(output, location);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private isProjectInputCompilerOutput(
|
|
911
|
+
location: string,
|
|
912
|
+
identities: ProjectInputPathIdentityContext,
|
|
913
|
+
): boolean {
|
|
914
|
+
const key = identities.resolve(location).key;
|
|
915
|
+
return (
|
|
916
|
+
[...this.outputFiles.values()].some(
|
|
917
|
+
(output) => identities.resolve(output).key === key,
|
|
918
|
+
) || this.isProjectInputCompilerOutputDirectory(location, identities)
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private isProjectInputDirectory(location: string): boolean {
|
|
923
|
+
const resolved = path.resolve(location);
|
|
924
|
+
const identities = createProjectInputPathIdentityContext();
|
|
925
|
+
return (
|
|
926
|
+
this.projectInputs.files.some(
|
|
927
|
+
(file) =>
|
|
928
|
+
identities.isWithin(resolved, file) ||
|
|
929
|
+
identities.isWithin(path.dirname(file), resolved),
|
|
930
|
+
) ||
|
|
931
|
+
(this.projectInputs.reloadFiles ?? []).some(
|
|
932
|
+
(file) =>
|
|
933
|
+
identities.isWithin(resolved, file) ||
|
|
934
|
+
identities.isWithin(path.dirname(file), resolved),
|
|
935
|
+
) ||
|
|
936
|
+
(this.projectInputs.reloadDirectories ?? []).some(
|
|
937
|
+
(directory) =>
|
|
938
|
+
identities.isWithin(resolved, directory) ||
|
|
939
|
+
identities.isWithin(directory, resolved),
|
|
940
|
+
) ||
|
|
941
|
+
this.projectInputs.globs.some((glob) => {
|
|
942
|
+
const root = literalGlobRoot(glob);
|
|
943
|
+
return (
|
|
944
|
+
identities.isWithin(root, resolved) ||
|
|
945
|
+
identities.isWithin(resolved, root)
|
|
946
|
+
);
|
|
947
|
+
})
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private classifyCompilerInput(
|
|
952
|
+
location: string,
|
|
953
|
+
): "compiler" | "config" | "plugin" {
|
|
954
|
+
if (this.isPluginInput(location)) return "plugin";
|
|
955
|
+
return this.reloadFiles.has(pathKey(location)) ? "config" : "compiler";
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private isPluginInput(location: string): boolean {
|
|
959
|
+
const resolved = path.resolve(location);
|
|
960
|
+
return this.extraInputs.some(
|
|
961
|
+
(input) =>
|
|
962
|
+
pathKey(input) === pathKey(resolved) || isPathWithin(input, resolved),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export function reloadInputsForFailedTopologyRefresh(
|
|
968
|
+
reloadFiles: Iterable<string>,
|
|
969
|
+
changed?: string,
|
|
970
|
+
): string[] {
|
|
971
|
+
const changedKey = changed === undefined ? undefined : pathKey(changed);
|
|
972
|
+
const reloads = new Map<string, string>();
|
|
973
|
+
for (const location of reloadFiles) {
|
|
974
|
+
const resolved = path.resolve(location);
|
|
975
|
+
const key = pathKey(resolved);
|
|
976
|
+
if (
|
|
977
|
+
(changedKey !== undefined && key === changedKey) ||
|
|
978
|
+
fs.existsSync(resolved) === false
|
|
979
|
+
) {
|
|
980
|
+
reloads.set(key, resolved);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return [...reloads.values()].sort();
|
|
174
984
|
}
|
|
175
985
|
|
|
176
986
|
function resolveWatchTopology(
|
|
177
987
|
options: WatchTopologyOptions,
|
|
178
988
|
extraInputs: readonly string[],
|
|
179
989
|
): ResolvedWatchTopology {
|
|
990
|
+
let analysisOnly = options.emit === false;
|
|
180
991
|
const files = new Map<string, string>();
|
|
992
|
+
const outputFiles = new Map<string, string>();
|
|
181
993
|
const outputs = new Map<string, string>();
|
|
994
|
+
const reloadFiles = new Map<string, string>();
|
|
182
995
|
const roots: string[] = [];
|
|
183
996
|
if (options.files.length !== 0) {
|
|
184
997
|
const project = readProjectConfig({
|
|
@@ -186,29 +999,70 @@ function resolveWatchTopology(
|
|
|
186
999
|
projectRoot: options.projectRoot,
|
|
187
1000
|
tsconfig: options.tsconfig,
|
|
188
1001
|
});
|
|
1002
|
+
analysisOnly = watchTopologyAnalysisOnly(options, project);
|
|
189
1003
|
roots.push(project.root);
|
|
190
1004
|
addPaths(files, project.configPaths);
|
|
191
|
-
addPaths(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
options.files.map((file) => path.resolve(options.cwd, file)),
|
|
1005
|
+
addPaths(reloadFiles, project.configPaths);
|
|
1006
|
+
const positionalInputs = options.files.map((file) =>
|
|
1007
|
+
path.resolve(options.cwd, file),
|
|
195
1008
|
);
|
|
1009
|
+
if (
|
|
1010
|
+
positionalInputs.length === 1 &&
|
|
1011
|
+
(options.emit ?? project.compilerOptions.noEmit !== true)
|
|
1012
|
+
) {
|
|
1013
|
+
addPaths(outputFiles, [
|
|
1014
|
+
resolveSingleFileOutput({
|
|
1015
|
+
cliOutDir: options.outDir,
|
|
1016
|
+
cwd: options.cwd,
|
|
1017
|
+
file: positionalInputs[0]!,
|
|
1018
|
+
passthrough: options.passthrough,
|
|
1019
|
+
tsconfig: options.tsconfig,
|
|
1020
|
+
}),
|
|
1021
|
+
]);
|
|
1022
|
+
}
|
|
1023
|
+
addPaths(files, positionalInputs);
|
|
196
1024
|
} else {
|
|
197
|
-
|
|
1025
|
+
const projects = readReferencedProjects(options);
|
|
1026
|
+
if (projects[0] !== undefined) {
|
|
1027
|
+
analysisOnly = watchTopologyAnalysisOnly(options, projects[0]);
|
|
1028
|
+
}
|
|
1029
|
+
for (const project of projects) {
|
|
198
1030
|
roots.push(project.root);
|
|
199
1031
|
addPaths(files, project.configPaths);
|
|
200
|
-
addPaths(
|
|
201
|
-
|
|
1032
|
+
addPaths(reloadFiles, project.configPaths);
|
|
1033
|
+
const compilerInputs = listCompilerInputs(project, options);
|
|
1034
|
+
const compilerOutputs = resolveCompilerOutputs(project, options);
|
|
1035
|
+
addPaths(outputFiles, compilerOutputs.files);
|
|
1036
|
+
addPaths(
|
|
1037
|
+
outputFiles,
|
|
1038
|
+
inferAdjacentCompilerOutputs(project, options, compilerInputs),
|
|
1039
|
+
);
|
|
1040
|
+
addPaths(outputs, compilerOutputs.directories);
|
|
1041
|
+
addPaths(files, compilerInputs);
|
|
202
1042
|
}
|
|
203
1043
|
}
|
|
204
1044
|
addPaths(files, extraInputs);
|
|
205
1045
|
return {
|
|
1046
|
+
analysisOnly,
|
|
206
1047
|
directories: collectTopologyDirectories(files.values(), roots),
|
|
207
1048
|
files,
|
|
1049
|
+
outputFiles,
|
|
208
1050
|
outputs,
|
|
1051
|
+
reloadFiles,
|
|
209
1052
|
};
|
|
210
1053
|
}
|
|
211
1054
|
|
|
1055
|
+
function watchTopologyAnalysisOnly(
|
|
1056
|
+
options: WatchTopologyOptions,
|
|
1057
|
+
project: ITtscParsedProjectConfig,
|
|
1058
|
+
): boolean {
|
|
1059
|
+
if (options.emit !== undefined) return options.emit === false;
|
|
1060
|
+
const noEmit =
|
|
1061
|
+
passthroughBooleanOption(options.passthrough, "--noEmit") ??
|
|
1062
|
+
project.compilerOptions.noEmit === true;
|
|
1063
|
+
return noEmit;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
212
1066
|
function readReferencedProjects(
|
|
213
1067
|
options: WatchTopologyOptions,
|
|
214
1068
|
): ITtscParsedProjectConfig[] {
|
|
@@ -309,20 +1163,301 @@ function resolveCompilerOutputs(
|
|
|
309
1163
|
project: ITtscParsedProjectConfig,
|
|
310
1164
|
options: WatchTopologyOptions,
|
|
311
1165
|
): { directories: string[]; files: string[] } {
|
|
1166
|
+
const emit = effectiveCompilerEmit(project, options);
|
|
1167
|
+
const directories = new Set<string>();
|
|
1168
|
+
const files = new Set<string>();
|
|
1169
|
+
if (
|
|
1170
|
+
emit.outDir !== undefined &&
|
|
1171
|
+
(emit.javascript || (emit.declaration && emit.declarationDir === undefined))
|
|
1172
|
+
) {
|
|
1173
|
+
directories.add(emit.outDir);
|
|
1174
|
+
}
|
|
1175
|
+
if (emit.declaration && emit.declarationDir !== undefined) {
|
|
1176
|
+
directories.add(emit.declarationDir);
|
|
1177
|
+
}
|
|
1178
|
+
if (emit.outFile !== undefined) {
|
|
1179
|
+
if (emit.javascript) {
|
|
1180
|
+
files.add(emit.outFile);
|
|
1181
|
+
if (emit.sourceMap) files.add(`${emit.outFile}.map`);
|
|
1182
|
+
}
|
|
1183
|
+
if (emit.declaration) {
|
|
1184
|
+
const declaration = replaceOutputExtension(emit.outFile, ".d.ts");
|
|
1185
|
+
files.add(declaration);
|
|
1186
|
+
if (emit.declarationMap) files.add(`${declaration}.map`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (emit.incremental) {
|
|
1190
|
+
files.add(defaultTsBuildInfoFile(project, emit));
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
directories: [...directories],
|
|
1194
|
+
files: [...files],
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function inferAdjacentCompilerOutputs(
|
|
1199
|
+
project: ITtscParsedProjectConfig,
|
|
1200
|
+
options: WatchTopologyOptions,
|
|
1201
|
+
inputs: readonly string[],
|
|
1202
|
+
): string[] {
|
|
1203
|
+
const emit = effectiveCompilerEmit(project, options);
|
|
1204
|
+
const outputs = new Set<string>();
|
|
1205
|
+
for (const input of inputs) {
|
|
1206
|
+
const extension = path.extname(input).toLowerCase();
|
|
1207
|
+
if (!isCompilerEmittableSourceExtension(extension)) {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (/\.d\.(?:ts|mts|cts)$/i.test(input)) continue;
|
|
1211
|
+
const stem = input.slice(0, -extension.length);
|
|
1212
|
+
if (
|
|
1213
|
+
emit.javascript &&
|
|
1214
|
+
emit.outDir === undefined &&
|
|
1215
|
+
emit.outFile === undefined &&
|
|
1216
|
+
(!isJavaScriptSourceExtension(extension) ||
|
|
1217
|
+
(extension === ".jsx" && emit.jsx !== "preserve"))
|
|
1218
|
+
) {
|
|
1219
|
+
const javascriptExtension =
|
|
1220
|
+
extension === ".mts"
|
|
1221
|
+
? ".mjs"
|
|
1222
|
+
: extension === ".cts"
|
|
1223
|
+
? ".cjs"
|
|
1224
|
+
: extension === ".tsx" && emit.jsx === "preserve"
|
|
1225
|
+
? ".jsx"
|
|
1226
|
+
: ".js";
|
|
1227
|
+
const javascript = stem + javascriptExtension;
|
|
1228
|
+
outputs.add(javascript);
|
|
1229
|
+
if (emit.sourceMap) outputs.add(`${javascript}.map`);
|
|
1230
|
+
}
|
|
1231
|
+
if (
|
|
1232
|
+
emit.declaration &&
|
|
1233
|
+
emit.outDir === undefined &&
|
|
1234
|
+
emit.declarationDir === undefined &&
|
|
1235
|
+
emit.outFile === undefined
|
|
1236
|
+
) {
|
|
1237
|
+
const declarationExtension =
|
|
1238
|
+
extension === ".mts" || extension === ".mjs"
|
|
1239
|
+
? ".d.mts"
|
|
1240
|
+
: extension === ".cts" || extension === ".cjs"
|
|
1241
|
+
? ".d.cts"
|
|
1242
|
+
: ".d.ts";
|
|
1243
|
+
const declaration = stem + declarationExtension;
|
|
1244
|
+
outputs.add(declaration);
|
|
1245
|
+
if (emit.declarationMap) outputs.add(`${declaration}.map`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return [...outputs];
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function isCompilerEmittableSourceExtension(extension: string): boolean {
|
|
1252
|
+
return [
|
|
1253
|
+
".cjs",
|
|
1254
|
+
".cts",
|
|
1255
|
+
".js",
|
|
1256
|
+
".jsx",
|
|
1257
|
+
".mjs",
|
|
1258
|
+
".mts",
|
|
1259
|
+
".ts",
|
|
1260
|
+
".tsx",
|
|
1261
|
+
].includes(extension);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function isJavaScriptSourceExtension(extension: string): boolean {
|
|
1265
|
+
return [".cjs", ".js", ".jsx", ".mjs"].includes(extension);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
type EffectiveCompilerEmit = {
|
|
1269
|
+
declaration: boolean;
|
|
1270
|
+
declarationDir?: string;
|
|
1271
|
+
declarationMap: boolean;
|
|
1272
|
+
incremental: boolean;
|
|
1273
|
+
javascript: boolean;
|
|
1274
|
+
jsx?: unknown;
|
|
1275
|
+
outDir?: string;
|
|
1276
|
+
outFile?: string;
|
|
1277
|
+
rootDir?: string;
|
|
1278
|
+
sourceMap: boolean;
|
|
1279
|
+
tsBuildInfoFile?: string;
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
function effectiveCompilerEmit(
|
|
1283
|
+
project: ITtscParsedProjectConfig,
|
|
1284
|
+
options: WatchTopologyOptions,
|
|
1285
|
+
): EffectiveCompilerEmit {
|
|
312
1286
|
const compilerOptions = project.compilerOptions;
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
1287
|
+
const passthrough = options.passthrough;
|
|
1288
|
+
const noEmit =
|
|
1289
|
+
passthroughBooleanOption(passthrough, "--noEmit") ??
|
|
1290
|
+
(options.emit === false
|
|
1291
|
+
? true
|
|
1292
|
+
: options.emit === true
|
|
1293
|
+
? false
|
|
1294
|
+
: compilerOptions.noEmit === true);
|
|
1295
|
+
const composite =
|
|
1296
|
+
passthroughBooleanOption(passthrough, "--composite") ??
|
|
1297
|
+
compilerOptions.composite === true;
|
|
1298
|
+
const incremental =
|
|
1299
|
+
composite ||
|
|
1300
|
+
(passthroughBooleanOption(passthrough, "--incremental") ??
|
|
1301
|
+
compilerOptions.incremental === true);
|
|
1302
|
+
const emitDeclarationOnly =
|
|
1303
|
+
passthroughBooleanOption(passthrough, "--emitDeclarationOnly") ??
|
|
1304
|
+
(options.emit === true
|
|
1305
|
+
? false
|
|
1306
|
+
: compilerOptions.emitDeclarationOnly === true);
|
|
1307
|
+
const declaration =
|
|
1308
|
+
!noEmit &&
|
|
1309
|
+
(composite ||
|
|
1310
|
+
(passthroughBooleanOption(passthrough, "--declaration") ??
|
|
1311
|
+
compilerOptions.declaration === true));
|
|
1312
|
+
const javascript = !noEmit && !emitDeclarationOnly;
|
|
1313
|
+
const sourceMap =
|
|
1314
|
+
javascript &&
|
|
1315
|
+
(passthroughBooleanOption(passthrough, "--sourceMap") ??
|
|
1316
|
+
compilerOptions.sourceMap === true) &&
|
|
1317
|
+
!(
|
|
1318
|
+
passthroughBooleanOption(passthrough, "--inlineSourceMap") ??
|
|
1319
|
+
compilerOptions.inlineSourceMap === true
|
|
1320
|
+
);
|
|
1321
|
+
const declarationMap =
|
|
1322
|
+
declaration &&
|
|
1323
|
+
(passthroughBooleanOption(passthrough, "--declarationMap") ??
|
|
1324
|
+
compilerOptions.declarationMap === true);
|
|
1325
|
+
const cliOutDir = passthroughPathOption(passthrough, "--outDir");
|
|
1326
|
+
const cliDeclarationDir = passthroughPathOption(
|
|
1327
|
+
passthrough,
|
|
1328
|
+
"--declarationDir",
|
|
1329
|
+
);
|
|
1330
|
+
const cliOutFile = passthroughPathOption(passthrough, "--outFile");
|
|
1331
|
+
const cliRootDir = passthroughPathOption(passthrough, "--rootDir");
|
|
1332
|
+
const cliTsBuildInfoFile = passthroughPathOption(
|
|
1333
|
+
passthrough,
|
|
1334
|
+
"--tsBuildInfoFile",
|
|
1335
|
+
);
|
|
1336
|
+
const jsx =
|
|
1337
|
+
passthroughStringOption(passthrough, "--jsx") ?? compilerOptions.jsx;
|
|
316
1338
|
return {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
1339
|
+
declaration,
|
|
1340
|
+
declarationDir:
|
|
1341
|
+
cliDeclarationDir === null
|
|
1342
|
+
? undefined
|
|
1343
|
+
: cliDeclarationDir !== undefined
|
|
1344
|
+
? path.resolve(options.cwd, cliDeclarationDir)
|
|
1345
|
+
: typeof compilerOptions.declarationDir === "string"
|
|
1346
|
+
? path.resolve(compilerOptions.declarationDir)
|
|
1347
|
+
: undefined,
|
|
1348
|
+
declarationMap,
|
|
1349
|
+
incremental,
|
|
1350
|
+
javascript,
|
|
1351
|
+
outDir:
|
|
1352
|
+
cliOutDir === null
|
|
1353
|
+
? undefined
|
|
1354
|
+
: cliOutDir !== undefined
|
|
1355
|
+
? path.resolve(options.cwd, cliOutDir)
|
|
1356
|
+
: options.outDir !== undefined
|
|
1357
|
+
? path.resolve(options.cwd, options.outDir)
|
|
1358
|
+
: typeof compilerOptions.outDir === "string"
|
|
1359
|
+
? path.resolve(compilerOptions.outDir)
|
|
1360
|
+
: undefined,
|
|
1361
|
+
outFile:
|
|
1362
|
+
cliOutFile === null
|
|
1363
|
+
? undefined
|
|
1364
|
+
: cliOutFile !== undefined
|
|
1365
|
+
? path.resolve(options.cwd, cliOutFile)
|
|
1366
|
+
: typeof compilerOptions.outFile === "string"
|
|
1367
|
+
? path.resolve(compilerOptions.outFile)
|
|
1368
|
+
: undefined,
|
|
1369
|
+
rootDir:
|
|
1370
|
+
cliRootDir === null
|
|
1371
|
+
? undefined
|
|
1372
|
+
: cliRootDir !== undefined
|
|
1373
|
+
? path.resolve(options.cwd, cliRootDir)
|
|
1374
|
+
: typeof compilerOptions.rootDir === "string"
|
|
1375
|
+
? path.resolve(compilerOptions.rootDir)
|
|
1376
|
+
: undefined,
|
|
1377
|
+
sourceMap,
|
|
1378
|
+
tsBuildInfoFile:
|
|
1379
|
+
cliTsBuildInfoFile === null
|
|
1380
|
+
? undefined
|
|
1381
|
+
: cliTsBuildInfoFile !== undefined
|
|
1382
|
+
? path.resolve(options.cwd, cliTsBuildInfoFile)
|
|
1383
|
+
: typeof compilerOptions.tsBuildInfoFile === "string"
|
|
1384
|
+
? path.resolve(compilerOptions.tsBuildInfoFile)
|
|
1385
|
+
: undefined,
|
|
1386
|
+
jsx,
|
|
323
1387
|
};
|
|
324
1388
|
}
|
|
325
1389
|
|
|
1390
|
+
function defaultTsBuildInfoFile(
|
|
1391
|
+
project: ITtscParsedProjectConfig,
|
|
1392
|
+
emit: EffectiveCompilerEmit,
|
|
1393
|
+
): string {
|
|
1394
|
+
if (emit.tsBuildInfoFile !== undefined) return emit.tsBuildInfoFile;
|
|
1395
|
+
const configWithoutExtension = replaceOutputExtension(project.path, "");
|
|
1396
|
+
if (emit.outDir === undefined) return `${configWithoutExtension}.tsbuildinfo`;
|
|
1397
|
+
const relative =
|
|
1398
|
+
emit.rootDir === undefined
|
|
1399
|
+
? path.basename(configWithoutExtension)
|
|
1400
|
+
: path.relative(emit.rootDir, configWithoutExtension);
|
|
1401
|
+
return path.resolve(emit.outDir, `${relative}.tsbuildinfo`);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function replaceOutputExtension(location: string, extension: string): string {
|
|
1405
|
+
const current = path.extname(location);
|
|
1406
|
+
return current === ""
|
|
1407
|
+
? `${location}${extension}`
|
|
1408
|
+
: `${location.slice(0, -current.length)}${extension}`;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function passthroughBooleanOption(
|
|
1412
|
+
tokens: readonly string[] | undefined,
|
|
1413
|
+
name: string,
|
|
1414
|
+
): boolean | undefined {
|
|
1415
|
+
let value: boolean | undefined;
|
|
1416
|
+
for (let index = 0; index < (tokens?.length ?? 0); index++) {
|
|
1417
|
+
const token = tokens?.[index];
|
|
1418
|
+
if (token === undefined) continue;
|
|
1419
|
+
if (!passthroughOptionMatches(token, name)) continue;
|
|
1420
|
+
const next = tokens?.[index + 1];
|
|
1421
|
+
if (next === "true" || next === "false" || next === "null") {
|
|
1422
|
+
value = next === "true";
|
|
1423
|
+
index++;
|
|
1424
|
+
} else {
|
|
1425
|
+
value = true;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return value;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function passthroughPathOption(
|
|
1432
|
+
tokens: readonly string[] | undefined,
|
|
1433
|
+
name: string,
|
|
1434
|
+
): string | null | undefined {
|
|
1435
|
+
let value: string | null | undefined;
|
|
1436
|
+
for (let index = 0; index < (tokens?.length ?? 0); index++) {
|
|
1437
|
+
const token = tokens?.[index];
|
|
1438
|
+
if (token === undefined) continue;
|
|
1439
|
+
if (!passthroughOptionMatches(token, name)) continue;
|
|
1440
|
+
if (index + 1 < (tokens?.length ?? 0)) {
|
|
1441
|
+
const next = tokens?.[++index];
|
|
1442
|
+
value = next === "null" ? null : next;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return value;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function passthroughStringOption(
|
|
1449
|
+
tokens: readonly string[] | undefined,
|
|
1450
|
+
name: string,
|
|
1451
|
+
): string | undefined {
|
|
1452
|
+
return passthroughPathOption(tokens, name)?.toLowerCase() ?? undefined;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function passthroughOptionMatches(token: string, name: string): boolean {
|
|
1456
|
+
if (!token.startsWith("-")) return false;
|
|
1457
|
+
if (token.includes("=")) return false;
|
|
1458
|
+
return resolveFlagSpec(token)?.name === resolveFlagSpec(name)?.name;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
326
1461
|
function isCompilerOutput(
|
|
327
1462
|
file: string,
|
|
328
1463
|
outputs: { directories: readonly string[]; files: readonly string[] },
|
|
@@ -367,7 +1502,14 @@ function collectInputDirectories(input: string): string[] {
|
|
|
367
1502
|
while (stack.length !== 0) {
|
|
368
1503
|
const current = stack.pop()!;
|
|
369
1504
|
directories.push(current);
|
|
370
|
-
|
|
1505
|
+
let entries: fs.Dirent[];
|
|
1506
|
+
try {
|
|
1507
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
if (isVanishedFilesystemEntry(error)) continue;
|
|
1510
|
+
throw error;
|
|
1511
|
+
}
|
|
1512
|
+
for (const entry of entries) {
|
|
371
1513
|
if (entry.isDirectory()) {
|
|
372
1514
|
stack.push(path.join(current, entry.name));
|
|
373
1515
|
}
|
|
@@ -376,6 +1518,14 @@ function collectInputDirectories(input: string): string[] {
|
|
|
376
1518
|
return directories;
|
|
377
1519
|
}
|
|
378
1520
|
|
|
1521
|
+
function isVanishedFilesystemEntry(error: unknown): boolean {
|
|
1522
|
+
return (
|
|
1523
|
+
error instanceof Error &&
|
|
1524
|
+
"code" in error &&
|
|
1525
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
379
1529
|
function isDirectory(location: string): boolean {
|
|
380
1530
|
try {
|
|
381
1531
|
return fs.statSync(location).isDirectory();
|
|
@@ -384,27 +1534,42 @@ function isDirectory(location: string): boolean {
|
|
|
384
1534
|
}
|
|
385
1535
|
}
|
|
386
1536
|
|
|
387
|
-
|
|
388
|
-
|
|
1537
|
+
type SynchronizedWatcher = {
|
|
1538
|
+
close(): void;
|
|
1539
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
export function syncWatchers<T extends SynchronizedWatcher>(
|
|
1543
|
+
watchers: Map<string, T>,
|
|
389
1544
|
desired: ReadonlyMap<string, string>,
|
|
390
|
-
create: (location: string) =>
|
|
1545
|
+
create: (location: string, key: string) => T,
|
|
391
1546
|
onError: (location: string, error: unknown) => void,
|
|
392
|
-
):
|
|
393
|
-
|
|
394
|
-
if (desired.has(key)) continue;
|
|
395
|
-
watcher.close();
|
|
396
|
-
watchers.delete(key);
|
|
397
|
-
}
|
|
1547
|
+
): boolean {
|
|
1548
|
+
let complete = true;
|
|
398
1549
|
for (const [key, location] of desired) {
|
|
399
1550
|
if (watchers.has(key)) continue;
|
|
400
1551
|
try {
|
|
401
|
-
const watcher = create(location);
|
|
402
|
-
watcher.on("error", (error) =>
|
|
1552
|
+
const watcher = create(location, key);
|
|
1553
|
+
watcher.on("error", (error) => {
|
|
1554
|
+
if (watchers.get(key) === watcher) {
|
|
1555
|
+
watchers.delete(key);
|
|
1556
|
+
}
|
|
1557
|
+
watcher.close();
|
|
1558
|
+
onError(location, error);
|
|
1559
|
+
});
|
|
403
1560
|
watchers.set(key, watcher);
|
|
404
1561
|
} catch (error) {
|
|
1562
|
+
complete = false;
|
|
405
1563
|
onError(location, error);
|
|
406
1564
|
}
|
|
407
1565
|
}
|
|
1566
|
+
if (!complete) return false;
|
|
1567
|
+
for (const [key, watcher] of watchers) {
|
|
1568
|
+
if (desired.has(key)) continue;
|
|
1569
|
+
watcher.close();
|
|
1570
|
+
watchers.delete(key);
|
|
1571
|
+
}
|
|
1572
|
+
return true;
|
|
408
1573
|
}
|
|
409
1574
|
|
|
410
1575
|
function closeWatchers(watchers: Map<string, fs.FSWatcher>): void {
|
|
@@ -429,6 +1594,708 @@ function uniqueExistingPaths(paths: readonly string[]): string[] {
|
|
|
429
1594
|
return [...unique.values()];
|
|
430
1595
|
}
|
|
431
1596
|
|
|
1597
|
+
function normalizeProjectInputSnapshot(
|
|
1598
|
+
snapshot: ITtscProjectInputSnapshot,
|
|
1599
|
+
): ITtscProjectInputSnapshot {
|
|
1600
|
+
const identities = createProjectInputPathIdentityContext();
|
|
1601
|
+
const files = new Map<string, string>();
|
|
1602
|
+
const globs = new Map<string, string>();
|
|
1603
|
+
const reloadDirectories = new Map<string, string>();
|
|
1604
|
+
const reloadFiles = new Map<string, string>();
|
|
1605
|
+
for (const file of snapshot.files) {
|
|
1606
|
+
const identity = identities.resolve(file);
|
|
1607
|
+
files.set(identity.key, identity.path);
|
|
1608
|
+
}
|
|
1609
|
+
for (const glob of snapshot.globs) {
|
|
1610
|
+
const identity = identities.resolve(glob);
|
|
1611
|
+
globs.set(identity.key, identity.path.split(path.sep).join("/"));
|
|
1612
|
+
}
|
|
1613
|
+
for (const file of snapshot.reloadFiles ?? []) {
|
|
1614
|
+
const identity = identities.resolve(file);
|
|
1615
|
+
reloadFiles.set(identity.key, identity.path);
|
|
1616
|
+
}
|
|
1617
|
+
for (const directory of snapshot.reloadDirectories ?? []) {
|
|
1618
|
+
const identity = identities.resolve(directory);
|
|
1619
|
+
reloadDirectories.set(identity.key, identity.path);
|
|
1620
|
+
}
|
|
1621
|
+
return {
|
|
1622
|
+
files: [...files.values()].sort(),
|
|
1623
|
+
globs: [...globs.values()].sort(),
|
|
1624
|
+
reloadDirectories: [...reloadDirectories.values()].sort(),
|
|
1625
|
+
reloadFiles: [...reloadFiles.values()].sort(),
|
|
1626
|
+
root: identities.resolve(snapshot.root).path,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function projectInputSnapshotsEqual(
|
|
1631
|
+
left: ITtscProjectInputSnapshot,
|
|
1632
|
+
right: ITtscProjectInputSnapshot,
|
|
1633
|
+
): boolean {
|
|
1634
|
+
const identities = createProjectInputPathIdentityContext();
|
|
1635
|
+
return (
|
|
1636
|
+
// Every sibling comparison in this class decides on identity, and this one
|
|
1637
|
+
// is only safe today because both operands come from the same normalizer.
|
|
1638
|
+
identities.resolve(left.root || ".").key ===
|
|
1639
|
+
identities.resolve(right.root || ".").key &&
|
|
1640
|
+
arraysEqual(left.files, right.files) &&
|
|
1641
|
+
arraysEqual(left.globs, right.globs) &&
|
|
1642
|
+
arraysEqual(left.reloadDirectories ?? [], right.reloadDirectories ?? []) &&
|
|
1643
|
+
arraysEqual(left.reloadFiles ?? [], right.reloadFiles ?? [])
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
export function literalGlobRoot(pattern: string): string {
|
|
1648
|
+
const resolved = path.resolve(pattern);
|
|
1649
|
+
const normalized = resolved.split("\\").join("/");
|
|
1650
|
+
const wildcard = normalized.search(/[*?]/);
|
|
1651
|
+
if (wildcard === -1) return path.dirname(resolved);
|
|
1652
|
+
const separator = normalized.lastIndexOf("/", wildcard);
|
|
1653
|
+
const prefix = separator < 0 ? "." : normalized.slice(0, separator);
|
|
1654
|
+
const volumeRoot = path.parse(resolved).root;
|
|
1655
|
+
const normalizedVolumeRoot = volumeRoot.split("\\").join("/");
|
|
1656
|
+
if (
|
|
1657
|
+
prefix.length === 0 ||
|
|
1658
|
+
prefix === normalizedVolumeRoot.replace(/\/$/, "")
|
|
1659
|
+
) {
|
|
1660
|
+
return volumeRoot;
|
|
1661
|
+
}
|
|
1662
|
+
return path.resolve(prefix);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function projectInputDeclarationKey(
|
|
1666
|
+
kind: "file" | "glob" | "reload" | "reload-directory",
|
|
1667
|
+
declaration: string,
|
|
1668
|
+
): string {
|
|
1669
|
+
return `${kind}\0${resolveProjectInputPath(declaration)}`;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Chooses the one stable recursive watcher root owned by a project-input
|
|
1674
|
+
* declaration.
|
|
1675
|
+
*
|
|
1676
|
+
* Inputs inside the project share its physical root so directory replacement
|
|
1677
|
+
* cannot strand a child handle. External inputs use the nearest existing
|
|
1678
|
+
* ancestor of their declared parent, which is the explicit boundary for
|
|
1679
|
+
* observing a currently missing external tree without polling every file --
|
|
1680
|
+
* except that the boundary never rises to a directory holding the project,
|
|
1681
|
+
* since such a root outranks the project's own in the active merge and leaves
|
|
1682
|
+
* one handle over a shared system directory to carry everything. An external
|
|
1683
|
+
* declaration that can only be owned that way falls back to its own tree, and
|
|
1684
|
+
* is left unwatched when even that would contain the project.
|
|
1685
|
+
*/
|
|
1686
|
+
export function projectInputWatchDirectories(
|
|
1687
|
+
target: string,
|
|
1688
|
+
projectRoot: string,
|
|
1689
|
+
): string[] {
|
|
1690
|
+
const root = projectInputRecursiveWatchRoot(target, projectRoot);
|
|
1691
|
+
return root === undefined ? [] : [root];
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Removes recursive roots already covered by an ancestor without rewriting the
|
|
1696
|
+
* declaration-specific roots retained by WatchTopology.
|
|
1697
|
+
*/
|
|
1698
|
+
export function projectInputActiveWatchDirectories(
|
|
1699
|
+
directories: Iterable<string>,
|
|
1700
|
+
identities = createProjectInputPathIdentityContext(),
|
|
1701
|
+
): string[] {
|
|
1702
|
+
const unique = new Map<string, string>();
|
|
1703
|
+
for (const directory of directories) {
|
|
1704
|
+
// Coverage is decided on physical identity so two spellings of one
|
|
1705
|
+
// directory cannot both survive, but the surviving entry keeps the
|
|
1706
|
+
// caller's spelling: this function selects roots, it does not rename them.
|
|
1707
|
+
const identity = identities.resolve(directory);
|
|
1708
|
+
if (!unique.has(identity.key)) unique.set(identity.key, directory);
|
|
1709
|
+
}
|
|
1710
|
+
return [...unique]
|
|
1711
|
+
.filter(([key]) => {
|
|
1712
|
+
let ancestor = path.dirname(key);
|
|
1713
|
+
while (ancestor !== key) {
|
|
1714
|
+
if (unique.has(ancestor)) return false;
|
|
1715
|
+
const parent = path.dirname(ancestor);
|
|
1716
|
+
if (parent === ancestor) break;
|
|
1717
|
+
ancestor = parent;
|
|
1718
|
+
}
|
|
1719
|
+
return true;
|
|
1720
|
+
})
|
|
1721
|
+
.map(([, directory]) => directory);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
export function projectInputAvailableWatchDirectory(
|
|
1725
|
+
location: string,
|
|
1726
|
+
rejected: ReadonlySet<string>,
|
|
1727
|
+
identities: ProjectInputPathIdentityContext = createProjectInputPathIdentityContext(),
|
|
1728
|
+
projectRoot?: string,
|
|
1729
|
+
): string | undefined {
|
|
1730
|
+
const resolvedProjectRoot =
|
|
1731
|
+
projectRoot === undefined ? undefined : path.resolve(projectRoot);
|
|
1732
|
+
let current = path.resolve(location);
|
|
1733
|
+
while (true) {
|
|
1734
|
+
const identity = identities.resolve(current);
|
|
1735
|
+
// Escalation obeys the same ceiling root selection does. A watcher that
|
|
1736
|
+
// failed once would otherwise be replaced by one over a directory holding
|
|
1737
|
+
// the project, which outranks the project's own root in the active merge
|
|
1738
|
+
// and re-opens, through the recovery path, the exact swallow that selection
|
|
1739
|
+
// refuses to create. Giving up the failed root is the lesser loss.
|
|
1740
|
+
if (
|
|
1741
|
+
resolvedProjectRoot !== undefined &&
|
|
1742
|
+
identity.key !== identities.resolve(resolvedProjectRoot).key &&
|
|
1743
|
+
identities.isWithin(identity.path, resolvedProjectRoot)
|
|
1744
|
+
) {
|
|
1745
|
+
return undefined;
|
|
1746
|
+
}
|
|
1747
|
+
if (!rejected.has(identity.key)) return identity.path;
|
|
1748
|
+
const parent = path.dirname(current);
|
|
1749
|
+
if (parent === current) return undefined;
|
|
1750
|
+
const fallback = nearestExistingDirectory(parent);
|
|
1751
|
+
if (fallback === undefined) return undefined;
|
|
1752
|
+
current = fallback;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function projectInputRecursiveWatchRoot(
|
|
1757
|
+
target: string,
|
|
1758
|
+
projectRoot: string,
|
|
1759
|
+
identities = createProjectInputPathIdentityContext(),
|
|
1760
|
+
): string | undefined {
|
|
1761
|
+
const resolvedTarget = path.resolve(target);
|
|
1762
|
+
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
1763
|
+
if (identities.isWithin(resolvedProjectRoot, resolvedTarget)) {
|
|
1764
|
+
return nearestExistingDirectory(resolvedProjectRoot);
|
|
1765
|
+
}
|
|
1766
|
+
// An external anchor rises to the declared parent so a tree that does not
|
|
1767
|
+
// exist yet is still observed, and so siblings under it share one handle. It
|
|
1768
|
+
// may not rise past the project, though: a directory that contains the
|
|
1769
|
+
// project swallows the project's own root when the two are merged, and every
|
|
1770
|
+
// in-project declaration then rides one recursive handle over a shared system
|
|
1771
|
+
// directory — a temp root, or the filesystem root itself — which delivers
|
|
1772
|
+
// nothing. Prefer the declared parent, fall back to the target's own tree,
|
|
1773
|
+
// and decline rather than widen past the project.
|
|
1774
|
+
for (const candidate of [
|
|
1775
|
+
nearestExistingDirectory(path.dirname(resolvedTarget)),
|
|
1776
|
+
nearestExistingDirectory(resolvedTarget),
|
|
1777
|
+
]) {
|
|
1778
|
+
if (candidate === undefined) continue;
|
|
1779
|
+
// The project root cannot outrank itself in the merge, so it is the one
|
|
1780
|
+
// container that is never a swallow — it is the owner the internal branch
|
|
1781
|
+
// would have chosen anyway. A declaration reached through an in-project
|
|
1782
|
+
// directory symlink lands here, and rejecting it would drop the hoist that
|
|
1783
|
+
// keeps a replaced directory from stranding a child handle.
|
|
1784
|
+
if (
|
|
1785
|
+
identities.resolve(candidate).key !==
|
|
1786
|
+
identities.resolve(resolvedProjectRoot).key &&
|
|
1787
|
+
identities.isWithin(candidate, resolvedProjectRoot)
|
|
1788
|
+
) {
|
|
1789
|
+
continue;
|
|
1790
|
+
}
|
|
1791
|
+
return candidate;
|
|
1792
|
+
}
|
|
1793
|
+
return undefined;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* A cheap identity for a tracked file's current bytes.
|
|
1798
|
+
*
|
|
1799
|
+
* Modification time and size together answer "did this move" without reading
|
|
1800
|
+
* the file, which is all the unnamed-event path needs; a file that vanished
|
|
1801
|
+
* answers as absent rather than as unchanged.
|
|
1802
|
+
*/
|
|
1803
|
+
function compilerFileStamp(location: string): string {
|
|
1804
|
+
try {
|
|
1805
|
+
const stats = fs.statSync(location);
|
|
1806
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
1807
|
+
} catch {
|
|
1808
|
+
return "";
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function isSymbolicLink(location: string): boolean {
|
|
1813
|
+
try {
|
|
1814
|
+
return fs.lstatSync(location).isSymbolicLink();
|
|
1815
|
+
} catch {
|
|
1816
|
+
return false;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function nearestExistingDirectory(location: string): string | undefined {
|
|
1821
|
+
let current = path.resolve(location);
|
|
1822
|
+
while (true) {
|
|
1823
|
+
if (isDirectory(current)) return current;
|
|
1824
|
+
const parent = path.dirname(current);
|
|
1825
|
+
if (parent === current) return undefined;
|
|
1826
|
+
current = parent;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function matchesProjectInput(
|
|
1831
|
+
snapshot: ITtscProjectInputSnapshot,
|
|
1832
|
+
location: string,
|
|
1833
|
+
identities = createProjectInputPathIdentityContext(),
|
|
1834
|
+
): boolean {
|
|
1835
|
+
const key = identities.resolve(location).key;
|
|
1836
|
+
return (
|
|
1837
|
+
snapshot.files.some((file) => identities.resolve(file).key === key) ||
|
|
1838
|
+
(snapshot.reloadFiles ?? []).some(
|
|
1839
|
+
(file) => identities.resolve(file).key === key,
|
|
1840
|
+
) ||
|
|
1841
|
+
(snapshot.reloadDirectories ?? []).some((directory) =>
|
|
1842
|
+
identities.isWithin(directory, location),
|
|
1843
|
+
) ||
|
|
1844
|
+
snapshot.globs.some((glob) =>
|
|
1845
|
+
matchesProjectInputGlob(glob, location, identities),
|
|
1846
|
+
)
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* Whether a replacement at this path leaves a recursive watcher bound to the
|
|
1852
|
+
* object that was replaced.
|
|
1853
|
+
*
|
|
1854
|
+
* Only one backend needs the answer. Node routes a recursive watch to its own
|
|
1855
|
+
* per-directory implementation when the platform is neither macOS nor Windows,
|
|
1856
|
+
* and that implementation keys its handles by path: the handle for a directory
|
|
1857
|
+
* renamed away stays bound to the object that left, and any child whose name
|
|
1858
|
+
* survives the swap is skipped as already known. The native subtree backends
|
|
1859
|
+
* both other platforms use follow the path, so retiring their watcher would buy
|
|
1860
|
+
* nothing and would open a window in which no events are delivered.
|
|
1861
|
+
*
|
|
1862
|
+
* The answer is deliberately narrower than the rescan rule, because
|
|
1863
|
+
* reinstalling a root costs one watch descriptor per entry beneath it, which an
|
|
1864
|
+
* install storm would pay thousands of times. A directory appearing inside a
|
|
1865
|
+
* glob root deserves a rescan but replaces nothing a root stands on, and a
|
|
1866
|
+
* reload directory anchors the directory that contains it rather than itself,
|
|
1867
|
+
* since its fingerprint is a digest of its own immediate entries and nothing
|
|
1868
|
+
* below it can reach the declared corpus.
|
|
1869
|
+
*/
|
|
1870
|
+
export function projectInputReplacementStrandsWatchers(
|
|
1871
|
+
snapshot: ITtscProjectInputSnapshot,
|
|
1872
|
+
location: string,
|
|
1873
|
+
identities = createProjectInputPathIdentityContext(),
|
|
1874
|
+
platform: NodeJS.Platform = process.platform,
|
|
1875
|
+
): boolean {
|
|
1876
|
+
if (platform === "darwin" || platform === "win32") return false;
|
|
1877
|
+
const changed = path.resolve(location);
|
|
1878
|
+
if (!isDirectory(changed)) return false;
|
|
1879
|
+
return projectInputAnchorsDeclaration(
|
|
1880
|
+
snapshot,
|
|
1881
|
+
path.dirname(changed),
|
|
1882
|
+
identities,
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function projectInputAnchorsDeclaration(
|
|
1887
|
+
snapshot: ITtscProjectInputSnapshot,
|
|
1888
|
+
directory: string,
|
|
1889
|
+
identities: ProjectInputPathIdentityContext,
|
|
1890
|
+
): boolean {
|
|
1891
|
+
return (
|
|
1892
|
+
snapshot.files.some((file) => identities.isWithin(directory, file)) ||
|
|
1893
|
+
(snapshot.reloadFiles ?? []).some((file) =>
|
|
1894
|
+
identities.isWithin(directory, file),
|
|
1895
|
+
) ||
|
|
1896
|
+
(snapshot.reloadDirectories ?? []).some(
|
|
1897
|
+
(entry) =>
|
|
1898
|
+
// A reload directory anchors the directory that contains it, never
|
|
1899
|
+
// itself. Its own fingerprint is a digest of its immediate entries, so
|
|
1900
|
+
// nothing below it can reach the corpus, and treating it as its own
|
|
1901
|
+
// anchor would rearm for every entry created directly inside it —
|
|
1902
|
+
// including `node_modules`, which contributors publish as one.
|
|
1903
|
+
identities.isWithin(directory, entry) &&
|
|
1904
|
+
identities.resolve(directory).key !== identities.resolve(entry).key,
|
|
1905
|
+
) ||
|
|
1906
|
+
snapshot.globs.some((glob) =>
|
|
1907
|
+
identities.isWithin(directory, literalGlobRoot(glob)),
|
|
1908
|
+
)
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* Decide whether an event that named no declared input can still have moved
|
|
1914
|
+
* one.
|
|
1915
|
+
*
|
|
1916
|
+
* The admitted set is the only bound on how often a watch session re-reads and
|
|
1917
|
+
* re-hashes its declared corpus, and both directions cost: too narrow drops an
|
|
1918
|
+
* atomic replacement, too wide re-fingerprints on every entry an install
|
|
1919
|
+
* creates. Exported so that boundary is pinned directly instead of being
|
|
1920
|
+
* inferred from a rebuild that a silent rescan and a skipped rescan produce
|
|
1921
|
+
* identically.
|
|
1922
|
+
*/
|
|
1923
|
+
export function projectInputTopologyMayAffect(
|
|
1924
|
+
snapshot: ITtscProjectInputSnapshot,
|
|
1925
|
+
location: string,
|
|
1926
|
+
previous: ReadonlyMap<string, string>,
|
|
1927
|
+
identities = createProjectInputPathIdentityContext(),
|
|
1928
|
+
): boolean {
|
|
1929
|
+
const changed = path.resolve(location);
|
|
1930
|
+
const anchors = (directory: string): boolean =>
|
|
1931
|
+
projectInputAnchorsDeclaration(snapshot, directory, identities);
|
|
1932
|
+
// An atomic replacement never names the declared file whose bytes it changed;
|
|
1933
|
+
// it names the directory that was swapped, and that directory can be one the
|
|
1934
|
+
// declaration does not contain — renaming `docs` away reports the arriving
|
|
1935
|
+
// `docs-old`, not `docs`. So a directory event is admitted from where it
|
|
1936
|
+
// happened rather than from what it contains: its own parent must already lie
|
|
1937
|
+
// on the path to a declared input. A tree no declaration reaches, such as
|
|
1938
|
+
// `node_modules` under an ordinary project, then costs nothing per created
|
|
1939
|
+
// entry instead of a population rescan and a full content re-fingerprint. A
|
|
1940
|
+
// glob whose literal root covers that tree still admits every directory
|
|
1941
|
+
// beneath it through the branch below, because a directory appearing inside a
|
|
1942
|
+
// glob root can hold matches; the declaration decides that reach, not this
|
|
1943
|
+
// rule.
|
|
1944
|
+
if (isDirectory(changed) && anchors(path.dirname(changed))) return true;
|
|
1945
|
+
return (
|
|
1946
|
+
anchors(changed) ||
|
|
1947
|
+
(snapshot.reloadDirectories ?? []).some((directory) =>
|
|
1948
|
+
identities.isWithin(directory, changed),
|
|
1949
|
+
) ||
|
|
1950
|
+
snapshot.globs.some((glob) => {
|
|
1951
|
+
const root = literalGlobRoot(glob);
|
|
1952
|
+
if (identities.isWithin(changed, root)) return true;
|
|
1953
|
+
if (identities.isWithin(root, changed) === false) return false;
|
|
1954
|
+
if (isDirectory(changed)) return true;
|
|
1955
|
+
return [...previous.values()].some((input) =>
|
|
1956
|
+
identities.isWithin(changed, input),
|
|
1957
|
+
);
|
|
1958
|
+
})
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
export function projectInputEventShouldNotify(input: {
|
|
1963
|
+
contentChanged: boolean;
|
|
1964
|
+
directlyMatched: boolean;
|
|
1965
|
+
membershipChanged: boolean;
|
|
1966
|
+
}): boolean {
|
|
1967
|
+
return input.contentChanged || input.membershipChanged;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function projectInputChangedPaths(input: {
|
|
1971
|
+
next: ReadonlyMap<string, string>;
|
|
1972
|
+
nextFingerprints: ReadonlyMap<string, string>;
|
|
1973
|
+
previous: ReadonlyMap<string, string>;
|
|
1974
|
+
previousFingerprints: ReadonlyMap<string, string>;
|
|
1975
|
+
}): string[] {
|
|
1976
|
+
const changed = new Map<string, string>();
|
|
1977
|
+
const keys = new Set([
|
|
1978
|
+
...input.previous.keys(),
|
|
1979
|
+
...input.next.keys(),
|
|
1980
|
+
...input.previousFingerprints.keys(),
|
|
1981
|
+
...input.nextFingerprints.keys(),
|
|
1982
|
+
]);
|
|
1983
|
+
for (const key of keys) {
|
|
1984
|
+
if (
|
|
1985
|
+
input.previous.has(key) === input.next.has(key) &&
|
|
1986
|
+
input.previousFingerprints.get(key) === input.nextFingerprints.get(key)
|
|
1987
|
+
) {
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
const location = input.next.get(key) ?? input.previous.get(key);
|
|
1991
|
+
if (location !== undefined) changed.set(key, location);
|
|
1992
|
+
}
|
|
1993
|
+
return [...changed.values()];
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Classify an exact execution-selection input ahead of ordinary project data.
|
|
1998
|
+
*
|
|
1999
|
+
* `changedInputs` carries fingerprint or membership deltas, so a filename-less
|
|
2000
|
+
* event can still select the cold lane. A named exact event selects the cold
|
|
2001
|
+
* lane only after the surrounding change detector admits the event; unchanged
|
|
2002
|
+
* bytes remain quiet before this classifier is observed.
|
|
2003
|
+
*/
|
|
2004
|
+
export function projectInputReloadEventShouldNotify(input: {
|
|
2005
|
+
changed?: string;
|
|
2006
|
+
changedInputs: readonly string[];
|
|
2007
|
+
reloadDirectories?: readonly string[];
|
|
2008
|
+
reloadFiles: readonly string[];
|
|
2009
|
+
globs?: readonly string[];
|
|
2010
|
+
}): boolean {
|
|
2011
|
+
const identities = createProjectInputPathIdentityContext();
|
|
2012
|
+
const reloadFiles = new Set(
|
|
2013
|
+
input.reloadFiles.map((location) => identities.resolve(location).key),
|
|
2014
|
+
);
|
|
2015
|
+
const reloadDirectories = (input.reloadDirectories ?? []).map((location) =>
|
|
2016
|
+
identities.resolve(location),
|
|
2017
|
+
);
|
|
2018
|
+
// Territory a declared glob is rooted at, judged against the resolution
|
|
2019
|
+
// directory that would otherwise claim it. A project root is published as a
|
|
2020
|
+
// resolution directory because the config lives there, so the directory a
|
|
2021
|
+
// glob is rooted at reads as a new immediate entry the moment it appears --
|
|
2022
|
+
// and appearing is what a declared population does. The data lane already
|
|
2023
|
+
// reports it, with program invalidation when the membership moved, which is
|
|
2024
|
+
// the cold-Program-same-process transition that belongs to data.
|
|
2025
|
+
//
|
|
2026
|
+
// Data can only carve out strictly below a resolution directory. A glob
|
|
2027
|
+
// rooted on that directory, or above it -- `literalGlobRoot` answers with the
|
|
2028
|
+
// volume root for a pattern with no literal prefix -- would otherwise exempt
|
|
2029
|
+
// everything the directory exists to classify, and the selection lane would
|
|
2030
|
+
// retire in silence. Its role predates any glob drawn around it.
|
|
2031
|
+
//
|
|
2032
|
+
// Only glob roots, either way. A declared file sitting directly in a
|
|
2033
|
+
// resolution directory is still a selection surface: its own bytes are what a
|
|
2034
|
+
// project rule reads to decide, once per execution.
|
|
2035
|
+
const globRoots = (input.globs ?? []).map((glob) =>
|
|
2036
|
+
identities.resolve(literalGlobRoot(glob)),
|
|
2037
|
+
);
|
|
2038
|
+
const exemptedFrom = (
|
|
2039
|
+
directory: ProjectInputPathIdentity,
|
|
2040
|
+
location: string,
|
|
2041
|
+
): boolean =>
|
|
2042
|
+
globRoots.some(
|
|
2043
|
+
(globRoot) =>
|
|
2044
|
+
globRoot.key !== directory.key &&
|
|
2045
|
+
identities.isWithin(directory.path, globRoot.path) &&
|
|
2046
|
+
identities.isWithin(globRoot.path, location),
|
|
2047
|
+
);
|
|
2048
|
+
// A reload directory is a non-recursive surface: its digest covers its
|
|
2049
|
+
// immediate entries' names, kinds, and link targets, so it moves only when the
|
|
2050
|
+
// directory itself moves or when an entry it holds directly does. Matching the
|
|
2051
|
+
// whole subtree instead makes a resolution ancestor -- the lint config graph
|
|
2052
|
+
// publishes every node_modules level it searched, up to the filesystem root --
|
|
2053
|
+
// read every edit beneath it as a selection change, which restarts the sidecar
|
|
2054
|
+
// on each keystroke and ends warm reuse for any project inside one.
|
|
2055
|
+
const holdsAsImmediateEntry = (location: string): boolean => {
|
|
2056
|
+
const parent = identities.resolve(path.dirname(location)).key;
|
|
2057
|
+
return reloadDirectories.some(
|
|
2058
|
+
(directory) =>
|
|
2059
|
+
directory.key === parent && exemptedFrom(directory, location) === false,
|
|
2060
|
+
);
|
|
2061
|
+
};
|
|
2062
|
+
const namesDirectory = (location: string): boolean => {
|
|
2063
|
+
const key = identities.resolve(location).key;
|
|
2064
|
+
return reloadDirectories.some((directory) => directory.key === key);
|
|
2065
|
+
};
|
|
2066
|
+
// A digest delta on the directory itself is the only signal there is when the
|
|
2067
|
+
// entry that appeared is not a declared match, so it cannot simply be dropped
|
|
2068
|
+
// -- but it also cannot say what appeared. The event being classified in the
|
|
2069
|
+
// same pass can: when it names data, the delta it caused is that data's, and
|
|
2070
|
+
// when it names anything else, or nothing at all, the safe reading is that
|
|
2071
|
+
// resolution moved. A missed reselection is a wrong answer; a spare restart is
|
|
2072
|
+
// a slow one.
|
|
2073
|
+
const explains = (directory: ProjectInputPathIdentity): boolean => {
|
|
2074
|
+
if (input.changed === undefined) return false;
|
|
2075
|
+
// Only an immediate entry can move this directory's digest, so only an
|
|
2076
|
+
// immediate entry can account for the delta. Asking whether the event was
|
|
2077
|
+
// data somewhere else answers a different directory's question, and one
|
|
2078
|
+
// exemption would then cancel every other directory's evidence.
|
|
2079
|
+
const parent = identities.resolve(path.dirname(input.changed)).key;
|
|
2080
|
+
return parent === directory.key && exemptedFrom(directory, input.changed);
|
|
2081
|
+
};
|
|
2082
|
+
const isReloadDirectoryEvent = (location: string): boolean =>
|
|
2083
|
+
namesDirectory(location) || holdsAsImmediateEntry(location);
|
|
2084
|
+
const isReloadDirectoryDelta = (location: string): boolean => {
|
|
2085
|
+
if (holdsAsImmediateEntry(location)) return true;
|
|
2086
|
+
const key = identities.resolve(location).key;
|
|
2087
|
+
return reloadDirectories.some(
|
|
2088
|
+
(directory) => directory.key === key && explains(directory) === false,
|
|
2089
|
+
);
|
|
2090
|
+
};
|
|
2091
|
+
return (
|
|
2092
|
+
(input.changed !== undefined &&
|
|
2093
|
+
(reloadFiles.has(identities.resolve(input.changed).key) ||
|
|
2094
|
+
isReloadDirectoryEvent(input.changed))) ||
|
|
2095
|
+
input.changedInputs.some(
|
|
2096
|
+
(location) =>
|
|
2097
|
+
reloadFiles.has(identities.resolve(location).key) ||
|
|
2098
|
+
isReloadDirectoryDelta(location),
|
|
2099
|
+
)
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/**
|
|
2104
|
+
* Return whether a project-input population transition can reshape a Program.
|
|
2105
|
+
*
|
|
2106
|
+
* JSON is data to a ProjectRule but may simultaneously be a `resolveJsonModule`
|
|
2107
|
+
* source. TypeScript and JavaScript paths can likewise overlap a project-input
|
|
2108
|
+
* declaration. Their creation or deletion therefore requires a cold Program
|
|
2109
|
+
* inside the existing resident process. A filename-less event cannot identify
|
|
2110
|
+
* the changed member and is conservatively invalidating whenever the population
|
|
2111
|
+
* moved.
|
|
2112
|
+
*/
|
|
2113
|
+
export function projectInputMembershipInvalidatesProgram(input: {
|
|
2114
|
+
changed?: string;
|
|
2115
|
+
changedInputs?: readonly string[];
|
|
2116
|
+
contentChanged?: boolean;
|
|
2117
|
+
next: ReadonlyMap<string, string>;
|
|
2118
|
+
previous: ReadonlyMap<string, string>;
|
|
2119
|
+
}): boolean {
|
|
2120
|
+
if (
|
|
2121
|
+
input.contentChanged === true &&
|
|
2122
|
+
(
|
|
2123
|
+
input.changedInputs ??
|
|
2124
|
+
(input.changed === undefined ? [] : [input.changed])
|
|
2125
|
+
).some(
|
|
2126
|
+
(location) => path.basename(location).toLowerCase() === "package.json",
|
|
2127
|
+
)
|
|
2128
|
+
) {
|
|
2129
|
+
return true;
|
|
2130
|
+
}
|
|
2131
|
+
if (mapsEqual(input.previous, input.next)) return false;
|
|
2132
|
+
if (input.changed === undefined) return true;
|
|
2133
|
+
for (const [key, location] of input.previous) {
|
|
2134
|
+
if (
|
|
2135
|
+
input.next.has(key) === false &&
|
|
2136
|
+
projectInputPathMayAffectProgram(location)
|
|
2137
|
+
) {
|
|
2138
|
+
return true;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
for (const [key, location] of input.next) {
|
|
2142
|
+
if (
|
|
2143
|
+
input.previous.has(key) === false &&
|
|
2144
|
+
projectInputPathMayAffectProgram(location)
|
|
2145
|
+
) {
|
|
2146
|
+
return true;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function projectInputPathMayAffectProgram(location: string): boolean {
|
|
2153
|
+
const extension = path.extname(location).toLowerCase();
|
|
2154
|
+
return extension === ".json" || isCompilerEmittableSourceExtension(extension);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function projectInputCompilerMembershipChange(
|
|
2158
|
+
snapshot: ITtscProjectInputSnapshot,
|
|
2159
|
+
previous: ReadonlyMap<string, string>,
|
|
2160
|
+
next: ReadonlyMap<string, string>,
|
|
2161
|
+
): string[] | undefined {
|
|
2162
|
+
const changed = new Map<string, string>();
|
|
2163
|
+
for (const [key, location] of previous) {
|
|
2164
|
+
if (next.has(key) === false) changed.set(key, location);
|
|
2165
|
+
}
|
|
2166
|
+
for (const [key, location] of next) {
|
|
2167
|
+
if (previous.has(key) === false) changed.set(key, location);
|
|
2168
|
+
}
|
|
2169
|
+
if (
|
|
2170
|
+
changed.size === 0 ||
|
|
2171
|
+
[...changed.values()].some(
|
|
2172
|
+
(location) =>
|
|
2173
|
+
matchesProjectInput(snapshot, location) === false ||
|
|
2174
|
+
projectInputPathMayAffectProgram(location) === false,
|
|
2175
|
+
)
|
|
2176
|
+
) {
|
|
2177
|
+
return undefined;
|
|
2178
|
+
}
|
|
2179
|
+
return [...changed.values()].sort();
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function fingerprintProjectInputMatches(
|
|
2183
|
+
matches: ReadonlyMap<string, string>,
|
|
2184
|
+
): Map<string, string> {
|
|
2185
|
+
const fingerprints = new Map<string, string>();
|
|
2186
|
+
for (const [key, location] of matches) {
|
|
2187
|
+
try {
|
|
2188
|
+
fingerprints.set(
|
|
2189
|
+
key,
|
|
2190
|
+
isDirectory(location)
|
|
2191
|
+
? fingerprintProjectInputDirectory(location)
|
|
2192
|
+
: crypto
|
|
2193
|
+
.createHash("sha256")
|
|
2194
|
+
.update(fs.readFileSync(location))
|
|
2195
|
+
.digest("hex"),
|
|
2196
|
+
);
|
|
2197
|
+
} catch {
|
|
2198
|
+
fingerprints.set(key, "");
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
return fingerprints;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function fingerprintProjectInputDirectory(location: string): string {
|
|
2205
|
+
const entries = fs
|
|
2206
|
+
.readdirSync(location, { withFileTypes: true })
|
|
2207
|
+
.map((entry) => {
|
|
2208
|
+
const kind = entry.isDirectory()
|
|
2209
|
+
? "directory"
|
|
2210
|
+
: entry.isFile()
|
|
2211
|
+
? "file"
|
|
2212
|
+
: entry.isSymbolicLink()
|
|
2213
|
+
? "symlink"
|
|
2214
|
+
: "other";
|
|
2215
|
+
let target = "";
|
|
2216
|
+
if (entry.isSymbolicLink()) {
|
|
2217
|
+
try {
|
|
2218
|
+
target = fs.readlinkSync(path.join(location, entry.name));
|
|
2219
|
+
} catch {
|
|
2220
|
+
target = "<unreadable>";
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
return entry.name + "\0" + kind + "\0" + target;
|
|
2224
|
+
})
|
|
2225
|
+
.sort();
|
|
2226
|
+
return crypto.createHash("sha256").update(entries.join("\0")).digest("hex");
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function matchesProjectInputGlob(
|
|
2230
|
+
pattern: string,
|
|
2231
|
+
location: string,
|
|
2232
|
+
identities = createProjectInputPathIdentityContext(),
|
|
2233
|
+
): boolean {
|
|
2234
|
+
const root = identities.resolve(literalGlobRoot(pattern));
|
|
2235
|
+
const candidate = identities.resolve(location);
|
|
2236
|
+
if (!isProjectInputPathIdentityWithin(root.key, candidate.key)) return false;
|
|
2237
|
+
const sensitive = identities.caseSensitive(root.path);
|
|
2238
|
+
const patternParts = path
|
|
2239
|
+
.relative(root.path, identities.resolve(pattern).path)
|
|
2240
|
+
.split(path.sep);
|
|
2241
|
+
const candidateParts = path
|
|
2242
|
+
.relative(root.path, candidate.path)
|
|
2243
|
+
.split(path.sep);
|
|
2244
|
+
return matchProjectInputGlobParts(
|
|
2245
|
+
sensitive
|
|
2246
|
+
? patternParts
|
|
2247
|
+
: patternParts.map((segment) => segment.toLowerCase()),
|
|
2248
|
+
sensitive
|
|
2249
|
+
? candidateParts
|
|
2250
|
+
: candidateParts.map((segment) => segment.toLowerCase()),
|
|
2251
|
+
);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function matchProjectInputGlobParts(
|
|
2255
|
+
pattern: readonly string[],
|
|
2256
|
+
candidate: readonly string[],
|
|
2257
|
+
): boolean {
|
|
2258
|
+
const memo = new Map<string, boolean>();
|
|
2259
|
+
const visit = (patternIndex: number, candidateIndex: number): boolean => {
|
|
2260
|
+
const key = `${String(patternIndex)}:${String(candidateIndex)}`;
|
|
2261
|
+
const cached = memo.get(key);
|
|
2262
|
+
if (cached !== undefined) return cached;
|
|
2263
|
+
let matched: boolean;
|
|
2264
|
+
if (patternIndex === pattern.length) {
|
|
2265
|
+
matched = candidateIndex === candidate.length;
|
|
2266
|
+
} else if (pattern[patternIndex] === "**") {
|
|
2267
|
+
matched =
|
|
2268
|
+
visit(patternIndex + 1, candidateIndex) ||
|
|
2269
|
+
(candidateIndex !== candidate.length &&
|
|
2270
|
+
visit(patternIndex, candidateIndex + 1));
|
|
2271
|
+
} else {
|
|
2272
|
+
matched =
|
|
2273
|
+
candidateIndex !== candidate.length &&
|
|
2274
|
+
matchProjectInputGlobSegment(
|
|
2275
|
+
pattern[patternIndex]!,
|
|
2276
|
+
candidate[candidateIndex]!,
|
|
2277
|
+
) &&
|
|
2278
|
+
visit(patternIndex + 1, candidateIndex + 1);
|
|
2279
|
+
}
|
|
2280
|
+
memo.set(key, matched);
|
|
2281
|
+
return matched;
|
|
2282
|
+
};
|
|
2283
|
+
return visit(0, 0);
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function matchProjectInputGlobSegment(
|
|
2287
|
+
pattern: string,
|
|
2288
|
+
candidate: string,
|
|
2289
|
+
): boolean {
|
|
2290
|
+
let source = "^";
|
|
2291
|
+
for (const char of pattern) {
|
|
2292
|
+
if (char === "*") source += ".*";
|
|
2293
|
+
else if (char === "?") source += ".";
|
|
2294
|
+
else source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
2295
|
+
}
|
|
2296
|
+
return new RegExp(`${source}$`, "u").test(candidate);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
432
2299
|
function mapsEqual(
|
|
433
2300
|
left: ReadonlyMap<string, string>,
|
|
434
2301
|
right: ReadonlyMap<string, string>,
|
|
@@ -456,11 +2323,93 @@ function isPathWithin(root: string, candidate: string): boolean {
|
|
|
456
2323
|
);
|
|
457
2324
|
}
|
|
458
2325
|
|
|
2326
|
+
/**
|
|
2327
|
+
* Plan one compiler-directory event without relying on backend timing.
|
|
2328
|
+
*
|
|
2329
|
+
* POSIX file watchers own ordinary content changes. A named rename re-arms the
|
|
2330
|
+
* replaced file; an unnamed event conservatively re-arms and reports every
|
|
2331
|
+
* surviving tracked input below the watch root. Windows has no per-file
|
|
2332
|
+
* watchers here, so both named and unnamed directory events report inputs.
|
|
2333
|
+
*/
|
|
2334
|
+
export function planCompilerDirectoryWatchEvent(input: {
|
|
2335
|
+
changed?: string;
|
|
2336
|
+
event: string;
|
|
2337
|
+
exists(location: string): boolean;
|
|
2338
|
+
location: string;
|
|
2339
|
+
platform: NodeJS.Platform;
|
|
2340
|
+
trackedFiles: ReadonlyMap<string, string>;
|
|
2341
|
+
}): CompilerDirectoryWatchEventPlan {
|
|
2342
|
+
const candidates =
|
|
2343
|
+
input.changed === undefined
|
|
2344
|
+
? [...input.trackedFiles.values()].filter(
|
|
2345
|
+
(file) =>
|
|
2346
|
+
input.exists(file) &&
|
|
2347
|
+
isPathWithin(input.location, path.resolve(file)),
|
|
2348
|
+
)
|
|
2349
|
+
: input.trackedFiles.has(
|
|
2350
|
+
pathKeyForPlatform(input.changed, input.platform),
|
|
2351
|
+
) && input.exists(input.changed)
|
|
2352
|
+
? [input.changed]
|
|
2353
|
+
: [];
|
|
2354
|
+
if (input.changed === undefined) {
|
|
2355
|
+
return {
|
|
2356
|
+
changes: candidates,
|
|
2357
|
+
rearm: input.platform === "win32" ? [] : candidates,
|
|
2358
|
+
refresh: true,
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
if (candidates.length === 0) {
|
|
2362
|
+
return { changes: [], rearm: [], refresh: true };
|
|
2363
|
+
}
|
|
2364
|
+
if (input.platform === "win32") {
|
|
2365
|
+
return { changes: candidates, rearm: [], refresh: false };
|
|
2366
|
+
}
|
|
2367
|
+
if (input.event === "rename") {
|
|
2368
|
+
return { changes: candidates, rearm: candidates, refresh: false };
|
|
2369
|
+
}
|
|
2370
|
+
return { changes: [], rearm: [], refresh: false };
|
|
2371
|
+
}
|
|
2372
|
+
|
|
459
2373
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
460
2374
|
return typeof value === "object" && value !== null;
|
|
461
2375
|
}
|
|
462
2376
|
|
|
2377
|
+
/**
|
|
2378
|
+
* Resolve the spelling a filesystem watcher must be registered under.
|
|
2379
|
+
*
|
|
2380
|
+
* A watch declaration keeps its lexical spelling, because classification,
|
|
2381
|
+
* containment, and notification are all expressed in the caller's own paths.
|
|
2382
|
+
* The backend needs the canonical spelling instead. libuv stores the directory
|
|
2383
|
+
* string it was handed, expands each delivered event that still exists on disk
|
|
2384
|
+
* to its long path, and then requires the stored string to be that expansion's
|
|
2385
|
+
* prefix. A short (8.3) component makes the two disagree, and the resulting
|
|
2386
|
+
* assertion aborts the whole process (libuv issue 5010; upstream now returns an
|
|
2387
|
+
* error instead, but the bundled libuv every supported Node ships still
|
|
2388
|
+
* asserts). `os.tmpdir()` routinely yields such a path, so ordinary projects
|
|
2389
|
+
* reach it, not only tests. macOS matches events against the watched path
|
|
2390
|
+
* resolved through symlinks, so `/var` and `/private/var` must not be mixed
|
|
2391
|
+
* either. Resolving here keeps every backend comparing two canonical spellings
|
|
2392
|
+
* while callers keep resolving events against what they declared.
|
|
2393
|
+
*/
|
|
2394
|
+
function watcherRegistrationPath(location: string): string {
|
|
2395
|
+
try {
|
|
2396
|
+
return fs.realpathSync.native?.(location) ?? fs.realpathSync(location);
|
|
2397
|
+
} catch {
|
|
2398
|
+
// A vanished target makes fs.watch throw anyway, which syncWatchers already
|
|
2399
|
+
// routes to the error path; an unreadable ancestor is better watched under
|
|
2400
|
+
// its declared spelling than not watched at all.
|
|
2401
|
+
return location;
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
463
2405
|
function pathKey(location: string): string {
|
|
2406
|
+
return pathKeyForPlatform(location, process.platform);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function pathKeyForPlatform(
|
|
2410
|
+
location: string,
|
|
2411
|
+
platform: NodeJS.Platform,
|
|
2412
|
+
): string {
|
|
464
2413
|
const resolved = path.resolve(location);
|
|
465
|
-
return
|
|
2414
|
+
return platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
466
2415
|
}
|