UNPKG

ttsc

Version:

General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.

988 lines 105 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WatchTopology = void 0; exports.reloadInputsForFailedTopologyRefresh = reloadInputsForFailedTopologyRefresh; exports.syncWatchers = syncWatchers; exports.literalGlobRoot = literalGlobRoot; exports.projectInputWatchDirectories = projectInputWatchDirectories; exports.projectInputActiveWatchDirectories = projectInputActiveWatchDirectories; exports.projectInputAvailableWatchDirectory = projectInputAvailableWatchDirectory; exports.projectInputReplacementStrandsWatchers = projectInputReplacementStrandsWatchers; exports.projectInputTopologyMayAffect = projectInputTopologyMayAffect; exports.projectInputEventShouldNotify = projectInputEventShouldNotify; exports.projectInputReloadEventShouldNotify = projectInputReloadEventShouldNotify; exports.projectInputMembershipInvalidatesProgram = projectInputMembershipInvalidatesProgram; exports.planCompilerDirectoryWatchEvent = planCompilerDirectoryWatchEvent; const node_crypto_1 = __importDefault(require("node:crypto")); const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const readConfigJson_1 = require("../../compiler/internal/project/readConfigJson"); const readProjectConfig_1 = require("../../compiler/internal/project/readProjectConfig"); const resolveTsgo_1 = require("../../compiler/internal/resolveTsgo"); const spawnNative_1 = require("../../compiler/internal/spawnNative"); const schema_1 = require("../../flags/schema"); const projectInputPathIdentity_1 = require("../../internal/projectInputPathIdentity"); const singleFileOutput_1 = require("./singleFileOutput"); /** * Keeps the launcher watch set aligned with the compiler's current program. * * TypeScript-Go's `--listFilesOnly` output is the authority for source and * declaration inputs. Configuration files, project-reference roots, and the * source trees of selected native plugins supplement that list, while compiler * outputs are filtered before any watcher is installed. */ class WatchTopology { options; callbacks; analysisOnly = false; closed = false; compilerPostRegistrationMembershipRefresh = false; compilerPostRegistrationReconciliationScheduled = false; compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = true; directories = new Map(); directoryWatchers = new Map(); extraInputs = []; extraWatchers = new Map(); compilerFileSnapshots = new Map(); files = new Map(); fileWatchers = new Map(); observedDirectories = new Map(); outputFiles = new Map(); outputs = new Map(); projectInputFingerprints = new Map(); projectInputMatches = new Map(); projectInputs = { files: [], globs: [], reloadDirectories: [], reloadFiles: [], root: "", }; declaredProjectInputs = { files: [], globs: [], reloadDirectories: [], reloadFiles: [], root: "", }; projectInputRecoveryScheduled = false; projectInputPostRegistrationReconciliationScheduled = false; projectInputRejectedWatchRoots = new Set(); projectInputRequiredWatchRoots = new Map(); projectInputUnobservedWatchRoots = new Map(); projectInputWatchRoots = new Map(); projectInputWatchers = new Map(); projectInputLinkWatchers = new Map(); projectInputCompilerOutputOverlaps = new WeakMap(); projectInputCompilerAcknowledgements = new Map(); reloadFiles = new Map(); constructor(options, callbacks) { this.options = options; this.callbacks = callbacks; } /** Re-resolve compiler inputs and notify only when their membership changed. */ refresh(notify) { this.refreshCompilerInputs(notify, false); } refreshCompilerInputs(notify, skipUnobservedProjectInputWatchRoots) { const next = resolveWatchTopology(this.options, this.extraInputs); const compilerProgramMembershipChange = next.analysisOnly && mapsEqual(this.reloadFiles, next.reloadFiles) && mapsEqual(this.outputFiles, next.outputFiles) && mapsEqual(this.outputs, next.outputs) ? compilerMembershipChange(this.files, next.files) : []; const projectInputProgramOverlap = projectInputCompilerMembershipChange(this.projectInputs, compilerProgramMembershipChange); const projectInputProgramChange = compilerProgramMembershipChange.length !== 0 && projectInputProgramOverlap.length === compilerProgramMembershipChange.length ? projectInputProgramOverlap : undefined; const changed = this.analysisOnly !== next.analysisOnly || mapsEqual(this.files, next.files) === false || mapsEqual(this.directories, next.directories) === false || mapsEqual(this.outputFiles, next.outputFiles) === false || mapsEqual(this.outputs, next.outputs) === false || mapsEqual(this.reloadFiles, next.reloadFiles) === false; this.analysisOnly = next.analysisOnly; this.files = next.files; // Stamp the tracked set as it is resolved, so the first event that cannot // name what changed compares against the state the compiler just saw rather // than against nothing, which would make it nominate everything once. for (const key of [...this.compilerFileSnapshots.keys()]) { if (!next.files.has(key)) this.compilerFileSnapshots.delete(key); } for (const [key, file] of next.files) { // Only a file with no stamp yet is seeded. Restamping one that already // has a baseline would advance it past a change nobody reported, and the // next unnamed event would then read that change as no change at all. if (!this.compilerFileSnapshots.has(key)) { this.compilerFileSnapshots.set(key, compilerFileSnapshot(file)); } } this.directories = next.directories; this.outputFiles = next.outputFiles; this.outputs = next.outputs; this.reloadFiles = next.reloadFiles; for (const key of this.projectInputCompilerAcknowledgements.keys()) { if (!next.files.has(key)) { this.projectInputCompilerAcknowledgements.delete(key); } } const projectInputProgramReload = projectInputProgramOverlap.length === 0 ? false : this.acknowledgeProjectInputCompilerMembership(projectInputProgramOverlap); const fileWatcherRegistered = this.syncFileWatchers(); const directoryWatcherRegistered = this.syncDirectoryWatchers(); this.syncExtraWatchers(); this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots); if (fileWatcherRegistered || directoryWatcherRegistered) { this.scheduleCompilerPostRegistrationReconciliation(directoryWatcherRegistered, skipUnobservedProjectInputWatchRoots); } if (notify && changed) { if (projectInputProgramChange !== undefined) { const changedPath = projectInputProgramChange.length === 1 ? projectInputProgramChange[0] : undefined; this.callbacks.onInputChange(projectInputProgramReload ? { kind: "config", path: changedPath } : { invalidate: true, kind: "project", path: changedPath, }); } else { this.callbacks.onTopologyChange(); } } } /** * Hand one Program-membership transition from the compiler lane to the * overlapping project-input lane. * * Windows can deliver the compiler membership refresh before the recursive * project watcher names the same creation. The rebuild scheduled here already * consumes the current project bytes, so publishing their strong fingerprints * keeps the later parent event from rediscovering the same population delta. * A newly tracked compiler file also remembers that fingerprint until its * first named content delivery; identical bytes are the delayed creation, * while different bytes are a real later edit and remain observable even * inside filesystem timestamp resolution. */ acknowledgeProjectInputCompilerMembership(changed) { const matches = this.collectProjectInputMatches(); const fingerprints = fingerprintProjectInputMatches(matches); const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)(); const changedInputs = projectInputChangedPaths({ next: matches, nextFingerprints: fingerprints, previous: this.projectInputMatches, previousFingerprints: this.projectInputFingerprints, }); const population = this.projectInputPopulation(); const causedBy = projectInputCompilerMembershipProjectChanges(changed, population.globs); const reload = projectInputReloadEventShouldNotify({ causedBy, changed: causedBy.length === 1 ? causedBy[0] : undefined, changedInputs, globs: population.globs, reloadDirectories: population.reloadDirectories, reloadFiles: population.reloadFiles ?? [], }); // The callback below consumes the complete population observed by this // scan. Crucially, reload classification runs against the old baseline // first, so a concurrent selection delta becomes one cold transition // instead of disappearing behind the warm compiler-membership handoff. this.projectInputMatches = matches; this.projectInputFingerprints = fingerprints; for (const location of changed) { const compilerKey = pathKey(location); if (!this.files.has(compilerKey)) continue; const fingerprint = fingerprints.get(identities.resolve(location).key); if (fingerprint !== undefined && fingerprint !== "") { this.projectInputCompilerAcknowledgements.set(compilerKey, fingerprint); } } return reload; } /** Add Go plugin source trees discovered by the real build lane. */ setExtraInputs(inputs) { const next = uniqueExistingPaths(inputs); if (arraysEqual(this.extraInputs, next)) return; this.extraInputs = next; this.refresh(false); } /** * Reconcile project-rule dependencies, retaining absent files and empty glob * populations as live topology. */ setProjectInputs(inputs) { const next = normalizeProjectInputSnapshot(inputs); // The declared spellings are recorded even when the normalized snapshot did // not move, because a republication can carry a new alias for identities // that already matched, and anchoring the spelling that was retired would // leave the live one unwatched. this.declaredProjectInputs = inputs.declared === undefined ? inputs : { ...inputs.declared, root: inputs.root }; if (projectInputSnapshotsEqual(this.projectInputs, next)) { this.syncProjectInputWatchers(); return; } this.projectInputs = next; this.projectInputRejectedWatchRoots.clear(); this.pruneProjectInputWatchRoots([ next, inputs, { ...(inputs.declared ?? inputs), root: inputs.root }, ]); this.projectInputMatches = this.collectProjectInputMatches(); this.projectInputFingerprints = fingerprintProjectInputMatches(this.projectInputMatches); this.syncProjectInputWatchers(); } /** Close every watcher so SIGINT/SIGTERM can drain the event loop. */ close() { this.closed = true; closeWatchers(this.fileWatchers); closeWatchers(this.directoryWatchers); closeWatchers(this.extraWatchers); closeWatchers(this.projectInputWatchers); closeWatchers(this.projectInputLinkWatchers); } syncFileWatchers(skipMissing = false) { const previous = new Map(this.fileWatchers); const files = process.platform === "win32" ? new Map() : skipMissing ? new Map([...this.files].filter(([, location]) => node_fs_1.default.existsSync(location))) : this.files; syncWatchers(this.fileWatchers, files, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true }, () => { // A per-file watcher fires on any filesystem attention its target // receives, and it carries no filename to distinguish an edit from // a touch. It answers the same question the unnamed directory event // answers, so it answers it the same way: from the bytes. const movement = this.compilerFileMovement(location); if (movement.owner) this.rearmFileWatchers([location], true); if (!movement.content) return; this.callbacks.onInputChange({ kind: this.classifyCompilerInput(location), path: location, }); }), (location, error) => this.callbacks.onError(location, error), () => this.closed === false); return [...this.fileWatchers].some(([key, watcher]) => previous.get(key) !== watcher); } /** Compare a tracked file's content and physical owner with its snapshot. */ compilerFileMovement(location) { const key = pathKey(location); const previous = this.compilerFileSnapshots.get(key); const next = compilerFileSnapshot(location); this.compilerFileSnapshots.set(key, next); return { content: previous?.content !== next.content, owner: previous?.owner !== next.owner, }; } syncDirectoryWatchers() { const previous = new Map(this.directoryWatchers); const desired = new Map(this.directories); for (const [key, location] of this.observedDirectories) { if (isDirectory(location) === false || this.isCompilerOutputDirectory(location) || this.isProjectInputDirectory(location)) { this.observedDirectories.delete(key); continue; } desired.set(key, location); } if (process.platform === "win32") { for (const [key, location] of desired) { if ([...desired].some(([candidateKey, candidate]) => candidateKey !== key && isPathWithin(candidate, location))) { desired.delete(key); } } } syncWatchers(this.directoryWatchers, desired, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true, recursive: process.platform === "win32", }, (event, filename) => { const changed = filename === null ? undefined : node_path_1.default.resolve(location, filename.toString()); const pluginInput = changed ?? location; if (this.isPluginInput(pluginInput)) { this.callbacks.onInputChange({ kind: "plugin", path: pluginInput, }); return; } const plan = planCompilerDirectoryWatchEvent({ changed, event, exists: node_fs_1.default.existsSync, location, platform: process.platform, trackedFiles: this.files, }); this.rearmFileWatchers(plan.rearm); for (const file of this.compilerChangesToReport(plan.changes, changed, event)) { this.callbacks.onInputChange({ kind: this.classifyCompilerInput(file), path: file, }); } if (plan.refresh) this.refreshFromDirectory(location, changed); }), (location, error) => this.callbacks.onError(location, error), () => this.closed === false); return [...this.directoryWatchers].some(([key, watcher]) => previous.get(key) !== watcher); } /** * Reconcile tracked compiler files after a newly registered watcher returns. * * A file or directory watcher can be returned before its backend is ready to * deliver the first event. The compiler-file stamps were captured before * registration, so one coalesced microtask can recover a change in that * handoff window. A real event updates the same stamp first and makes this * bounded scan a no-op. */ scheduleCompilerPostRegistrationReconciliation(refreshMembership, skipUnobservedProjectInputWatchRoots) { if (this.closed) return; if (refreshMembership) { this.compilerPostRegistrationMembershipRefresh = true; this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots && skipUnobservedProjectInputWatchRoots; } if (this.compilerPostRegistrationReconciliationScheduled) return; this.compilerPostRegistrationReconciliationScheduled = true; queueMicrotask(() => { this.compilerPostRegistrationReconciliationScheduled = false; if (this.closed) return; const refreshCompilerMembership = this.compilerPostRegistrationMembershipRefresh; const skipUnobservedProjectInputWatchRoots = this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots; this.compilerPostRegistrationMembershipRefresh = false; this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = true; const changed = []; const rearm = []; for (const file of this.files.values()) { const movement = this.compilerFileMovement(file); if (movement.content) changed.push(file); if (movement.owner) rearm.push(file); } // A replacement can move the path to a new inode without changing its // cheap content stamp or topology key. Rebind its physical owner without // inventing a content notification. Missing entries remain covered by // their parent directory and are retried when recreation is observed. this.rearmFileWatchers(rearm, true); for (const file of changed) { this.callbacks.onInputChange({ kind: this.classifyCompilerInput(file), path: file, }); } if (!refreshCompilerMembership) return; try { // Directory watchers own files not present in the current Program. // Re-resolve even when every tracked stamp is unchanged so a swallowed // startup event cannot strand a newly included source. this.refreshCompilerInputs(true, skipUnobservedProjectInputWatchRoots); } catch (error) { const reported = new Set(changed.map(pathKey)); const reconciledChange = changed.length === 1 ? changed[0] : undefined; for (const reload of reloadInputsForFailedTopologyRefresh(this.reloadFiles.values(), reconciledChange)) { if (reported.has(pathKey(reload))) continue; this.callbacks.onInputChange({ kind: "config", path: reload }); } this.callbacks.onError(reconciledChange === undefined ? (this.options.projectRoot ?? this.options.cwd) : node_path_1.default.dirname(reconciledChange), error); } }); } /** * Narrow a plan's changes to the tracked files that actually moved. * * A backend that cannot name what changed forces the plan to nominate every * tracked file under the watched directory, which is the only safe answer it * can give from an event carrying no filename. macOS delivers such events for * ordinary activity elsewhere in the project, so the compiler lane would wake * for sources nobody touched. Only a content notification passes through: it * is the one event that claims the bytes moved. A rename claims the directory * entry was rewritten and an unnamed event claims nothing, so both are * decided from the bytes, which is the question neither of them answered. */ compilerChangesToReport(changes, changed, event) { // A content notification is taken at its word: the backend is telling us // these bytes changed, and second-guessing it would lose an edit that // landed inside the clock's resolution. A rename says the directory entry // was rewritten, which is a different claim — a file can be moved back, or // replaced by an identical copy, without its content moving at all — and an // event that cannot name anything makes no claim about content either. // Those two are decided from the bytes, and the rearm they drive is // unaffected, because rebinding is about the inode and not the content. if (changed !== undefined && event !== "rename") { return changes.filter((file) => { const key = pathKey(file); const acknowledged = this.projectInputCompilerAcknowledgements.get(key); this.projectInputCompilerAcknowledgements.delete(key); this.recordCompilerFileSnapshot(file); return (acknowledged === undefined || acknowledged !== fingerprintProjectInputFile(file)); }); } return changes.filter((file) => this.compilerFileMovement(file).content); } recordCompilerFileSnapshot(file) { this.compilerFileSnapshots.set(pathKey(file), compilerFileSnapshot(file)); } rearmFileWatchers(files, skipMissing = false) { for (const file of files) { const key = pathKey(file); this.fileWatchers.get(key)?.close(); this.fileWatchers.delete(key); } if (this.syncFileWatchers(skipMissing)) { this.scheduleCompilerPostRegistrationReconciliation(false, true); } } syncExtraWatchers() { const directories = new Map(); for (const input of this.extraInputs) { for (const directory of collectInputDirectories(input)) { directories.set(pathKey(directory), directory); } } syncWatchers(this.extraWatchers, directories, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true }, (_event, filename) => { const changed = filename === null ? undefined : node_path_1.default.resolve(location, filename.toString()); this.callbacks.onInputChange({ kind: "plugin", path: changed ?? location, }); }), (location, error) => this.callbacks.onError(location, error), () => this.closed === false); } syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots = false) { if (this.closed) return; const previous = new Map(this.projectInputWatchers); const previousLinks = new Map(this.projectInputLinkWatchers); const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)(); const desired = new Map(); const required = new Map(); for (const file of this.projectInputDeclarations("file")) { if (this.isProjectInputCompilerOutput(file, identities)) continue; const location = this.projectInputWatchRoot("file", file, node_path_1.default.dirname(file)); this.retainProjectInputWatchRoot(required, desired, identities, location, node_path_1.default.dirname(file), skipUnobservedProjectInputWatchRoots); } for (const glob of this.projectInputDeclarations("glob")) { const root = literalGlobRoot(glob); if (this.isProjectInputCompilerOutputDirectory(root, identities)) { continue; } const location = this.projectInputWatchRoot("glob", glob, root); this.retainProjectInputWatchRoot(required, desired, identities, location, root, skipUnobservedProjectInputWatchRoots); } for (const file of this.projectInputDeclarations("reload")) { if (this.isProjectInputCompilerOutput(file, identities)) continue; const location = this.projectInputWatchRoot("reload", file, node_path_1.default.dirname(file)); this.retainProjectInputWatchRoot(required, desired, identities, location, node_path_1.default.dirname(file), skipUnobservedProjectInputWatchRoots); } for (const directory of this.projectInputDeclarations("reload-directory")) { if (this.isProjectInputCompilerOutputDirectory(directory, identities)) { continue; } const location = this.projectInputWatchRoot("reload-directory", directory, directory); this.retainProjectInputWatchRoot(required, desired, identities, location, directory, skipUnobservedProjectInputWatchRoots); } const active = new Map(); for (const location of projectInputActiveWatchDirectories(desired.values(), identities)) { const identity = identities.resolve(location); active.set(identity.key, identity.path); } this.projectInputRequiredWatchRoots = required; syncWatchers(this.projectInputWatchers, active, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true, recursive: true }, (_event, filename) => { const changed = filename === null ? undefined : node_path_1.default.resolve(location, filename.toString()); this.refreshProjectInputs(location, changed); }), (location, error) => { const key = identities.resolve(location).key; const firstFailure = !this.projectInputRejectedWatchRoots.has(key); this.projectInputRejectedWatchRoots.add(key); this.callbacks.onError(location, error); if (firstFailure && !this.closed) { this.scheduleProjectInputWatcherRecovery(); } }, () => this.closed === false); if (this.closed) return; if (!this.projectInputRecoveryScheduled) { this.reportUnobservedProjectInputWatchRoots(required); } this.syncProjectInputLinkWatchers(identities); const watcherRegistered = [...this.projectInputWatchers].some(([key, watcher]) => previous.get(key) !== watcher) || [...this.projectInputLinkWatchers].some(([key, watcher]) => previousLinks.get(key) !== watcher); if (watcherRegistered) { this.scheduleProjectInputPostRegistrationReconciliation(); } this.callbacks.onProjectInputWatchRoots?.([...this.projectInputWatchers.keys()] .map((key) => active.get(key) ?? identities.resolve(key).path) .sort()); } /** * Watch the directory that holds a declaration which is itself a link. * * A recursive watcher cannot report the link being replaced. The backend that * keys its handles by path skips an entry it already knows, and the handle it * put on the entry followed the link to the target's inode, which unlinking * and recreating the link never touches. A plain directory watch has neither * property: it reports the entry by name the moment it moves. These are kept * apart from the recursive roots because they are not roots — they observe * one directory, they are never reported as watch roots, and an ancestor * covering them does not make them redundant. */ syncProjectInputLinkWatchers(identities) { const desired = new Map(); for (const kind of ["file", "reload"]) { for (const declaration of this.projectInputDeclarations(kind)) { const declared = node_path_1.default.resolve(declaration); // The test is whether the declaration is itself a link, not whether its // spelling is canonical. Comparing against the resolved identity would // admit every declaration whose ancestor is aliased — which on macOS is // every declaration under the system temporary directory — and it would // still miss the retarget, because the watcher goes below the link. if (!isSymbolicLink(declared)) continue; if (this.isProjectInputCompilerOutput(declared, identities)) continue; const parent = nearestExistingDirectory(node_path_1.default.dirname(declared)); if (parent === undefined) continue; desired.set(identities.resolve(parent).key, parent); } } syncWatchers(this.projectInputLinkWatchers, desired, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true }, (_event, filename) => { const changed = filename === null ? undefined : node_path_1.default.resolve(location, filename.toString()); this.refreshProjectInputs(location, changed); }), (location, error) => this.callbacks.onError(location, error), () => this.closed === false); } /** Drop the watcher that just reported a directory replacement. */ retireProjectInputWatcher(location, identities) { const key = identities.resolve(location).key; // A plain directory watcher binds an inode, so a replacement strands it // exactly as it strands a recursive root. Both maps are keyed the same way, // so both are retired together and the next sync reinstalls whichever the // declarations still call for. for (const watchers of [ this.projectInputWatchers, this.projectInputLinkWatchers, ]) { const watcher = watchers.get(key); if (watcher === undefined) continue; watcher.close(); watchers.delete(key); } } retainProjectInputWatchRoot(required, desired, identities, location, target, skipUnobservedProjectInputWatchRoots) { const requiredIdentity = identities.resolve(location ?? target); required.set(requiredIdentity.key, requiredIdentity.path); if (skipUnobservedProjectInputWatchRoots) { const retainedActiveRoot = [...this.projectInputWatchers.keys()].find((root) => (0, projectInputPathIdentity_1.isProjectInputPathIdentityWithin)(root, requiredIdentity.key)); if (retainedActiveRoot !== undefined) { desired.set(retainedActiveRoot, retainedActiveRoot); return; } } if (location === undefined || (skipUnobservedProjectInputWatchRoots && this.projectInputUnobservedWatchRoots.has(requiredIdentity.key))) { return; } const available = projectInputAvailableWatchDirectory(location, this.projectInputRejectedWatchRoots, identities, this.projectInputs.root); if (available === undefined) return; const identity = identities.resolve(available); desired.set(identity.key, identity.path); } /** * Retry a failed root on the next reconciliation instead of retiring it for * the session. * * This immediate recovery pass still honors the rejected root so it can * install a safe ancestor where one exists. Only the recovery fixpoint * reports a genuinely uncovered lane; transient gaps between fallback * candidates are not user-visible. The rejection then expires. A later * compiler refresh or an unchanged project-input republication can retry the * original root, while a permanently failing backend costs at most one * attempt per sync. */ scheduleProjectInputWatcherRecovery() { if (this.projectInputRecoveryScheduled) return; this.projectInputRecoveryScheduled = true; queueMicrotask(() => { try { let previousRejectionCount = -1; while (this.closed === false && previousRejectionCount !== this.projectInputRejectedWatchRoots.size) { previousRejectionCount = this.projectInputRejectedWatchRoots.size; this.syncProjectInputWatchers(); } } finally { this.projectInputRejectedWatchRoots.clear(); this.projectInputRecoveryScheduled = false; if (!this.closed) { this.reportUnobservedProjectInputWatchRoots(this.projectInputRequiredWatchRoots); } } }); } /** * Reconcile the snapshot-to-watcher handoff after the caller's current turn. * * A recursive watcher can return before its backend is ready to deliver the * first event. The publication baseline is necessarily captured before that * watcher exists, so an input materialized synchronously after * `setProjectInputs()` would otherwise depend entirely on that startup event. * The ordinary fingerprint update makes this scan and a real backend event * race safely: whichever arrives first records the new population and the * other becomes a no-op. */ scheduleProjectInputPostRegistrationReconciliation() { if (this.closed || this.projectInputPostRegistrationReconciliationScheduled) { return; } this.projectInputPostRegistrationReconciliationScheduled = true; queueMicrotask(() => { this.projectInputPostRegistrationReconciliationScheduled = false; if (this.closed) return; this.refreshPublishedProjectInputIdentities(); this.refreshProjectInputs(this.projectInputs.root, undefined, true); }); } /** * Re-resolve declarations after watcher registration. * * A missing path can become a symlink before the handoff scan. The retained * normalized snapshot still names the pre-link spelling in that case, so a * scan can find the first target file without installing the physical owner * that must observe later target changes. */ refreshPublishedProjectInputIdentities() { const next = normalizeProjectInputSnapshot(this.declaredProjectInputs); if (projectInputSnapshotsEqual(this.projectInputs, next)) return; this.projectInputs = next; this.pruneProjectInputWatchRoots([next, this.declaredProjectInputs]); } /** Drop retained owner choices for declarations no longer published. */ pruneProjectInputWatchRoots(snapshots) { const declarations = new Set(snapshots.flatMap((snapshot) => [ ...snapshot.files.map((file) => projectInputDeclarationKey("file", file)), ...snapshot.globs.map((glob) => projectInputDeclarationKey("glob", glob)), ...(snapshot.reloadFiles ?? []).map((file) => projectInputDeclarationKey("reload", file)), ...(snapshot.reloadDirectories ?? []).map((directory) => projectInputDeclarationKey("reload-directory", directory)), ])); for (const key of this.projectInputWatchRoots.keys()) { if (!declarations.has(key)) this.projectInputWatchRoots.delete(key); } } /** Report only newly uncovered project-input roots as an observation loss. */ reportUnobservedProjectInputWatchRoots(required) { const active = [...this.projectInputWatchers.keys()]; const unavailable = new Map([...required].filter(([key]) => active.every((root) => !(0, projectInputPathIdentity_1.isProjectInputPathIdentityWithin)(root, key)))); const newlyUnavailable = [...unavailable] .filter(([key]) => !this.projectInputUnobservedWatchRoots.has(key)) .map(([, location]) => location) .sort(); this.projectInputUnobservedWatchRoots = unavailable; if (newlyUnavailable.length !== 0) { this.callbacks.onProjectInputWatchUnavailable?.(newlyUnavailable); } } /** * One snapshot holding every spelling of every declaration. * * Consumers that decide from a population rather than from a single path have * to see both, or half of them answer from the file a link pointed at when * the snapshot was published while the event they are judging resolved to the * file it points at now. */ projectInputPopulation() { return { files: this.projectInputDeclarations("file"), globs: this.projectInputDeclarations("glob"), reloadDirectories: this.projectInputDeclarations("reload-directory"), reloadFiles: this.projectInputDeclarations("reload"), root: this.projectInputs.root, }; } /** * Every spelling of one declaration that has to be anchored separately. * * The retained snapshot is normalized to physical identities, which is what * every comparison needs but not what every watcher needs: a declaration * reached through a symlink resolves to its target's directory, so anchoring * the normalized form alone watches the bytes and never the link. Retargeting * or replacing the link then goes unobserved, even though it is exactly what * decides which bytes the declaration names next. Both spellings are planned * through the same root selection, so the project-root hoist and the * nearest-existing-ancestor boundary still bound each of them, and the active * set drops one again whenever they coincide or share an ancestor. */ projectInputDeclarations(kind) { const select = (snapshot) => kind === "file" ? snapshot.files : kind === "glob" ? snapshot.globs : kind === "reload" ? (snapshot.reloadFiles ?? []) : (snapshot.reloadDirectories ?? []); const seen = new Set(); const declarations = []; for (const entry of [ ...select(this.projectInputs), ...select(this.declaredProjectInputs), ]) { const key = (0, projectInputPathIdentity_1.resolveProjectInputPath)(entry); if (seen.has(key)) continue; seen.add(key); declarations.push(entry); } return declarations; } projectInputWatchRoot(kind, declaration, target) { const key = projectInputDeclarationKey(kind, declaration); const retained = this.projectInputWatchRoots.get(key); if (retained !== undefined && isDirectory(retained)) return retained; const resolved = projectInputRecursiveWatchRoot(target, this.projectInputs.root); if (resolved !== undefined) this.projectInputWatchRoots.set(key, resolved); return resolved; } refreshProjectInputs(location, changed, skipUnobservedProjectInputWatchRoots = false) { try { const previous = this.projectInputMatches; const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)(); // One population for the whole decision. Every question below is asked of // the same declarations, and rebuilding it per question would both cost // more and let two answers disagree about what was declared. const population = this.projectInputPopulation(); const directlyMatched = changed !== undefined && (previous.has(identities.resolve(changed).key) || matchesProjectInput(population, changed, identities)); const topologyMatched = changed !== undefined && projectInputTopologyMayAffect(population, changed, previous, identities); if (changed !== undefined && (this.isProjectInputCompilerOutput(changed, identities) || (directlyMatched === false && topologyMatched === false))) { return; } // Rearm before snapshotting. A watcher that has to be replaced stops // delivering the moment it is closed, so a scan taken first would become // the baseline for a window in which nothing was watched, and anything // written there would never be announced again. Reinstalling first makes // the scan below observe whatever the gap swallowed. if (changed !== undefined && projectInputReplacementStrandsWatchers(population, changed, identities)) { this.retireProjectInputWatcher(location, identities); this.syncProjectInputWatchers(); } const next = this.collectProjectInputMatches(); const membershipChanged = mapsEqual(previous, next) === false; const nextFingerprints = changed === undefined || membershipChanged || directlyMatched || topologyMatched ? fingerprintProjectInputMatches(next) : this.projectInputFingerprints; const contentChanged = mapsEqual(this.projectInputFingerprints, nextFingerprints) === false; const changedInputs = projectInputChangedPaths({ next, nextFingerprints, previous, previousFingerprints: this.projectInputFingerprints, }); const reconciledChange = changed ?? (changedInputs.length === 1 ? changedInputs[0] : undefined); // Both spellings classify the event. The normalized form names the file a // link pointed at when the snapshot was published, so after a retarget it // names the wrong one; only the declared form resolves to what the link // points at now, which is the selection this lane exists to protect. const reload = projectInputReloadEventShouldNotify({ changed: reconciledChange, changedInputs, globs: population.globs, reloadDirectories: population.reloadDirectories ?? [], reloadFiles: population.reloadFiles ?? [], }); const invalidate = projectInputMembershipInvalidatesProgram({ changed: reconciledChange, changedInputs, contentChanged, next, previous, }); this.projectInputMatches = next; this.projectInputFingerprints = nextFingerprints; this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots); // A JSON/TS/JS project-input member can simultaneously enter or leave // the compiler Program. Reconcile the compiler watch snapshot before // scheduling its resident invalidation, so runWatch's post-cycle refresh // does not rediscover the same delta as a broader execution reload. if (invalidate) { this.refreshCompilerInputs(false, skipUnobservedProjectInputWatchRoots); } if (projectInputEventShouldNotify({ contentChanged, directlyMatched, membershipChanged, }) && (reconciledChange === undefined || this.isProjectInputCompilerOutput(reconciledChange, identities) === false)) { this.callbacks.onInputChange(reload ? { kind: "config", path: reconciledChange } : { ...(invalidate ? { invalidate: true } : {}), kind: "project", path: reconciledChange, }); } } catch (error) { // A rename can invalidate the old filesystem object before the // replacement is readable. Rebind ancestor ownership even when the // population scan races that transient gap, so a later create cannot be // stranded without a watcher. this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots); this.callbacks.onError(location, error); } } collectProjectInputMatches() { const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)(); const matches = new Map(); // Both spellings are scanned, and each is resolved here rather than when // the snapshot arrived. A declaration reached through a symlink otherwise // keeps the identity it had when it was published, so retargeting the link // moves no key, changes no fingerprint, and the cycle never learns that the // bytes it depends on are now a different file. for (const file of this.projectInputDeclarations("file")) { if (node_fs_1.default.existsSync(file) && this.isProjectInputCompilerOutput(file, identities) === false) { const identity = identities.resolve(file); matches.set(identity.key, identity.path); } } for (const file of this.projectInputDeclarations("reload")) { if (node_fs_1.default.existsSync(file) && this.isProjectInputCompilerOutput(file, identities) === false) { const identity = identities.resolve(file); matches.set(identity.key, identity.path); } } for (const directory of this.projectInputDeclarations("reload-directory")) { if (isDirectory(directory) && this.isProjectInputCompilerOutputDirectory(directory, identities) === false) { const identity = identities.resolve(directory); matches.set(identity.key, identity.path); } } for (const glob of this.projectInputDeclarations("glob")) { const root = literalGlobRoot(glob); if (isDirectory(root) === false || this.isProjectInputCompilerOutputDirectory(root, identities)) { continue; } const stack = [root]; while (stack.length !== 0) { const current = stack.pop(); let entries; try { entries = node_fs_1.default.readdirSync(current, { withFileTypes: true }); } catch (error) { if (isVanishedFilesystemEntry(error)) continue; throw error; } for (const entry of entries) { const location = node_path_1.default.join(current, entry.name); if (this.isProjectInputCompilerOutput(location, identities)) { continue; } if (entry.isDirectory()) { stack.push(location); } else if (entry.isFile() && matchesProjectInputGlob(glob, location, identities)) { const identity = identities.resolve(location); matches.set(identity.key, identity.path); } } } } return matches; } refreshFromDirectory(location, changed) { if (changed !== undefined && isDirectory(changed) && this.isCompilerOutputDirectory(changed) === false && this.isProjectInputDirectory(changed) === false) { this.observedDirectories.set(pathKey(changed), changed); } try { this.refresh(true); } catch (error) { for (const reload of reloadInputsForFailedTopologyRefresh(this.reloadFiles.values(), changed)) { this.callbacks.onInputChange({ kind: "config", path: reload }); } this.callbacks.onError(location, error); } } isCompilerOutputDirectory(location) { return [...this.outputs.values()].some((output) => isPathWithin(output, location)); } isCompilerOutput(location) { return (this.outputFiles.has(pathKey(location)) || this.isCompilerOutputDirectory(location)); } isProjectInputCompilerOutputDirectory(location, identities) { const root = this.projectInputs.root; let overlaps = this.projectInputCompilerOutputOverlaps.get(identities); if (overlaps === undefined) { overlaps = new Map(); this.projectInputCompilerOutputOverlaps.set(identities, overlaps); } return [...