diff --git a/tsc/internal/bundled/embed.go b/tsc/internal/bundled/embed.go index c13ec10b98998..477e92739e558 100644 --- a/tsc/internal/bundled/embed.go +++ b/tsc/internal/bundled/embed.go @@ -7,7 +7,9 @@ import ( "strings" "time" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/watchalias" ) const embedded = true @@ -46,6 +48,21 @@ func (vfs *wrappedFS) UseCaseSensitiveFileNames() bool { return vfs.fs.UseCaseSensitiveFileNames() } +func (vfs *wrappedFS) WatchPathComparer(directory string) (fswatch.PathComparer, error) { + if !IsBundled(directory) { + if provider, ok := vfs.fs.(interface { + WatchPathComparer(directory string) (fswatch.PathComparer, error) + }); ok { + return provider.WatchPathComparer(directory) + } + } + return fswatch.PathComparer{}, nil +} + +func (vfs *wrappedFS) WatchPathComparisonEnabled() bool { + return watchalias.Enabled(vfs.fs) +} + func (vfs *wrappedFS) FileExists(path string) bool { if rest, ok := splitPath(path); ok { _, ok := embeddedContents[rest] @@ -152,6 +169,13 @@ func (vfs *wrappedFS) Realpath(path string) string { return vfs.fs.Realpath(path) } +func (fsys *wrappedFS) RealpathWithParent(path string, realpath func(string) string) string { + if IsBundled(path) { + return path + } + return vfs.RealpathWithParent(fsys.fs, path, realpath) +} + func (vfs *wrappedFS) WriteFile(path string, data string) error { if _, ok := splitPath(path); ok { panic("cannot write to embedded file system") diff --git a/tsc/internal/execute/build/buildtask.go b/tsc/internal/execute/build/buildtask.go index 96d776a4ccc3a..dfc171cc8fefd 100644 --- a/tsc/internal/execute/build/buildtask.go +++ b/tsc/internal/execute/build/buildtask.go @@ -20,6 +20,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/execute/tsc" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/trackingvfs" ) type buildKind uint @@ -37,10 +38,18 @@ type upstreamTask struct { type buildInfoEntry struct { buildInfo *incremental.BuildInfo path tspath.Path + fileName string mTime time.Time dtsTime *time.Time } +func (b *buildInfoEntry) directory() string { + if b.fileName != "" { + return tspath.GetDirectoryPath(b.fileName) + } + return tspath.GetDirectoryPath(string(b.path)) +} + type taskResult struct { builder strings.Builder reportStatus tsc.DiagnosticReporter @@ -68,6 +77,7 @@ type BuildTask struct { buildInfoEntry *buildInfoEntry buildInfoEntryMu sync.Mutex packageJsons []string + seenFiles []string errors []*ast.Diagnostic pending atomic.Bool @@ -147,11 +157,11 @@ func (t *BuildTask) report(orchestrator *Orchestrator, configPath tspath.Path, b close(t.reportDone) } -func (t *BuildTask) buildProject(orchestrator *Orchestrator, path tspath.Path) { +func (t *BuildTask) buildProject(orchestrator *Orchestrator, path tspath.Path, force bool) { // Wait on upstream tasks to complete t.waitOnUpstream() if t.pending.Load() { - t.status = t.getUpToDateStatus(orchestrator, path) + t.status = t.getUpToDateStatus(orchestrator, path, force) t.reportUpToDateStatus(orchestrator) if !t.handleStatusThatDoesntRequireBuild(orchestrator) { t.compileAndEmit(orchestrator, path) @@ -247,6 +257,9 @@ func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.Path) trace: tsc.GetTraceWithWriterFromSys(&t.result.builder, orchestrator.opts.Command.Locale(), orchestrator.opts.Testing), contentMapperProject: contentMapperProject, } + if orchestrator.opts.Command.CompilerOptions.Watch.IsTrue() { + compilerHost.tracked = &trackingvfs.FS{Inner: orchestrator.host.FS()} + } if !orchestrator.opts.Command.BuildOptions.Force.IsTrue() { oldProgram = incremental.ReadBuildInfoProgram(t.resolved, orchestrator.host, compilerHost) } @@ -256,6 +269,9 @@ func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.Path) Config: t.resolved, Host: compilerHost, }) + if compilerHost.tracked != nil { + t.seenFiles = compilerHost.tracked.SeenFiles.ToSlice() + } compileTimes.ParseTime = orchestrator.opts.Sys.Now().Sub(parseStart) changesComputeStart := orchestrator.opts.Sys.Now() t.result.program = incremental.NewProgram(program, oldProgram, orchestrator.host, orchestrator.opts.Sys.Now, orchestrator.opts.Testing != nil) @@ -348,7 +364,7 @@ func (t *BuildTask) handleStatusThatDoesntRequireBuild(orchestrator *Orchestrato return false } -func (t *BuildTask) getUpToDateStatus(orchestrator *Orchestrator, configPath tspath.Path) *upToDateStatus { +func (t *BuildTask) getUpToDateStatus(orchestrator *Orchestrator, configPath tspath.Path, force bool) *upToDateStatus { if t.status != nil { return t.status } @@ -369,7 +385,7 @@ func (t *BuildTask) getUpToDateStatus(orchestrator *Orchestrator, configPath tsp } } - if orchestrator.opts.Command.BuildOptions.Force.IsTrue() { + if orchestrator.opts.Command.BuildOptions.Force.IsTrue() || force { return &upToDateStatus{kind: upToDateStatusTypeForceBuild} } @@ -854,6 +870,7 @@ func (t *BuildTask) loadOrStoreBuildInfo(orchestrator *Orchestrator, configPath t.buildInfoEntry = &buildInfoEntry{ buildInfo: incremental.NewBuildInfoReader(orchestrator.host).ReadBuildInfo(t.resolved), path: path, + fileName: buildInfoFileName, } var mTime time.Time if t.buildInfoEntry.buildInfo != nil { @@ -876,6 +893,7 @@ func (t *BuildTask) onBuildInfoEmit(orchestrator *Orchestrator, buildInfoFileNam t.buildInfoEntry = &buildInfoEntry{ buildInfo: buildInfo, path: orchestrator.toPath(buildInfoFileName), + fileName: buildInfoFileName, mTime: mTime, dtsTime: dtsTime, } diff --git a/tsc/internal/execute/build/compilerHost.go b/tsc/internal/execute/build/compilerHost.go index 47a990be14a97..05249f6f94be4 100644 --- a/tsc/internal/execute/build/compilerHost.go +++ b/tsc/internal/execute/build/compilerHost.go @@ -8,17 +8,22 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/trackingvfs" ) type compilerHost struct { host *host trace func(msg *diagnostics.Message, args ...any) contentMapperProject contentmapper.Project + tracked *trackingvfs.FS } var _ compiler.CompilerHost = (*compilerHost)(nil) func (h *compilerHost) FS() vfs.FS { + if h.tracked != nil { + return h.tracked + } return h.host.FS() } @@ -35,6 +40,9 @@ func (h *compilerHost) Trace(msg *diagnostics.Message, args ...any) { } func (h *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.SourceFile { + if h.tracked != nil { + h.tracked.SeenFiles.Add(opts.FileName) + } return h.host.GetSourceFile(opts) } diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index a5ca48c30c234..36be78c05db3d 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -253,7 +253,7 @@ func (o *Orchestrator) Start(ctx context.Context) tsc.CommandLineResult { o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) } o.GenerateGraph(nil) - result := o.buildOrClean() + result := o.buildOrClean(false) if o.opts.Command.CompilerOptions.Watch.IsTrue() { o.Watch(ctx) result.Watcher = o @@ -272,8 +272,8 @@ func (o *Orchestrator) Watch(ctx context.Context) { } o.updateWatch() - desiredDirs := o.computeDesiredWatches() - if err := o.wm.ReconcileWatches(desiredDirs); err != nil { + watchFiles, desiredDirs := o.computeDesiredWatches() + if err := o.wm.ReconcileWatches(watchFiles, desiredDirs, o.host.FS()); err != nil { fmt.Fprintf(o.opts.Sys.Writer(), "%v\n", err) o.wm.ForceOverflow() } @@ -314,8 +314,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch path := o.toPath(config) task := o.getTask(path) - configPath := o.toPath(task.config) - if _, changed := normalizedPaths[configPath]; changed { + if o.watchFileChanged(task.config, normalizedPaths) { task.resetConfig(o, path) needsConfigUpdate.Store(true) needsUpdate.Store(true) @@ -328,8 +327,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch configChanged := false for _, file := range task.resolved.ExtendedSourceFiles() { - fp := o.toPath(file) - if _, changed := normalizedPaths[fp]; changed { + if o.watchFileChanged(file, normalizedPaths) { task.resetConfig(o, path) needsConfigUpdate.Store(true) needsUpdate.Store(true) @@ -344,8 +342,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch if mapper.PackageDirectory == "" || mapper.ContributionID != "" { continue } - manifestPath := o.toPath(tspath.CombinePaths(mapper.PackageDirectory, "package.json")) - if _, changed := normalizedPaths[manifestPath]; changed { + if o.watchFileChanged(tspath.CombinePaths(mapper.PackageDirectory, "package.json"), normalizedPaths) { task.resetConfig(o, path) needsConfigUpdate.Store(true) needsUpdate.Store(true) @@ -367,7 +364,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch rootChanged = true } for _, fileName := range watchedFiles { - if _, changed := normalizedPaths[o.toPath(fileName)]; changed { + if o.watchFileChanged(fileName, normalizedPaths) { task.refreshContentMapperProject(o) task.resetStatus() needsUpdate.Store(true) @@ -382,7 +379,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch fp := o.toPath(file) roots.Add(fp) if !rootChanged { - if _, changed := normalizedPaths[fp]; changed { + if o.watchFileChanged(file, normalizedPaths) { task.resetStatus() needsUpdate.Store(true) rootChanged = true @@ -395,27 +392,27 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch bi := task.buildInfoEntry task.buildInfoEntryMu.Unlock() if bi != nil && bi.buildInfo != nil { - buildInfoDir := tspath.GetDirectoryPath(string(bi.path)) + buildInfoDir := bi.directory() for _, fileName := range bi.buildInfo.FileNames { fp := o.toPath(o.resolveBuildInfoFileName(fileName, buildInfoDir)) if roots.Has(fp) { continue } - if _, changed := normalizedPaths[fp]; changed { + if o.watchFileChanged(o.resolveBuildInfoFileName(fileName, buildInfoDir), normalizedPaths) { task.resetStatus() needsUpdate.Store(true) break } } for packageJson := range bi.buildInfo.GetPackageJsons(buildInfoDir) { - if o.packageJsonLookupChanged(packageJson, normalizedPaths) { + if o.watchFileChanged(packageJson, normalizedPaths) { task.resetStatus() needsUpdate.Store(true) break } } for packageJson := range bi.buildInfo.GetMissingPackageJsons(buildInfoDir) { - if o.packageJsonLookupChanged(packageJson, normalizedPaths) { + if o.watchFileChanged(packageJson, normalizedPaths) { task.resetStatus() needsUpdate.Store(true) break @@ -423,7 +420,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch } } for _, packageJson := range task.packageJsons { - if o.packageJsonLookupChanged(packageJson, normalizedPaths) { + if o.watchFileChanged(packageJson, normalizedPaths) { task.resetStatus() needsUpdate.Store(true) break @@ -461,31 +458,41 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch } } -func (o *Orchestrator) packageJsonLookupChanged(packageJson string, changedPaths map[tspath.Path]fswatch.EventKind) bool { - packageJsonPath := o.toPath(packageJson) - if _, changed := changedPaths[packageJsonPath]; changed { - return true - } - for changedPath, kind := range changedPaths { - if kind == fswatch.EventDelete && tspath.ContainsPath(string(changedPath), string(packageJsonPath), o.comparePathsOptions) { - return true - } - } - return false +func (o *Orchestrator) watchFileChanged(fileName string, changedPaths map[tspath.Path]fswatch.EventKind) bool { + _, changed := changedPaths[o.toPath(fileName)] + return changed } -func (o *Orchestrator) computeDesiredWatches() map[string]bool { +func (o *Orchestrator) computeDesiredWatches() ([]string, map[string]bool) { + realpath := func(name string) string { return o.wm.Realpath(name, o.host.FS()) } desiredDirs := watchmanager.NewDirWatchSet(o.comparePathsOptions) + var watchFiles []string for i := range o.order { config := o.order[i] path := o.toPath(config) task := o.getTask(path) + watchFiles = append(watchFiles, task.config) + watchFiles = append(watchFiles, task.seenFiles...) + // Buildinfo stores compiler keys. Prefer the original read spellings + // captured in this session, retaining every identity for alias fanout. + originals := make(map[tspath.Path][]string, len(task.seenFiles)) + for _, name := range task.seenFiles { + key := o.toPath(name) + originals[key] = append(originals[key], name) + } + originalNames := func(name string) []string { + if names := originals[o.toPath(name)]; len(names) != 0 { + return names + } + return []string{name} + } // Watch config file directory configDir := tspath.GetDirectoryPath(task.config) - realConfigDir := o.host.FS().Realpath(configDir) + realConfigDir := realpath(configDir) desiredDirs.Set(realConfigDir, false) + desiredDirs.Set(tspath.GetDirectoryPath(realpath(task.config)), false) if task.resolved == nil { continue @@ -493,20 +500,23 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { // Extended config file directories for _, cfgPath := range task.resolved.ExtendedSourceFiles() { - realPath := o.host.FS().Realpath(cfgPath) + watchFiles = append(watchFiles, cfgPath) + realPath := realpath(cfgPath) dir := tspath.GetDirectoryPath(realPath) desiredDirs.Set(dir, false) } // Wildcard directories from tsconfig for dir, recursive := range task.resolved.WildcardDirectories() { - realDir := o.host.FS().Realpath(dir) + watchFiles = append(watchFiles, dir) + realDir := realpath(dir) desiredDirs.Set(realDir, recursive) } // Input file directories not already covered for _, fileName := range task.resolved.FileNames() { absPath := tspath.GetNormalizedAbsolutePath(fileName, o.opts.Sys.GetCurrentDirectory()) + watchFiles = append(watchFiles, absPath) dir := tspath.GetDirectoryPath(absPath) if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { desiredDirs.Set(dir, false) @@ -516,6 +526,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { continue } manifestPath := tspath.CombinePaths(mapper.PackageDirectory, "package.json") + watchFiles = append(watchFiles, manifestPath) dir := tspath.GetDirectoryPath(manifestPath) if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { desiredDirs.Set(dir, false) @@ -528,7 +539,8 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { task.contentMapperProjectErr = err } for _, fileName := range watchedFiles { - absPath := o.host.FS().Realpath(fileName) + watchFiles = append(watchFiles, fileName) + absPath := realpath(fileName) dir := tspath.GetDirectoryPath(absPath) if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { desiredDirs.Set(dir, false) @@ -541,32 +553,44 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { bi := task.buildInfoEntry task.buildInfoEntryMu.Unlock() if bi != nil && bi.buildInfo != nil { - buildInfoDir := tspath.GetDirectoryPath(string(bi.path)) + buildInfoDir := bi.directory() roots := collections.NewSetFromItems(core.Map(task.resolved.FileNames(), o.toPath)...) for _, fileName := range bi.buildInfo.FileNames { - absPath := o.host.FS().Realpath(o.resolveBuildInfoFileName(fileName, buildInfoDir)) - fp := o.toPath(absPath) - if roots.Has(fp) { - continue - } - dir := tspath.GetDirectoryPath(absPath) - if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { - desiredDirs.Set(dir, false) + for _, original := range originalNames(o.resolveBuildInfoFileName(fileName, buildInfoDir)) { + watchFiles = append(watchFiles, original) + absPath := realpath(original) + fp := o.toPath(absPath) + if roots.Has(fp) { + continue + } + dir := tspath.GetDirectoryPath(absPath) + if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { + desiredDirs.Set(dir, false) + } } } for packageJson := range bi.buildInfo.GetPackageJsons(buildInfoDir) { - o.addPackageJsonWatchDirs(desiredDirs, packageJson) + for _, original := range originalNames(packageJson) { + watchFiles = append(watchFiles, original) + o.addPackageJsonWatchDirs(desiredDirs, original) + } } for packageJson := range bi.buildInfo.GetMissingPackageJsons(buildInfoDir) { - o.addPackageJsonWatchDirs(desiredDirs, packageJson) + for _, original := range originalNames(packageJson) { + watchFiles = append(watchFiles, original) + o.addPackageJsonWatchDirs(desiredDirs, original) + } } } for _, packageJson := range task.packageJsons { - o.addPackageJsonWatchDirs(desiredDirs, packageJson) + for _, original := range originalNames(packageJson) { + watchFiles = append(watchFiles, original) + o.addPackageJsonWatchDirs(desiredDirs, original) + } } } - return o.wm.ResolveDesiredDirs(desiredDirs.Dirs()) + return watchFiles, o.wm.ResolveDesiredDirs(desiredDirs.Dirs()) } func (o *Orchestrator) addWatchDir(desiredDirs *watchmanager.DirWatchSet, dir string) { @@ -608,7 +632,13 @@ func (o *Orchestrator) DoCycle() { o.wm.Lock() defer o.wm.Unlock() - changedPaths, overflow := o.wm.DrainEvents() + changes := o.wm.DrainEvents() + realpathsChanged, err := o.wm.RefreshResolutions(changes) + changedPaths, overflow := changes.Changes, changes.Overflow + if err != nil { + fmt.Fprintf(o.opts.Sys.Writer(), "%v\n", err) + overflow = true + } hasEvents := len(changedPaths) > 0 || overflow if !hasEvents { @@ -621,8 +651,11 @@ func (o *Orchestrator) DoCycle() { var needsConfigUpdate atomic.Bool var needsUpdate atomic.Bool - if overflow { - // Overflow: reset all tasks to force a full rebuild. + if realpathsChanged || overflow { + o.resetCaches() + } + if overflow || realpathsChanged { + // A new namespace can replace inputs without changing their timestamps. o.rangeTask(func(path tspath.Path, task *BuildTask) { task.resetConfig(o, path) task.reportDone = make(chan struct{}) @@ -646,10 +679,10 @@ func (o *Orchestrator) DoCycle() { o.GenerateGraphReusingOldTasks() } - o.buildOrClean() + o.buildOrClean(realpathsChanged || overflow) o.updateWatch() - desiredDirs := o.computeDesiredWatches() - if err := o.wm.ReconcileWatches(desiredDirs); err != nil { + watchFiles, desiredDirs := o.computeDesiredWatches() + if err := o.wm.ReconcileWatches(watchFiles, desiredDirs, o.host.FS()); err != nil { fmt.Fprintf(o.opts.Sys.Writer(), "%v\n", err) // Mark overflow so the next event triggers a full rebuild o.wm.ForceOverflow() @@ -657,7 +690,7 @@ func (o *Orchestrator) DoCycle() { o.resetCaches() } -func (o *Orchestrator) buildOrClean() tsc.CommandLineResult { +func (o *Orchestrator) buildOrClean(force bool) tsc.CommandLineResult { if !o.opts.Command.BuildOptions.Clean.IsTrue() && o.opts.Command.BuildOptions.Verbose.IsTrue() { o.createBuilderStatusReporter(nil)(ast.NewCompilerDiagnostic( diagnostics.Projects_in_this_build_Colon_0, @@ -670,7 +703,7 @@ func (o *Orchestrator) buildOrClean() tsc.CommandLineResult { if len(o.errors) == 0 { buildResult.statistics.Projects = len(o.Order()) o.rangeTask(func(path tspath.Path, task *BuildTask) { - o.buildOrCleanProject(task, path, &buildResult) + o.buildOrCleanProject(task, path, &buildResult, force) }) } else { // Circularity errors prevent any project from being built @@ -721,12 +754,12 @@ func (o *Orchestrator) rangeTask(f func(path tspath.Path, task *BuildTask)) { } } -func (o *Orchestrator) buildOrCleanProject(task *BuildTask, path tspath.Path, buildResult *orchestratorResult) { +func (o *Orchestrator) buildOrCleanProject(task *BuildTask, path tspath.Path, buildResult *orchestratorResult, force bool) { task.result = &taskResult{} task.result.reportStatus = o.createBuilderStatusReporter(task) task.result.diagnosticReporter = o.createDiagnosticReporter(task) if !o.opts.Command.BuildOptions.Clean.IsTrue() { - task.buildProject(o, path) + task.buildProject(o, path, force) } else { task.cleanProject(o, path) } @@ -749,7 +782,7 @@ func (o *Orchestrator) createDiagnosticReporter(task *BuildTask) tsc.DiagnosticR } func NewOrchestrator(opts Options) *Orchestrator { - wm := watchmanager.NewWatchManager(opts.Sys.Writer(), opts.Sys.FS().DirectoryExists) + wm := watchmanager.NewWatchManager(opts.Sys.Writer(), opts.Sys.FS().DirectoryExists, opts.Sys.FS()) orchestrator := &Orchestrator{ opts: opts, comparePathsOptions: tspath.ComparePathsOptions{ diff --git a/tsc/internal/execute/build/watchchanges_test.go b/tsc/internal/execute/build/watchchanges_test.go new file mode 100644 index 0000000000000..332885fd81c07 --- /dev/null +++ b/tsc/internal/execute/build/watchchanges_test.go @@ -0,0 +1,54 @@ +package build + +import ( + "fmt" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "github.com/microsoft/TypeScript/tsc/internal/watchalias" + "gotest.tools/v3/assert" +) + +func TestWatchFileChangedAncestors(t *testing.T) { + t.Parallel() + for _, caseSensitive := range []bool{false, true} { + o := &Orchestrator{comparePathsOptions: tspath.ComparePathsOptions{ + CurrentDirectory: "/repo", UseCaseSensitiveFileNames: caseSensitive, + }} + raw := map[string]fswatch.EventKind{ + "/repo/src": fswatch.EventDelete, + "/repo/lib": fswatch.EventUpdate, + "/repo/lib/a.ts": fswatch.EventUpdate, + "/repo/s": fswatch.EventDelete, + } + for i := range 10000 { + raw[fmt.Sprintf("/unrelated/%d", i)] = fswatch.EventDelete + } + tests := []struct { + name string + want bool + }{ + {"src/nested/a.ts", true}, + {"SRC/a.ts", !caseSensitive}, + {"src-other/a.ts", false}, + {"lib/a.ts", true}, + {"lib/b.ts", false}, + {"ſ/a.ts", false}, + {"unknown/a.ts", false}, + } + index := watchalias.New(vfstest.FromMap(map[string]string{}, caseSensitive)) + for _, test := range tests { + name := tspath.GetNormalizedAbsolutePath(test.name, "/repo") + assert.NilError(t, index.Register(watchalias.Registration{Name: name, Realpath: name, Dependency: true})) + } + events := make(map[tspath.Path]fswatch.EventKind) + for name, kind := range index.Match(raw).Changes { + events[o.toPath(name)] = kind + } + for _, test := range tests { + assert.Equal(t, o.watchFileChanged(test.name, events), test.want, "%s (caseSensitive=%v)", test.name, caseSensitive) + } + } +} diff --git a/tsc/internal/execute/tsctests/contentmapper_watch_test.go b/tsc/internal/execute/tsctests/contentmapper_watch_test.go index fb1c0c4fd5a8b..ae5043b1708b4 100644 --- a/tsc/internal/execute/tsctests/contentmapper_watch_test.go +++ b/tsc/internal/execute/tsctests/contentmapper_watch_test.go @@ -480,3 +480,44 @@ func TestContentMapperBuildWatchSharedLifecycle(t *testing.T) { } } } + +func TestContentMapperWatchManifestCasing(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + args []string + }{ + {name: "watch", args: []string{"--watch", "--runExternalCode"}}, + {name: "build watch", args: []string{"--build", "--watch", "--runExternalCode"}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + const manifest = "/home/src/workspaces/Mapper/package.json" + input := &tscInput{ignoreCase: true, files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ + "compilerOptions": { "composite": true }, + "contentMappers": [{ "package": "mapper", "extensions": [".vue"] }] + }`, + "/home/src/workspaces/project/app.vue": `export const app = 1;`, + "/home/src/workspaces/project/node_modules/mapper": vfstest.Symlink("/home/src/workspaces/Mapper"), + manifest: contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), + }} + testSys := newTestSys(input, false) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + sys := &recordingContentMapperSystem{TestSys: testSys, spawner: spawner} + result := execute.CommandLine(t.Context(), sys, test.args, testSys) + assert.Assert(t, result.Watcher != nil) + assert.Equal(t, spawner.spawns.Load(), int32(1)) + + testSys.writeFileNoError(manifest, strings.Replace( + contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), + `"version": "1.0.0"`, `"version": "2.0.0"`, 1, + )) + testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: strings.ToUpper(manifest)}}) + result.Watcher.DoCycle() + + assert.Equal(t, spawner.spawns.Load(), int32(2)) + assert.Equal(t, spawner.closes.Load(), int32(1)) + }) + } +} diff --git a/tsc/internal/execute/tsctests/tscwatch_test.go b/tsc/internal/execute/tsctests/tscwatch_test.go index 16a26026f3326..7010314e33202 100644 --- a/tsc/internal/execute/tsctests/tscwatch_test.go +++ b/tsc/internal/execute/tsctests/tscwatch_test.go @@ -1,11 +1,26 @@ package tsctests import ( + "context" "fmt" + "os" + "path/filepath" + "strconv" "strings" + "sync/atomic" "testing" + "time" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/compiler" + "github.com/microsoft/TypeScript/tsc/internal/execute" + "github.com/microsoft/TypeScript/tsc/internal/execute/incremental" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" ) func TestWatch(t *testing.T) { @@ -705,3 +720,546 @@ func TestTscNoEmitWatch(t *testing.T) { test.run(t, "noEmit") } } + +type aliasWatchSystem struct { + *TestSys +} + +func (s *aliasWatchSystem) FS() vfs.FS { return osvfs.FS() } +func (s *aliasWatchSystem) Now() time.Time { return time.Now() } +func (s *aliasWatchSystem) OnProgram(*incremental.Program) {} +func (s *aliasWatchSystem) OnEmittedFiles(*compiler.EmitResult, *collections.SyncMap[tspath.Path, time.Time]) { +} + +var aliasWatchDirectoryID atomic.Uint64 + +func aliasWatchDirectory(t *testing.T) string { + t.Helper() + dir, err := filepath.Abs(fmt.Sprintf(".watch-alias-%d-%d", os.Getpid(), aliasWatchDirectoryID.Add(1))) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.ToSlash(dir) +} + +func writeAliasWatchFile(t *testing.T, name, text string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +func sendAliasWatchEvent(t *testing.T, backend *MockWatchBackend, event fswatch.Event) { + t.Helper() + for _, watch := range backend.Dirs { + if watch.Closed || !osvfs.FS().DirectoryExists(watch.Path) { + continue + } + comparer, err := fswatch.PathComparerForPath(watch.Path) + if err != nil { + t.Fatal(err) + } + opts := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: osvfs.FS().UseCaseSensitiveFileNames()} + _, covered := comparer.Rebase(event.Path, watch.Path, watch.Path) + if !covered && !tspath.ContainsPath(watch.Path, event.Path, opts) { + continue + } + parent := filepath.ToSlash(filepath.Dir(event.Path)) + if !watch.Recursive && comparer.Key(parent) != comparer.Key(watch.Path) && + tspath.GetCanonicalFileName(parent, opts.UseCaseSensitiveFileNames) != tspath.GetCanonicalFileName(watch.Path, opts.UseCaseSensitiveFileNames) { + continue + } + watch.Callback([]fswatch.Event{event}, nil) + return + } + t.Fatalf("no covering watch for %q", event.Path) +} + +func TestWatchRealpathAliases(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + for _, shape := range []string{"file", "directory", "delete-directory", "delete-file-target-directory", "retarget-file", "retarget-directory"} { + t.Run(fmt.Sprintf("build=%v/%s", build, shape), func(t *testing.T) { + t.Parallel() + root := aliasWatchDirectory(t) + dependency := root + "/physical/value.ts" + writeAliasWatchFile(t, dependency, "export const value = 1;") + target, link := root+"/physical", root+"/linked" + imported := "./linked/value" + if shape == "file" || shape == "delete-file-target-directory" || shape == "retarget-file" { + target, link = dependency, root+"/linked.ts" + imported = "./linked" + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + writeAliasWatchFile(t, root+"/main.ts", fmt.Sprintf(`import {value} from %q; const x: number = value;`, imported)) + writeAliasWatchFile(t, root+"/lib.es5.d.ts", tscDefaultLibContent) + writeAliasWatchFile(t, root+"/tsconfig.json", fmt.Sprintf(`{"compilerOptions":{"lib":["es5"],"noEmit":true,"preserveSymlinks":true,"incremental":%v},"files":["main.ts"],"include":[]}`, build)) + base := newTestSys(&tscInput{files: FileMap{}, cwd: root}, false) + base.defaultLibraryPath = root + base.mockWatchBackend.DirectoryExists = osvfs.FS().DirectoryExists + base.mockWatchBackend.UseCaseSensitiveFileNames = osvfs.FS().UseCaseSensitiveFileNames() + sys := &aliasWatchSystem{TestSys: base} + args := []string{"--watch", "--project", root + "/tsconfig.json"} + if build { + args = []string{"--build", "--watch", root + "/tsconfig.json"} + } + result := execute.CommandLine(context.Background(), sys, args, sys) + if result.Watcher == nil || !strings.Contains(base.currentWrite.String(), "Found 0 errors") { + t.Fatalf("initial compilation failed: %s", base.currentWrite.String()) + } + originalInfo, statErr := os.Stat(dependency) + if statErr != nil { + t.Fatal(statErr) + } + base.currentWrite.Reset() + writeAliasWatchFile(t, dependency, `export const value = "changed";`) + if build && (shape == "file" || shape == "directory") { + buildInfo, err := os.Stat(root + "/tsconfig.tsbuildinfo") + if err != nil { + t.Fatal(err) + } + // Build mode requires a newer input timestamp. Consecutive writes + // can share a filesystem timestamp even after compilation completes. + modified := buildInfo.ModTime().Add(time.Second) + assert.NilError(t, os.Chtimes(dependency, modified, modified)) + } + event := fswatch.Event{Path: dependency, Kind: fswatch.EventUpdate} + want := "TS2322" + if strings.HasPrefix(shape, "retarget-") { + dependency = root + "/replacement/value.ts" + writeAliasWatchFile(t, dependency, `export const value = "retargeted";`) + if err := os.Chtimes(dependency, originalInfo.ModTime(), originalInfo.ModTime()); err != nil { + t.Fatal(err) + } + assertTarget := root + "/replacement" + if shape == "retarget-file" { + assertTarget = dependency + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(assertTarget, link); err != nil { + t.Fatal(err) + } + event.Path = link + } + if strings.HasPrefix(shape, "delete-") { + event = fswatch.Event{Path: root + "/physical", Kind: fswatch.EventDelete} + if err := os.RemoveAll(event.Path); err != nil { + t.Fatal(err) + } + want = "TS2307" + } + sendAliasWatchEvent(t, base.mockWatchBackend, event) + result.Watcher.DoCycle() + if !strings.Contains(base.currentWrite.String(), want) { + t.Fatalf("watch retained stale symlink dependency: %s", base.currentWrite.String()) + } + if strings.HasPrefix(shape, "retarget-") { + base.currentWrite.Reset() + writeAliasWatchFile(t, dependency, `export const value = 2;`) + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: dependency, Kind: fswatch.EventUpdate}) + result.Watcher.DoCycle() + if !strings.Contains(base.currentWrite.String(), "Found 0 errors") { + t.Fatalf("watch retained old symlink target: %s", base.currentWrite.String()) + } + } + }) + } + } +} + +func TestWatchFilesystemAliases(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + for _, shape := range []string{"filename", "descendant", "root", "multiple", "delete-directory"} { + for _, pair := range []struct{ name, disk, alias string }{ + {"ascii", "ASCII", "ascii"}, + {"long-s", "s", "\u017f"}, + {"sigma", "\u03c3", "\u03c2"}, + {"sharp-s", "SS", "\u00df"}, + {"dotted-i", "i\u0307", "\u0130"}, + {"ligature", "ffi", "\ufb03"}, + {"normalization", "\u00e9", "e\u0301"}, + } { + t.Run(fmt.Sprintf("build=%v/%s/%s", build, shape, pair.name), func(t *testing.T) { + t.Parallel() + if shape == "multiple" && pair.name == "ascii" { + t.Skip("ASCII spellings intentionally share one compiler identity") + } + dir := aliasWatchDirectory(t) + comparer, err := fswatch.PathComparerForPath(dir) + if err != nil { + t.Fatal(err) + } + if pair.name != "ascii" && comparer.Key(pair.disk) != comparer.Key(pair.alias) { + t.Skip("native watcher does not equate these Unicode spellings") + } + root, requestedRoot := dir, dir + dependency, imported := pair.disk, pair.alias + include := "[]" + if shape == "descendant" || shape == "delete-directory" { + dependency += "/value" + imported += "/value" + include = `["**/*.unmatched"]` + } else if shape == "root" { + root += "/" + pair.disk + requestedRoot += "/" + pair.alias + dependency, imported = "value", "value" + } + dependency = root + "/" + dependency + ".ts" + writeAliasWatchFile(t, dependency, "export const value = 1;") + diskInfo, err := os.Stat(dependency) + if err != nil { + t.Fatal(err) + } + aliasInfo, err := os.Stat(requestedRoot + "/" + imported + ".ts") + if err != nil || !os.SameFile(diskInfo, aliasInfo) { + t.Skip("volume does not equate these filename spellings") + } + main := fmt.Sprintf(`import { value } from "./%s"; const x: number = value;`, imported) + if shape == "multiple" { + main += fmt.Sprintf(`import { value as other } from "./%s"; const y: number = other;`, pair.disk) + } + writeAliasWatchFile(t, root+"/main.ts", main) + writeAliasWatchFile(t, root+"/lib.es5.d.ts", tscDefaultLibContent) + writeAliasWatchFile(t, root+"/tsconfig.json", fmt.Sprintf(`{"compilerOptions":{"lib":["es5"],"noEmit":true,"incremental":%v},"files":["main.ts"],"include":%s}`, build, include)) + base := newTestSys(&tscInput{files: FileMap{}, cwd: requestedRoot}, false) + base.defaultLibraryPath = root + base.mockWatchBackend.DirectoryExists = osvfs.FS().DirectoryExists + base.mockWatchBackend.UseCaseSensitiveFileNames = osvfs.FS().UseCaseSensitiveFileNames() + sys := &aliasWatchSystem{TestSys: base} + args := []string{"--watch", "--project", requestedRoot + "/tsconfig.json"} + if build { + args = []string{"--build", "--watch", requestedRoot + "/tsconfig.json"} + } + result := execute.CommandLine(context.Background(), sys, args, sys) + if result.Watcher == nil || !strings.Contains(base.currentWrite.String(), "Found 0 errors") { + t.Fatalf("initial compilation failed: %s", base.currentWrite.String()) + } + base.currentWrite.Reset() + writeAliasWatchFile(t, dependency, `export const value = "changed";`) + event := fswatch.Event{Path: dependency, Kind: fswatch.EventUpdate} + wantDiagnostic := "TS2322" + if shape == "delete-directory" { + event.Path = filepath.ToSlash(filepath.Dir(dependency)) + event.Kind = fswatch.EventDelete + if err := os.RemoveAll(event.Path); err != nil { + t.Fatal(err) + } + wantDiagnostic = "TS2307" + } + // Deliver the physical spelling through a genuinely covering + // subscription, as a recursive native backend does. + sendAliasWatchEvent(t, base.mockWatchBackend, event) + result.Watcher.DoCycle() + if !strings.Contains(base.currentWrite.String(), wantDiagnostic) { + t.Fatalf("watch retained stale aliased dependency: %s", base.currentWrite.String()) + } + + if shape == "multiple" && strings.Count(base.currentWrite.String(), "TS2322") != 2 { + t.Fatalf("both compiler identities must be invalidated: %s", base.currentWrite.String()) + } + }) + } + } + } +} + +func TestWatchFilesystemAliasLookups(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + for _, lookup := range []string{"config", "package", "package-directory-delete", "discovery"} { + t.Run(fmt.Sprintf("build=%v/%s", build, lookup), func(t *testing.T) { + t.Parallel() + root := aliasWatchDirectory(t) + comparer, err := fswatch.PathComparerForPath(root) + if err != nil { + t.Fatal(err) + } + if comparer.Key("s") != comparer.Key("\u017f") { + t.Skip("native watcher does not equate these Unicode spellings") + } + writeAliasWatchFile(t, root+"/s/marker", "") + disk, err := os.Stat(root + "/s") + if err != nil { + t.Fatal(err) + } + alias, err := os.Stat(root + "/\u017f") + if err != nil || !os.SameFile(disk, alias) { + t.Skip("volume does not equate these filename spellings") + } + main := `import {value} from "ſ"; const x: number = value;` + extra := "" + include := "[]" + event := fswatch.Event{Path: root + "/node_modules/s/package.json", Kind: fswatch.EventUpdate} + change := func() { + writeAliasWatchFile(t, event.Path, `{"types":"string.d.ts"}`) + } + want := "TS2322" + switch lookup { + case "config": + main = `const x: string = null;` + extra = `,"extends":"./ſ/base.json"` + event.Path = root + "/s/base.json" + writeAliasWatchFile(t, event.Path, `{"compilerOptions":{"strictNullChecks":false}}`) + change = func() { + writeAliasWatchFile(t, event.Path, `{"compilerOptions":{"strictNullChecks":true}}`) + } + case "discovery": + main = `import {value} from "./ſ/new"; const x: number = value;` + include = `["**/*.ts"]` + event.Path = root + "/s/new.ts" + change = func() { writeAliasWatchFile(t, event.Path, `export const value = "changed";`) } + default: + writeAliasWatchFile(t, event.Path, `{"types":"number.d.ts"}`) + writeAliasWatchFile(t, root+"/node_modules/s/number.d.ts", `export declare const value: number;`) + writeAliasWatchFile(t, root+"/node_modules/s/string.d.ts", `export declare const value: string;`) + if lookup == "package-directory-delete" { + event.Path = root + "/node_modules/s" + event.Kind = fswatch.EventDelete + change = func() { + if removeErr := os.RemoveAll(event.Path); removeErr != nil { + t.Fatal(removeErr) + } + } + want = "TS2307" + } + } + writeAliasWatchFile(t, root+"/main.ts", main) + writeAliasWatchFile(t, root+"/lib.es5.d.ts", tscDefaultLibContent) + writeAliasWatchFile(t, root+"/tsconfig.json", fmt.Sprintf(`{"compilerOptions":{"lib":["es5"],"module":"nodenext","noEmit":true,"incremental":%v},"files":["main.ts"],"include":%s%s}`, build, include, extra)) + base := newTestSys(&tscInput{files: FileMap{}, cwd: root}, false) + base.defaultLibraryPath = root + base.mockWatchBackend.DirectoryExists = osvfs.FS().DirectoryExists + sys := &aliasWatchSystem{TestSys: base} + args := []string{"--watch", "--project", root + "/tsconfig.json"} + if build { + args = []string{"--build", "--watch", root + "/tsconfig.json"} + } + result := execute.CommandLine(context.Background(), sys, args, sys) + initial := "Found 0 errors" + if lookup == "discovery" { + initial = "TS2307" + } + if result.Watcher == nil || !strings.Contains(base.currentWrite.String(), initial) { + t.Fatalf("initial compilation failed: %s", base.currentWrite.String()) + } + base.currentWrite.Reset() + change() + sendAliasWatchEvent(t, base.mockWatchBackend, event) + result.Watcher.DoCycle() + if !strings.Contains(base.currentWrite.String(), want) { + t.Fatalf("watch retained stale %s lookup: %s", lookup, base.currentWrite.String()) + } + }) + } + } +} + +func TestWatchConfigRetargetWithEqualTime(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + for _, extended := range []bool{false, true} { + t.Run(fmt.Sprintf("build=%v/extended=%v", build, extended), func(t *testing.T) { + t.Parallel() + root := aliasWatchDirectory(t) + config := root + "/tsconfig.json" + link := config + baseConfig := fmt.Sprintf(`"compilerOptions":{"lib":["es5"],"noEmit":true,"incremental":%v,"noImplicitAny":%%v},"files":["main.ts"],"include":[]`, build) + if extended { + link = root + "/options.json" + writeAliasWatchFile(t, config, fmt.Sprintf(`{%s,"extends":"./options.json"}`, fmt.Sprintf(baseConfig, true))) + baseConfig = `"compilerOptions":{"strictNullChecks":%v}` + } + writeAliasWatchFile(t, root+"/one.json", "{"+fmt.Sprintf(baseConfig, false)+"}") + writeAliasWatchFile(t, root+"/two.json", "{"+fmt.Sprintf(baseConfig, true)+"}") + text, diagnostic := "export function f(x) { return x; }", "TS7006" + if extended { + text, diagnostic = "export const x: string = null;", "TS2322" + } + writeAliasWatchFile(t, root+"/main.ts", text) + writeAliasWatchFile(t, root+"/lib.es5.d.ts", tscDefaultLibContent) + stamp := time.Unix(1700000000, 0) + assert.NilError(t, os.Chtimes(root+"/one.json", stamp, stamp)) + assert.NilError(t, os.Chtimes(root+"/two.json", stamp, stamp)) + if err := os.Symlink(root+"/one.json", link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + base := newTestSys(&tscInput{files: FileMap{}, cwd: root}, false) + base.defaultLibraryPath = root + base.mockWatchBackend.DirectoryExists = osvfs.FS().DirectoryExists + base.mockWatchBackend.UseCaseSensitiveFileNames = osvfs.FS().UseCaseSensitiveFileNames() + sys := &aliasWatchSystem{TestSys: base} + args := []string{"--watch", "--project", config} + if build { + args = []string{"--build", "--watch", config} + } + result := execute.CommandLine(context.Background(), sys, args, sys) + assert.Assert(t, result.Watcher != nil) + assert.Assert(t, strings.Contains(base.currentWrite.String(), "Found 0 errors"), base.currentWrite.String()) + base.currentWrite.Reset() + assert.NilError(t, os.Remove(link)) + assert.NilError(t, os.Symlink(root+"/two.json", link)) + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: link, Kind: fswatch.EventUpdate}) + result.Watcher.DoCycle() + assert.Assert(t, strings.Contains(base.currentWrite.String(), diagnostic), base.currentWrite.String()) + }) + } + } +} + +type lifecycleWatchSystem struct { + *aliasWatchSystem + filesystem vfs.FS +} + +func (s *lifecycleWatchSystem) FS() vfs.FS { return s.filesystem } + +type failedConfigReadFS struct { + vfs.FS + config string + fail atomic.Bool +} + +func (f *failedConfigReadFS) ReadFile(name string) (string, bool) { + if name == f.config && f.fail.Swap(false) { + return "", false + } + return f.FS.ReadFile(name) +} + +func startPhysicalAliasWatch(t *testing.T, root string, build bool, filesystem vfs.FS) (*TestSys, func()) { + t.Helper() + writeAliasWatchFile(t, root+"/lib.es5.d.ts", tscDefaultLibContent) + base := newTestSys(&tscInput{files: FileMap{}, cwd: root}, false) + base.defaultLibraryPath = root + base.mockWatchBackend.DirectoryExists = osvfs.FS().DirectoryExists + base.mockWatchBackend.UseCaseSensitiveFileNames = osvfs.FS().UseCaseSensitiveFileNames() + sys := &lifecycleWatchSystem{aliasWatchSystem: &aliasWatchSystem{TestSys: base}, filesystem: filesystem} + args := []string{"--watch", "--project", root + "/tsconfig.json"} + if build { + args = []string{"--build", "--watch", root + "/tsconfig.json"} + } + result := execute.CommandLine(context.Background(), sys, args, sys) + assert.Assert(t, result.Watcher != nil) + assert.Assert(t, strings.Contains(base.currentWrite.String(), "Found 0 errors"), base.currentWrite.String()) + return base, result.Watcher.DoCycle +} + +func TestWatchRetargetIdenticalSourceText(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + t.Run(strconv.FormatBool(build), func(t *testing.T) { + t.Parallel() + root := aliasWatchDirectory(t) + stamp := time.Unix(1700000000, 0) + for _, target := range []string{"one", "two"} { + writeAliasWatchFile(t, root+"/"+target+"/index.ts", `export {value} from "./dep";`) + value := "1" + if target == "two" { + value = `"two"` + } + writeAliasWatchFile(t, root+"/"+target+"/dep.ts", "export const value = "+value+";") + for _, name := range []string{"index.ts", "dep.ts"} { + assert.NilError(t, os.Chtimes(root+"/"+target+"/"+name, stamp, stamp)) + } + } + link := root + "/linked" + if err := os.Symlink(root+"/one", link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + writeAliasWatchFile(t, root+"/main.ts", `import {value} from "./linked/index"; const x: number = value;`) + writeAliasWatchFile(t, root+"/tsconfig.json", fmt.Sprintf(`{"compilerOptions":{"lib":["es5"],"noEmit":true,"preserveSymlinks":true,"incremental":%v},"files":["main.ts"],"include":[]}`, build)) + base, cycle := startPhysicalAliasWatch(t, root, build, osvfs.FS()) + base.currentWrite.Reset() + assert.NilError(t, os.Remove(link)) + assert.NilError(t, os.Symlink(root+"/two", link)) + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: link, Kind: fswatch.EventUpdate}) + cycle() + assert.Assert(t, strings.Contains(base.currentWrite.String(), "TS2322"), base.currentWrite.String()) + base.currentWrite.Reset() + writeAliasWatchFile(t, root+"/two/dep.ts", "export const value = 2;") + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: root + "/two/dep.ts", Kind: fswatch.EventUpdate}) + cycle() + assert.Assert(t, strings.Contains(base.currentWrite.String(), "Found 0 errors"), base.currentWrite.String()) + }) + } +} + +func TestWatchRetargetConfigErrorRecovery(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + t.Run(strconv.FormatBool(build), func(t *testing.T) { + t.Parallel() + root := aliasWatchDirectory(t) + config := fmt.Sprintf(`{"compilerOptions":{"lib":["es5"],"noEmit":true,"incremental":%v},"files":["main.ts"],"include":[]}`, build) + writeAliasWatchFile(t, root+"/one/config.json", config) + writeAliasWatchFile(t, root+"/two/config.json", "{") + stamp := time.Unix(1700000000, 0) + assert.NilError(t, os.Chtimes(root+"/one/config.json", stamp, stamp)) + assert.NilError(t, os.Chtimes(root+"/two/config.json", stamp, stamp)) + link := root + "/tsconfig.json" + if err := os.Symlink(root+"/one/config.json", link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + writeAliasWatchFile(t, root+"/main.ts", "export const value = 1;") + base, cycle := startPhysicalAliasWatch(t, root, build, osvfs.FS()) + base.currentWrite.Reset() + assert.NilError(t, os.Remove(link)) + assert.NilError(t, os.Symlink(root+"/two/config.json", link)) + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: link, Kind: fswatch.EventUpdate}) + cycle() + assert.Assert(t, strings.Contains(base.currentWrite.String(), "TS1005"), base.currentWrite.String()) + base.currentWrite.Reset() + writeAliasWatchFile(t, root+"/two/config.json", config) + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: root + "/two/config.json", Kind: fswatch.EventUpdate}) + cycle() + assert.Assert(t, strings.Contains(base.currentWrite.String(), "Found 0 errors"), base.currentWrite.String()) + }) + } +} + +func TestWatchRetargetConfigReadFailureRecovery(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + t.Run(strconv.FormatBool(build), func(t *testing.T) { + t.Parallel() + root := aliasWatchDirectory(t) + config := fmt.Sprintf(`{"compilerOptions":{"lib":["es5"],"noEmit":true,"incremental":%v},"files":["main.ts"],"include":[]}`, build) + writeAliasWatchFile(t, root+"/one/config.json", config) + writeAliasWatchFile(t, root+"/two/config.json", config) + link := root + "/tsconfig.json" + if err := os.Symlink(root+"/one/config.json", link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + writeAliasWatchFile(t, root+"/main.ts", "export const value = 1;") + fs := &failedConfigReadFS{FS: osvfs.FS(), config: link} + base, cycle := startPhysicalAliasWatch(t, root, build, fs) + base.currentWrite.Reset() + assert.NilError(t, os.Remove(link)) + assert.NilError(t, os.Symlink(root+"/two/config.json", link)) + fs.fail.Store(true) + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: link, Kind: fswatch.EventUpdate}) + cycle() + diagnostic := "TS5083" + if build { + diagnostic = "TS6053" + } + assert.Assert(t, strings.Contains(base.currentWrite.String(), diagnostic), base.currentWrite.String()) + base.currentWrite.Reset() + sendAliasWatchEvent(t, base.mockWatchBackend, fswatch.Event{Path: root + "/two/config.json", Kind: fswatch.EventUpdate}) + cycle() + assert.Assert(t, strings.Contains(base.currentWrite.String(), "Found 0 errors"), base.currentWrite.String()) + }) + } +} diff --git a/tsc/internal/execute/watcher.go b/tsc/internal/execute/watcher.go index 3a3b9405340c9..bbcb7c36019be 100644 --- a/tsc/internal/execute/watcher.go +++ b/tsc/internal/execute/watcher.go @@ -19,6 +19,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/cachedvfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/trackingvfs" ) @@ -111,7 +112,7 @@ func createWatcher( reportErrorSummary tsc.DiagnosticsReporter, testing tsc.CommandLineTesting, ) *Watcher { - wm := watchmanager.NewWatchManager(sys.Writer(), sys.FS().DirectoryExists) + wm := watchmanager.NewWatchManager(sys.Writer(), sys.FS().DirectoryExists, sys.FS()) if t, ok := testing.(watchmanager.CommandLineTestingWithWatchBackend); ok { wm.SetBackend(t.WatchBackend()) } @@ -158,7 +159,7 @@ func (w *Watcher) start(ctx context.Context) { w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) w.watchSetDirty = true - if err := w.doBuild(); err != nil { + if err := w.doBuild(false); err != nil { w.wm.ForceOverflow() } w.wm.Unlock() @@ -204,28 +205,29 @@ func (w *Watcher) contentMapperWatchedFiles() []string { return files } -func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool { +func (w *Watcher) computeDesiredWatches(seenFilePaths []string, filesystem vfs.FS) map[string]bool { cwd := w.sys.GetCurrentDirectory() + realpath := func(name string) string { return w.wm.Realpath(name, filesystem) } desiredDirs := make(map[string]bool) // dir → recursive // Wildcard directories from tsconfig (recursive or non-recursive) if w.config.ConfigFile != nil { for dir, recursive := range w.config.WildcardDirectories() { - realDir := w.sys.FS().Realpath(dir) + realDir := realpath(dir) desiredDirs[realDir] = recursive } } // For no-config CLI mode, ensure CWD is watched if w.config.ConfigFile == nil && len(desiredDirs) == 0 { - dir := w.sys.FS().Realpath(cwd) + dir := realpath(cwd) desiredDirs[dir] = false } // Config file parent directories as non-recursive watches for _, cfgPath := range w.configFilePaths { - realPath := w.sys.FS().Realpath(cfgPath) + realPath := realpath(cfgPath) dir := tspath.GetDirectoryPath(realPath) if _, has := desiredDirs[dir]; !has { desiredDirs[dir] = false @@ -236,7 +238,7 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool if w.config.ConfigFile == nil { for _, fileName := range w.config.FileNames() { absPath := tspath.GetNormalizedAbsolutePath(fileName, cwd) - realPath := w.sys.FS().Realpath(absPath) + realPath := realpath(absPath) dir := tspath.GetDirectoryPath(realPath) if _, has := desiredDirs[dir]; !has { desiredDirs[dir] = false @@ -253,7 +255,7 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool coverage.Set(dir, recursive) } for _, filePath := range seenFilePaths { - dir := tspath.GetDirectoryPath(filePath) + dir := tspath.GetDirectoryPath(realpath(filePath)) if !coverage.Covered(dir) && watchmanager.CanWatchDirectory(dir) { coverage.Set(dir, false) } @@ -263,9 +265,19 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool return w.wm.ResolveDesiredDirs(coverage.Dirs()) } -func (w *Watcher) reconcileWatches(seenFilePaths []string) error { - desiredDirs := w.computeDesiredWatches(seenFilePaths) - return w.wm.ReconcileWatches(desiredDirs) +func (w *Watcher) reconcileWatches(seenFilePaths []string, filesystem vfs.FS) error { + watchFiles := append(slices.Clone(seenFilePaths), w.configFilePaths...) + watchFiles = append(watchFiles, w.contentMapperWatchedFiles()...) + for _, file := range w.config.FileNames() { + watchFiles = append(watchFiles, tspath.GetNormalizedAbsolutePath(file, w.sys.GetCurrentDirectory())) + } + if w.config.ConfigFile != nil { + for dir := range w.config.WildcardDirectories() { + watchFiles = append(watchFiles, dir) + } + } + desiredDirs := w.computeDesiredWatches(seenFilePaths, filesystem) + return w.wm.ReconcileWatches(watchFiles, desiredDirs, filesystem) } func (w *Watcher) comparePathsOptions() tspath.ComparePathsOptions { @@ -279,14 +291,28 @@ func (w *Watcher) DoCycle() { w.wm.Lock() defer w.wm.Unlock() - changedPaths, overflow := w.wm.DrainEvents() + changes := w.wm.DrainEvents() + realpathsChanged, err := w.wm.RefreshResolutions(changes) + changedPaths, overflow := changes.Changes, changes.Overflow + if err != nil { + fmt.Fprintf(w.sys.Writer(), "%v\n", err) + overflow = true + } hasEvents := len(changedPaths) > 0 || overflow - if w.recheckTsConfig(w.contentMapperManifestChanged(changedPaths)) { + if w.recheckTsConfig(overflow || realpathsChanged || w.contentMapperManifestChanged(changedPaths)) { + if realpathsChanged || overflow { + // A malformed replacement config must still be watched at its new + // target so fixing it can recover without another logical-link event. + if err := w.reconcileWatches(w.wm.WatchFiles(), w.sys.FS()); err != nil { + fmt.Fprintf(w.sys.Writer(), "%v\n", err) + w.wm.ForceOverflow() + } + } return } - if hasEvents && !overflow && !w.configModified { + if hasEvents && !overflow && !w.configModified && !realpathsChanged { // Filter fswatch events against known dependencies if w.isRelevantChange(changedPaths) { w.evictChangedSourceFiles(changedPaths) @@ -297,7 +323,11 @@ func (w *Watcher) DoCycle() { return tspath.ToPath(fileName, cwd, caseSensitive) })...) contentMapperConfigChanged := false - for eventPath := range changedPaths { + for eventPath, kind := range changedPaths { + if kind == fswatch.EventDelete { + w.watchSetDirty = true + w.forceFullRebuild = true + } if w.sys.FS().DirectoryExists(eventPath) { // A watched directory changed: the wildcard file set may have // changed, so reload file names on the next build. @@ -369,7 +399,7 @@ func (w *Watcher) DoCycle() { } w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.File_change_detected_Starting_incremental_compilation)) - if err := w.doBuild(); err != nil { + if err := w.doBuild(realpathsChanged); err != nil { // Mid-cycle watch failure; force a full rebuild on the next event w.wm.ForceOverflow() } @@ -405,8 +435,11 @@ func (w *Watcher) isRelevantChange(changedPaths map[string]fswatch.EventKind) bo return false } -func (w *Watcher) doBuild() error { - if w.configModified { +func (w *Watcher) doBuild(realpathsChanged bool) error { + if realpathsChanged { + w.forceFullRebuild = true + } + if w.configModified || realpathsChanged { w.sourceFileCache = &collections.SyncMap[tspath.Path, *cachedSourceFile]{} w.watchSetDirty = true } @@ -487,7 +520,7 @@ func (w *Watcher) doBuild() error { w.fullBuilds++ result := w.compileAndEmit() - cached.DisableAndClearCache() + defer cached.DisableAndClearCache() caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() cwd := w.sys.GetCurrentDirectory() @@ -504,10 +537,11 @@ func (w *Watcher) doBuild() error { } } - if err := w.reconcileWatches(seenSlice); err != nil { + if err := w.reconcileWatches(seenSlice, cached); err != nil { fmt.Fprintf(w.sys.Writer(), "%v\n", err) return err } + cached.DisableAndClearCache() w.watchSetDirty = false w.configModified = false w.forceFullRebuild = false @@ -619,11 +653,22 @@ func (w *Watcher) compileAndEmit() tsc.CompileAndEmitResult { } func (w *Watcher) contentMapperManifestChanged(changedPaths map[string]fswatch.EventKind) bool { - for _, mapper := range w.config.ContentMappers() { + mappers := w.config.ContentMappers() + if len(mappers) == 0 || len(changedPaths) == 0 { + return false + } + cwd := w.sys.GetCurrentDirectory() + caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() + changed := collections.NewSetWithSizeHint[tspath.Path](len(changedPaths)) + for fileName := range changedPaths { + changed.Add(tspath.ToPath(fileName, cwd, caseSensitive)) + } + for _, mapper := range mappers { if mapper.PackageDirectory == "" || mapper.ContributionID != "" { continue } - if _, changed := changedPaths[tspath.CombinePaths(mapper.PackageDirectory, "package.json")]; changed { + manifest := tspath.CombinePaths(mapper.PackageDirectory, "package.json") + if changed.Has(tspath.ToPath(manifest, cwd, caseSensitive)) { return true } } diff --git a/tsc/internal/execute/watchmanager/watchalias_darwin_test.go b/tsc/internal/execute/watchmanager/watchalias_darwin_test.go new file mode 100644 index 0000000000000..b6fcf9fb186bc --- /dev/null +++ b/tsc/internal/execute/watchmanager/watchalias_darwin_test.go @@ -0,0 +1,408 @@ +package watchmanager + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/cachedvfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" + "gotest.tools/v3/assert" +) + +func TestWatchAliasNativeContainment(t *testing.T) { //nolint:paralleltest // Keep native subscriptions sequential to bound kqueue descriptors and event delays. + for _, backend := range []fswatch.Watcher{fswatch.Default(), fswatch.Kqueue()} { //nolint:paralleltest // Native subscriptions are intentionally sequential. + for _, pair := range []struct{ disk, requested string }{ + {"ASCII", "ascii"}, + {"s", "\u017f"}, + {"SS", "\u00df"}, + {"\u00e9", "e\u0301"}, + } { + t.Run(backend.Name()+"/"+pair.disk, func(t *testing.T) { + dir, err := filepath.Abs(fmt.Sprintf(".watch-alias-%d-%s-%s", os.Getpid(), backend.Name(), pair.disk)) + if err != nil { + t.Fatal(err) + } + if err = os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + disk := filepath.Join(dir, pair.disk) + requested := filepath.Join(dir, pair.requested) + if err = os.Mkdir(disk, 0o755); err != nil { + t.Fatal(err) + } + a, err := os.Stat(disk) + if err != nil { + t.Fatal(err) + } + b, err := os.Stat(requested) + if err != nil || !os.SameFile(a, b) { + t.Skip("volume does not equate these filename spellings") + } + filesystem := osvfs.FS() + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + wm.SetBackend(&FSWatchBackend{Inner: backend}) + wm.Lock() + err = wm.ReconcileWatches(nil, map[string]bool{requested: false}, nil) + wm.Unlock() + if err != nil { + t.Fatal(err) + } + defer wm.CloseAllWatches() + if err := os.Mkdir(filepath.Join(disk, "child"), 0o755); err != nil { + t.Fatal(err) + } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for { + select { + case <-wm.DoCycleCh(): + wm.Lock() + changes := wm.DrainEvents() + found := false + covered := false + for event := range changes.Changes { + if strings.HasSuffix(event, "/child") { + found = true + if wm.IsPathUnderWatch(event, caseInsensitiveOpts) { + covered = true + } + } + } + wm.Unlock() + if changes.Overflow { + t.Fatal("unexpected overflow") + } + if found { + if !covered { + t.Errorf("child events %v are outside their requested root %q", changes.Changes, requested) + } + return + } + case <-deadline.C: + t.Fatal("no delivered child event") + } + } + }) + } + } +} + +func watchResolutionDirectory(t *testing.T) string { + t.Helper() + dir, err := filepath.Abs(fmt.Sprintf(".watch-resolution-%d-%d", os.Getpid(), time.Now().UnixNano())) + assert.NilError(t, err) + assert.NilError(t, os.Mkdir(dir, 0o755)) + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func TestWatchResolutionNamespaceChanges(t *testing.T) { + t.Parallel() + for _, directory := range []bool{false, true} { + t.Run(strconv.FormatBool(directory), func(t *testing.T) { + t.Parallel() + dir := watchResolutionDirectory(t) + for _, target := range []string{"one", "two"} { + assert.NilError(t, os.Mkdir(dir+"/"+target, 0o755)) + assert.NilError(t, os.WriteFile(dir+"/"+target+"/file.ts", nil, 0o600)) + } + link := dir + "/link" + targetSuffix := "/file.ts" + if directory { + targetSuffix = "" + } + assert.NilError(t, os.Symlink(dir+"/one"+targetSuffix, link)) + name := link + if directory { + name += "/file.ts" + } + filesystem := osvfs.FS() + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + reconcile := func() { + t.Helper() + assert.NilError(t, wm.ReconcileWatches([]string{name}, map[string]bool{dir: true}, nil)) + } + reconcile() + assert.Equal(t, wm.Realpath(name, nil), dir+"/one/file.ts") + assert.NilError(t, os.Remove(link)) + assert.NilError(t, os.Symlink(dir+"/two"+targetSuffix, link)) + wm.onWatchEvents([]fswatch.Event{{Path: link, Kind: fswatch.EventUpdate}}, nil) + changes := wm.DrainEvents() + retargeted, err := wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.Assert(t, retargeted) + reconcile() + assert.Equal(t, wm.Realpath(name, nil), dir+"/two/file.ts") + wm.onWatchEvents([]fswatch.Event{{Path: dir + "/two/file.ts", Kind: fswatch.EventUpdate}}, nil) + changes = wm.DrainEvents() + _, ok := changes.Changes[name] + assert.Assert(t, ok, "new target did not expand: %v", changes.Changes) + _, err = wm.RefreshResolutions(changes) + assert.NilError(t, err) + reconcile() + wm.onWatchEvents([]fswatch.Event{{Path: dir + "/one/file.ts", Kind: fswatch.EventUpdate}}, nil) + changes = wm.DrainEvents() + _, ok = changes.Changes[name] + assert.Assert(t, !ok, "old target still expands: %v", changes.Changes) + }) + } +} + +func TestWatchResolutionAbsentDirectoryAppears(t *testing.T) { + t.Parallel() + dir := watchResolutionDirectory(t) + name := dir + "/new/file.ts" + filesystem := osvfs.FS() + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + assert.NilError(t, wm.ReconcileWatches([]string{name}, nil, nil)) + assert.NilError(t, os.Mkdir(dir+"/new", 0o755)) + assert.NilError(t, os.WriteFile(dir+"/target.ts", nil, 0o600)) + assert.NilError(t, os.Symlink(dir+"/target.ts", name)) + wm.onWatchEvents([]fswatch.Event{{Path: dir + "/new", Kind: fswatch.EventUpdate}}, nil) + changes := wm.DrainEvents() + _, err := wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.NilError(t, wm.ReconcileWatches([]string{name}, nil, nil)) + assert.Equal(t, wm.Realpath(name, nil), dir+"/target.ts") + assert.NilError(t, os.RemoveAll(dir+"/new")) + wm.onWatchEvents([]fswatch.Event{{Path: dir + "/new", Kind: fswatch.EventDelete}}, nil) + changes = wm.DrainEvents() + assert.Equal(t, changes.Changes[name], fswatch.EventDelete) + _, err = wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.NilError(t, wm.ReconcileWatches([]string{name}, nil, nil)) + assert.Equal(t, wm.Realpath(name, nil), name) +} + +func TestWatchResolutionRefreshWithoutReconcile(t *testing.T) { + t.Parallel() + dir := watchResolutionDirectory(t) + for _, target := range []string{"one.ts", "two.ts"} { + assert.NilError(t, os.WriteFile(dir+"/"+target, nil, 0o600)) + } + name := dir + "/link.ts" + assert.NilError(t, os.Symlink(dir+"/one.ts", name)) + filesystem := osvfs.FS() + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + assert.NilError(t, wm.ReconcileWatches([]string{name}, nil, nil)) + assert.Equal(t, wm.Realpath(name, nil), dir+"/one.ts") + aliases := wm.aliases + resolved := *wm.resolvedPaths[name] + + assert.NilError(t, os.Remove(name)) + assert.NilError(t, os.Symlink(dir+"/two.ts", name)) + wm.onWatchEvents([]fswatch.Event{{Path: name, Kind: fswatch.EventUpdate}}, nil) + changes := wm.DrainEvents() + assert.Equal(t, *wm.resolvedPaths[name], resolved, "draining must not mutate cached resolutions") + assert.Assert(t, wm.aliases == aliases, "draining must match with the old alias index") + retargeted, err := wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.Assert(t, retargeted) + assert.Equal(t, wm.Realpath(name, nil), dir+"/two.ts") + assert.Assert(t, wm.aliases != aliases, "refresh must publish the new alias index without reconciling") + + wm.onWatchEvents([]fswatch.Event{{Path: dir + "/two.ts", Kind: fswatch.EventUpdate}}, nil) + changes = wm.DrainEvents() + _, ok := changes.Changes[name] + assert.Assert(t, ok, "new target did not expand without reconciling: %v", changes.Changes) + _, err = wm.RefreshResolutions(changes) + assert.NilError(t, err) + + wm.onWatchEvents([]fswatch.Event{{Path: dir + "/one.ts", Kind: fswatch.EventUpdate}}, nil) + changes = wm.DrainEvents() + _, ok = changes.Changes[name] + assert.Assert(t, !ok, "old target still expands without reconciling: %v", changes.Changes) +} + +func TestWatchResolutionFileBecomesDirectory(t *testing.T) { + t.Parallel() + dir := watchResolutionDirectory(t) + assert.NilError(t, os.WriteFile(dir+"/prefix", nil, 0o600)) + assert.NilError(t, os.Mkdir(dir+"/target", 0o755)) + assert.NilError(t, os.WriteFile(dir+"/target/file.ts", nil, 0o600)) + filesystem := osvfs.FS() + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + name := dir + "/prefix/file.ts" + assert.NilError(t, wm.ReconcileWatches([]string{name}, nil, nil)) + assert.Equal(t, wm.Realpath(name, nil), name) + assert.NilError(t, os.Remove(dir+"/prefix")) + assert.NilError(t, os.Symlink(dir+"/target", dir+"/prefix")) + wm.onWatchEvents([]fswatch.Event{{Path: dir + "/prefix", Kind: fswatch.EventUpdate}}, nil) + changes := wm.DrainEvents() + _, err := wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.NilError(t, wm.ReconcileWatches([]string{name}, nil, nil)) + assert.Equal(t, wm.Realpath(name, nil), dir+"/target/file.ts") +} + +type benchmarkWatchFS struct { + vfs.FS + resolves int + leafResolves int + scans int + comparerQueries int +} + +func (f *benchmarkWatchFS) RealpathWithParent(path string, realpath func(string) string) string { + f.leafResolves++ + return vfs.RealpathWithParent(f.FS, path, realpath) +} + +func (f *benchmarkWatchFS) Realpath(path string) string { + f.resolves++ + return f.FS.Realpath(path) +} + +func (f *benchmarkWatchFS) GetAccessibleEntries(path string) vfs.Entries { + f.scans++ + return f.FS.GetAccessibleEntries(path) +} + +func (f *benchmarkWatchFS) WatchPathComparer(path string) (fswatch.PathComparer, error) { + f.comparerQueries++ + return f.FS.(interface { + WatchPathComparer(directory string) (fswatch.PathComparer, error) + }).WatchPathComparer(path) +} + +func BenchmarkWatchAliasGeneration(b *testing.B) { + root, rootErr := filepath.Abs(fmt.Sprintf("../../../../.watch-alias-bench-%d-%d", os.Getpid(), time.Now().UnixNano())) + if rootErr != nil { + b.Fatal(rootErr) + } + if err := os.Mkdir(root, 0o755); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { os.RemoveAll(root) }) + root = filepath.ToSlash(root) + comparer, comparerErr := fswatch.PathComparerForPath(root) + if comparerErr != nil { + b.Fatal(comparerErr) + } + if comparer.Key("s") != comparer.Key("\u017f") { + b.Skip("requires native case-insensitive watch comparison") + } + for _, count := range []int{1000, 10000, 50000} { + for _, spelling := range []string{"ascii", "unicode"} { + b.Run(fmt.Sprintf("%s/%d", spelling, count), func(b *testing.B) { + filesystem := &benchmarkWatchFS{FS: osvfs.FS()} + dir := fmt.Sprintf("%s/%s-%d", root, spelling, count) + if err := os.Mkdir(dir, 0o755); err != nil { + b.Fatal(err) + } + names := make([]string, count) + desired := make(map[string]bool) + for i := range names { + physical := fmt.Sprintf("%s/s%d", dir, i/10) + if i%10 == 0 { + if err := os.Mkdir(physical, 0o755); err != nil { + b.Fatal(err) + } + } + prefix := "s" + if spelling == "unicode" { + prefix = "\u017f" + } + // Missing leaves model failed lookups; their subscription + // directories exist and use the actual native volume comparer. + names[i] = fmt.Sprintf("%s/%s%d/file%d.ts", dir, prefix, i/10, i) + desired[fmt.Sprintf("%s/%s%d", dir, prefix, i/10)] = false + } + var resolutionFS vfs.FS + reconcile := func(wm *WatchManager) { + // Include the caller's dependency-directory computation. + for _, name := range names { + wm.Realpath(name, resolutionFS) + } + if err := wm.ReconcileWatches(names, desired, resolutionFS); err != nil { + b.Fatal(err) + } + } + create := func() *WatchManager { + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + reconcile(wm) + return wm + } + for _, fixture := range []string{"missing", "existing"} { + if fixture == "existing" { + for i, name := range names { + if i%100 == 0 { + target := fmt.Sprintf("%s/target%d.ts", dir, i) + if err := os.WriteFile(target, nil, 0o600); err != nil { + b.Fatal(err) + } + if err := os.Symlink(target, name); err != nil { + b.Fatal(err) + } + } else if err := os.WriteFile(name, nil, 0o600); err != nil { + b.Fatal(err) + } + } + } + b.Run(fixture, func(b *testing.B) { + report := func(b *testing.B, operation func()) { + filesystem.resolves, filesystem.leafResolves, filesystem.scans, filesystem.comparerQueries = 0, 0, 0, 0 + b.ReportAllocs() + for b.Loop() { + operation() + } + b.ReportMetric(float64(filesystem.resolves)/float64(b.N), "realpaths/op") + b.ReportMetric(float64(filesystem.leafResolves)/float64(b.N), "leaf-resolutions/op") + b.ReportMetric(float64(filesystem.scans)/float64(b.N), "scans/op") + b.ReportMetric(float64(filesystem.comparerQueries)/float64(b.N), "comparer-queries/op") + } + b.Run("construct-native", func(b *testing.B) { + report(b, func() { create() }) + }) + b.Run("construct-build-cache", func(b *testing.B) { + cached := cachedvfs.From(filesystem) + for directory := range desired { + cached.GetAccessibleEntries(directory) + } + // Model the build's already-resolved dependencies, not + // free cold work: setup is explicitly reported separately. + for _, name := range names { + cached.Realpath(name) + } + resolutionFS = cached + report(b, func() { create() }) + resolutionFS = nil + b.ReportMetric(float64(len(desired)), "setup-scans") + b.ReportMetric(float64(len(names)), "setup-realpaths") + }) + wm := create() + event := []fswatch.Event{{Path: dir + "/s0/file1.ts", Kind: fswatch.EventUpdate}} + b.Run("unchanged", func(b *testing.B) { + report(b, func() { reconcile(wm) }) + }) + b.Run("incremental", func(b *testing.B) { + report(b, func() { + wm.onWatchEvents(event, nil) + changes := wm.DrainEvents() + if _, err := wm.RefreshResolutions(changes); err != nil { + b.Fatal(err) + } + reconcile(wm) + }) + }) + b.Run("expand-event", func(b *testing.B) { + report(b, func() { + wm.onWatchEvents(event, nil) + wm.DrainEvents() + }) + }) + }) + } + }) + } + } +} diff --git a/tsc/internal/execute/watchmanager/watchmanager.go b/tsc/internal/execute/watchmanager/watchmanager.go index f037d93e2f863..5606a92da3d22 100644 --- a/tsc/internal/execute/watchmanager/watchmanager.go +++ b/tsc/internal/execute/watchmanager/watchmanager.go @@ -5,11 +5,14 @@ import ( "errors" "fmt" "io" + "maps" "sync" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/watchalias" ) type watchedDir struct { @@ -22,6 +25,21 @@ type dirWatchUpdate struct { recursive bool } +type resolution struct { + path string + stale bool +} + +type watchRequest struct { + dependency bool + directory bool +} + +type Changes struct { + watchalias.Matches + Overflow bool +} + // WatchManager manages fswatch directory watches, event accumulation, // and DoCycle signaling. It is shared by the CLI watcher and the build // mode orchestrator. @@ -31,10 +49,14 @@ type dirWatchUpdate struct { // - ReconcileWatches must be called under Lock. // - CloseAllWatches and handleWatchTerminated manage their own locking. type WatchManager struct { - mu sync.Mutex - backend WatchBackend - watchedDirs map[string]*watchedDir - doCycleCh chan struct{} + mu sync.Mutex + backend WatchBackend + watchedDirs map[string]*watchedDir + doCycleCh chan struct{} + filesystem vfs.FS + aliases *watchalias.Index + resolvedPaths map[string]*resolution + registrations map[string]watchRequest // DebugLog receives verbose watch diagnostics when non-nil DebugLog io.Writer @@ -47,13 +69,27 @@ type WatchManager struct { changedOverflow bool } -func NewWatchManager(warnWriter io.Writer, dirExists func(string) bool) *WatchManager { - return &WatchManager{ +func NewWatchManager(warnWriter io.Writer, dirExists func(string) bool, filesystem ...vfs.FS) *WatchManager { + wm := &WatchManager{ watchedDirs: make(map[string]*watchedDir), doCycleCh: make(chan struct{}, 1), warnWriter: warnWriter, dirExists: dirExists, } + if len(filesystem) != 0 { + wm.filesystem = filesystem[0] + } + return wm +} + +func (wm *WatchManager) WatchFiles() []string { + files := make([]string, 0, len(wm.registrations)) + for name, request := range wm.registrations { + if request.dependency { + files = append(files, name) + } + } + return files } func (wm *WatchManager) SetBackend(b WatchBackend) { wm.backend = b } @@ -76,14 +112,75 @@ func (wm *WatchManager) Unlock() { wm.mu.Unlock() } func (wm *WatchManager) DoCycleCh() <-chan struct{} { return wm.doCycleCh } -func (wm *WatchManager) DrainEvents() (changed map[string]fswatch.EventKind, overflow bool) { +func (wm *WatchManager) DrainEvents() Changes { wm.changedMu.Lock() - changed = wm.changedPaths - overflow = wm.changedOverflow + changed := wm.changedPaths + overflow := wm.changedOverflow wm.changedPaths = nil wm.changedOverflow = false wm.changedMu.Unlock() - return + if wm.aliases != nil && len(changed) != 0 { + return Changes{Matches: wm.aliases.Match(changed), Overflow: overflow} + } + return Changes{Matches: watchalias.Matches{Changes: changed}, Overflow: overflow || wm.aliases == nil && wm.registrations != nil} +} + +// Realpath shares watch resolution between computing subscription directories +// and registering aliases. Filesystems may authoritatively resolve a leaf using +// its cached parent; others retain their full resolver. +func (wm *WatchManager) Realpath(name string, filesystem vfs.FS) string { + return wm.realpath(name, filesystem, nil) +} + +func (wm *WatchManager) realpath(name string, filesystem vfs.FS, retargeted *bool) string { + if filesystem == nil { + filesystem = wm.filesystem + } + if filesystem == nil { + return name + } + previous := wm.resolvedPaths[name] + if previous != nil && !previous.stale { + return previous.path + } + if wm.resolvedPaths == nil { + wm.resolvedPaths = make(map[string]*resolution) + } + resolved := vfs.RealpathWithParent(filesystem, name, func(parent string) string { + return wm.realpath(parent, filesystem, retargeted) + }) + if previous == nil { + wm.resolvedPaths[name] = &resolution{path: resolved} + } else { + if retargeted != nil && previous.path != resolved { + *retargeted = true + } + previous.path, previous.stale = resolved, false + } + return resolved +} + +// RefreshResolutions runs once under the cycle lock, after matching with the +// old index and before any build decision. Publishing here also handles cycles +// that subsequently return early without compiling or reconciling subscriptions. +func (wm *WatchManager) RefreshResolutions(changes Changes) (bool, error) { + retargeted := false + if changes.Overflow { + wm.resolvedPaths = nil + } else { + for _, name := range changes.Affected { + if entry := wm.resolvedPaths[name]; entry != nil { + entry.stale = true + } + } + for _, name := range changes.Affected { + wm.realpath(name, wm.filesystem, &retargeted) + } + } + if changes.Overflow || changes.NamespaceChanged || retargeted { + return retargeted, wm.rebuildAliases(wm.registrations, wm.filesystem) + } + return retargeted, nil } func (wm *WatchManager) ForceOverflow() { @@ -223,7 +320,21 @@ func (wm *WatchManager) ResolveDesiredDirs(desiredDirs map[string]bool) map[stri return resolved } -func (wm *WatchManager) ReconcileWatches(desiredDirs map[string]bool) error { +func (wm *WatchManager) ReconcileWatches(files []string, desiredDirs map[string]bool, filesystem vfs.FS) error { + registrations := make(map[string]watchRequest, len(files)+len(desiredDirs)) + for _, name := range files { + registrations[name] = watchRequest{dependency: true} + } + for name := range desiredDirs { + request := registrations[name] + request.directory = true + registrations[name] = request + } + if wm.aliases == nil || !maps.Equal(wm.registrations, registrations) { + if err := wm.rebuildAliases(registrations, filesystem); err != nil { + return err + } + } if wm.backend == nil { return nil } @@ -261,6 +372,46 @@ func (wm *WatchManager) ReconcileWatches(desiredDirs map[string]bool) error { return wm.createDirWatches(additions) } +func (wm *WatchManager) rebuildAliases(registrations map[string]watchRequest, filesystem vfs.FS) error { + aliases := watchalias.New(wm.filesystem) + wm.registrations = registrations + register := func(name string, request watchRequest) error { + for { + registration := watchalias.Registration{ + Name: name, Realpath: wm.Realpath(name, filesystem), + Dependency: request.dependency, Directory: request.directory, + } + if aliases.Covers(registration) { + break + } + if err := aliases.Register(registration); err != nil { + return err + } + parent := tspath.GetDirectoryPath(name) + if parent == name || parent == "" { + break + } + name = parent + request = watchRequest{directory: true} + } + return nil + } + for name, request := range registrations { + if err := register(name, request); err != nil { + wm.aliases = nil + wm.ForceOverflow() + return err + } + } + for name, entry := range wm.resolvedPaths { + if !aliases.Covers(watchalias.Registration{Name: name, Realpath: entry.path}) { + delete(wm.resolvedPaths, name) + } + } + wm.aliases = aliases + return nil +} + func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error { if len(updates) == 0 { return nil @@ -293,14 +444,16 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error { // already present in the set, or when it is contained within a recursive watch // directory already in the set. type DirWatchSet struct { - opts tspath.ComparePathsOptions - dirs map[string]bool + opts tspath.ComparePathsOptions + dirs map[string]bool + names map[string]string } func NewDirWatchSet(opts tspath.ComparePathsOptions) *DirWatchSet { return &DirWatchSet{ - opts: opts, - dirs: make(map[string]bool), + opts: opts, + dirs: make(map[string]bool), + names: make(map[string]string), } } @@ -309,7 +462,11 @@ func (s *DirWatchSet) canonical(dir string) string { } func (s *DirWatchSet) Set(dir string, recursive bool) { + original := dir dir = s.canonical(dir) + if _, exists := s.names[dir]; !exists { + s.names[dir] = original + } s.dirs[dir] = s.dirs[dir] || recursive } @@ -329,7 +486,11 @@ func (s *DirWatchSet) Covered(dir string) bool { } func (s *DirWatchSet) Dirs() map[string]bool { - return s.dirs + dirs := make(map[string]bool, len(s.dirs)) + for key, recursive := range s.dirs { + dirs[s.names[key]] = recursive + } + return dirs } func (wm *WatchManager) IsPathUnderWatch(path string, opts tspath.ComparePathsOptions) bool { diff --git a/tsc/internal/execute/watchmanager/watchmanager_test.go b/tsc/internal/execute/watchmanager/watchmanager_test.go index 633afde563c34..771b9621429c6 100644 --- a/tsc/internal/execute/watchmanager/watchmanager_test.go +++ b/tsc/internal/execute/watchmanager/watchmanager_test.go @@ -1,12 +1,203 @@ package watchmanager import ( + "fmt" + "io" + "slices" + "strconv" "testing" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/cachedvfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +type eventOnlyFS struct { + vfs.FS + caseSensitive bool +} + +type countingWatchFS struct { + vfs.FS + realpathCalls int + entriesCalls int +} + +func (f *countingWatchFS) Realpath(path string) string { + f.realpathCalls++ + return f.FS.Realpath(path) +} + +func (f *countingWatchFS) GetAccessibleEntries(path string) vfs.Entries { + f.entriesCalls++ + entries := f.FS.GetAccessibleEntries(path) + // This fixture contains no symlinks. + entries.Symlinks = map[string]struct{}{} + return entries +} + +func TestWatchGenerationReusesUnchangedResolution(t *testing.T) { + t.Parallel() + files := make(map[string]string) + var names []string + for i := range 1000 { + name := fmt.Sprintf("/repo/src/file%d.ts", i) + names = append(names, name) + files[name] = "" + } + filesystem := &countingWatchFS{FS: vfstest.FromMap(files, true)} + cached := cachedvfs.From(filesystem) + for _, name := range names { + cached.Realpath(name) + } + initialCalls := filesystem.realpathCalls + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + assert.NilError(t, wm.ReconcileWatches(names, map[string]bool{"/repo": true}, cached)) + assert.Equal(t, filesystem.entriesCalls, 0, "resolution does not require directory listings") + assert.Assert(t, filesystem.realpathCalls-initialCalls < 10, "reuse the build's authoritative resolutions") + calls, scans := filesystem.realpathCalls, filesystem.entriesCalls + aliases := wm.aliases + assert.NilError(t, wm.ReconcileWatches(names, map[string]bool{"/repo": true}, nil)) + assert.Equal(t, filesystem.realpathCalls, calls) + assert.Equal(t, filesystem.entriesCalls, scans) + assert.Assert(t, wm.aliases == aliases, "unchanged generation must retain its alias index") + resolved := *wm.resolvedPaths[names[0]] + wm.onWatchEvents([]fswatch.Event{{Path: names[0], Kind: fswatch.EventUpdate}}, nil) + changes := wm.DrainEvents() + assert.Equal(t, filesystem.realpathCalls, calls, "events must not resolve paths") + assert.Equal(t, filesystem.entriesCalls, scans, "events must not scan directories") + assert.Equal(t, *wm.resolvedPaths[names[0]], resolved, "draining must not mutate cached resolutions") + assert.Assert(t, wm.aliases == aliases, "draining must not replace the alias index") + retargeted, err := wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.Assert(t, !retargeted) + assert.NilError(t, wm.ReconcileWatches(names, map[string]bool{"/repo": true}, nil)) + assert.Assert(t, filesystem.realpathCalls-calls < 10, "one changed leaf must not resolve all unchanged leaves") + assert.Assert(t, filesystem.entriesCalls-scans < 10, "one changed leaf must not scan all directories") + assert.Assert(t, wm.aliases == aliases, "ordinary file updates must retain their alias index") + reordered := slices.Clone(names) + slices.Reverse(reordered) + reordered = append(reordered, names[0]) + assert.NilError(t, wm.ReconcileWatches(reordered, map[string]bool{"/repo": true}, nil)) + assert.Assert(t, wm.aliases == aliases, "order and duplicate observations do not change the generation") + reordered[0] = names[0] + assert.NilError(t, wm.ReconcileWatches(reordered, map[string]bool{"/repo": true}, nil)) + assert.Assert(t, wm.aliases != aliases, "replacing a dependency with a duplicate must rebuild the generation") +} + +func TestWatchGenerationReusesRegistrationsWhenRecursionChanges(t *testing.T) { + t.Parallel() + filesystem := &countingWatchFS{FS: vfstest.FromMap(map[string]string{"/repo/src/file.ts": ""}, true)} + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + names := []string{"/repo/src/file.ts"} + assert.NilError(t, wm.ReconcileWatches(names, map[string]bool{"/repo/src": false}, nil)) + aliases := wm.aliases + calls, scans := filesystem.realpathCalls, filesystem.entriesCalls + for _, recursive := range []bool{true, false} { + assert.NilError(t, wm.ReconcileWatches(names, map[string]bool{"/repo/src": recursive}, nil)) + assert.Equal(t, len(wm.registrations), 2) + assert.Equal(t, wm.registrations["/repo/src"], watchRequest{directory: true}) + assert.Equal(t, wm.registrations["/repo/src/file.ts"], watchRequest{dependency: true}) + assert.Assert(t, wm.aliases == aliases, "subscription recursion does not change the alias generation") + assert.Equal(t, filesystem.realpathCalls, calls) + assert.Equal(t, filesystem.entriesCalls, scans) + } +} + +func TestWatchGenerationMissingLeafUpdate(t *testing.T) { + t.Parallel() + filesystem := &countingWatchFS{FS: vfstest.FromMap(map[string]string{}, true)} + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + names := []string{"/repo/missing.ts"} + assert.NilError(t, wm.ReconcileWatches(names, map[string]bool{"/repo": true}, nil)) + assert.Equal(t, filesystem.entriesCalls, 0, "resolution must not enumerate directories") + aliases := wm.aliases + wm.onWatchEvents([]fswatch.Event{{Path: "/repo/missing.ts", Kind: fswatch.EventUpdate}}, nil) + changes := wm.DrainEvents() + retargeted, err := wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.Assert(t, !retargeted) + assert.NilError(t, wm.ReconcileWatches(names, map[string]bool{"/repo": true}, nil)) + assert.Assert(t, wm.aliases == aliases, "an unchanged missing resolution must retain its index") +} + +func (f *eventOnlyFS) UseCaseSensitiveFileNames() bool { return f.caseSensitive } + +func TestWatchDirectoryDeletionExpandsTrackedSubtree(t *testing.T) { + t.Parallel() + for _, caseSensitive := range []bool{false, true} { + t.Run(strconv.FormatBool(caseSensitive), func(t *testing.T) { + t.Parallel() + filesystem := vfstest.FromMap(map[string]string{}, caseSensitive) + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + wm.Lock() + defer wm.Unlock() + files := []string{"/repo/src/a.ts", "/repo/src/nested/b.ts", "/repo/src-other/c.ts", "/repo/SRC/other.ts", "/repo/ſ/d.ts"} + for i := range 10000 { + files = append(files, fmt.Sprintf("/unrelated/%d/file.ts", i)) + } + assert.NilError(t, wm.ReconcileWatches(files, map[string]bool{"/repo": true}, nil)) + // Event processing must not query the filesystem after deletion. + wm.filesystem = &eventOnlyFS{caseSensitive: caseSensitive} + wm.onWatchEvents([]fswatch.Event{ + {Path: "/repo/src", Kind: fswatch.EventDelete}, + {Path: "/repo/src/nested", Kind: fswatch.EventDelete}, + {Path: "/repo/src/a.ts", Kind: fswatch.EventUpdate}, + {Path: "/repo/s", Kind: fswatch.EventDelete}, + {Path: "/unknown", Kind: fswatch.EventDelete}, + }, nil) + changes := wm.DrainEvents() + assert.Assert(t, !changes.Overflow) + expected := map[string]fswatch.EventKind{ + "/repo/src": fswatch.EventDelete, + "/repo/src/nested": fswatch.EventDelete, + "/repo/src/a.ts": fswatch.EventDelete, + "/repo/src/nested/b.ts": fswatch.EventDelete, + "/repo/s": fswatch.EventDelete, + "/unknown": fswatch.EventDelete, + } + if !caseSensitive { + expected["/repo/SRC/other.ts"] = fswatch.EventDelete + } + assert.DeepEqual(t, changes.Changes, expected) + wm.filesystem = filesystem + _, err := wm.RefreshResolutions(changes) + assert.NilError(t, err) + assert.NilError(t, wm.ReconcileWatches([]string{"/repo/new.ts"}, map[string]bool{"/repo": true}, nil)) + wm.onWatchEvents([]fswatch.Event{{Path: "/repo/src", Kind: fswatch.EventDelete}}, nil) + changes = wm.DrainEvents() + assert.Assert(t, !changes.Overflow) + assert.DeepEqual(t, changes.Changes, map[string]fswatch.EventKind{"/repo/src": fswatch.EventDelete}) + }) + } +} + +func TestWatchAliasesDoNotFoldMockPaths(t *testing.T) { + t.Parallel() + for _, caseSensitive := range []bool{false, true} { + filesystem := vfstest.FromMap(map[string]string{}, caseSensitive) + wm := NewWatchManager(io.Discard, filesystem.DirectoryExists, filesystem) + wm.Lock() + assert.NilError(t, wm.ReconcileWatches([]string{"/repo/ſ.ts", "/repo/e\u0301.ts"}, map[string]bool{"/repo": true}, nil)) + wm.onWatchEvents([]fswatch.Event{ + {Path: "/repo/s.ts", Kind: fswatch.EventUpdate}, + {Path: "/repo/é.ts", Kind: fswatch.EventDelete}, + {Path: "/repo/new.ts", Kind: fswatch.EventUpdate}, + }, nil) + changes := wm.DrainEvents() + wm.Unlock() + assert.Assert(t, !changes.Overflow) + assert.DeepEqual(t, changes.Changes, map[string]fswatch.EventKind{ + "/repo/s.ts": fswatch.EventUpdate, + "/repo/é.ts": fswatch.EventDelete, + "/repo/new.ts": fswatch.EventUpdate, + }) + } +} + var ( caseSensitiveOpts = tspath.ComparePathsOptions{UseCaseSensitiveFileNames: true, CurrentDirectory: "/repo"} caseInsensitiveOpts = tspath.ComparePathsOptions{UseCaseSensitiveFileNames: false, CurrentDirectory: "/repo"} @@ -83,8 +274,8 @@ func TestDirWatchSetCanonicalDedup(t *testing.T) { dirs := insensitive.Dirs() assert.Equal(t, len(dirs), 1, "differently-cased dirs must collapse to one entry") - _, canonical := dirs["/repo/node_modules/pkgname"] - assert.Assert(t, canonical, "Dirs must be keyed by the canonicalized path") + _, original := dirs["/repo/Node_Modules/PkgName"] + assert.Assert(t, original, "Dirs must retain the original spelling used for registration") sensitive := NewDirWatchSet(caseSensitiveOpts) sensitive.Set("/repo/Node_Modules/PkgName", false) diff --git a/tsc/internal/fswatch/CHANGES.md b/tsc/internal/fswatch/CHANGES.md index 3f3fb4153991b..ffa849099e80e 100644 --- a/tsc/internal/fswatch/CHANGES.md +++ b/tsc/internal/fswatch/CHANGES.md @@ -149,6 +149,34 @@ logical root, physical root, event-ID cutoff, and termination state, so late-added watches don't receive older queued events and symlinked watch roots continue reporting caller-visible paths. +### macOS path comparison + +FSEvents and kqueue use the watched volume's case sensitivity, queried with +`pathconf`, rather than assuming event paths have the same spelling as the +subscription. On case-insensitive volumes, CoreFoundation case folding and NFC +normalization recognize Unicode aliases, including expansions such as sharp s / +`SS` and ligatures / letter sequences. This is not width- or +diacritic-insensitive comparison. + +Folded forms are comparison keys, never displayed or opened paths. Watch roots +and subscribed filenames are normalized to NFC. Directory events retain the +caller's root casing, with NFC suffixes for FSEvents and on-disk child spellings +for kqueue; `WatchFile` events use the subscribed NFC filename. Rebasing uses +original path boundaries rather than folded byte lengths. FSEvents routing, +shared callback filtering, overflow matching, and logical-root deletion use the +same comparison rules. + +An allocation-free ASCII comparison fast path avoids native folding. Watch-root +comparison forms are prepared at subscription time, while event paths are +folded lazily and reused across routing comparisons and within callback +filtering passes. `WatchFile` reuses its parent subscription's comparer rather +than querying filesystem case sensitivity twice. + +The native fold has been compared with aliases and distinct names on +case-insensitive APFS, but is not a guarantee of identical lookup tables on every +filesystem or macOS version. Case-sensitive comparison and watcher backends on +other platforms remain unchanged. + ## New backends **fanotify** (Linux, kernel ≥ 5.13) is the default on Linux when available. It diff --git a/tsc/internal/fswatch/README.md b/tsc/internal/fswatch/README.md index 82f2759224a4d..c53f8a8328ed8 100644 --- a/tsc/internal/fswatch/README.md +++ b/tsc/internal/fswatch/README.md @@ -90,9 +90,20 @@ if errors.Is(err, fswatch.ErrWatchTerminated) { - Event order within a batch is **not guaranteed**. - The callback runs on a library goroutine, not the caller's. Each watch's callback is serialized (never concurrent with itself). -- Paths in events are absolute. **Resolve symlinks before subscribing**; - backends report canonical paths: - - ```go - realDir, err := filepath.EvalSymlinks(dir) - ``` +- Paths in events are absolute. Subscribing through a directory symlink follows + its target while preserving the caller-visible root in delivered paths. + +On macOS, watch roots and subscribed filenames are normalized to NFC. On volumes +reporting case-insensitive lookup, FSEvents and kqueue match paths using +CoreFoundation's case-insensitive fold, including expansions such as sharp s / +`SS` and ligatures / letter sequences. This is not width- or +diacritic-insensitive comparison. Folded forms are only comparison keys: +directory events retain the caller's root casing, with an NFC suffix for +FSEvents and the on-disk child spelling for kqueue; file events use the +subscribed NFC filename. Symlink-root subscriptions likewise retain the +caller-visible root. + +The fold has been compared with actual aliases and distinct names on +case-insensitive APFS. It is not a guarantee of identical Unicode lookup +tables on every filesystem or macOS version. Case-sensitive volumes and +watcher backends on other platforms retain exact comparison. diff --git a/tsc/internal/fswatch/canonicalize_darwin.go b/tsc/internal/fswatch/canonicalize_darwin.go index 9a81ffdfb5b77..2ef38e3a8da67 100644 --- a/tsc/internal/fswatch/canonicalize_darwin.go +++ b/tsc/internal/fswatch/canonicalize_darwin.go @@ -2,13 +2,35 @@ package fswatch -// canonicalizePath returns the path in the form the library uses for -// internal bookkeeping and event delivery. On macOS, paths from FSEvents -// arrive using whatever Unicode normalization form is stored on disk; -// usually NFC, but sometimes NFD (e.g. files created on legacy HFS+ -// volumes or copied from systems that use NFD). APFS resolves either form -// to the same inode, but raw string comparisons against caller-supplied -// paths (typically NFC) silently break. Normalizing every path the -// library ingests to NFC keeps watch keys, dirWatch lookups, WatchFile -// filters, and event paths all in one consistent form. +import ( + "os" + + "golang.org/x/sys/unix" +) + +// canonicalizePath normalizes watch keys, subscribed filenames, and incoming +// FSEvents paths to NFC. kqueue retains on-disk child spellings for its fd +// bookkeeping and directory events; on case-insensitive volumes, the native +// path comparer handles normalization differences when filtering WatchFile. func canonicalizePath(p string) string { return normalizeNFC(p) } + +func (w *watcher) pathComparer(dir string) (pathComparer, error) { + if w.name != "fsevents" && w.name != "kqueue" { + return pathComparer{}, nil + } + c, err := PathComparerForPath(dir) + return c.comparer, err +} + +// PathComparerForPath queries an existing path's volume. Errors are returned to +// the caller; a failed query must not silently enable or disable native folding. +func PathComparerForPath(path string) (PathComparer, error) { + // _PC_CASE_SENSITIVE from sys/unistd.h. Query the watched volume rather + // than assuming every volume mounted on macOS is case-insensitive. + const pcCaseSensitive = 11 + sensitive, err := unix.Pathconf(path, pcCaseSensitive) + if err != nil { + return PathComparer{}, &os.PathError{Op: "pathconf", Path: path, Err: err} + } + return PathComparer{comparer: pathComparer{ignoreCase: sensitive == 0}}, nil +} diff --git a/tsc/internal/fswatch/canonicalize_other.go b/tsc/internal/fswatch/canonicalize_other.go index 5ed784c394748..2cad36f9d5b93 100644 --- a/tsc/internal/fswatch/canonicalize_other.go +++ b/tsc/internal/fswatch/canonicalize_other.go @@ -2,7 +2,23 @@ package fswatch +const nativePathFolding = false + +func foldNativePath(string) string { + panic("fswatch: native path folding is only available on Darwin") +} + // canonicalizePath is a no-op on platforms whose watchers report paths // using the same bytes the caller provided. See canonicalize_darwin.go // for the rationale on macOS. func canonicalizePath(p string) string { return p } + +func (w *watcher) pathComparer(dir string) (pathComparer, error) { + return pathComparer{}, nil +} + +// PathComparerForPath returns exact comparison on platforms without native +// Darwin watch aliases. It does not inspect the host filesystem. +func PathComparerForPath(path string) (PathComparer, error) { + return PathComparer{}, nil +} diff --git a/tsc/internal/fswatch/fsevents_darwin.go b/tsc/internal/fswatch/fsevents_darwin.go index 6e0614ef0aad6..c7b4f4c1d4745 100644 --- a/tsc/internal/fswatch/fsevents_darwin.go +++ b/tsc/internal/fswatch/fsevents_darwin.go @@ -507,6 +507,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) { if path == "" { continue } + comparison := comparisonPath{path: path} isRemoved := flag&flagItemRemoved != 0 isRenamed := flag&flagItemRenamed != 0 @@ -527,7 +528,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) { if watch.state.terminated.Load() { continue } - if fseventsOverflowMatches(watch.w, path) { + if fseventsOverflowMatchesPrepared(watch.w, &comparison) { watch.w.events.setError(overflow) touched[watch.w] = struct{}{} } @@ -551,7 +552,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) { continue } w := watch.w - displayPath, ok := fseventsDisplayPath(w, rawPath) + displayPath, ok := fseventsDisplayPathPrepared(w, &comparison) if !ok { continue } @@ -623,18 +624,42 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) { } func fseventsDisplayPath(w *dirWatch, rawPath string) (string, bool) { - if isInDirectoryOrSelf(w.physicalDir, rawPath) { - return w.displayPath(rawPath), true + path := comparisonPath{path: rawPath} + return fseventsDisplayPathPrepared(w, &path) +} + +func fseventsDisplayPathPrepared(w *dirWatch, rawPath *comparisonPath) (string, bool) { + physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""} + if path, ok := w.comparer.rebasePrepared(rawPath, physical, w.dir); ok { + return path, true } - if w.physicalDir != w.dir && isInDirectoryOrSelf(w.dir, rawPath) { - return rawPath, true + if w.physicalDir != w.dir { + logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""} + return w.comparer.rebasePrepared(rawPath, logical, w.dir) } return "", false } func fseventsOverflowMatches(w *dirWatch, rawPath string) bool { - if isInDirectoryOrSelf(w.physicalDir, rawPath) || isInDirectoryOrSelf(rawPath, w.physicalDir) { + path := comparisonPath{path: rawPath} + return fseventsOverflowMatchesPrepared(w, &path) +} + +func fseventsOverflowMatchesPrepared(w *dirWatch, rawPath *comparisonPath) bool { + physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""} + if _, ok := w.comparer.suffixPrepared(physical, rawPath); ok { return true } - return w.physicalDir != w.dir && (isInDirectoryOrSelf(w.dir, rawPath) || isInDirectoryOrSelf(rawPath, w.dir)) + if _, ok := w.comparer.suffixPrepared(*rawPath, &physical); ok { + return true + } + if w.physicalDir != w.dir { + logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""} + if _, ok := w.comparer.suffixPrepared(logical, rawPath); ok { + return true + } + _, ok := w.comparer.suffixPrepared(*rawPath, &logical) + return ok + } + return false } diff --git a/tsc/internal/fswatch/fsevents_darwin_ffi.go b/tsc/internal/fswatch/fsevents_darwin_ffi.go index 30294b9a43f51..d3932f8a340ad 100644 --- a/tsc/internal/fswatch/fsevents_darwin_ffi.go +++ b/tsc/internal/fswatch/fsevents_darwin_ffi.go @@ -8,7 +8,9 @@ import ( "os" "runtime" "slices" + "strings" "syscall" + "unicode/utf8" "unsafe" "golang.org/x/sys/unix" @@ -150,13 +152,16 @@ func cfArrayGetValueAtIndex(array uintptr, index int) uintptr { // FSEvents reports paths using whatever bytes are stored on disk. APFS is // normalization-insensitive for lookups (a file created as NFD opens fine // under the NFC form, and vice versa) but it stores and reports the original -// bytes. The library normalizes every path that crosses the darwin boundary -// to Unicode NFC so that: +// bytes. The library normalizes watch paths and incoming FSEvents paths to +// Unicode NFC so that: // - WatchDirectory("/.../caf\u00e9") and WatchDirectory("/.../cafe\u0301") // coalesce to a single dir watch; -// - WatchFile filters by exact-string compare in NFC always match; +// - WatchFile filters and directory routing compare the same normalized paths; // - subscribers can compare event paths against their own NFC strings. // +// kqueue retains on-disk child spellings; its WatchFile comparisons also use +// the native fold below on volumes reporting case-insensitive lookup. +// // All-ASCII inputs are bit-identical in NFC and NFD, so the hot path skips // the FFI entirely. The rare non-ASCII case round-trips through CoreFoundation // (UTF-8 → CFString → CFMutableString → CFStringNormalize → UTF-8) with no Go @@ -183,6 +188,46 @@ func cfStringNormalize(mutStr uintptr, form uintptr) { _, _, _ = syscall_syscall6(fse_CFStringNormalize_trampoline_addr, mutStr, form, 0, 0, 0, 0) } +//go:cgo_import_dynamic fse_CFStringFold CFStringFold "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFStringFold_trampoline_addr uintptr + +const nativePathFolding = true + +// foldNativePath is a comparison form, never a displayed or opened path. +// Case folding expands sharp s and ligatures without making diacritics, +// dotless i, circled letters, or character widths interchangeable. +func foldNativePath(s string) string { + if isASCII(s) { + return strings.ToLower(s) + } + if !utf8.ValidString(s) || strings.IndexByte(s, 0) >= 0 { + return "" + } + cstr := append([]byte(s), 0) + src := cfStringCreate(0, unsafe.Pointer(&cstr[0]), cfStringEncodingUTF8) + if src == 0 { + panic("fswatch: cannot create CFString for path folding") + } + defer cfRelease(src) + mut := cfStringCreateMutableCopy(0, 0, src) + if mut == 0 { + panic("fswatch: cannot copy CFString for path folding") + } + defer cfRelease(mut) + // Normalize before folding as well: a decomposed capital I with dot + // must have the same comparison form as precomposed dotted capital I. + cfStringNormalize(mut, cfStringNormalizationFormC) + const cfCompareCaseInsensitive = 1 + _, _, _ = syscall_syscall6(fse_CFStringFold_trampoline_addr, mut, cfCompareCaseInsensitive, 0, 0, 0, 0) + cfStringNormalize(mut, cfStringNormalizationFormC) + folded := cfStringToGo(mut) + if folded == "" { + panic("fswatch: cannot extract folded CFString") + } + return folded +} + //go:cgo_import_dynamic fse_CFStringGetLength CFStringGetLength "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" var fse_CFStringGetLength_trampoline_addr uintptr diff --git a/tsc/internal/fswatch/fsevents_darwin_ffi.s b/tsc/internal/fswatch/fsevents_darwin_ffi.s index 07608498b5097..fed8e24c42f68 100644 --- a/tsc/internal/fswatch/fsevents_darwin_ffi.s +++ b/tsc/internal/fswatch/fsevents_darwin_ffi.s @@ -62,6 +62,12 @@ TEXT fse_CFStringNormalize_trampoline<>(SB), NOSPLIT, $0-0 GLOBL ·fse_CFStringNormalize_trampoline_addr(SB), RODATA, $8 DATA ·fse_CFStringNormalize_trampoline_addr(SB)/8, $fse_CFStringNormalize_trampoline<>(SB) +TEXT fse_CFStringFold_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFStringFold(SB) + +GLOBL ·fse_CFStringFold_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFStringFold_trampoline_addr(SB)/8, $fse_CFStringFold_trampoline<>(SB) + TEXT fse_CFStringGetLength_trampoline<>(SB), NOSPLIT, $0-0 JMP fse_CFStringGetLength(SB) diff --git a/tsc/internal/fswatch/fsevents_darwin_nfd_test.go b/tsc/internal/fswatch/fsevents_darwin_nfd_test.go index 820f4c69d6fe8..863c86aa31ef9 100644 --- a/tsc/internal/fswatch/fsevents_darwin_nfd_test.go +++ b/tsc/internal/fswatch/fsevents_darwin_nfd_test.go @@ -3,9 +3,12 @@ package fswatch import ( + "errors" "os" "path/filepath" + "strings" "testing" + "time" ) // These tests document a real cross-normalization failure mode on macOS: @@ -174,3 +177,439 @@ func TestFSEventsNFDOnDiskNFCWatchFile(t *testing.T) { } } } + +// Check identity independently of the comparer, including exclusive creation. +// Filesystems that do not support a particular alias cannot exercise its watch. +func requireDarwinAlias(t *testing.T, a, b string) { + t.Helper() + first, err := os.Stat(a) + if err != nil { + t.Fatal(err) + } + second, err := os.Stat(b) + if errors.Is(err, os.ErrNotExist) { + t.Skip("filesystem does not alias these spellings") + } + if err != nil { + t.Fatal(err) + } + if !os.SameFile(first, second) { + t.Fatal("alternate spelling resolved to a different inode") + } + if err := os.Mkdir(b, 0o755); !errors.Is(err, os.ErrExist) { + t.Fatalf("exclusive alternate creation: %v", err) + } +} + +func TestDarwinWatchFileComparison(t *testing.T) { + t.Parallel() + cases := []struct { + name string + diskRoot, watchRoot string + diskFile, watchFile string + alias bool + }{ + {"root-case", "Mixed", "mixed", "file.ts", "file.ts", true}, + {"leaf-case", "root", "root", "File.ts", "file.ts", true}, + {"sharp-s", "root", "root", "\u00df.ts", "SS.ts", true}, + {"dotted-i", "root", "root", "\u0130.ts", "i\u0307.ts", true}, + {"ligature", "root", "root", "\ufb03.ts", "ffi.ts", true}, + {"normalization", "root", "root", "cafe\u0301.ts", "caf\u00e9.ts", true}, + {"dotless-i", "root", "root", "I.ts", "\u0131.ts", false}, + {"ascii-dotted-i", "root", "root", "i.ts", "\u0130.ts", false}, + {"fullwidth", "root", "root", "\uff21.ts", "A.ts", false}, + {"sibling", "root", "root", "file.ts2", "file.ts", false}, + {"case-sensitive", "root", "root", "File.ts", "file.ts", false}, + } + for _, impl := range []Watcher{Kqueue(), FSEvents()} { + for _, c := range cases { + t.Run(impl.Name()+"/"+c.name, func(t *testing.T) { + t.Parallel() + for _, reverse := range []bool{false, true} { + diskRoot, watchRoot := c.diskRoot, c.watchRoot + diskName, watchName := c.diskFile, c.watchFile + if reverse { + diskRoot, watchRoot = watchRoot, diskRoot + diskName, watchName = watchName, diskName + } + parent := newTmpDir(t) + diskRoot, watchRoot = filepath.Join(parent, diskRoot), filepath.Join(parent, watchRoot) + if err := os.Mkdir(diskRoot, 0o755); err != nil { + t.Fatal(err) + } + requireDarwinAlias(t, diskRoot, watchRoot) + diskFile, watchFile := filepath.Join(diskRoot, diskName), filepath.Join(watchRoot, watchName) + if err := os.WriteFile(diskFile, nil, 0o644); err != nil { + t.Fatal(err) + } + if c.alias { + requireDarwinAlias(t, diskFile, watchFile) + } else { + f, err := os.OpenFile(watchFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, os.ErrExist) { + t.Skip("filesystem aliases these spellings") + } + if err != nil { + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + a, err := os.Stat(diskFile) + if err != nil { + t.Fatal(err) + } + b, err := os.Stat(watchFile) + if err != nil || os.SameFile(a, b) { + t.Fatalf("expected distinct inodes: %v", err) + } + } + // Subscribe while the target is absent to exercise discovery + // as well as subsequent fd-based updates and deletion. + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + dir, _ := subscribeForOpts(t, watchRoot, impl) + file, _ := subscribeFileFor(t, watchFile, impl) + wantDir := filepath.Join(watchRoot, diskName) + if impl == FSEvents() { + wantDir = canonicalizePath(wantDir) + } + for round := range 3 { + kind := EventUpdate + if round == 2 { + kind = EventDelete + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(diskFile, make([]byte, round+1), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, dir, kind, wantDir) + if c.alias { + events := expectContains(t, file, kind, canonicalizePath(watchFile)) + for _, e := range events { + if e.Path != canonicalizePath(watchFile) { + t.Fatalf("unexpected file event spelling: %q", e.Path) + } + } + } else if events := file.next(400 * time.Millisecond); len(events) != 0 { + t.Fatalf("cross-routed distinct filename: %v", events) + } + } + } + }) + } + } +} + +func TestNativePathComparerKeys(t *testing.T) { + t.Parallel() + c := PathComparer{comparer: pathComparer{ignoreCase: true}} + for _, pair := range [][2]string{ + {"/A/file.ts", "/a/FILE.TS"}, + {"/straße/İ.ts", "/STRASSE/i\u0307.ts"}, + {"/\ufb03/ſ.ts", "/ffi/S.ts"}, + {"/Σ/ς.ts", "/σ/σ.ts"}, + {"/cafe\u0301.ts", "/caf\u00e9.ts"}, + } { + if c.Key(pair[0]) != c.Key(pair[1]) { + t.Errorf("unequal keys for aliases %q", pair) + } + } + for _, pair := range [][2]string{ + {"/I.ts", "/\u0131.ts"}, + {"/i.ts", "/İ.ts"}, + {"/A.ts", "/A.ts"}, + {"/file.ts2", "/file.ts"}, + } { + if c.Key(pair[0]) == c.Key(pair[1]) { + t.Errorf("equal keys for distinct paths %q", pair) + } + } + for _, name := range []string{"/A\xff.ts", "/A\x00.ts"} { + if got := c.Key(name); got != name { + t.Errorf("malformed path was folded: %q => %q", name, got) + } + } + if got, ok := c.Rebase("/STRASSE/new.ts", "/straße", "/original"); !ok || got != "/original/new.ts" { + t.Fatalf("expanding fold rebased at wrong boundary: %q, %v", got, ok) + } +} + +func TestPathComparerVolumeQueryError(t *testing.T) { + t.Parallel() + name := "./watcher_test.go/missing" + _, err := PathComparerForPath(name) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || pathErr.Op != "pathconf" || pathErr.Path != name { + t.Fatalf("volume query did not preserve path error: %v", err) + } +} + +func TestFSEventsDifferentCasing(t *testing.T) { + t.Parallel() + + for _, recursive := range []bool{false, true} { + name := "nonrecursive" + if recursive { + name = "recursive" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + parent := newTmpDir(t) + diskDir := filepath.Join(parent, "MixedCase") + watchDir := filepath.Join(parent, "mixedcase") + if err := os.Mkdir(diskDir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(watchDir); errors.Is(err, os.ErrNotExist) { + t.Skip("requires a case-insensitive filesystem") + } else if err != nil { + t.Fatal(err) + } + + var opts []WatchOption + if recursive { + opts = append(opts, WithRecursive()) + } + r, _ := subscribeForOpts(t, watchDir, FSEvents(), opts...) + file := filepath.Join(diskDir, "File.ts") + want := filepath.Join(watchDir, "File.ts") + if err := os.WriteFile(file, []byte("export {}"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, want) + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventDelete, want) + }) + } +} + +func TestFSEventsWatchFileDifferentCasing(t *testing.T) { + t.Parallel() + dir := newTmpDir(t) + diskFile := filepath.Join(dir, "File.ts") + watchFile := filepath.Join(dir, "file.ts") + if err := os.WriteFile(diskFile, []byte("export {}"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(watchFile); errors.Is(err, os.ErrNotExist) { + t.Skip("requires a case-insensitive filesystem") + } else if err != nil { + t.Fatal(err) + } + r, _ := subscribeFileFor(t, watchFile, FSEvents()) + if err := os.WriteFile(diskFile, []byte("export const x = 1;"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, watchFile) + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventDelete, watchFile) + + missingFile := filepath.Join(dir, "missing.ts") + missing, _ := subscribeFileFor(t, missingFile, FSEvents()) + if err := os.WriteFile(filepath.Join(dir, "Missing.ts"), []byte("export {}"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, missing, EventUpdate, missingFile) +} + +var fseventsFoldPairs = []struct { + name string + a, b string + alias bool +}{ + {"sharp-s", "\u00df", "SS", true}, + {"capital-sharp-s", "\u1e9e", "SS", true}, + {"dotted-i", "\u0130", "i\u0307", true}, + {"ligature-ff", "\ufb00", "ff", true}, + {"ligature-ffi", "\ufb03", "ffi", true}, + {"long-s", "\u017f", "S", true}, + {"sigma", "\u03c2", "\u03a3", true}, + {"accent", "\u00e9", "E\u0301", true}, + {"dotless-i", "I", "\u0131", false}, + {"ascii-dotted-i", "i", "\u0130", false}, + {"ascii-i-dot", "I", "i\u0307", false}, + {"circled-a", "\u24d0", "a", false}, + {"fullwidth-a", "\uff21", "A", false}, +} + +func TestNativePathFold(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + a, b := foldNativePath(pair.a), foldNativePath(pair.b) + if (a == b) != pair.alias { + t.Errorf("%s: folds %q / %q, alias=%v", pair.name, a, b, pair.alias) + } + if a != foldNativePath(canonicalizePath(pair.a)) || b != foldNativePath(canonicalizePath(pair.b)) { + t.Errorf("%s: normalization changed folding", pair.name) + } + } + for _, input := range []string{"\u0130", "I\u0307", "i\u0307"} { + if got := foldNativePath(input); got != "i\u0307" { + t.Errorf("fold(%q) = %q", input, got) + } + } + for _, input := range []string{"/\xff", "/\xfe", "/\u00df\x00suffix"} { + if got := foldNativePath(input); got != "" { + t.Errorf("invalid native path %q produced %q", input, got) + } + } + c := pathComparer{ignoreCase: true} + for _, pair := range [][2]string{{"/SS/", "/\u00df/File.ts"}, {"/\u00df/", "/SS/File.ts"}} { + if suffix, ok := c.suffix(pair[0], pair[1]); !ok || suffix != "File.ts" { + t.Errorf("trailing separator: got (%q, %v)", suffix, ok) + } + } +} + +func TestFSEventsExpansionAliases(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + if !pair.alias { + continue + } + t.Run(pair.name, func(t *testing.T) { + t.Parallel() + for _, reverse := range []bool{false, true} { + a, b := pair.a, pair.b + if reverse { + a, b = b, a + } + parent := newTmpDir(t) + disk, root := filepath.Join(parent, a), filepath.Join(parent, b) + if err := os.Mkdir(disk, 0o755); err != nil { + t.Fatal(err) + } + requireDarwinAlias(t, disk, root) + nested := filepath.Join(disk, "Nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + direct, _ := subscribeForOpts(t, root, FSEvents()) + recursive, _ := subscribeFor(t, root, FSEvents()) + link := filepath.Join(parent, "Link") + makeDirSymlink(t, root, link) + linked, _ := subscribeFor(t, link, FSEvents()) + file, _ := subscribeFileFor(t, filepath.Join(root, b+".ts"), FSEvents()) + control, _ := subscribeFileFor(t, filepath.Join(disk, a+".ts"), FSEvents()) + diskFile := filepath.Join(disk, a+".ts") + for round := range 3 { + kind := EventUpdate + if round == 2 { + kind = EventDelete + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(diskFile, []byte(strings.Repeat("x", round+1)), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, control, kind, canonicalizePath(diskFile)) + expectContains(t, file, kind, canonicalizePath(filepath.Join(root, b+".ts"))) + expectContains(t, direct, kind, canonicalizePath(filepath.Join(root, a+".ts"))) + expectContains(t, recursive, kind, canonicalizePath(filepath.Join(root, a+".ts"))) + expectContains(t, linked, kind, canonicalizePath(filepath.Join(link, a+".ts"))) + } + child := filepath.Join(nested, "File.ts") + if err := os.WriteFile(child, nil, 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, recursive, EventUpdate, canonicalizePath(filepath.Join(root, "Nested", "File.ts"))) + expectContains(t, linked, EventUpdate, filepath.Join(link, "Nested", "File.ts")) + if events := direct.next(400 * time.Millisecond); len(events) != 0 { + t.Fatalf("nonrecursive watch received nested events: %v", events) + } + if err := os.Remove(child); err != nil { + t.Fatal(err) + } + expectContains(t, recursive, EventDelete, canonicalizePath(filepath.Join(root, "Nested", "File.ts"))) + if err := os.Remove(nested); err != nil { + t.Fatal(err) + } + expectContains(t, direct, EventDelete, canonicalizePath(filepath.Join(root, "Nested"))) + if err := os.Remove(disk); err != nil { + t.Fatal(err) + } + expectContains(t, recursive, EventDelete, canonicalizePath(root)) + terminated := false + deadline := time.Now().Add(direct.deadline()) + for !terminated && time.Now().Before(deadline) { + direct.mu.Lock() + for _, err := range direct.errs { + terminated = terminated || errors.Is(err, ErrWatchTerminated) + } + direct.mu.Unlock() + if !terminated { + time.Sleep(20 * time.Millisecond) + } + } + if !terminated { + t.Fatal("missing root termination") + } + } + }) + } +} + +func TestFSEventsFoldDistinctNames(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + if pair.alias { + continue + } + t.Run(pair.name, func(t *testing.T) { + t.Parallel() + for _, reverse := range []bool{false, true} { + parent := newTmpDir(t) + roots := []string{filepath.Join(parent, pair.a), filepath.Join(parent, pair.b)} + if reverse { + roots[0], roots[1] = roots[1], roots[0] + } + recorders := make([]*recordingWatcher, 2) + files := make([]*recordingWatcher, 2) + for i, root := range roots { + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + recorders[i], _ = subscribeFor(t, root, FSEvents()) + files[i], _ = subscribeFileFor(t, root+".ts", FSEvents()) + } + a, err := os.Stat(roots[0]) + if err != nil { + t.Fatal(err) + } + b, err := os.Stat(roots[1]) + if err != nil || os.SameFile(a, b) { + t.Fatalf("expected distinct inodes: %v", err) + } + child := filepath.Join(roots[0], "File.ts") + file := roots[0] + ".ts" + for round := range 3 { + kind := EventUpdate + for _, path := range []string{child, file} { + if round == 2 { + kind = EventDelete + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(path, []byte(strings.Repeat("x", round+1)), 0o644); err != nil { + t.Fatal(err) + } + } + expectContains(t, recorders[0], kind, canonicalizePath(child)) + expectContains(t, files[0], kind, canonicalizePath(file)) + for _, r := range []*recordingWatcher{recorders[1], files[1]} { + if events := r.next(400 * time.Millisecond); len(events) != 0 { + t.Fatalf("cross-routed distinct name: %v", events) + } + } + } + } + }) + } +} diff --git a/tsc/internal/fswatch/fsevents_darwin_shared_test.go b/tsc/internal/fswatch/fsevents_darwin_shared_test.go index e9a9fb03eaa17..9e090b27257d7 100644 --- a/tsc/internal/fswatch/fsevents_darwin_shared_test.go +++ b/tsc/internal/fswatch/fsevents_darwin_shared_test.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "slices" + "strconv" + "strings" "testing" "time" ) @@ -290,3 +292,314 @@ func TestFSEventsOverflowMatchesWatch(t *testing.T) { }) } } + +func TestFSEventsCaseSensitiveRouting(t *testing.T) { + t.Parallel() + for _, ignoreCase := range []bool{false, true} { + w := &dirWatch{ + dir: "/logical/root", + physicalDir: "/physical/root", + comparer: pathComparer{ignoreCase: ignoreCase}, + } + for _, root := range []string{"/PHYSICAL/ROOT", "/LOGICAL/ROOT"} { + for _, suffix := range []string{"", "/File.ts", "/Nested/File.ts"} { + path, ok := fseventsDisplayPath(w, root+suffix) + if ok != ignoreCase || ok && path != w.dir+suffix { + t.Errorf("display path for %q, ignoreCase=%v: got (%q, %v)", root+suffix, ignoreCase, path, ok) + } + } + if fseventsOverflowMatches(w, root+"/Nested") != ignoreCase { + t.Errorf("overflow descendant %q, ignoreCase=%v", root, ignoreCase) + } + if fseventsOverflowMatches(w, filepath.Dir(root)) != ignoreCase { + t.Errorf("overflow ancestor %q, ignoreCase=%v", root, ignoreCase) + } + if _, ok := fseventsDisplayPath(w, root+"2/File.ts"); ok { + t.Errorf("matched sibling of %q, ignoreCase=%v", root, ignoreCase) + } + if fseventsOverflowMatches(w, root+"2") { + t.Errorf("overflow matched sibling of %q, ignoreCase=%v", root, ignoreCase) + } + } + } +} + +func TestFSEventsConsolidatedDifferentCasing(t *testing.T) { + t.Parallel() + for _, recursive := range []bool{false, true} { + dw := newDirectWatcher(t, "/parent") + dw.comparer = pathComparer{ignoreCase: true} + child := "/parent/child" + var got []Event + var gotErr error + dw.watch(child, child, recursive, func(events []Event, err error) { + got = append(got, events...) + gotErr = err + }, func(path string) bool { + return path == child+"/Ignored.ts" + }) + dw.events.update("/parent/CHILD/File.ts") + dw.events.update("/parent/CHILD/Ignored.ts") + dw.events.update("/parent/CHILD2/File.ts") + dw.events.update("/parent/CHILD/Nested/File.ts") + dw.triggerCallbacks() + wantCount := 1 + if recursive { + wantCount++ + } + if len(got) != wantCount || gotErr != nil { + t.Fatalf("recursive=%v: got events=%v, err=%v", recursive, got, gotErr) + } + for _, e := range got { + if e.Path != child+"/File.ts" && e.Path != child+"/Nested/File.ts" { + t.Fatalf("unexpected path %q", e.Path) + } + } + got = nil + if !dw.terminateCallbacksForDeletedRoot("/parent/CHILD", 1, ErrWatchTerminated) { + t.Fatal("expected differently cased child root to terminate") + } + dw.events.removeWatchRootAt("/parent/CHILD", 1) + dw.triggerCallbacks() + if !errors.Is(gotErr, ErrWatchTerminated) || len(got) != 1 || got[0].Kind != EventDelete || got[0].Path != child { + t.Fatalf("expected child deletion and termination: got events=%v, err=%v", got, gotErr) + } + } +} + +func TestFSEventsConsolidatedExpansion(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + for _, reverse := range []bool{false, true} { + a, b := pair.a, pair.b + if reverse { + a, b = b, a + } + for _, recursive := range []bool{false, true} { + dw := newDirectWatcher(t, "/parent") + dw.setComparer(pathComparer{ignoreCase: true}) + child, raw := "/parent/"+canonicalizePath(a), "/parent/"+canonicalizePath(b) + var got []Event + var gotErr error + dw.watch(child, child, recursive, func(events []Event, err error) { + got = append(got, events...) + gotErr = err + }, func(path string) bool { return path == child+"/Ignored.ts" }) + dw.events.update(raw + "/File.ts") + dw.events.update(raw + "/Nested/File.ts") + dw.events.update(raw + "/Ignored.ts") + dw.events.update(raw + "2/File.ts") + dw.triggerCallbacks() + want := 0 + if pair.alias { + want = 1 + if recursive { + want++ + } + } + if len(got) != want || gotErr != nil { + t.Fatalf("%s reverse=%v recursive=%v: got %v, %v", pair.name, reverse, recursive, got, gotErr) + } + for _, e := range got { + if e.Path != child+"/File.ts" && e.Path != child+"/Nested/File.ts" { + t.Fatalf("incorrect rebasing: %v", e) + } + } + got = nil + if dw.terminateCallbacksForDeletedRoot(raw, 1, ErrWatchTerminated) != pair.alias { + t.Fatalf("%s: incorrect termination", pair.name) + } + dw.events.removeWatchRootAt(raw, 1) + dw.triggerCallbacks() + if pair.alias && (len(got) != 1 || got[0].Path != child || got[0].Kind != EventDelete || !errors.Is(gotErr, ErrWatchTerminated)) { + t.Fatalf("%s: missing deletion/termination: %v, %v", pair.name, got, gotErr) + } + if !pair.alias && (len(got) != 0 || gotErr != nil) { + t.Fatalf("%s: cross-routed deletion/termination: %v, %v", pair.name, got, gotErr) + } + } + } + } +} + +func TestFSEventsExpansionRouting(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + for _, reverse := range []bool{false, true} { + a, b := pair.a, pair.b + if reverse { + a, b = b, a + } + for padding := range 16 { + root := "/physical/" + strings.Repeat("x", padding) + canonicalizePath(a) + raw := "/PHYSICAL/" + strings.Repeat("x", padding) + canonicalizePath(b) + for _, ignoreCase := range []bool{false, true} { + w := &dirWatch{dir: "/logical/Caller", physicalDir: root, comparer: pathComparer{ignoreCase: ignoreCase}} + w.setComparer(w.comparer) + want := ignoreCase && pair.alias + for _, suffix := range []string{"", "/File\u00df.ts", "/Nested/File.ts"} { + got, ok := fseventsDisplayPath(w, raw+suffix) + if ok != want || ok && got != w.dir+suffix { + t.Fatalf("%s reverse=%v padding=%d ignoreCase=%v: got (%q, %v)", pair.name, reverse, padding, ignoreCase, got, ok) + } + } + if fseventsOverflowMatches(w, raw+"/Nested") != want || fseventsOverflowMatches(w, raw) != want { + t.Fatalf("%s: incorrect overflow routing", pair.name) + } + if _, ok := fseventsDisplayPath(w, raw+"2/File.ts"); ok { + t.Fatalf("%s: matched sibling", pair.name) + } + w.physicalDir += "/Nested" + w.setComparer(w.comparer) + if fseventsOverflowMatches(w, raw) != want { + t.Fatalf("%s: incorrect ancestor overflow routing", pair.name) + } + } + } + } + } +} + +func TestFSEventsLazyPathFolding(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + root, event string + match, fold bool + }{ + {"/root", "/root/File\u00df.ts", true, false}, + {"/root", "/other/\u00df/File.ts", false, false}, + {"/ROOT", "/root/File.ts", true, false}, + {"/SS", "/\u00df/File.ts", true, true}, + {"/\u00df", "/SS/File.ts", true, true}, + } { + w := &dirWatch{dir: tt.root, physicalDir: tt.root} + w.setComparer(pathComparer{ignoreCase: true}) + event := comparisonPath{path: tt.event} + for range 10 { + if _, ok := fseventsDisplayPathPrepared(w, &event); ok != tt.match || event.ready != tt.fold { + t.Fatalf("root=%q event=%q: match=%v, folded=%v", tt.root, tt.event, ok, event.ready) + } + } + } + var cache comparisonCache + for _, root := range []string{"/SS", "/ss"} { + cb := callback{ + dir: root, physicalDir: root, comparer: pathComparer{ignoreCase: true}, + physicalComparison: pathComparer{ignoreCase: true}.prepare(root), + } + e := cb.mapEventCached(Event{Path: "/\u00df/File.ts", Kind: EventUpdate}, &cache) + if e.Path != root+"/File.ts" { + t.Fatalf("cached callback: %v", e) + } + } + if len(cache) != 1 || cache["/\u00df/File.ts"] != "/ss/file.ts" { + t.Fatalf("expected one shared event comparison, got %v", cache) + } +} + +func BenchmarkFSEventsDisplayPath(b *testing.B) { + const root = "/Users/developer/work/TypeScript/packages/vscode-typescript" + for _, scenario := range []struct { + name string + root string + path string + want string + ok bool + }{ + {"exact-match", root, root + "/src/File.ts", root + "/src/File.ts", true}, + {"case-mismatch", strings.ToLower(root), root + "/src/File.ts", strings.ToLower(root) + "/src/File.ts", true}, + {"sibling-miss", root, "/Users/developer/work/TypeScript/packages/other-package/src/File.ts", "", false}, + {"unrelated-miss", root, "/private/tmp/other/File.ts", "", false}, + {"unicode-match", "/Users/developer/work/caf\u00e9", "/Users/developer/work/CAF\u00c9/File.ts", "/Users/developer/work/caf\u00e9/File.ts", true}, + {"unicode-length-match", "/Users/developer/work/s", "/Users/developer/work/\u017f/File.ts", "/Users/developer/work/s/File.ts", true}, + {"expanding-event", "/Users/developer/work/SS", "/Users/developer/work/\u00df/File.ts", "/Users/developer/work/SS/File.ts", true}, + {"expanding-root", "/Users/developer/work/\u00df", "/Users/developer/work/SS/File.ts", "/Users/developer/work/\u00df/File.ts", true}, + {"unicode-unrelated-miss", root, "/private/tmp/\u00df/File.ts", "", false}, + } { + b.Run(scenario.name, func(b *testing.B) { + w := &dirWatch{dir: scenario.root, physicalDir: scenario.root, comparer: pathComparer{ignoreCase: true}} + w.setComparer(w.comparer) + if got, ok := fseventsDisplayPath(w, scenario.path); got != scenario.want || ok != scenario.ok { + b.Fatalf("got (%q, %v), want (%q, %v)", got, ok, scenario.want, scenario.ok) + } + b.ReportAllocs() + for b.Loop() { + fseventsDisplayPath(w, scenario.path) + } + }) + } +} + +func BenchmarkFSEventsRoutingFanout(b *testing.B) { + for _, count := range []int{100, 1000} { + watches := make([]dirWatch, count) + for i := range watches { + dir := fmt.Sprintf("/Users/developer/work/TypeScript/packages/package%04d", i) + watches[i] = dirWatch{dir: dir, physicalDir: dir, comparer: pathComparer{ignoreCase: true}} + watches[i].setComparer(watches[i].comparer) + } + path := watches[count-1].dir + "/src/File.ts" + b.Run(strconv.Itoa(count), func(b *testing.B) { + matches := 0 + for i := range watches { + if got, ok := fseventsDisplayPath(&watches[i], path); ok { + matches++ + if got != path { + b.Fatalf("got %q, want %q", got, path) + } + } + } + if matches != 1 { + b.Fatalf("got %d matches, want 1", matches) + } + b.ReportAllocs() + for b.Loop() { + event := comparisonPath{path: path} + for i := range watches { + fseventsDisplayPathPrepared(&watches[i], &event) + } + + } + }) + } +} + +func BenchmarkFSEventsUnicodeFanout(b *testing.B) { + for _, scenario := range []struct{ name, root, event string }{ + {"simple", "S", "\u017f"}, + {"expanding-event", "SS", "\u00df"}, + {"expanding-root", "\u00df", "SS"}, + } { + for _, count := range []int{100, 1000} { + b.Run(scenario.name+"/"+strconv.Itoa(count), func(b *testing.B) { + watches := make([]dirWatch, count) + for i := range watches { + dir := fmt.Sprintf("/Users/developer/work/%s/package%04d", scenario.root, i) + watches[i] = dirWatch{dir: dir, physicalDir: dir} + watches[i].setComparer(pathComparer{ignoreCase: true}) + } + path := fmt.Sprintf("/Users/developer/work/%s/package%04d/File.ts", scenario.event, count-1) + matches := 0 + event := comparisonPath{path: path} + for i := range watches { + if got, ok := fseventsDisplayPathPrepared(&watches[i], &event); ok { + matches++ + if got != watches[i].dir+"/File.ts" { + b.Fatalf("unexpected display path %q", got) + } + } + } + if matches != 1 || !event.ready { + b.Fatalf("matches=%d, event folded=%v", matches, event.ready) + } + b.ReportAllocs() + for b.Loop() { + event := comparisonPath{path: path} + for i := range watches { + fseventsDisplayPathPrepared(&watches[i], &event) + } + } + }) + } + } +} diff --git a/tsc/internal/fswatch/pathcompare.go b/tsc/internal/fswatch/pathcompare.go new file mode 100644 index 0000000000000..32ebc332fe8e7 --- /dev/null +++ b/tsc/internal/fswatch/pathcompare.go @@ -0,0 +1,186 @@ +package fswatch + +import ( + "strings" + "unicode/utf8" +) + +type pathComparer struct { + ignoreCase bool +} + +// Watch roots are prepared before publication and are immutable thereafter. +// Event paths are local to one routing operation and folded only on demand. +type comparisonPath struct { + path string + folded string + ready bool + cache *comparisonCache +} + +// Shared only within a synchronous callback/termination pass, never published +// to a subscriber or stored on a watch. +type comparisonCache map[string]string + +func (c pathComparer) prepare(path string) comparisonPath { + p := comparisonPath{path: path} + if c.ignoreCase && nativePathFolding { + p.fold() + } + return p +} + +func (p *comparisonPath) fold() string { + if !p.ready { + if p.cache != nil { + if folded, ok := (*p.cache)[p.path]; ok { + p.folded, p.ready = folded, true + return folded + } + } + p.folded = foldNativePath(p.path) + p.ready = true + if p.cache != nil { + if *p.cache == nil { + *p.cache = make(comparisonCache) + } + (*p.cache)[p.path] = p.folded + } + } + return p.folded +} + +// suffix returns the part of path below root, respecting directory boundaries. +func (c pathComparer) suffix(root, path string) (string, bool) { + p := comparisonPath{path: path} + return c.suffixPrepared(comparisonPath{path: root}, &p) +} + +func (c pathComparer) suffixPrepared(root comparisonPath, path *comparisonPath) (string, bool) { + if isInDirectoryOrSelf(root.path, path.path) { + return path.path[len(root.path):], true + } + if !c.ignoreCase || root.path == "" { + return "", false + } + suffix, ok, unicode := pathSuffixASCII(root.path, path.path) + if !unicode { + return suffix, ok + } + return c.suffixUnicode(root, path) +} + +func (c pathComparer) suffixUnicode(root comparisonPath, path *comparisonPath) (string, bool) { + if !nativePathFolding { + return pathSuffixFoldUnicode(root.path, path.path) + } + a, b := root.fold(), path.fold() + if a == "" || b == "" { + // CFString cannot represent invalid UTF-8. Retain the simple-fold + // behavior for malformed paths rather than truncating or losing bytes. + return pathSuffixFoldUnicode(root.path, path.path) + } + if !isInDirectoryOrSelf(a, b) { + return "", false + } + if a == b { + return "", true + } + // Folding and canonical normalization preserve separators, but not byte + // lengths. Find the matching boundary in the original event, not its fold. + offset := 0 + separators := strings.Count(root.path, "/") + trailingSeparator := root.path[len(root.path)-1] == '/' + if !trailingSeparator { + separators++ + } + for range separators { + i := strings.IndexByte(path.path[offset:], '/') + if i < 0 { + panic("fswatch: folded path lost a directory boundary") + } + offset += i + 1 + } + if trailingSeparator { + return path.path[offset:], true + } + return path.path[offset-1:], true +} + +// The third result requests Unicode comparison; an ASCII rejection must not +// reject an expanding alias just because the other spelling is ASCII. +func pathSuffixASCII(root, path string) (string, bool, bool) { + i := 0 + // Skip shared prefixes a word at a time, which is common when routing an + // event past sibling watches. String slice comparisons do not allocate. + for i+8 <= len(root) && i+8 <= len(path) && root[i:i+8] == path[i:i+8] { + i += 8 + } + for ; i < len(root) && i < len(path); i++ { + a, b := root[i], path[i] + if a >= utf8.RuneSelf || b >= utf8.RuneSelf { + return "", false, true + } + if a == b { + continue + } + a |= 0x20 + b |= 0x20 + if a != b || a < 'a' || a > 'z' { + return "", false, false + } + } + if i == len(root) && (i == len(path) || path[i] == '/') { + return path[i:], true, false + } + return "", false, i < len(root) && root[i] >= utf8.RuneSelf || i < len(path) && path[i] >= utf8.RuneSelf +} + +// Comparing the remaining components avoids assuming case-equivalent UTF-8 +// strings have the same byte length (for example, s and long s). +func pathSuffixFoldUnicode(root, path string) (string, bool) { + for { + rootPart, rootRest, rootMore := strings.Cut(root, "/") + pathPart, pathRest, pathMore := strings.Cut(path, "/") + if !strings.EqualFold(rootPart, pathPart) { + return "", false + } + if !rootMore { + if pathMore { + return path[len(pathPart):], true + } + return "", true + } + if !pathMore { + return "", false + } + root, path = rootRest, pathRest + } +} + +func (c pathComparer) contains(root, path string) bool { + _, ok := c.suffix(root, path) + return ok +} + +func (c pathComparer) rebase(path, from, to string) (string, bool) { + p := comparisonPath{path: path} + return c.rebasePrepared(&p, comparisonPath{path: from}, to) +} + +func (c pathComparer) rebasePrepared(path *comparisonPath, from comparisonPath, to string) (string, bool) { + if isInDirectoryOrSelf(from.path, path.path) { + return rebasePath(path.path, from.path, to), true + } + if !c.ignoreCase || from.path == "" { + return "", false + } + suffix, ok, unicode := pathSuffixASCII(from.path, path.path) + if unicode { + suffix, ok = c.suffixUnicode(from, path) + } + if !ok { + return "", false + } + return joinPathSuffix(to, suffix), true +} diff --git a/tsc/internal/fswatch/pathkey.go b/tsc/internal/fswatch/pathkey.go new file mode 100644 index 0000000000000..699a57d705dfd --- /dev/null +++ b/tsc/internal/fswatch/pathkey.go @@ -0,0 +1,42 @@ +package fswatch + +import ( + "strings" + "unicode/utf8" +) + +// NativePathComparisonAvailable reports whether this platform implements the +// native watch-name comparison. Volume sensitivity is queried separately. +const NativePathComparisonAvailable = nativePathFolding + +// PathComparer describes the filename equivalence of a watched volume. Its zero +// value compares bytes exactly. It is immutable and safe to share. +type PathComparer struct { + comparer pathComparer +} + +// Key returns a watch-only comparison key, not a filesystem path or a compiler +// identity. Native Darwin comparers use the same CoreFoundation folding as the +// watcher. Other comparers preserve bytes, including malformed UTF-8. +func (c PathComparer) Key(path string) string { + if !c.comparer.ignoreCase || !nativePathFolding { + return path + } + if strings.IndexByte(path, 0) >= 0 { + return path + } + if folded := foldNativePath(path); folded != "" { + return folded + } + // Invalid UTF-8 and NUL-containing names are opaque, not Unicode aliases. + return path +} + +// Rebase replaces a matching directory prefix while preserving the spelling and +// byte boundaries of the remaining event path. +func (c PathComparer) Rebase(path, from, to string) (string, bool) { + if !utf8.ValidString(path) || !utf8.ValidString(from) || strings.IndexByte(path, 0) >= 0 || strings.IndexByte(from, 0) >= 0 { + return (pathComparer{}).rebase(path, from, to) + } + return c.comparer.rebase(path, from, to) +} diff --git a/tsc/internal/fswatch/watcher.go b/tsc/internal/fswatch/watcher.go index fa8a7489841d7..cfaaacd1b15cc 100644 --- a/tsc/internal/fswatch/watcher.go +++ b/tsc/internal/fswatch/watcher.go @@ -119,6 +119,17 @@ type WatchDirectoryRequest struct { type watchOptions struct { ignore func(path string) bool recursive bool + file string +} + +// fileOption defers the file filter until the parent directory's comparer is +// available, so WatchFile does not need a second filesystem query. +type fileOption struct { + path string +} + +func (o fileOption) applyWatchOption(opts *watchOptions) { + opts.file = o.path } type ignoreOption struct { @@ -371,10 +382,10 @@ func (w *watcher) keyForDirWatch(dir string, recursive bool) string { return dir } -func (w *watcher) findCoveringRecursiveWatchLocked(dir string, physicalDir string) *dirWatch { +func (w *watcher) findCoveringRecursiveWatchLocked(dir string, physicalDir string, comparer pathComparer) *dirWatch { var best *dirWatch for _, dw := range w.dirWatches { - if !dw.recursive || !isInDirectoryOrSelf(dw.dir, dir) || !isInDirectoryOrSelf(dw.physicalDir, physicalDir) { + if !dw.recursive || dw.comparer != comparer || !isInDirectoryOrSelf(dw.dir, dir) || !isInDirectoryOrSelf(dw.physicalDir, physicalDir) { continue } if best == nil || len(dw.dir) > len(best.dir) { @@ -416,7 +427,7 @@ func (w *watcher) findConsolidationDirLocked(dir string, physicalDir string) str return "" } -func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive bool) *dirWatch { +func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive bool, comparer pathComparer) (*dirWatch, error) { w.mu.Lock() defer w.mu.Unlock() if w.dirWatches == nil { @@ -427,28 +438,35 @@ func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive } if w.canShareRecursiveDirWatches() { - if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil { - return dw + if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir, comparer); dw != nil { + return dw, nil } if consolidationDir := w.findConsolidationDirLocked(dir, physicalDir); consolidationDir != "" { - dir = consolidationDir - physicalDir = physicalDirFor(dir) - recursive = true - if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil { - return dw + parentComparer, err := w.pathComparer(consolidationDir) + if err != nil { + return nil, err + } + if parentComparer == comparer { + dir = consolidationDir + physicalDir = physicalDirFor(dir) + recursive = true + if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir, comparer); dw != nil { + return dw, nil + } } } } key := w.keyForDirWatch(dir, recursive) if dw, ok := w.dirWatches[key]; ok { - return dw + return dw, nil } dw := newDirWatch(dir, physicalDir, w.debounce) + dw.setComparer(comparer) dw.sequence = w.sequence dw.recursive = recursive w.dirWatches[key] = dw - return dw + return dw, nil } func (w *watcher) removeDirWatch(dw *dirWatch) { @@ -524,8 +542,17 @@ func (w *watcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, e o.applyWatchOption(&sopts) } - dw := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive) - id, _ := dw.watch(dir, physicalDir, sopts.recursive, fn, sopts.ignore) + comparer, err := w.pathComparer(dir) + if err != nil { + rollback() + return nil, err + } + dw, err := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive, comparer) + if err != nil { + rollback() + return nil, err + } + id, _ := dw.addCallback(dir, physicalDir, sopts.recursive, fn, sopts.ignore, sopts.file) prepared = append(prepared, preparedWatch{dw: dw, id: id, recursive: sopts.recursive, dir: dir}) if _, ok := seenDirWatches[dw]; !ok { seenDirWatches[dw] = struct{}{} @@ -578,25 +605,7 @@ func (w *watcher) WatchFile(path string, fn WatchCallback) (Watch, error) { return nil, errRootPath } - return w.WatchDirectory(dir, fileCallback(path, fn)) -} - -// fileCallback wraps a WatchCallback so it only sees events for the -// specific target path. Errors are always forwarded (with any matching -// events delivered alongside) so callers don't lose overflow signals -// just because their target wasn't in the same batch. -func fileCallback(target string, fn WatchCallback) WatchCallback { - return func(events []Event, err error) { - var filtered []Event - for _, e := range events { - if e.Path == target { - filtered = append(filtered, e) - } - } - if len(filtered) > 0 || err != nil { - fn(filtered, err) - } - } + return w.WatchDirectory(dir, fn, fileOption{path: path}) } type watch struct { @@ -771,17 +780,21 @@ func (b *watcherBase) handleWatcherError(werr *dirWatchError) { // ----- dirWatch: per-directory watch state ------------------------- type callback struct { - id uint64 - dir string - physicalDir string - watchDir string - watchPhysicalDir string - recursive bool - fn WatchCallback - ignore func(path string) bool - sinceSeq uint64 - terminal error - delivered bool + id uint64 + dir string + physicalDir string + watchDir string + watchPhysicalDir string + recursive bool + fn WatchCallback + ignore func(path string) bool + sinceSeq uint64 + terminal error + delivered bool + comparer pathComparer + dirComparison comparisonPath + physicalComparison comparisonPath + fileComparison comparisonPath } // dirWatchError associates an error with a specific directory watch. @@ -800,9 +813,12 @@ type dirWatch struct { dir string // physicalDir is the path passed to OS watcher APIs. It differs from dir // when dir or an ancestor is a symlink or reparse point to a directory. - physicalDir string - recursive bool - events eventList + physicalDir string + recursive bool + events eventList + comparer pathComparer + dirFold string + physicalDirFold string // state stores per-directory platform-specific bookkeeping (fsevents, windows). state any @@ -822,6 +838,16 @@ func newDirWatch(dir string, physicalDir string, db *debounce) *dirWatch { return dw } +func (dw *dirWatch) setComparer(comparer pathComparer) { + dw.comparer = comparer + dw.dirFold = comparer.prepare(dw.dir).folded + if dw.physicalDir == dw.dir { + dw.physicalDirFold = dw.dirFold + } else { + dw.physicalDirFold = comparer.prepare(dw.physicalDir).folded + } +} + // physicalDirFor returns the physical path to watch for dir. If dir, or an // ancestor of dir, is a symlink or reparse point, events are subscribed on its // realpath while callbacks still use dir. @@ -968,12 +994,20 @@ func (dw *dirWatch) triggerCallbacks() { } dw.mu.Unlock() + var comparisons comparisonCache for i, cb := range cbs { cbEvents := eventsByCallback[i] - if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir { + if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir || cb.fileComparison.path != "" { filtered := make([]Event, 0, len(cbEvents)) for _, e := range cbEvents { - e = cb.mapEvent(e) + e = cb.mapEventCached(e, &comparisons) + if cb.fileComparison.path != "" { + path := comparisonPath{path: e.Path, cache: &comparisons} + if suffix, ok := cb.comparer.suffixPrepared(cb.fileComparison, &path); !ok || suffix != "" { + continue + } + e.Path = cb.fileComparison.path + } if cb.ignore != nil && cb.ignore(e.Path) { continue } @@ -1002,10 +1036,18 @@ func (dw *dirWatch) triggerCallbacks() { } func (cb callback) mapEvent(e Event) Event { - if cb.physicalDir != "" && cb.physicalDir != cb.dir { - physicalPath := cb.eventPhysicalPath(e.Path) - if isInDirectoryOrSelf(cb.physicalDir, physicalPath) { - e.Path = rebasePath(physicalPath, cb.physicalDir, cb.dir) + return cb.mapEventCached(e, nil) +} + +func (cb callback) mapEventCached(e Event, cache *comparisonCache) Event { + if cb.physicalDir != "" && (cb.physicalDir != cb.dir || cb.comparer.ignoreCase) { + physicalPath := comparisonPath{path: cb.eventPhysicalPath(e.Path), cache: cache} + root := cb.physicalComparison + if root.path == "" { + root.path = cb.physicalDir + } + if path, ok := cb.comparer.rebasePrepared(&physicalPath, root, cb.dir); ok { + e.Path = path } } return e @@ -1022,13 +1064,18 @@ func (dw *dirWatch) terminateCallbacksForDeletedRoot(path string, seq uint64, er dw.mu.Lock() defer dw.mu.Unlock() changed := false + var comparisons comparisonCache + deleted := comparisonPath{path: path, cache: &comparisons} for i := range dw.callbacks { cb := &dw.callbacks[i] if cb.delivered || cb.terminal != nil || cb.sinceSeq >= seq { continue } - physicalPath := cb.eventPhysicalPath(path) - if isInDirectoryOrSelf(path, cb.dir) || (cb.physicalDir != cb.dir && isInDirectoryOrSelf(physicalPath, cb.physicalDir)) { + physicalPath := comparisonPath{path: cb.eventPhysicalPath(path), cache: &comparisons} + dir, physical := cb.dirComparison, cb.physicalComparison + _, logicalMatch := cb.comparer.suffixPrepared(deleted, &dir) + _, physicalMatch := cb.comparer.suffixPrepared(physicalPath, &physical) + if logicalMatch || physicalMatch { cb.terminal = err changed = true } @@ -1074,6 +1121,10 @@ func isDirectChild(dir, path string) bool { } func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn WatchCallback, ignore func(path string) bool) (uint64, bool) { + return dw.addCallback(dir, physicalDir, recursive, fn, ignore, "") +} + +func (dw *dirWatch) addCallback(dir string, physicalDir string, recursive bool, fn WatchCallback, ignore func(path string) bool, file string) (uint64, bool) { dw.mu.Lock() defer dw.mu.Unlock() dw.nextCBID++ @@ -1082,7 +1133,12 @@ func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn Wat if dw.sequence != nil { sinceSeq = dw.sequence() } - dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq}) + dw.callbacks = append(dw.callbacks, callback{ + id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, + recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq, comparer: dw.comparer, + dirComparison: dw.comparer.prepare(dir), physicalComparison: dw.comparer.prepare(physicalDir), + fileComparison: dw.comparer.prepare(file), + }) return id, true } diff --git a/tsc/internal/fswatch/watcher_test.go b/tsc/internal/fswatch/watcher_test.go index 577c6acb2f4da..3f447733e9e71 100644 --- a/tsc/internal/fswatch/watcher_test.go +++ b/tsc/internal/fswatch/watcher_test.go @@ -575,6 +575,130 @@ func containsEvent(got []Event, typ EventKind, path string) bool { return false } +func TestPathComparer(t *testing.T) { + t.Parallel() + tests := []struct { + root string + path string + suffix string + exact bool + ignoreCase bool + }{ + {"/root", "/root", "", true, true}, + {"/root", "/root/file.ts", "/file.ts", true, true}, + {"/root", "/ROOT", "", false, true}, + {"/root", "/ROOT/File.ts", "/File.ts", false, true}, + {"/root", "/ROOT/Nested/File.ts", "/Nested/File.ts", false, true}, + {"/root", "/ROOT2/File.ts", "", false, false}, + {"/root", "/roo", "", false, false}, + {"/root/sub", "/ROOT", "", false, false}, + {"/root", "/other/File.ts", "", false, false}, + {"/root", "/ROOTish/File.ts", "", false, false}, + {"/root/sub", "/ROOT/SUB", "", false, true}, + {"/root/sub", "/ROOT/su", "", false, false}, + {"/root/[", "/ROOT/{/File.ts", "", false, false}, + {"/root/@", "/ROOT/`/File.ts", "", false, false}, + {"/", "/File.ts", "File.ts", true, true}, + {"/", "/", "", true, true}, + {"", "", "", false, false}, + {"", "/File.ts", "", false, false}, + {"/caf\u00e9", "/CAF\u00c9/File.ts", "/File.ts", false, true}, + {"/s", "/\u017f/File.ts", "/File.ts", false, true}, + {"/\u017f", "/S/File.ts", "/File.ts", false, true}, + {"/s/sub", "/\u017f/SUB/File.ts", "/File.ts", false, true}, + {"/\u017f/sub", "/S/SUB/File.ts", "/File.ts", false, true}, + {"/s", "/\u017foo/File.ts", "", false, false}, + {"/k", "/\u212a/File.ts", "/File.ts", false, true}, + {"/\u03c3", "/\u03c2/File.ts", "/File.ts", false, true}, + {"/\u00e9", "/\u00c8/File.ts", "", false, false}, + {"/\u00df", "/SS/File.ts", "/File.ts", false, nativePathFolding}, + {"/root/s", "/ROOT/\u017f/File.ts", "/File.ts", false, true}, + {"/root/\u017f", "/ROOT/S", "", false, true}, + } + for _, tt := range tests { + for _, ignoreCase := range []bool{false, true} { + comparer := pathComparer{ignoreCase: ignoreCase} + want := tt.exact + if ignoreCase { + want = tt.ignoreCase + } + suffix, ok := comparer.suffix(tt.root, tt.path) + if ok != want || ok && suffix != tt.suffix { + t.Errorf("suffix(%q, %q), ignoreCase=%v: got (%q, %v), want (%q, %v)", tt.root, tt.path, ignoreCase, suffix, ok, tt.suffix, want) + } + if comparer.contains(tt.root, tt.path) != want { + t.Errorf("contains(%q, %q), ignoreCase=%v: want %v", tt.root, tt.path, ignoreCase, want) + } + for _, to := range []string{"/display", "/"} { + rebased, ok := comparer.rebase(tt.path, tt.root, to) + if ok != want || ok && rebased != joinPathSuffix(to, tt.suffix) { + t.Errorf("rebase(%q, %q, %q), ignoreCase=%v: got (%q, %v)", tt.path, tt.root, to, ignoreCase, rebased, ok) + } + } + } + } +} + +func TestPathComparerUnicodeAlignment(t *testing.T) { + t.Parallel() + parts := []string{"s", "S", "\u017f", "k", "K", "\u212a", "\u03c3", "\u03c2", "\u00e9", "\u00c9", "\u00c8", "\U00010400", "\U00010428", "\xff", "\xfe", "\xc3"} + comparer := pathComparer{ignoreCase: true} + for padding := range 16 { + prefix := "/" + strings.Repeat("a", padding) + for _, a := range parts { + for _, b := range parts { + for _, child := range []string{"", "/child"} { + root := prefix + a + child + path := prefix + b + strings.ToUpper(child) + "/File.ts" + want := strings.EqualFold(a, b) + suffix, ok := comparer.suffix(root, path) + if ok != want || ok && suffix != "/File.ts" { + t.Fatalf("suffix(%q, %q): got (%q, %v), want match=%v", root, path, suffix, ok, want) + } + } + } + } + } +} + +func TestFileCallbackCaseSensitivity(t *testing.T) { + t.Parallel() + for _, ignoreCase := range []bool{false, true} { + var got []Event + dw := newDirectWatcher(t, "/root") + dw.setComparer(pathComparer{ignoreCase: ignoreCase}) + dw.addCallback("/root", "/root", false, func(events []Event, err error) { + got = append(got, events...) + }, nil, "/root/file.ts") + dw.events.update("/root/FILE.ts") + dw.events.update("/root/other.ts") + dw.triggerCallbacks() + if ignoreCase { + if len(got) != 1 || got[0].Path != "/root/file.ts" { + t.Fatalf("case-insensitive callback: got %v", got) + } + } else if len(got) != 0 { + t.Fatalf("case-sensitive callback: got %v", got) + } + } +} + +func TestPathComparerExactKeys(t *testing.T) { + t.Parallel() + c := PathComparer{} + for _, path := range []string{"/A/File.ts", "/straße/İ.ts", "/cafe\u0301.ts", "/bad\xff.ts"} { + if got := c.Key(path); got != path { + t.Fatalf("Key(%q) = %q", path, got) + } + } + if _, ok := c.Rebase("/A/file.ts", "/a", "/target"); ok { + t.Fatal("zero comparer must use exact matching") + } + if got, ok := c.Rebase("/a/file.ts", "/a", "/target"); !ok || got != "/target/file.ts" { + t.Fatalf("Rebase = %q, %v", got, ok) + } +} + func TestRebasePath(t *testing.T) { t.Parallel() @@ -2107,9 +2231,23 @@ func TestFileCallbackForwardsErrAlongsideEvents(t *testing.T) { err error } var got []call - cb := fileCallback(target, func(events []Event, err error) { + dw := newDirectWatcher(t, "/abs/dir") + dw.addCallback("/abs/dir", "/abs/dir", false, func(events []Event, err error) { got = append(got, call{events: events, err: err}) - }) + }, nil, target) + cb := func(events []Event, err error) { + for _, e := range events { + if e.Kind == EventDelete { + dw.events.remove(e.Path) + } else { + dw.events.update(e.Path) + } + } + if err != nil { + dw.events.setError(err) + } + dw.triggerCallbacks() + } // Plain events: only target events pass through, sibling dropped. cb([]Event{{Kind: EventUpdate, Path: target}, {Kind: EventUpdate, Path: other}}, nil) diff --git a/tsc/internal/project/api.go b/tsc/internal/project/api.go index 05c3ca026fc53..3526aedb2ee04 100644 --- a/tsc/internal/project/api.go +++ b/tsc/internal/project/api.go @@ -17,6 +17,9 @@ func (s *Session) APIUpdate(ctx context.Context, apiFileChanges FileChangeSummar s.cancelScheduledSnapshotUpdate() fileChanges, overlays, ataChanges, _ := s.flushChanges(ctx) + snapshot := s.Snapshot() + fileChanges = snapshot.prepareWatchSummary(fileChanges) + apiFileChanges = snapshot.prepareWatchSummary(apiFileChanges) mergeFileChangeSummary(&fileChanges, apiFileChanges) newSnapshot := s.updateSnapshotRef(ctx, overlays, SnapshotChange{ diff --git a/tsc/internal/project/configfileregistry.go b/tsc/internal/project/configfileregistry.go index 7d960a0e619b2..3fe4b693a9d4f 100644 --- a/tsc/internal/project/configfileregistry.go +++ b/tsc/internal/project/configfileregistry.go @@ -224,6 +224,7 @@ func (c *ConfigFileRegistry) GetTestConfigFileNamesEntry(path tspath.Path) *Test } type configFileNames struct { + fileName string // nearestConfigFileName is the file name of the nearest ancestor config file. nearestConfigFileName string // ancestors is a map from one ancestor config file path to the next. @@ -238,6 +239,7 @@ type configFileNames struct { func (c *configFileNames) Clone() *configFileNames { return &configFileNames{ + fileName: c.fileName, nearestConfigFileName: c.nearestConfigFileName, ancestors: maps.Clone(c.ancestors), } diff --git a/tsc/internal/project/configfileregistrybuilder.go b/tsc/internal/project/configfileregistrybuilder.go index 1366c27abd59f..b9577d98b35bf 100644 --- a/tsc/internal/project/configfileregistrybuilder.go +++ b/tsc/internal/project/configfileregistrybuilder.go @@ -409,11 +409,16 @@ func (c *configFileRegistryBuilder) DidChangeCustomConfigFileName(logger *loggin return true } -func (c *configFileRegistryBuilder) invalidateCache(logger *logging.LogTree) changeFileResult { +func (c *configFileRegistryBuilder) invalidateCache(logger *logging.LogTree, forceFullReload bool) changeFileResult { var affectedProjects map[tspath.Path]struct{} var affectedFiles map[tspath.Path]struct{} - logger.Log("Too many files changed; marking all configs for reload") + if forceFullReload { + logger.Log("Invalidating all config state; marking all configs for full reload") + c.invalidateContentMappers() + } else { + logger.Log("Too many files changed; marking all configs for reload") + } c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { if affectedFiles == nil { affectedFiles = make(map[tspath.Path]struct{}) @@ -426,7 +431,11 @@ func (c *configFileRegistryBuilder) invalidateCache(logger *logging.LogTree) cha c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { entry.Change(func(entry *configFileEntry) { affectedProjects = core.CopyMapInto(affectedProjects, entry.retainingProjects) - if entry.pendingReload != PendingReloadFull { + if forceFullReload { + // An unrecognized alias may have changed an extended config or mapper + // manifest even when this config's own text is unchanged. + entry.pendingReload = PendingReloadFull + } else if entry.pendingReload != PendingReloadFull { text, ok := c.FS().ReadFile(entry.fileName) if !ok || entry.commandLine == nil || text != entry.commandLine.ConfigFile.SourceFile.Text() { entry.pendingReload = PendingReloadFull @@ -508,12 +517,16 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo c.didCloseFile(path) } + if summary.InvalidateAll { + return c.invalidateCache(logger, true /*forceFullReload*/) + } + // Handle changes to stored config files and their content mapper package manifests. logger.Log("Checking if any changed files are configuration files") for path := range createdOrChangedOrDeletedFiles { if entry, ok := c.configs.Load(path); ok { if hasExcessiveChanges { - return c.invalidateCache(logger) + return c.invalidateCache(logger, false /*forceFullReload*/) } affectedProjects = core.CopyMapInto(affectedProjects, c.handleConfigChange(entry, logger)) @@ -542,7 +555,7 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo // Handle created/deleted files named "tsconfig.json" or "jsconfig.json" for path := range createdOrDeletedConfigFiles { if hasExcessiveChanges { - return c.invalidateCache(logger) + return c.invalidateCache(logger, false /*forceFullReload*/) } directoryPath := path.GetDirectoryPath() c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { @@ -587,7 +600,7 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo return !shouldInvalidateCache }) if shouldInvalidateCache { - return c.invalidateCache(logger) + return c.invalidateCache(logger, false /*forceFullReload*/) } } @@ -625,7 +638,7 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo return !shouldInvalidateCache }) if shouldInvalidateCache { - return c.invalidateCache(logger) + return c.invalidateCache(logger, false /*forceFullReload*/) } } @@ -727,6 +740,7 @@ func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, pa configName := c.computeConfigFileName(fileName, false, logger) if c.isOpenFile(path) { c.configFileNames.Add(path, &configFileNames{ + fileName: fileName, nearestConfigFileName: configName, }) } diff --git a/tsc/internal/project/filechange.go b/tsc/internal/project/filechange.go index cae24fb454d5c..3bbefa9afb468 100644 --- a/tsc/internal/project/filechange.go +++ b/tsc/internal/project/filechange.go @@ -1,6 +1,8 @@ package project import ( + "slices" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" ) @@ -49,6 +51,11 @@ type FileChangeSummary struct { IncludesWatchChangeOutsideNodeModules bool // InvalidateAll indicates that all cached file state should be discarded. InvalidateAll bool + + // Preserve events hidden by overlay coalescing and content filtering. Alias + // directory comparers and realpaths must be refreshed even when file text is unchanged. + hasFileSystemChanges bool + preparedWatchChanges *preparedWatchChanges } func (f FileChangeSummary) IsEmpty() bool { @@ -65,6 +72,20 @@ func (f FileChangeSummary) HasExcessiveNonCreateWatchEvents() bool { // mergeFileChangeSummary merges src into dst, combining their change sets. func mergeFileChangeSummary(dst *FileChangeSummary, src FileChangeSummary) { + dst.hasFileSystemChanges = dst.hasFileSystemChanges || src.hasFileSystemChanges + if prepared := src.preparedWatchChanges; prepared != nil { + if previous := dst.preparedWatchChanges; previous != nil { + if previous.snapshotID != prepared.snapshotID { + panic("cannot merge watch changes prepared for different snapshots") + } + dst.preparedWatchChanges = &preparedWatchChanges{ + snapshotID: prepared.snapshotID, + affected: slices.Concat(previous.affected, prepared.affected), + } + } else { + dst.preparedWatchChanges = prepared + } + } if src.IsEmpty() { return } diff --git a/tsc/internal/project/overlayfs.go b/tsc/internal/project/overlayfs.go index fb25338c26868..e0498b2ee7116 100644 --- a/tsc/internal/project/overlayfs.go +++ b/tsc/internal/project/overlayfs.go @@ -73,7 +73,7 @@ func (f *fileBase) ECMALineInfo() *sourcemap.ECMALineInfo { type diskFile struct { fileBase needsReload bool - realpathPath tspath.Path + realpathName string } func newDiskFile(fileName string, content string) *diskFile { @@ -106,7 +106,7 @@ func (f *diskFile) Kind() core.ScriptKind { func (f *diskFile) Clone() *diskFile { return &diskFile{ - realpathPath: f.realpathPath, + realpathName: f.realpathName, fileBase: fileBase{ fileName: f.fileName, content: f.content, @@ -228,6 +228,7 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma // Reduced collection of changes that occurred on a single file type fileEvents struct { + uri lsproto.DocumentUri openChange *FileChange closeChange *FileChange watchChanged bool @@ -237,19 +238,25 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma deleted bool } - fileEventMap := make(map[lsproto.DocumentUri]*fileEvents) + fileEventMap := make(map[tspath.Path]*fileEvents) for _, change := range changes { + if change.Kind.IsWatchKind() || change.Kind == FileChangeKindSave { + result.hasFileSystemChanges = true + } uri := change.URI - events, exists := fileEventMap[uri] + path := uri.Path(fs.fs.UseCaseSensitiveFileNames()) + events, exists := fileEventMap[path] if exists { if events.openChange != nil { panic("should see no changes after open") } } else { events = &fileEvents{} - fileEventMap[uri] = events + fileEventMap[path] = events } + // Coalesce compiler-equivalent paths while retaining notification spelling. + events.uri = uri if !result.IncludesWatchChangeOutsideNodeModules && change.Kind.IsWatchKind() && !strings.Contains(string(uri), "/node_modules/") { result.IncludesWatchChangeOutsideNodeModules = true @@ -306,8 +313,8 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma } // Process deduplicated events per file - for uri, events := range fileEventMap { - path := uri.Path(fs.fs.UseCaseSensitiveFileNames()) + for path, events := range fileEventMap { + uri := events.uri o := newOverlays[path] if events.openChange != nil { diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 80cacf0b7b834..e3e4c2df7ce43 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -76,7 +76,7 @@ type Project struct { // Only set before actually loading config file to get actual project references potentialProjectReferences *collections.Set[tspath.Path] - programFilesWatch *WatchedFiles[*collections.SyncSet[tspath.Path]] + programFilesWatch *WatchedFiles[*collections.SyncMap[tspath.Path, string]] typingsWatch *WatchedFiles[PatternsAndIgnored] contentMapperWatch *WatchedFiles[[]string] contentMapperWatchedFiles *collections.Set[tspath.Path] @@ -470,7 +470,7 @@ func (p *Project) CreateProgram() CreateProgramResult { } } -func (p *Project) CloneWatchers() *WatchedFiles[*collections.SyncSet[tspath.Path]] { +func (p *Project) CloneWatchers() *WatchedFiles[*collections.SyncMap[tspath.Path, string]] { return p.programFilesWatch.Clone(p.host.sourceFS.seenFiles) } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 347b69caf7a1a..cbbaf568082e5 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -1317,6 +1317,7 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo project.ProgramLastUpdate = b.newSnapshotID if result.UpdateKind == ProgramUpdateKindCloned { project.host.sourceFS.seenFiles = oldHost.sourceFS.seenFiles + project.host.sourceFS.missingDirectories = oldHost.sourceFS.missingDirectories } if result.UpdateKind == ProgramUpdateKindNewFiles { filesChanged = true diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index 545287e54af33..5736af534f246 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -440,13 +440,28 @@ func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto. Kind: kind, URI: change.Uri, }) + } + preview, prepared, invalidateAll := snapshot.prepareWatchNotifications(fileChanges) + if prepared != nil { + for _, name := range prepared.affected { + preview = append(preview, FileChange{Kind: FileChangeKindWatchChange, URI: lsconv.FileNameToDocumentURI(name)}) + } + } + for _, change := range preview { + kind := change.Kind + + if invalidateAll { + // A failed index cannot rule out aliases of source or config files. + hasRelevantChange = true + hasConfigChange = true + } - if !hasConfigChange && configFileRegistry.isTracked(s.toPath(change.Uri.FileName())) { + if !hasConfigChange && configFileRegistry.isTracked(s.toPath(change.URI.FileName())) { hasConfigChange = true } if !hasRelevantChange { - fileName := change.Uri.FileName() + fileName := change.URI.FileName() path := s.toPath(fileName).RemoveTrailingDirectorySeparator() pathStr := string(path) if contentMapperWatchedFiles.Has(path) { @@ -461,9 +476,6 @@ func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto. if kind != FileChangeKindWatchDelete { hasRelevantChange = s.fs.fs.DirectoryExists(fileName) } else { - s.snapshotMu.RLock() - snapshot := s.snapshot - s.snapshotMu.RUnlock() if _, ok := snapshot.fs.diskDirectories[path]; ok || isNodeModulesPath(path) { hasRelevantChange = true } @@ -497,6 +509,8 @@ func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto. } func (s *Session) DidChangeCompilerOptionsForInferredProjects(ctx context.Context, options *core.CompilerOptions) { + s.snapshotUpdateMu.Lock() + defer s.snapshotUpdateMu.Unlock() s.compilerOptionsForInferredProjects = options s.UpdateSnapshot(ctx, s.fs.Overlays(), SnapshotChange{ reason: UpdateReasonDidChangeCompilerOptionsForInferredProjects, @@ -623,7 +637,7 @@ func (s *Session) ScheduleSnapshotUpdate(reason UpdateReason) { defer s.snapshotUpdateMu.Unlock() fileChanges, overlays, ataChanges, newConfig := s.flushChanges(ctx) - if fileChanges.IsEmpty() && len(ataChanges) == 0 && newConfig == nil { + if fileChanges.IsEmpty() && !s.watchAliasesNeedRefresh(fileChanges) && len(ataChanges) == 0 && newConfig == nil { return } @@ -1035,7 +1049,7 @@ func (s *Session) getSnapshot( s.cancelScheduledSnapshotUpdate() fileChanges, overlays, ataChanges, newConfig := s.flushChanges(ctx) - updateSnapshot := !fileChanges.IsEmpty() || len(ataChanges) > 0 || newConfig != nil + updateSnapshot := !fileChanges.IsEmpty() || s.watchAliasesNeedRefresh(fileChanges) || len(ataChanges) > 0 || newConfig != nil if updateSnapshot { // If there are pending file changes, we need to update the snapshot. // Sending the requested URI ensures that the project for this URI is loaded. @@ -1306,6 +1320,8 @@ func (s *Session) tryAdoptSnapshotChangeInBackground(baseSnapshot, newSnapshot * // session has moved on, the snapshot is discarded; the next request needing // auto-imports will redo the work on the latest snapshot. func (s *Session) adoptSnapshotChange(baseSnapshot, newSnapshot *Snapshot) { + s.snapshotUpdateMu.Lock() + defer s.snapshotUpdateMu.Unlock() s.snapshotMu.Lock() oldSnapshot := s.snapshot if oldSnapshot == baseSnapshot { @@ -1341,6 +1357,7 @@ func (s *Session) adoptSnapshotChange(baseSnapshot, newSnapshot *Snapshot) { } } +// The caller holds snapshotUpdateMu from notification preparation through publication. func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]*Overlay, change SnapshotChange) { s.updateSnapshot(ctx, overlays, change, false) } @@ -1711,7 +1728,10 @@ func (s *Session) flushChangesLocked(ctx context.Context) (FileChangeSummary, ma } start := time.Now() - changes, overlays := s.fs.processChanges(s.pendingFileChanges) + notifications, prepared, invalidateAll := s.Snapshot().prepareWatchNotifications(s.pendingFileChanges) + changes, overlays := s.fs.processChanges(notifications) + changes.preparedWatchChanges = prepared + changes.InvalidateAll = invalidateAll if s.options.LoggingEnabled { s.logger.Log(fmt.Sprintf("Processed %d file changes in %v", len(s.pendingFileChanges), time.Since(start))) } @@ -1768,7 +1788,7 @@ func (s *Session) logCacheStats(snapshot *Snapshot) { s.logger.Log("\n======== Cache Statistics ========") s.logger.Logf("Open file count: %6d", len(snapshot.fs.overlays)) s.logger.Logf("Cached disk files: %6d", len(snapshot.fs.diskFiles)) - s.logger.Logf("Realpath aliases: %6d", len(snapshot.fs.nodeModulesRealpathAliases)) + s.logger.Logf("Realpath files: %6d", snapshot.fs.realpathFiles) s.logger.Logf("Project count: %6d", len(snapshot.ProjectCollection.Projects())) s.logger.Logf("Config count: %6d", len(snapshot.ConfigFileRegistry.configs)) if s.logger.IsVerbose() { diff --git a/tsc/internal/project/session_test.go b/tsc/internal/project/session_test.go index 209d9d70d2516..b55ebf428bf44 100644 --- a/tsc/internal/project/session_test.go +++ b/tsc/internal/project/session_test.go @@ -691,7 +691,7 @@ func TestSession(t *testing.T) { programBefore := lsBefore.GetProgram() session.WaitForBackgroundTasks() - assert.Check(t, utils.WatchesFile("/home/projects/ts/x.ts")) + assert.Check(t, utils.WatchesFile("/home/projects/TS/x.ts")) err = utils.FS().WriteFile("/home/projects/TS/x.ts", `export const x = 2;`) assert.NilError(t, err) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 37800279caeec..ffdfdeaa9ea33 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -24,6 +24,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/sourcemap" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfsmatch" + "github.com/microsoft/TypeScript/tsc/internal/watchalias" ) type Snapshot struct { @@ -47,6 +48,8 @@ type Snapshot struct { contentMapperWatchStateOnce sync.Once contentMapperExtensions []string contentMapperWatchedFiles *collections.Set[tspath.Path] + watchAliases *watchalias.Index + watchAliasesError error builderLogs *logging.LogTree apiError error @@ -126,7 +129,8 @@ func (s *Snapshot) cloneForProgram( } start := time.Now() - fs := newSnapshotFSBuilder(store.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, store.options.PositionEncoding, store.toPath) + reuseWatchAliases := s.watchAliasChangesAreContentOnly(fileChanges, s.fs.overlays) + fs := newSnapshotFSBuilder(store.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.realpathFiles, store.options.PositionEncoding, store.toPath) fileChanges = s.processFileChanges(fs, fileChanges, logger, nil) newSnapshotID := store.nextSnapshotID() @@ -232,6 +236,7 @@ func (s *Snapshot) cloneForProgram( if logger != nil { logger.Logf("Finished cloning snapshot %d into snapshot %d for program in %v", s.id, newSnapshot.id, time.Since(start)) } + newSnapshot.initializeWatchAliasesFrom(s, reuseWatchAliases, sessionLogger) return newSnapshot } @@ -260,12 +265,13 @@ func (s *Snapshot) cloneWithTemporaryFile( } overlays[path] = newOverlay(uri.FileName(), newText, version, scriptKind) - return s.Clone(ctx, SnapshotChange{ + snapshot := s.Clone(ctx, SnapshotChange{ fileChanges: fileChanges, ResourceRequest: ResourceRequest{ Documents: []lsproto.DocumentUri{uri}, }, - }, overlays, nil), nil + }, overlays, nil) + return snapshot, snapshot.apiError } func (s *Snapshot) processFileChanges( @@ -274,6 +280,21 @@ func (s *Snapshot) processFileChanges( logger *logging.LogTree, contentMapperContributions *ContentMapperContributions, ) FileChangeSummary { + contentOnly := s.watchAliasChangesAreContentOnly(fileChanges, fs.overlays) + var affected []string + fileChanges, affected = s.matchWatchChanges(fileChanges) + if !contentOnly { + for _, name := range affected { + path := s.host.toPath(name) + if entry, ok := fs.diskFiles.Load(path); ok && entry.Value() != nil { + if fs.recordRealpathAlias(entry, name, path) { + // A new target may contain identical text but resolve its + // imports or configuration differently. + fileChanges.InvalidateAll = true + } + } + } + } if fileChanges.HasExcessiveWatchEvents() { invalidateStart := time.Now() if fileChanges.InvalidateAll { @@ -281,7 +302,7 @@ func (s *Snapshot) processFileChanges( if logger != nil { logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart)) } - } else if !fs.watchChangesOverlapCache(fileChanges) { + } else if !fs.watchChangesOverlapCache(fileChanges) && !s.watchChangesOverlapProjectState(fileChanges) { // All watch changes/deletes are files we haven't seen; should be irrelevant to us (probably an external tool's build or something) fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} @@ -308,10 +329,10 @@ func (s *Snapshot) processFileChanges( } _, contentMapperWatchedFiles := s.contentMapperWatchState() fileChanges = fs.expandAndFilterWatchEvents(fileChanges, contentMapperExtensions, contentMapperWatchedFiles) - fileChanges = s.fs.expandRealpathAliases(fileChanges) fileChanges = fs.markDirtyFiles(fileChanges) fileChanges = fs.convertOpenAndCloseToChanges(fileChanges) } + fileChanges.preparedWatchChanges = nil return fileChanges } @@ -538,13 +559,14 @@ func (s *Snapshot) Clone( } start := time.Now() + reuseWatchAliases := s.watchAliasChangesAreContentOnly(change.fileChanges, overlays) inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { inferredContentMappers = change.contentMapperContributions.Mappers inferredContentMapperExtensions = change.contentMapperContributions.Extensions } - fs := newSnapshotFSBuilder(store.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, store.options.PositionEncoding, store.toPath) + fs := newSnapshotFSBuilder(store.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.realpathFiles, store.options.PositionEncoding, store.toPath) change.fileChanges = s.processFileChanges(fs, change.fileChanges, logger, change.contentMapperContributions) compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects @@ -750,6 +772,7 @@ func (s *Snapshot) Clone( autoImportHost.Dispose() logger.Logf("Finished cloning snapshot %d into snapshot %d in %v", s.id, newSnapshot.id, time.Since(start)) + newSnapshot.initializeWatchAliasesFrom(s, reuseWatchAliases, sessionLogger) return newSnapshot } diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index e1782e138c0d9..b8cb7a4e33c58 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -3,11 +3,24 @@ package project import ( "context" "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" "testing" + "time" "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) @@ -385,3 +398,509 @@ func BenchmarkSnapshotCloneRefCost(b *testing.B) { }) } } + +func TestWatchAliasSnapshotReuse(t *testing.T) { + t.Parallel() + disk := vfstest.FromMap(map[string]string{ + "/src/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`, + "/src/main.ts": "export const value = 1;", + "/src/other.ts": "export const other = 1;", + }, true) + fs := &failingWatchComparerFS{FS: disk} + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{CurrentDirectory: "/src", WatchEnabled: true}}) + defer host.Close() + root := host.NewStandaloneRootSnapshot() + defer root.Deref() + snapshot, err := host.CloneSnapshot(context.Background(), root, FileChangeSummary{}, &APISnapshotRequest{ + OpenProjects: collections.NewSetFromItems("/src/tsconfig.json"), + }) + if err != nil { + t.Fatal(err) + } + defer func() { snapshot.Deref() }() + opened, err := host.CloneSnapshotWithTemporaryFile(context.Background(), snapshot, "file:///src/main.ts", "export const value = 1;") + if err != nil { + t.Fatal(err) + } + snapshot.Deref() + snapshot = opened + for range 3 { + calls, aliases := fs.calls, snapshot.watchAliases + next, cloneErr := host.CloneSnapshotWithTemporaryFile(context.Background(), snapshot, "file:///src/main.ts", "export const value = 2;") + if cloneErr != nil { + t.Fatal(cloneErr) + } + snapshot.Deref() + snapshot = next + if fs.calls != calls || snapshot.watchAliases != aliases { + t.Fatalf("content edit rebuilt immutable aliases: comparer queries %d -> %d", calls, fs.calls) + } + } + for _, name := range []lsproto.DocumentUri{"file:///unrelated/ignored", "file:///src/main.ts", "file:///src/node_modules/ignored"} { + calls, aliases := fs.calls, snapshot.watchAliases + var changes FileChangeSummary + changes.Changed.Add(name) + next, cloneErr := host.CloneSnapshot(context.Background(), snapshot, changes, nil) + if cloneErr != nil { + t.Fatal(cloneErr) + } + snapshot.Deref() + snapshot = next + if fs.calls == calls || snapshot.watchAliases == aliases { + t.Fatalf("filesystem change %s reused aliases after filtering", name) + } + } + calls, aliases := fs.calls, snapshot.watchAliases + next, err := host.CloneSnapshotWithTemporaryFile(context.Background(), snapshot, "file:///src/main.ts", `import "./other"; export const value = 3;`) + if err != nil { + t.Fatal(err) + } + snapshot.Deref() + snapshot = next + if fs.calls == calls || aliases == snapshot.watchAliases { + t.Fatal("new import names reused aliases") + } + if snapshot.ProjectCollection.ConfiguredProject(host.toPath("/src/tsconfig.json")).Program.GetSourceFile("/src/other.ts") == nil { + t.Fatal("new import was not loaded") + } + calls, aliases = fs.calls, snapshot.watchAliases + next, err = host.CloneSnapshotWithTemporaryFile(context.Background(), snapshot, "file:///src/main.ts", "export const value = 4;") + if err != nil { + t.Fatal(err) + } + snapshot.Deref() + snapshot = next + if fs.calls != calls || aliases != snapshot.watchAliases { + t.Fatal("removing an import needlessly rebuilt immutable alias coverage") + } + if snapshot.ProjectCollection.ConfiguredProject(host.toPath("/src/tsconfig.json")).Program.GetSourceFile("/src/other.ts") != nil { + t.Fatal("alias reuse retained a removed import in the program") + } + if err = disk.WriteFile("/src/other.ts", "export const other = 2;"); err != nil { + t.Fatal(err) + } + var removedDependencyChange FileChangeSummary + removedDependencyChange.Changed.Add("file:///src/other.ts") + next, err = host.CloneSnapshot(context.Background(), snapshot, removedDependencyChange, nil) + if err != nil { + t.Fatal(err) + } + snapshot.Deref() + snapshot = next + program := snapshot.ProjectCollection.ConfiguredProject(host.toPath("/src/tsconfig.json")).Program + if program.GetSourceFile("/src/other.ts") != nil || program.GetSourceFile("/src/main.ts").Text() != "export const value = 4;" { + t.Fatal("notification for surplus alias coverage changed live sources") + } + if snapshot.watchAliases == aliases { + t.Fatal("filesystem notification did not rebuild alias coverage") + } + calls, aliases = fs.calls, snapshot.watchAliases + if err = disk.WriteFile("/src/tsconfig.json", `{"compilerOptions":{"noLib":true,"types":[]},"include":["*.ts"]}`); err != nil { + t.Fatal(err) + } + var configChange FileChangeSummary + configChange.Changed.Add("file:///src/tsconfig.json") + next, err = host.CloneSnapshot(context.Background(), snapshot, configChange, nil) + if err != nil { + t.Fatal(err) + } + snapshot.Deref() + snapshot = next + if fs.calls == calls || aliases == snapshot.watchAliases { + t.Fatal("config change reused aliases") + } +} + +func TestWatchAliasProgramCloneReuse(t *testing.T) { + t.Parallel() + fs := &failingWatchComparerFS{FS: vfstest.FromMap(map[string]string{ + "/src/s.ts": "export const s = 1;", + "/src/ſ.ts": "export const longS = 1;", + }, true)} + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{CurrentDirectory: "/src", WatchEnabled: true}}) + defer host.Close() + root := host.NewStandaloneRootSnapshot() + defer root.Deref() + options := &core.CompilerOptions{NoLib: core.TSTrue, Types: []string{}} + snapshot := host.CloneSnapshotForProgram(context.Background(), root, []string{"/src/s.ts"}, options, nil, nil, nil, FileChangeSummary{}) + defer snapshot.Deref() + calls := fs.calls + next := host.CloneSnapshotForProgram(context.Background(), snapshot, []string{"/src/s.ts"}, options, nil, nil, snapshot.ProjectCollection.inferredProject, FileChangeSummary{}) + defer next.Deref() + if snapshot.watchAliases != next.watchAliases || calls != fs.calls { + t.Fatal("unchanged createProgram rebuilt alias inputs") + } + last := host.CloneSnapshotForProgram(context.Background(), next, []string{"/src/s.ts", "/src/ſ.ts"}, options, nil, nil, next.ProjectCollection.inferredProject, FileChangeSummary{}) + defer last.Deref() + if last.watchAliases == next.watchAliases || calls == fs.calls { + t.Fatal("new original root name reused aliases") + } + if len(last.ProjectCollection.inferredProject.Program.GetSourceFiles()) != 2 { + t.Fatal("distinct s and long-s root identities collapsed") + } +} + +func TestWatchAliasCoalescedFilesystemChanges(t *testing.T) { + t.Parallel() + for _, kind := range []FileChangeKind{FileChangeKindWatchCreate, FileChangeKindWatchChange, FileChangeKindWatchDelete, FileChangeKindSave} { + fs := vfstest.FromMap(map[string]string{"/src/node_modules/main.ts": "export {};"}, true) + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{CurrentDirectory: "/src", WatchEnabled: true}}) + snapshot := host.NewStandaloneRootSnapshot() + overlays := newOverlayFS(fs, make(map[tspath.Path]*Overlay), lsproto.PositionEncodingKindUTF8, host.toPath) + _, snapshot.fs.overlays = overlays.processChanges([]FileChange{{ + Kind: FileChangeKindOpen, URI: "file:///src/node_modules/main.ts", Content: "export {};", + }}) + change, nextOverlays := overlays.processChanges([]FileChange{ + {Kind: kind, URI: "file:///src/node_modules/main.ts"}, + { + Kind: FileChangeKindChange, URI: "file:///src/node_modules/main.ts", Version: 2, + Changes: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "export const value = 1;"}}}, + }, + }) + var merged FileChangeSummary + mergeFileChangeSummary(&merged, change) + if !merged.hasFileSystemChanges || snapshot.watchAliasChangesAreContentOnly(merged, nextOverlays) { + t.Fatalf("coalesced filesystem event kind %v was treated as an overlay-only edit", kind) + } + snapshot.Deref() + host.Close() + } +} + +func TestWatchAliasSessionRefreshesFilteredEvents(t *testing.T) { + t.Parallel() + fs := &failingWatchComparerFS{FS: vfstest.FromMap(map[string]string{ + "/src/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`, + "/src/main.ts": "export const value = 1;", + }, true)} + session := NewSession(&SessionInit{ + BackgroundCtx: context.Background(), + FS: fs, Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/src", WatchEnabled: true}, + }) + defer session.Close() + session.DidOpenFile(context.Background(), "file:///src/main.ts", 1, "export const value = 1;", lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + previous, calls := session.Snapshot(), fs.calls + session.pendingFileChangesMu.Lock() + session.pendingFileChanges = append(session.pendingFileChanges, + FileChange{Kind: FileChangeKindWatchCreate, URI: "file:///src/node_modules/ignored.ts"}, + FileChange{Kind: FileChangeKindWatchDelete, URI: "file:///src/node_modules/ignored.ts"}, + ) + session.pendingFileChangesMu.Unlock() + if _, err := session.GetLanguageService(context.Background(), "file:///src/main.ts"); err != nil { + t.Fatal(err) + } + session.WaitForBackgroundTasks() + if next := session.Snapshot(); next == previous || next.watchAliases == previous.watchAliases || fs.calls == calls { + t.Fatal("coalesced namespace events failed to refresh the session alias generation") + } +} + +func TestWatchAliasRealpathStateReuseAndRefresh(t *testing.T) { + t.Parallel() + files := func(target string) map[string]any { + result := map[string]any{ + "/var": vfstest.Symlink("/private"), + "/private/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true},"files":["main.ts"]}`, + "/private/project/main.ts": `import { value } from "pkg"; export { value };`, + "/packages/one/index.d.ts": `export const value: "one";`, + "/packages/two/index.d.ts": `export const value: "two";`, + } + if target != "" { + result["/private/project/node_modules/pkg"] = vfstest.Symlink("/packages/" + target) + } + return result + } + fs := &countedWatchAliasFS{FS: vfstest.FromMap(files("one"), true)} + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{CurrentDirectory: "/var/project", WatchEnabled: true}}) + defer host.Close() + root := host.NewStandaloneRootSnapshot() + defer root.Deref() + snapshot, err := host.CloneSnapshot(context.Background(), root, FileChangeSummary{}, &APISnapshotRequest{ + OpenProjects: collections.NewSetFromItems("/var/project/tsconfig.json"), + }) + if err != nil { + t.Fatal(err) + } + defer snapshot.Deref() + if snapshot.watchAliases == nil || fs.comparerQueries.Load() != 0 { + t.Fatal("mock must retain physical aliases without querying native comparer") + } + if !slices.Contains(snapshot.watchNames("/var/project"), "/private/project") { + t.Fatal("disabled native comparison lost requested realpath root") + } + opened, err := host.CloneSnapshotWithTemporaryFile(context.Background(), snapshot, "file:///var/project/main.ts", `import { value } from "pkg"; export { value };`) + if err != nil { + t.Fatal(err) + } + defer opened.Deref() + calls := fs.realpaths.Load() + edited, err := host.CloneSnapshotWithTemporaryFile(context.Background(), opened, "file:///var/project/main.ts", `import { value } from "pkg"; export { value }; // edited`) + if err != nil { + t.Fatal(err) + } + defer edited.Deref() + if calls != fs.realpaths.Load() { + t.Fatalf("content edit repeated realpath queries: %d -> %d", calls, fs.realpaths.Load()) + } + if edited.watchAliases != opened.watchAliases { + t.Fatal("content edit rebuilt immutable physical aliases") + } + if !slices.Contains(edited.watchNames("/var/project/node_modules/pkg"), "/packages/one") { + t.Fatal("edit lost known package realpath") + } + fs.FS = vfstest.FromMap(files(""), true) + var changes FileChangeSummary + changes.Deleted.Add("file:///var/project/node_modules/pkg") + removed := host.update(context.Background(), edited, SnapshotChange{ + fileChanges: changes, ResourceRequest: ResourceRequest{Projects: []tspath.Path{"/var/project/tsconfig.json"}}, + }) + defer removed.Deref() + if slices.Contains(removed.watchNames("/var/project/node_modules/pkg"), "/packages/one") { + t.Fatal("deleted symlink retained old realpath") + } + fs.FS = vfstest.FromMap(files("two"), true) + changes = FileChangeSummary{} + changes.Created.Add("file:///var/project/node_modules/pkg") + retargeted := host.update(context.Background(), removed, SnapshotChange{ + fileChanges: changes, ResourceRequest: ResourceRequest{Projects: []tspath.Path{"/var/project/tsconfig.json"}}, + }) + if retargeted.apiError != nil { + t.Fatal(retargeted.apiError) + } + defer retargeted.Deref() + project := retargeted.ProjectCollection.ConfiguredProject(tspath.Path("/var/project/tsconfig.json")) + source := project.Program.GetSourceFile("/var/project/node_modules/pkg/index.d.ts") + if source == nil || source.Text() != `export const value: "two";` { + t.Fatal("symlink retarget retained old source contents") + } + if names := retargeted.watchNames("/var/project/node_modules/pkg"); !slices.Contains(names, "/packages/two") || slices.Contains(names, "/packages/one") { + t.Fatalf("symlink retarget retained old realpath: %v", names) + } + if names := edited.watchNames("/var/project/node_modules/pkg"); !slices.Contains(names, "/packages/one") || slices.Contains(names, "/packages/two") { + t.Fatal("refresh mutated published physical aliases") + } + fs.FS = vfstest.FromMap(files(""), true) + changes = FileChangeSummary{} + changes.Deleted.Add("file:///var/project/node_modules/pkg") + deleted := host.update(context.Background(), retargeted, SnapshotChange{ + fileChanges: changes, ResourceRequest: ResourceRequest{Projects: []tspath.Path{"/var/project/tsconfig.json"}}, + }) + if deleted.apiError != nil { + t.Fatal(deleted.apiError) + } + defer deleted.Deref() + if slices.Contains(deleted.watchNames("/var/project/node_modules/pkg"), "/packages/two") { + t.Fatal("symlink deletion retained old realpath") + } + if fs.comparerQueries.Load() != 0 { + t.Fatal("mock queried native comparer while refreshing physical aliases") + } +} + +func TestWatchRealpathRetargetIdenticalText(t *testing.T) { + t.Parallel() + for _, watchEnabled := range []bool{false, true} { + files := func(target, value string) map[string]any { + return map[string]any{ + "/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true},"files":["main.ts"]}`, + "/project/main.ts": `import { value } from "pkg"; export { value };`, + "/project/node_modules/pkg": vfstest.Symlink("/packages/" + target), + "/packages/one/index.d.ts": `export { value } from "./dep";`, + "/packages/two/index.d.ts": `export { value } from "./dep";`, + "/packages/one/dep.d.ts": `export const value: "one";`, + "/packages/two/dep.d.ts": value, + } + } + fs := &countedWatchAliasFS{FS: vfstest.FromMap(files("one", `export const value: "two";`), true)} + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: watchEnabled}}) + root := host.NewStandaloneRootSnapshot() + snapshot, err := host.CloneSnapshot(context.Background(), root, FileChangeSummary{}, &APISnapshotRequest{ + OpenProjects: collections.NewSetFromItems("/project/tsconfig.json"), + }) + assert.NilError(t, err) + fs.FS = vfstest.FromMap(files("two", `export const value: "two";`), true) + var changes FileChangeSummary + changes.Changed.Add("file:///project/node_modules/pkg") + next := host.update(context.Background(), snapshot, SnapshotChange{ + fileChanges: changes, ResourceRequest: ResourceRequest{Projects: []tspath.Path{"/project/tsconfig.json"}}, + }) + assert.NilError(t, next.apiError) + file := next.ProjectCollection.ConfiguredProject("/project/tsconfig.json").Program.GetSourceFile("/project/node_modules/pkg/dep.d.ts") + assert.Assert(t, file != nil) + assert.Equal(t, file.Text(), `export const value: "two";`) + assert.Equal(t, snapshot.ProjectCollection.ConfiguredProject("/project/tsconfig.json").Program.GetSourceFile("/project/node_modules/pkg/dep.d.ts").Text(), `export const value: "one";`) + assert.NilError(t, fs.WriteFile("/packages/two/dep.d.ts", `export const value: "updated";`)) + changes = FileChangeSummary{} + changes.Changed.Add("file:///packages/two/dep.d.ts") + last := host.update(context.Background(), next, SnapshotChange{ + fileChanges: changes, ResourceRequest: ResourceRequest{Projects: []tspath.Path{"/project/tsconfig.json"}}, + }) + assert.NilError(t, last.apiError) + assert.Equal(t, last.ProjectCollection.ConfiguredProject("/project/tsconfig.json").Program.GetSourceFile("/project/node_modules/pkg/dep.d.ts").Text(), `export const value: "updated";`) + last.Deref() + next.Deref() + snapshot.Deref() + root.Deref() + host.Close() + } +} + +type countedWatchAliasFS struct { + vfs.FS + comparisonFS vfs.FS + comparerQueries atomic.Int64 + realpaths atomic.Int64 +} + +func (f *countedWatchAliasFS) WatchPathComparisonEnabled() bool { + return f.comparisonFS != nil +} + +func (f *countedWatchAliasFS) WatchPathComparer(directory string) (fswatch.PathComparer, error) { + f.comparerQueries.Add(1) + return f.comparisonFS.(interface { + WatchPathComparer(directory string) (fswatch.PathComparer, error) + }).WatchPathComparer(directory) +} + +func (f *countedWatchAliasFS) Realpath(name string) string { + f.realpaths.Add(1) + return f.FS.Realpath(name) +} + +// These benchmarks exercise snapshot publication itself, not a detached Index. +// File contents are virtual; native comparer queries use existing repository +// ancestors, so setup needs neither 50,000 disk files nor a native watcher. +func BenchmarkSnapshotWatchAliases(b *testing.B) { + benchmarkSnapshotWatchAliases(b, false) +} + +func BenchmarkSnapshotWatchAliasRealpaths(b *testing.B) { + benchmarkSnapshotWatchAliases(b, true) +} + +func benchmarkSnapshotWatchAliases(b *testing.B, symlink bool) { + directory, directoryErr := os.Getwd() + if directoryErr != nil { + b.Fatal(directoryErr) + } + directory = filepath.ToSlash(directory) + for _, size := range []int{1000, 10000, 50000} { + for _, spelling := range []string{"ASCII", "Unicode"} { + for _, mode := range []string{"native", "mock", "disabled"} { + b.Run(fmt.Sprintf("%s/%s/%d", mode, spelling, size), func(b *testing.B) { + names := make([]string, size) + files := make(map[string]any, size) + logicalRoot := directory + "/watch-bench" + physicalRoot := logicalRoot + if symlink { + logicalRoot += "/node_modules/pkg" + physicalRoot += "/physical" + files[logicalRoot] = vfstest.Symlink(physicalRoot) + } + for i := range size { + base := "file" + if spelling == "Unicode" { + base = "Café_İ_ſ" + } + suffix := fmt.Sprintf("/group%d/%s%d.ts", i/100, base, i) + names[i] = logicalRoot + suffix + files[physicalRoot+suffix] = "export const value = 1;" + } + fs := &countedWatchAliasFS{FS: vfstest.FromMap(files, true)} + configName := directory + "/watch-bench/tsconfig.json" + config, configErr := json.Marshal(map[string]any{ + "compilerOptions": map[string]any{"noLib": true, "types": []string{}}, + "files": names, + }) + if configErr != nil { + b.Fatal(configErr) + } + if err := fs.WriteFile(configName, string(config)); err != nil { + b.Fatal(err) + } + if mode == "native" { + fs.comparisonFS = osvfs.FS() + if !fswatch.NativePathComparisonAvailable { + b.Skip("native comparison unavailable") + } + } + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{ + CurrentDirectory: directory, WatchEnabled: mode != "disabled", + }}) + defer host.Close() + b.Run("cold", func(b *testing.B) { + snapshot := host.newRootSnapshot(0, false) + defer snapshot.Deref() + snapshot.fs.diskFiles = make(map[tspath.Path]*diskFile, size) + for _, name := range names { + file := newDiskFile(name, "export const value = 1;") + if symlink { + file.realpathName = physicalRoot + strings.TrimPrefix(name, logicalRoot) + snapshot.fs.realpathFiles++ + } + snapshot.fs.diskFiles[host.toPath(name)] = file + } + fs.comparerQueries.Store(0) + fs.realpaths.Store(0) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + snapshot.initializeWatchAliases(nil) + } + b.ReportMetric(float64(fs.comparerQueries.Load())/float64(b.N), "comparer-queries/op") + b.ReportMetric(float64(fs.realpaths.Load())/float64(b.N), "realpath/op") + }) + b.Run("clone-edit", func(b *testing.B) { + setupStart := time.Now() + fs.comparerQueries.Store(0) + fs.realpaths.Store(0) + root := host.NewStandaloneRootSnapshot() + defer root.Deref() + projects := collections.NewSetFromItems(configName) + snapshot, err := host.CloneSnapshot(context.Background(), root, FileChangeSummary{}, &APISnapshotRequest{OpenProjects: projects}) + if err != nil { + b.Fatal(err) + } + uri := lsconv.FileNameToDocumentURI(names[0]) + opened, err := host.CloneSnapshotWithTemporaryFile(context.Background(), snapshot, uri, "export const value = 1;") + snapshot.Deref() + if err != nil { + b.Fatal(err) + } + snapshot = opened + if project := snapshot.ProjectCollection.ConfiguredProject(host.toPath(configName)); project == nil || len(project.Program.GetSourceFiles()) != size { + b.Fatal("benchmark lost its configured project files") + } + setupTime := time.Since(setupStart) + setupComparerQueries, setupRealpaths := fs.comparerQueries.Load(), fs.realpaths.Load() + fs.comparerQueries.Store(0) + fs.realpaths.Store(0) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + next, err := host.CloneSnapshotWithTemporaryFile(context.Background(), snapshot, uri, fmt.Sprintf("export const value = %d;", snapshot.id)) + if err != nil { + b.Fatal(err) + } + snapshot.Deref() + snapshot = next + if len(snapshot.ProjectCollection.ConfiguredProject(host.toPath(configName)).Program.GetSourceFiles()) != size { + b.Fatal("clone lost its configured project files") + } + } + b.StopTimer() + b.ReportMetric(float64(fs.comparerQueries.Load())/float64(b.N), "comparer-queries/op") + b.ReportMetric(float64(fs.realpaths.Load())/float64(b.N), "realpath/op") + b.ReportMetric(float64(setupComparerQueries), "setup-comparer-queries") + b.ReportMetric(float64(setupRealpaths), "setup-realpath") + b.ReportMetric(float64(setupTime.Nanoseconds()), "setup-ns") + snapshot.Deref() + }) + }) + } + } + } +} diff --git a/tsc/internal/project/snapshotfs.go b/tsc/internal/project/snapshotfs.go index 61fc7312977e1..d09a6bd123695 100644 --- a/tsc/internal/project/snapshotfs.go +++ b/tsc/internal/project/snapshotfs.go @@ -30,29 +30,6 @@ var ( _ FileSource = (*SnapshotFS)(nil) ) -// realpathAliasSet is a thread-safe set of symlink paths that alias a single realpath. -// It implements dirty.Cloneable so it can be used as a value in dirty.SyncMap. -type realpathAliasSet struct { - mu sync.Mutex - paths collections.Set[tspath.Path] -} - -func (s *realpathAliasSet) Add(path tspath.Path) { - s.mu.Lock() - defer s.mu.Unlock() - s.paths.Add(path) -} - -func (s *realpathAliasSet) Clone() *realpathAliasSet { - s.mu.Lock() - defer s.mu.Unlock() - clone := &realpathAliasSet{} - if s.paths.Len() > 0 { - clone.paths = *s.paths.Clone() - } - return clone -} - type SnapshotFS struct { toPath func(fileName string) tspath.Path fs vfs.FS @@ -61,10 +38,7 @@ type SnapshotFS struct { diskFiles map[tspath.Path]*diskFile diskDirectories map[tspath.Path]dirty.CloneableMap[tspath.Path, string] readFiles collections.SyncMap[tspath.Path, memoizedDiskFile] - // nodeModulesRealpathAliases maps realpath-based keys to sets of symlink-based keys, - // for files inside node_modules that are accessed through directory symlinks. - // This allows watch events (which use realpaths) to invalidate files cached under symlink paths. - nodeModulesRealpathAliases map[tspath.Path]*realpathAliasSet + realpathFiles int } type memoizedDiskFile func() FileHandle @@ -133,15 +107,16 @@ func (s *SnapshotFS) isFile(path tspath.Path) bool { } type snapshotFSBuilder struct { - fs vfs.FS - prevOverlays map[tspath.Path]*Overlay - overlays map[tspath.Path]*Overlay - overlayDirectories map[tspath.Path]map[tspath.Path]string - diskFiles *dirty.SyncMap[tspath.Path, *diskFile] - diskDirectories *dirty.Map[tspath.Path, dirty.CloneableMap[tspath.Path, string]] - nodeModulesRealpathAliases *dirty.SyncMap[tspath.Path, *realpathAliasSet] - toPath func(string) tspath.Path - accessibleEntries collections.SyncMap[tspath.Path, *vfs.Entries] + fs vfs.FS + prevOverlays map[tspath.Path]*Overlay + overlays map[tspath.Path]*Overlay + overlayDirectories map[tspath.Path]map[tspath.Path]string + diskFiles *dirty.SyncMap[tspath.Path, *diskFile] + previousDiskFiles map[tspath.Path]*diskFile + diskDirectories *dirty.Map[tspath.Path, dirty.CloneableMap[tspath.Path, string]] + realpathFiles int + toPath func(string) tspath.Path + accessibleEntries collections.SyncMap[tspath.Path, *vfs.Entries] } func newSnapshotFSBuilder( @@ -150,7 +125,7 @@ func newSnapshotFSBuilder( overlays map[tspath.Path]*Overlay, diskFiles map[tspath.Path]*diskFile, diskDirectories map[tspath.Path]dirty.CloneableMap[tspath.Path, string], - nodeModulesRealpathAliases map[tspath.Path]*realpathAliasSet, + realpathFiles int, positionEncoding lsproto.PositionEncodingKind, toPath func(fileName string) tspath.Path, ) *snapshotFSBuilder { @@ -181,14 +156,15 @@ func newSnapshotFSBuilder( } return &snapshotFSBuilder{ - fs: cachedFS, - prevOverlays: prevOverlays, - overlays: overlays, - overlayDirectories: overlayDirectories, - diskFiles: dirty.NewSyncMap(diskFiles), - diskDirectories: dirty.NewMap(diskDirectories), - nodeModulesRealpathAliases: dirty.NewSyncMap(nodeModulesRealpathAliases), - toPath: toPath, + fs: cachedFS, + prevOverlays: prevOverlays, + overlays: overlays, + overlayDirectories: overlayDirectories, + diskFiles: dirty.NewSyncMap(diskFiles), + previousDiskFiles: diskFiles, + diskDirectories: dirty.NewMap(diskDirectories), + realpathFiles: realpathFiles, + toPath: toPath, } } @@ -242,6 +218,9 @@ func (s *snapshotFSBuilder) Finalize() (*SnapshotFS, bool) { diskFiles, changed := s.diskFiles.FinalizeWith(dirty.FinalizationHooks[tspath.Path, *diskFile]{ OnDelete: func(key tspath.Path, value *diskFile) { + if previous := s.previousDiskFiles[key]; previous != nil && previous.realpathName != "" { + s.realpathFiles-- + } if deleted == nil { deleted = make(map[tspath.Path]*diskFile) } @@ -249,6 +228,17 @@ func (s *snapshotFSBuilder) Finalize() (*SnapshotFS, bool) { }, OnAdd: func(key tspath.Path, value *diskFile) { onAddedFile(key, value.FileName()) + if value.realpathName != "" { + s.realpathFiles++ + } + }, + OnChange: func(_ tspath.Path, old, next *diskFile) { + if old.realpathName != "" { + s.realpathFiles-- + } + if next.realpathName != "" { + s.realpathFiles++ + } }, }) @@ -256,35 +246,15 @@ func (s *snapshotFSBuilder) Finalize() (*SnapshotFS, bool) { onDeletedFileOrDirectory(path) } - // Prune deleted symlink paths from realpath alias sets before finalizing, - // so that empty sets are dropped during finalization. - for deletedPath, deletedFile := range deleted { - if deletedFile.realpathPath == "" { - continue - } - if entry, ok := s.nodeModulesRealpathAliases.Load(deletedFile.realpathPath); ok { - entry.Locked(func(e dirty.Value[*realpathAliasSet]) { - e.Change(func(aliasSet *realpathAliasSet) { - aliasSet.paths.Delete(deletedPath) - }) - if e.Value().paths.Len() == 0 { - e.Delete() - } - }) - } - } - - nodeModulesRealpathAliases, aliasesChanged := s.nodeModulesRealpathAliases.Finalize() - return &SnapshotFS{ - fs: s.fs, - overlays: s.overlays, - overlayDirectories: s.overlayDirectories, - diskFiles: diskFiles, - diskDirectories: core.FirstResult(s.diskDirectories.Finalize()), - nodeModulesRealpathAliases: nodeModulesRealpathAliases, - toPath: s.toPath, - }, changed || aliasesChanged + fs: s.fs, + overlays: s.overlays, + overlayDirectories: s.overlayDirectories, + diskFiles: diskFiles, + diskDirectories: core.FirstResult(s.diskDirectories.Finalize()), + realpathFiles: s.realpathFiles, + toPath: s.toPath, + }, changed } func (s *snapshotFSBuilder) isOpenFile(path tspath.Path) bool { @@ -355,21 +325,17 @@ func (s *snapshotFSBuilder) getDiskFile(fileName string, path tspath.Path, force return nil } -// recordRealpathAlias checks if fileName is accessed through a symlink and, if so, -// records a mapping from the realpath-based key to the symlink-based key. -// This is only called for files inside node_modules where symlinks are common. -func (s *snapshotFSBuilder) recordRealpathAlias(diskFileEntry *dirty.SyncMapEntry[tspath.Path, *diskFile], symlinkFileName string, symlinkPath tspath.Path) { - realpath := s.fs.Realpath(symlinkFileName) - realpathPath := s.toPath(realpath) - if realpathPath != symlinkPath { - diskFileEntry.Change(func(file *diskFile) { - file.realpathPath = realpathPath - }) - entry, _ := s.nodeModulesRealpathAliases.LoadOrStore(realpathPath, &realpathAliasSet{}) - entry.Change(func(aliasSet *realpathAliasSet) { - aliasSet.Add(symlinkPath) - }) +// Physical observations live with the file. Reverse lookup is derived only +// when publishing the snapshot's watch index. +func (s *snapshotFSBuilder) recordRealpathAlias(entry *dirty.SyncMapEntry[tspath.Path, *diskFile], fileName string, path tspath.Path) bool { + realpath := s.fs.Realpath(fileName) + if s.toPath(realpath) == path { + realpath = "" } + return entry.ChangeIf( + func(file *diskFile) bool { return file.realpathName != realpath }, + func(file *diskFile) { file.realpathName = realpath }, + ) } func (s *snapshotFSBuilder) reloadEntry(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) FileHandle { @@ -441,16 +407,13 @@ func (s *snapshotFSBuilder) watchChangesOverlapCache(change FileChangeSummary) b if _, ok := s.diskFiles.Load(path); ok { return true } - if _, ok := s.nodeModulesRealpathAliases.Load(path); ok { - return true - } } for uri := range change.Deleted.Keys() { path := s.toPath(uri.FileName()) - if _, ok := s.diskFiles.Load(path); ok { + if _, ok := s.diskDirectories.Get(path); ok { return true } - if _, ok := s.nodeModulesRealpathAliases.Load(path); ok { + if _, ok := s.diskFiles.Load(path); ok { return true } } @@ -548,44 +511,6 @@ func (s *snapshotFSBuilder) reloadEntryIfContentChanged(entry *dirty.SyncMapEntr return changed } -// expandRealpathAliases adds synthetic URIs to the Changed and Deleted sets for -// files that were accessed through node_modules symlinks. When a watch event arrives -// using a realpath, this expands it to include the symlink-based path so that -// downstream consumers (markDirtyFiles, markFilesChanged) can find cached entries. -func (s *SnapshotFS) expandRealpathAliases(change FileChangeSummary) FileChangeSummary { - if len(s.nodeModulesRealpathAliases) == 0 { - return change - } - - var additionalChanged collections.Set[lsproto.DocumentUri] - for uri := range change.Changed.Keys() { - path := s.toPath(uri.FileName()) - if aliases, ok := s.nodeModulesRealpathAliases[path]; ok { - for aliasPath := range aliases.paths.Keys() { - additionalChanged.Add(lsconv.FileNameToDocumentURI(string(aliasPath))) - } - } - } - for uri := range additionalChanged.Keys() { - change.Changed.Add(uri) - } - - var additionalDeleted collections.Set[lsproto.DocumentUri] - for uri := range change.Deleted.Keys() { - path := s.toPath(uri.FileName()) - if aliases, ok := s.nodeModulesRealpathAliases[path]; ok { - for aliasPath := range aliases.paths.Keys() { - additionalDeleted.Add(lsconv.FileNameToDocumentURI(string(aliasPath))) - } - } - } - for uri := range additionalDeleted.Keys() { - change.Deleted.Add(uri) - } - - return change -} - // isRelevantFileName returns true if the given URI refers to a file that // could affect the project: it has a TypeScript-relevant or configured content-mapper extension, // is a dynamic (e.g. untitled) file, or is currently open as an overlay. @@ -721,8 +646,8 @@ func (s *snapshotFSBuilder) convertOpenAndCloseToChanges(change FileChangeSummar type sourceFS struct { tracking bool toPath func(fileName string) tspath.Path - missingDirectories *collections.SyncSet[tspath.Path] - seenFiles *collections.SyncSet[tspath.Path] + missingDirectories *collections.SyncMap[tspath.Path, string] + seenFiles *collections.SyncMap[tspath.Path, string] source FileSource } @@ -733,8 +658,8 @@ func newSourceFS(tracking bool, source FileSource, toPath func(fileName string) source: source, } if tracking { - fs.seenFiles = &collections.SyncSet[tspath.Path]{} - fs.missingDirectories = &collections.SyncSet[tspath.Path]{} + fs.seenFiles = &collections.SyncMap[tspath.Path, string]{} + fs.missingDirectories = &collections.SyncMap[tspath.Path, string]{} } return fs } @@ -749,23 +674,24 @@ func (fs *sourceFS) Track(fileName string) { if !fs.tracking { return } - fs.seenFiles.Add(fs.toPath(fileName)) + fs.seenFiles.LoadOrStore(fs.toPath(fileName), fileName) } func (fs *sourceFS) SeenFile(path tspath.Path) bool { if fs.seenFiles == nil { return false } - return fs.seenFiles.Has(path) + _, ok := fs.seenFiles.Load(path) + return ok } func (fs *sourceFS) SeenFileOrMissingParentDirectory(path tspath.Path) bool { - if fs.seenFiles != nil && fs.seenFiles.Has(path) { + if fs.SeenFile(path) { return true } - if fs.missingDirectories != nil && !fs.missingDirectories.IsEmpty() { + if fs.missingDirectories != nil { for { - if fs.missingDirectories.Has(path) { + if _, ok := fs.missingDirectories.Load(path); ok { return true } @@ -793,7 +719,7 @@ func (fs *sourceFS) GetFileByPath(fileName string, path tspath.Path) FileHandle func (fs *sourceFS) DirectoryExists(path string) bool { exists := fs.source.FS().DirectoryExists(path) if !exists && fs.tracking { - fs.missingDirectories.Add(fs.toPath(path)) + fs.missingDirectories.LoadOrStore(fs.toPath(path), path) } return exists } diff --git a/tsc/internal/project/snapshotfs_test.go b/tsc/internal/project/snapshotfs_test.go index 26bae11240f89..088854794e387 100644 --- a/tsc/internal/project/snapshotfs_test.go +++ b/tsc/internal/project/snapshotfs_test.go @@ -33,7 +33,7 @@ func TestSnapshotFSBuilder(t *testing.T) { make(map[tspath.Path]*Overlay), // overlays make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -73,7 +73,7 @@ func TestSnapshotFSBuilder(t *testing.T) { make(map[tspath.Path]*Overlay), // overlays make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -121,7 +121,7 @@ func TestSnapshotFSBuilder(t *testing.T) { make(map[tspath.Path]*Overlay), // overlays existingDiskFiles, existingDirs, - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -174,7 +174,7 @@ func TestSnapshotFSBuilder(t *testing.T) { make(map[tspath.Path]*Overlay), // overlays existingDiskFiles, existingDirs, - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -236,7 +236,7 @@ func TestSnapshotFSBuilder(t *testing.T) { make(map[tspath.Path]*Overlay), // overlays existingDiskFiles, existingDirs, - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -280,7 +280,7 @@ func TestSnapshotFSBuilder(t *testing.T) { make(map[tspath.Path]*Overlay), // overlays existingDiskFiles, existingDirs, - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -313,7 +313,7 @@ func TestSnapshotFSBuilder(t *testing.T) { overlays, make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -358,7 +358,7 @@ func TestSnapshotFSBuilder(t *testing.T) { make(map[tspath.Path]*Overlay), // overlays existingDiskFiles, existingDirs, - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -445,7 +445,7 @@ func TestSnapshotFSBuilder(t *testing.T) { overlays, make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -487,7 +487,7 @@ func TestSnapshotFSBuilder(t *testing.T) { overlays, make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -521,7 +521,7 @@ func TestSnapshotFSBuilder(t *testing.T) { overlays, make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -735,6 +735,43 @@ func TestSnapshotFS(t *testing.T) { }) } +func TestSourceFSTrackedOriginalNames(t *testing.T) { + t.Parallel() + + toPath := func(name string) tspath.Path { + return tspath.ToPath(name, "/src", false) + } + source := newSourceFS(true, &SnapshotFS{fs: vfstest.FromMap(map[string]string{}, false)}, toPath) + for _, name := range []string{"/src/K.ts", "/src/ſ.ts", "/src/s.ts"} { + source.Track(name) + original, ok := source.seenFiles.Load(toPath(name)) + assert.Assert(t, ok) + assert.Equal(t, original, name) + } + source.Track("/src/k.ts") + original, ok := source.seenFiles.Load(toPath("/src/k.ts")) + assert.Assert(t, ok) + assert.Equal(t, original, "/src/K.ts") + assert.Equal(t, source.seenFiles.Size(), 3) + + const missing = "/src/K/Missing" + assert.Assert(t, !source.DirectoryExists(missing)) + original, ok = source.missingDirectories.Load(toPath(missing)) + assert.Assert(t, ok) + assert.Equal(t, original, missing) + assert.Assert(t, !source.SeenFile(toPath(missing))) + assert.Assert(t, source.SeenFileOrMissingParentDirectory(toPath(missing+"/child/file.ts"))) + assert.Assert(t, !source.SeenFileOrMissingParentDirectory(toPath("/src/K/Other/file.ts"))) + + source.DisableTracking() + source.Track("/src/Other.ts") + assert.Assert(t, !source.DirectoryExists("/src/Other")) + assert.Assert(t, !source.SeenFile(toPath("/src/Other.ts"))) + assert.Assert(t, !source.SeenFileOrMissingParentDirectory(toPath("/src/Other/child.ts"))) + assert.Equal(t, source.seenFiles.Size(), 3) + assert.Equal(t, source.missingDirectories.Size(), 1) +} + func TestSourceFS(t *testing.T) { t.Parallel() @@ -908,7 +945,7 @@ func TestAutoImportBuilderFS(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, // nodeModulesRealpathAliases + 0, // realpathFiles lsproto.PositionEncodingKindUTF16, toPath, ) @@ -944,6 +981,20 @@ func TestRealpathAliasLifecycle(t *testing.T) { toPath := func(fileName string) tspath.Path { return tspath.Path(fileName) } + watchSnapshot := func(t *testing.T, fs *SnapshotFS) *Snapshot { + t.Helper() + snapshot := &Snapshot{ + fs: fs, + host: &SnapshotHost{ + fs: fs.fs, + toPath: fs.toPath, + options: &SessionOptions{CurrentDirectory: "/", WatchEnabled: false}, + }, + } + snapshot.initializeWatchAliases(nil) + assert.NilError(t, snapshot.watchAliasesError) + return snapshot + } t.Run("alias recorded when reading symlinked node_modules file", func(t *testing.T) { t.Parallel() @@ -960,7 +1011,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -976,14 +1027,10 @@ func TestRealpathAliasLifecycle(t *testing.T) { snapshot, _ := builder.Finalize() - // Alias exists for the symlinked file. - aliases, ok := snapshot.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok, "alias should exist for realpath of symlinked file") - assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json"))) - - // No alias for the non-symlinked file. - _, ok = snapshot.nodeModulesRealpathAliases[tspath.Path("/project/node_modules/nolink/package.json")] - assert.Assert(t, !ok, "no alias should exist for non-symlinked file") + assert.Equal(t, snapshot.realpathFiles, 1) + assert.Equal(t, snapshot.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + assert.Equal(t, snapshot.diskFiles[toPath("/project/node_modules/nolink/package.json")].realpathName, "") + assert.Assert(t, slices.Contains(watchSnapshot(t, snapshot).watchNames("/packages/mylib/package.json"), "/project/node_modules/mylib/package.json")) }) t.Run("no alias recorded for files outside node_modules", func(t *testing.T) { @@ -999,7 +1046,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1008,7 +1055,9 @@ func TestRealpathAliasLifecycle(t *testing.T) { assert.Assert(t, fh != nil) snapshot, _ := builder.Finalize() - assert.Equal(t, len(snapshot.nodeModulesRealpathAliases), 0, "no aliases for non-node_modules symlinks") + assert.Equal(t, snapshot.realpathFiles, 0, "no aliases for non-node_modules symlinks") + assert.Equal(t, snapshot.diskFiles[toPath("/project/link/index.ts")].realpathName, "") + assert.DeepEqual(t, watchSnapshot(t, snapshot).watchNames("/elsewhere/index.ts"), []string{"/elsewhere/index.ts"}) }) t.Run("aliases carried over across snapshots", func(t *testing.T) { @@ -1025,7 +1074,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1039,16 +1088,16 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), snapshot1.diskFiles, snapshot1.diskDirectories, - snapshot1.nodeModulesRealpathAliases, + snapshot1.realpathFiles, lsproto.PositionEncodingKindUTF16, toPath, ) snapshot2, _ := builder2.Finalize() - // Alias should still be present. - aliases, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok, "alias should survive across snapshots") - assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json"))) + assert.Equal(t, snapshot2.realpathFiles, 1) + assert.Equal(t, snapshot2.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + assert.Assert(t, slices.Contains(watchSnapshot(t, snapshot2).watchNames("/packages/mylib/package.json"), "/project/node_modules/mylib/package.json"), + "alias should survive across snapshots") }) t.Run("alias pruned when symlinked file is deleted", func(t *testing.T) { @@ -1066,7 +1115,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1074,11 +1123,9 @@ func TestRealpathAliasLifecycle(t *testing.T) { builder1.GetFile("/project/node_modules/mylib/index.d.ts") snapshot1, _ := builder1.Finalize() - // Both should be aliased under the same realpath directory but separate files. - _, ok := snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok) - _, ok = snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/index.d.ts")] - assert.Assert(t, ok) + assert.Equal(t, snapshot1.realpathFiles, 2) + assert.Equal(t, snapshot1.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + assert.Equal(t, snapshot1.diskFiles[toPath("/project/node_modules/mylib/index.d.ts")].realpathName, "/packages/mylib/index.d.ts") // Build second snapshot — delete one file via markDirtyFiles. builder2 := newSnapshotFSBuilder( @@ -1087,27 +1134,25 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), snapshot1.diskFiles, snapshot1.diskDirectories, - snapshot1.nodeModulesRealpathAliases, + snapshot1.realpathFiles, lsproto.PositionEncodingKindUTF16, toPath, ) // Simulate deletion of index.d.ts from the disk file cache. - var entry *dirty.SyncMapEntry[tspath.Path, *diskFile] - if entry, ok = builder2.diskFiles.Load(tspath.Path("/project/node_modules/mylib/index.d.ts")); ok { + if entry, ok := builder2.diskFiles.Load(tspath.Path("/project/node_modules/mylib/index.d.ts")); ok { entry.Delete() } snapshot2, _ := builder2.Finalize() - // package.json alias should remain. - aliases, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok, "package.json alias should survive") - assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json"))) - - // index.d.ts alias should be fully pruned (empty set → removed from map). - _, ok = snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/index.d.ts")] - assert.Assert(t, !ok, "index.d.ts alias should be pruned after deletion") + assert.Equal(t, snapshot2.realpathFiles, 1) + assert.Equal(t, snapshot2.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + _, ok := snapshot2.diskFiles[toPath("/project/node_modules/mylib/index.d.ts")] + assert.Assert(t, !ok, "deleted cached file should no longer contribute a realpath observation") + assert.Equal(t, snapshot1.realpathFiles, 2) + assert.Equal(t, snapshot1.diskFiles[toPath("/project/node_modules/mylib/index.d.ts")].realpathName, "/packages/mylib/index.d.ts", + "deletion must not mutate the previous snapshot") }) t.Run("multiple symlinks to same realpath", func(t *testing.T) { @@ -1124,7 +1169,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1137,10 +1182,13 @@ func TestRealpathAliasLifecycle(t *testing.T) { snapshot, _ := builder.Finalize() - aliases, ok := snapshot.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok, "alias should exist") - assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json"))) - assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/alias/package.json"))) + assert.Equal(t, snapshot.realpathFiles, 2) + assert.Equal(t, snapshot.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + assert.Equal(t, snapshot.diskFiles[toPath("/project/node_modules/alias/package.json")].realpathName, "/packages/mylib/package.json") + names := watchSnapshot(t, snapshot).watchNames("/packages/mylib/package.json") + assert.Equal(t, len(names), 3) + assert.Assert(t, slices.Contains(names, "/project/node_modules/mylib/package.json")) + assert.Assert(t, slices.Contains(names, "/project/node_modules/alias/package.json")) }) t.Run("multiple symlinks pruned individually", func(t *testing.T) { @@ -1158,13 +1206,14 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) builder1.GetFile("/project/node_modules/mylib/package.json") builder1.GetFile("/project/node_modules/alias/package.json") snapshot1, _ := builder1.Finalize() + watches1 := watchSnapshot(t, snapshot1) // Build second snapshot – delete ONE of the symlink disk entries. builder2 := newSnapshotFSBuilder( @@ -1173,7 +1222,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), snapshot1.diskFiles, snapshot1.diskDirectories, - snapshot1.nodeModulesRealpathAliases, + snapshot1.realpathFiles, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1182,14 +1231,19 @@ func TestRealpathAliasLifecycle(t *testing.T) { } snapshot2, _ := builder2.Finalize() - // The realpath alias set should still exist, but only contain the surviving symlink. - aliases, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok, "alias set should still exist") - assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")), "surviving symlink should remain") - assert.Assert(t, !aliases.paths.Has(tspath.Path("/project/node_modules/alias/package.json")), "deleted symlink should be pruned") + assert.Equal(t, snapshot2.realpathFiles, 1) + assert.Equal(t, snapshot2.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + _, ok := snapshot2.diskFiles[toPath("/project/node_modules/alias/package.json")] + assert.Assert(t, !ok, "deleted symlink observation should be pruned") + names := watchSnapshot(t, snapshot2).watchNames("/packages/mylib/package.json") + assert.Assert(t, slices.Contains(names, "/project/node_modules/mylib/package.json"), "surviving symlink should remain") + assert.Assert(t, !slices.Contains(names, "/project/node_modules/alias/package.json"), "deleted symlink should be pruned") + assert.Equal(t, snapshot1.realpathFiles, 2) + assert.Assert(t, slices.Contains(watches1.watchNames("/packages/mylib/package.json"), "/project/node_modules/alias/package.json"), + "deletion must not mutate published aliases") }) - t.Run("expandRealpathAliases expands change events", func(t *testing.T) { + t.Run("expandWatchAliases expands change events", func(t *testing.T) { t.Parallel() testFS := vfstest.FromMap(map[string]any{ "/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"), @@ -1202,7 +1256,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1213,14 +1267,14 @@ func TestRealpathAliasLifecycle(t *testing.T) { change := FileChangeSummary{} change.Changed.Add("file:///packages/mylib/package.json") - expanded := snapshot.expandRealpathAliases(change) + expanded := watchSnapshot(t, snapshot).expandWatchAliases(change) // Should now also contain the symlink path. assert.Assert(t, expanded.Changed.Has("file:///packages/mylib/package.json"), "original event should remain") assert.Assert(t, expanded.Changed.Has("file:///project/node_modules/mylib/package.json"), "symlink event should be added") }) - t.Run("expandRealpathAliases expands delete events", func(t *testing.T) { + t.Run("expandWatchAliases expands delete events", func(t *testing.T) { t.Parallel() testFS := vfstest.FromMap(map[string]any{ "/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"), @@ -1233,7 +1287,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1244,22 +1298,23 @@ func TestRealpathAliasLifecycle(t *testing.T) { change := FileChangeSummary{} change.Deleted.Add("file:///packages/mylib/package.json") - expanded := snapshot.expandRealpathAliases(change) + expanded := watchSnapshot(t, snapshot).expandWatchAliases(change) + assert.Assert(t, expanded.Deleted.Has("file:///packages/mylib/package.json"), "original deletion should remain") assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules/mylib/package.json"), "symlink deletion should be added") }) - t.Run("expandRealpathAliases is a no-op with no aliases", func(t *testing.T) { + t.Run("expandWatchAliases is a no-op with no aliases", func(t *testing.T) { t.Parallel() snapshot := &SnapshotFS{ - toPath: toPath, - nodeModulesRealpathAliases: nil, + toPath: toPath, + fs: vfstest.FromMap(map[string]string{}, false), } change := FileChangeSummary{} change.Changed.Add("file:///some/file.ts") - expanded := snapshot.expandRealpathAliases(change) + expanded := watchSnapshot(t, snapshot).expandWatchAliases(change) assert.Equal(t, expanded.Changed.Len(), 1) assert.Assert(t, expanded.Changed.Has("file:///some/file.ts")) }) @@ -1278,7 +1333,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1298,7 +1353,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), snapshot1.diskFiles, snapshot1.diskDirectories, - snapshot1.nodeModulesRealpathAliases, + snapshot1.realpathFiles, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1307,7 +1362,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { change.Changed.Add("file:///packages/mylib/package.json") // Expand the realpath event to include the symlink path. - change = snapshot1.expandRealpathAliases(change) + change = watchSnapshot(t, snapshot1).expandWatchAliases(change) // Now mark dirty — should find the file under the symlink key. builder2.markDirtyFiles(change) @@ -1340,7 +1395,7 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1354,24 +1409,23 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), snapshot1.diskFiles, snapshot1.diskDirectories, - snapshot1.nodeModulesRealpathAliases, + snapshot1.realpathFiles, lsproto.PositionEncodingKindUTF16, toPath, ) builder2.GetFile("/project/node_modules/other/package.json") snapshot2, _ := builder2.Finalize() - // snapshot1 should only have mylib alias. - _, ok := snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok, "snapshot1 should have mylib alias") - _, ok = snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/other/package.json")] - assert.Assert(t, !ok, "snapshot1 should NOT have other alias — it was added in a later snapshot") - - // snapshot2 should have both. - _, ok = snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok, "snapshot2 should have mylib alias") - _, ok = snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/other/package.json")] - assert.Assert(t, ok, "snapshot2 should have other alias") + assert.Equal(t, snapshot1.realpathFiles, 1) + assert.Equal(t, snapshot1.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + _, ok := snapshot1.diskFiles[toPath("/project/node_modules/other/package.json")] + assert.Assert(t, !ok, "snapshot1 should NOT have other observation — it was added in a later snapshot") + assert.DeepEqual(t, watchSnapshot(t, snapshot1).watchNames("/packages/other/package.json"), []string{"/packages/other/package.json"}) + + assert.Equal(t, snapshot2.realpathFiles, 2) + assert.Equal(t, snapshot2.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + assert.Equal(t, snapshot2.diskFiles[toPath("/project/node_modules/other/package.json")].realpathName, "/packages/other/package.json") + assert.Assert(t, slices.Contains(watchSnapshot(t, snapshot2).watchNames("/packages/other/package.json"), "/project/node_modules/other/package.json")) }) t.Run("adding symlink to inherited realpath key does not mutate previous snapshot", func(t *testing.T) { @@ -1389,45 +1443,45 @@ func TestRealpathAliasLifecycle(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) builder1.GetFile("/project/node_modules/mylib/package.json") snapshot1, _ := builder1.Finalize() - // Verify snapshot1 has exactly one alias for the realpath. - aliases1, ok := snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok) - assert.Equal(t, aliases1.paths.Len(), 1) - assert.Assert(t, aliases1.paths.Has(tspath.Path("/project/node_modules/mylib/package.json"))) + assert.Equal(t, snapshot1.realpathFiles, 1) + assert.Equal(t, snapshot1.diskFiles[toPath("/project/node_modules/mylib/package.json")].realpathName, "/packages/mylib/package.json") + watches1 := watchSnapshot(t, snapshot1) + names1 := watches1.watchNames("/packages/mylib/package.json") + assert.Equal(t, len(names1), 2) + assert.Assert(t, slices.Contains(names1, "/project/node_modules/mylib/package.json")) // Snapshot 2: read via the SECOND symlink, which maps to the same realpath. - // This exercises the case where LoadOrStore finds the key in the base map - // and must clone-on-write rather than mutating the shared set. builder2 := newSnapshotFSBuilder( testFS, make(map[tspath.Path]*Overlay), make(map[tspath.Path]*Overlay), snapshot1.diskFiles, snapshot1.diskDirectories, - snapshot1.nodeModulesRealpathAliases, + snapshot1.realpathFiles, lsproto.PositionEncodingKindUTF16, toPath, ) builder2.GetFile("/project/node_modules/alias/package.json") snapshot2, _ := builder2.Finalize() - // Snapshot 2 should have both symlinks. - aliases2, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")] - assert.Assert(t, ok) - assert.Equal(t, aliases2.paths.Len(), 2) - assert.Assert(t, aliases2.paths.Has(tspath.Path("/project/node_modules/mylib/package.json"))) - assert.Assert(t, aliases2.paths.Has(tspath.Path("/project/node_modules/alias/package.json"))) - - // Snapshot 1 must NOT have been mutated — it should still have only one alias. - assert.Equal(t, aliases1.paths.Len(), 1, "snapshot1 alias set must not be mutated by snapshot2") - assert.Assert(t, !aliases1.paths.Has(tspath.Path("/project/node_modules/alias/package.json")), + assert.Equal(t, snapshot2.realpathFiles, 2) + assert.Equal(t, snapshot2.diskFiles[toPath("/project/node_modules/alias/package.json")].realpathName, "/packages/mylib/package.json") + names2 := watchSnapshot(t, snapshot2).watchNames("/packages/mylib/package.json") + assert.Equal(t, len(names2), 3) + assert.Assert(t, slices.Contains(names2, "/project/node_modules/mylib/package.json")) + assert.Assert(t, slices.Contains(names2, "/project/node_modules/alias/package.json")) + + assert.Equal(t, snapshot1.realpathFiles, 1) + assert.Assert(t, slices.Equal(watches1.watchNames("/packages/mylib/package.json"), names1), "snapshot1 aliases must not be mutated by snapshot2") + _, ok := snapshot1.diskFiles[toPath("/project/node_modules/alias/package.json")] + assert.Assert(t, !ok, "snapshot1 must not contain alias added in snapshot2") }) } @@ -1446,7 +1500,7 @@ func TestExpandAndFilterWatchEvents(t *testing.T) { make(map[tspath.Path]*Overlay), make(map[tspath.Path]*diskFile), make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]), - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) @@ -1527,7 +1581,7 @@ func TestExpandAndFilterWatchEvents(t *testing.T) { make(map[tspath.Path]*Overlay), existingDiskFiles, existingDirs, - nil, + 0, lsproto.PositionEncodingKindUTF16, toPath, ) diff --git a/tsc/internal/project/watch.go b/tsc/internal/project/watch.go index f5640f2240dcc..df3ae56880c4a 100644 --- a/tsc/internal/project/watch.go +++ b/tsc/internal/project/watch.go @@ -295,20 +295,20 @@ func (w *WatchedFiles[T]) Clone(input T) *WatchedFiles[T] { } } -func createResolutionLookupGlobMapper(workspaceDirectory string, libDirectory string, currentDirectory string, useCaseSensitiveFileNames bool) func(data *collections.SyncSet[tspath.Path]) PatternsAndIgnored { +func createResolutionLookupGlobMapper(workspaceDirectory string, libDirectory string, currentDirectory string, useCaseSensitiveFileNames bool) func(data *collections.SyncMap[tspath.Path, string]) PatternsAndIgnored { workspaceDirectoryPath := tspath.ToPath(workspaceDirectory, currentDirectory, useCaseSensitiveFileNames) currentDirectoryPath := tspath.ToPath(currentDirectory, currentDirectory, useCaseSensitiveFileNames) libDirectoryPath := tspath.ToPath(libDirectory, currentDirectory, useCaseSensitiveFileNames) - return func(data *collections.SyncSet[tspath.Path]) PatternsAndIgnored { + return func(data *collections.SyncMap[tspath.Path, string]) PatternsAndIgnored { var ignored map[string]struct{} var seenDirs collections.Set[tspath.Path] var includeWorkspace, includeRoot, includeLib bool - var nodeModulesDirectories collections.Set[tspath.Path] - var externalDirectories collections.Set[tspath.Path] + nodeModulesDirectories := make(map[tspath.Path]string) + externalDirectories := make(map[tspath.Path]string) if data != nil { - data.Range(func(path tspath.Path) bool { + data.Range(func(path tspath.Path, fileName string) bool { if tspath.IsDynamicFileName(string(path)) { return true } @@ -326,9 +326,13 @@ func createResolutionLookupGlobMapper(workspaceDirectory string, libDirectory st } else if libDirectoryPath.ContainsPath(path) { includeLib = true } else if idx := strings.Index(string(path), "/node_modules/"); idx != -1 { - nodeModulesDirectories.Add(path[:idx+len("/node_modules")]) + directoryPath := path[:idx+len("/node_modules")] + // Canonicalization can change byte lengths, but not path component counts. + components := tspath.GetNormalizedPathComponents(fileName, currentDirectory) + count := len(tspath.GetPathComponents(string(directoryPath), "")) + nodeModulesDirectories[directoryPath] = tspath.GetPathFromPathComponents(components[:count]) } else { - externalDirectories.Add(path.GetDirectoryPath()) + externalDirectories[path.GetDirectoryPath()] = tspath.GetDirectoryPath(fileName) } return true }) @@ -336,33 +340,29 @@ func createResolutionLookupGlobMapper(workspaceDirectory string, libDirectory st var globs []string if includeWorkspace { - globs = append(globs, getRecursiveGlobPattern(string(workspaceDirectoryPath))) + globs = append(globs, getRecursiveGlobPattern(workspaceDirectory)) } if includeRoot { - globs = append(globs, getRecursiveGlobPattern(string(currentDirectoryPath))) + globs = append(globs, getRecursiveGlobPattern(currentDirectory)) } if includeLib { - globs = append(globs, getRecursiveGlobPattern(string(libDirectoryPath))) + globs = append(globs, getRecursiveGlobPattern(libDirectory)) } - if nodeModulesDirectories.Len() > 0 { - nodeModulesGlobs := make([]string, 0, nodeModulesDirectories.Len()) - for dir := range nodeModulesDirectories.Keys() { - nodeModulesGlobs = append(nodeModulesGlobs, getRecursiveGlobPattern(string(dir))) + if len(nodeModulesDirectories) > 0 { + nodeModulesGlobs := make([]string, 0, len(nodeModulesDirectories)) + for _, dir := range nodeModulesDirectories { + nodeModulesGlobs = append(nodeModulesGlobs, getRecursiveGlobPattern(dir)) } slices.Sort(nodeModulesGlobs) globs = append(globs, nodeModulesGlobs...) } var outsideDirs []string - if externalDirectories.Len() > 0 { - externalDirStrings := make([]string, 0, externalDirectories.Len()) - for dir := range externalDirectories.Keys() { - externalDirStrings = append(externalDirStrings, string(dir)) - } + if len(externalDirectories) > 0 { externalDirectoryParents, ignoredExternalDirs := tspath.GetCommonParents( - externalDirStrings, + slices.Collect(maps.Values(externalDirectories)), minWatchLocationDepth, getPathComponentsForWatching, - tspath.ComparePathsOptions{UseCaseSensitiveFileNames: true}, // Already using tspath.Path + tspath.ComparePathsOptions{CurrentDirectory: currentDirectory, UseCaseSensitiveFileNames: useCaseSensitiveFileNames}, ) slices.Sort(externalDirectoryParents) ignored = ignoredExternalDirs diff --git a/tsc/internal/project/watch_test.go b/tsc/internal/project/watch_test.go index 28dd915c6232e..7598720f46985 100644 --- a/tsc/internal/project/watch_test.go +++ b/tsc/internal/project/watch_test.go @@ -1,11 +1,66 @@ package project import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync/atomic" + "syscall" "testing" + "time" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project/dirty" + "github.com/microsoft/TypeScript/tsc/internal/project/logging" + "github.com/microsoft/TypeScript/tsc/internal/testutil/contentmappertest" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +func TestResolutionLookupGlobsPreserveOriginalNames(t *testing.T) { + t.Parallel() + + const workspace, current, lib = "/Workspace/K", "/Current/K", "/Library/K" + for _, tc := range []struct { + name, file, glob, outside string + }{ + {name: "workspace", file: workspace + "/main.ts", glob: workspace + "/**/*"}, + {name: "current", file: current + "/main.ts", glob: current + "/**/*"}, + {name: "library", file: lib + "/lib.d.ts", glob: lib + "/**/*"}, + {name: "node modules", file: "/External/K/NODE_MODULES/Pkg/main.ts", glob: "/External/K/NODE_MODULES/**/*"}, + {name: "external", file: "/External/K/Project/main.ts", outside: "/External/K/Project"}, + {name: "NFD", file: "/External/e\u0301/Project/main.ts", outside: "/External/e\u0301/Project"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + source := newSourceFS(true, nil, func(name string) tspath.Path { + return tspath.ToPath(name, current, false) + }) + source.Track(tc.file) + result := createResolutionLookupGlobMapper(workspace, lib, current, false)(source.seenFiles) + if tc.glob != "" { + assert.DeepEqual(t, result.patternsInsideWorkspace, []string{tc.glob}) + assert.Equal(t, len(result.directoriesOutsideWorkspace), 0) + } else { + assert.DeepEqual(t, result.directoriesOutsideWorkspace, []string{tc.outside}) + assert.Equal(t, len(result.patternsInsideWorkspace), 0) + } + }) + } +} + func TestGetPathComponentsForWatching(t *testing.T) { t.Parallel() @@ -26,3 +81,726 @@ func TestNilWatchedFilesClone(t *testing.T) { result := w.Clone(42) assert.Assert(t, result == nil, "clone on a nil `WatchedFiles` should return nil") } + +func TestWatchAliasesMockFilesystem(t *testing.T) { + t.Parallel() + for _, sensitive := range []bool{false, true} { + fs := vfstest.FromMap(map[string]string{"/src/ſ.ts": "original"}, sensitive) + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{CurrentDirectory: "/src", WatchEnabled: true}}) + snapshot := host.newSnapshot(1, &SnapshotFS{ + diskFiles: map[tspath.Path]*diskFile{host.toPath("/src/ſ.ts"): newDiskFile("/src/ſ.ts", "original")}, + }, &ConfigFileRegistry{}, nil, host.newRootSnapshot(0, false).userPreferences, nil, nil) + snapshot.initializeWatchAliases(nil) + if got := snapshot.watchNames("/src/s.ts"); !slices.Equal(got, []string{"/src/s.ts"}) { + t.Fatalf("mock filesystem acquired host Unicode semantics: %q", got) + } + source := newSourceFS(true, snapshot.fs, host.toPath) + source.Track("/src/İ.ts") + if name, ok := source.seenFiles.Load(host.toPath("/src/İ.ts")); !ok || name != "/src/İ.ts" { + t.Fatal("watch index input lost original spelling") + } + } +} + +type failingWatchComparerFS struct { + vfs.FS + err error + calls int +} + +func (fs *failingWatchComparerFS) WatchPathComparer(string) (fswatch.PathComparer, error) { + fs.calls++ + return fswatch.PathComparer{}, fs.err +} + +type watchAliasClient struct { + noopClient + refreshes atomic.Int32 +} + +func (c *watchAliasClient) RefreshDiagnostics(context.Context) error { + c.refreshes.Add(1) + return nil +} + +func TestWatchAliasComparerErrorsSurface(t *testing.T) { + t.Parallel() + for _, want := range []error{syscall.EACCES, syscall.EIO} { + t.Run(want.Error(), func(t *testing.T) { + t.Parallel() + const mainText = `import { value } from "./value";` + disk := vfstest.FromMap(map[string]string{ + "/src/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`, + "/src/main.ts": mainText, + "/src/value.ts": "export const value = 1;", + }, true) + fs := &failingWatchComparerFS{FS: disk, err: want} + var logs bytes.Buffer + client := &watchAliasClient{} + session := NewSession(&SessionInit{ + BackgroundCtx: context.Background(), + Options: &SessionOptions{ + CurrentDirectory: "/src", + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: true, + }, + FS: fs, Logger: logging.NewLogger(&logs), Client: client, + }) + var recovered any + func() { + defer func() { recovered = recover() }() + session.DidOpenFile(context.Background(), "file:///src/main.ts", 1, mainText, lsproto.LanguageKindTypeScript) + }() + if recovered != nil { + t.Fatalf("filesystem comparison lookup failure panicked while adopting a session snapshot: %v", recovered) + } + defer session.Close() + session.WaitForBackgroundTasks() + if !session.snapshotMu.TryLock() { + t.Fatal("filesystem comparison lookup failure left the snapshot mutex locked") + } + session.snapshotMu.Unlock() + snapshot := session.Snapshot() + if !errors.Is(snapshot.watchAliasesError, want) || snapshot.watchAliases != nil { + t.Fatalf("failed generation did not retain the filesystem comparison lookup error: %v", snapshot.watchAliasesError) + } + if !strings.Contains(logs.String(), want.Error()) || !strings.Contains(logs.String(), "invalidat") { + t.Fatalf("missing degraded-watch warning with logging disabled: %s", logs.String()) + } + for _, kind := range []lsproto.FileChangeType{lsproto.FileChangeTypeCreated, lsproto.FileChangeTypeChanged, lsproto.FileChangeTypeDeleted} { + var changes FileChangeSummary + switch kind { + case lsproto.FileChangeTypeCreated: + changes.Created.Add("file:///unrelated/alias.data") + case lsproto.FileChangeTypeChanged: + changes.Changed.Add("file:///unrelated/alias.data") + case lsproto.FileChangeTypeDeleted: + changes.Deleted.Add("file:///unrelated/alias.data") + } + if !snapshot.expandWatchAliases(changes).InvalidateAll { + t.Fatal("failed index did not conservatively invalidate watch events") + } + } + if snapshot.expandWatchAliases(FileChangeSummary{}).InvalidateAll { + t.Fatal("failed index invalidated a generation without watch events") + } + calls := fs.calls + if err := disk.WriteFile("/src/value.ts", `export const value = "changed";`); err != nil { + t.Fatal(err) + } + fs.err = nil + refreshes := client.refreshes.Load() + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{ + Uri: "file:///unrelated/alias.data", Type: lsproto.FileChangeTypeChanged, + }}) + session.WaitForBackgroundTasks() + next := session.Snapshot() + if next == snapshot || client.refreshes.Load() <= refreshes { + t.Fatal("failed index did not schedule snapshot and diagnostic refreshes for an unrecognized alias") + } + if fs.calls <= calls { + t.Fatal("next generation did not retry the comparer query") + } + var changes FileChangeSummary + changes.Changed.Add("file:///unrelated/alias.data") + if next.expandWatchAliases(changes).InvalidateAll { + t.Fatal("successful generation retained degraded invalidation") + } + service, err := session.GetLanguageService(context.Background(), "file:///src/main.ts") + if err != nil { + t.Fatal(err) + } + source := service.GetProgram().GetSourceFile("/src/value.ts") + if source == nil || source.Text() != `export const value = "changed";` { + t.Fatal("degraded watch refresh did not discard stale source contents") + } + }) + } +} + +func TestWatchAliasesDisabledDoesNotProbe(t *testing.T) { + t.Parallel() + fs := &failingWatchComparerFS{FS: vfstest.FromMap(map[string]string{}, true), err: errors.New("unexpected comparer query")} + host := NewSnapshotHost(&SessionInit{FS: fs, Options: &SessionOptions{CurrentDirectory: "/src"}}) + snapshot := host.newRootSnapshot(0, false) + defer snapshot.Deref() + snapshot.fs.diskFiles = map[tspath.Path]*diskFile{"/src/main.ts": newDiskFile("/src/main.ts", "")} + snapshot.initializeWatchAliases(nil) + if fs.calls != 0 { + t.Fatal("watch-disabled snapshot queried native watch comparer") + } +} + +func TestWatchAliasesStandaloneErrors(t *testing.T) { + t.Parallel() + for _, watchEnabled := range []bool{false, true} { + for _, program := range []bool{false, true} { + fs := &failingWatchComparerFS{ + FS: vfstest.FromMap(map[string]string{ + "/src/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`, + "/src/main.ts": "export const value = 1;", + }, true), + err: syscall.EACCES, + } + + host := NewSnapshotHost(&SessionInit{ + FS: fs, Options: &SessionOptions{CurrentDirectory: "/src", WatchEnabled: watchEnabled}, + }) + root := host.NewStandaloneRootSnapshot() + var snapshot *Snapshot + var err error + if program { + snapshot = host.CloneSnapshotForProgram( + context.Background(), root, []string{"/src/main.ts"}, + &core.CompilerOptions{NoLib: core.TSTrue}, nil, nil, nil, FileChangeSummary{}, + ) + err = snapshot.apiError + } else { + var projects collections.Set[string] + projects.Add("/src/tsconfig.json") + snapshot, err = host.CloneSnapshot(context.Background(), root, FileChangeSummary{}, &APISnapshotRequest{OpenProjects: &projects}) + } + if watchEnabled { + if !errors.Is(err, syscall.EACCES) { + t.Fatalf("standalone clone (program=%v) lost filesystem comparison lookup error: %v", program, err) + } + } else if err != nil || fs.calls != 0 { + t.Fatalf("watch-disabled clone (program=%v) queried comparer: calls=%d, err=%v", program, fs.calls, err) + } + snapshot.Deref() + if watchEnabled && !program { + overlaySnapshot, err := host.CloneSnapshotWithTemporaryFile(context.Background(), root, "file:///src/main.ts", "export const value = 2;") + overlaySnapshot.Deref() + if !errors.Is(err, syscall.EACCES) { + t.Fatalf("temporary-file clone lost filesystem comparison lookup error: %v", err) + } + } + root.Deref() + host.Close() + } + } +} + +func TestWatchAliasesRegularFileAncestor(t *testing.T) { + t.Parallel() + directory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + host := NewSnapshotHost(&SessionInit{ + FS: osvfs.FS(), Options: &SessionOptions{CurrentDirectory: filepath.ToSlash(directory), WatchEnabled: true}, + }) + defer host.Close() + root := host.NewStandaloneRootSnapshot() + defer root.Deref() + root.compilerOptionsForInferredProjects = &core.CompilerOptions{NoLib: core.TSTrue} + // Keep the source in an overlay; watchalias.go is an existing regular file, + // so this unresolved import makes native comparer queries encounter ENOTDIR. + snapshot, err := host.CloneSnapshotWithTemporaryFile(context.Background(), root, + lsconv.FileNameToDocumentURI(filepath.ToSlash(filepath.Join(directory, "watch-alias-malformed-import.ts"))), + `import "./watchalias.go/missing";`, + ) + if snapshot != nil { + defer snapshot.Deref() + } + if err != nil { + t.Fatal(err) + } + if (snapshot.watchAliases != nil) != fswatch.NativePathComparisonAvailable || snapshot.watchAliasesError != nil { + t.Fatalf("regular-file ancestor disabled native aliases: %v", snapshot.watchAliasesError) + } +} + +func newComparerErrorConfigSession(t *testing.T, files map[string]any, main string) (*Session, *failingWatchComparerFS) { + t.Helper() + fs := &failingWatchComparerFS{FS: vfstest.FromMap(files, true), err: syscall.EIO} + session := NewSession(&SessionInit{ + BackgroundCtx: context.Background(), + Options: &SessionOptions{ + CurrentDirectory: "/src", + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: true, + RunExternalCode: true, + }, + FS: fs, Client: &noopClient{}, Spawner: contentmappertest.NewSpawner(), + }) + t.Cleanup(session.Close) + session.DidOpenFile(context.Background(), lsconv.FileNameToDocumentURI(main), 1, files[main].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + assert.Assert(t, errors.Is(session.Snapshot().watchAliasesError, syscall.EIO)) + return session, fs +} + +func recoverComparerWithConfigEvent(t *testing.T, session *Session, fs *failingWatchComparerFS, name string, kind lsproto.FileChangeType) { + t.Helper() + assert.DeepEqual(t, session.Snapshot().watchNames(name), []string{name}) + fs.err = nil + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{ + Uri: lsconv.FileNameToDocumentURI(name), Type: kind, + }}) + session.WaitForBackgroundTasks() + assert.NilError(t, session.Snapshot().watchAliasesError) +} + +func TestWatchAliasComparerErrorExtendedConfig(t *testing.T) { + t.Parallel() + const main = "/src/main.ts" + const base = "/src/e\u0301/base.json" + session, fs := newComparerErrorConfigSession(t, map[string]any{ + main: "export const value = 1;", + "/src/tsconfig.json": `{"extends":"./middle.json","files":["main.ts"]}`, + "/src/middle.json": `{"extends":"./e\u0301/base.json"}`, + base: `{"compilerOptions":{"noLib":true,"types":[],"strict":false}}`, + "/src/other/main.ts": "export const value = 2;", + "/src/other/tsconfig.json": `{"extends":"../middle.json","files":["main.ts"]}`, + }, main) + otherURI := lsconv.FileNameToDocumentURI("/src/other/main.ts") + session.DidOpenFile(context.Background(), otherURI, 1, "export const value = 2;", lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + old := session.Snapshot() + for _, name := range []string{main, otherURI.FileName()} { + assert.Equal(t, old.GetDefaultProject(lsconv.FileNameToDocumentURI(name)).Program.Options().Strict, core.TSFalse) + } + assert.NilError(t, fs.WriteFile(base, `{"compilerOptions":{"noLib":true,"types":[],"strict":true}}`)) + recoverComparerWithConfigEvent(t, session, fs, "/src/\u00e9/base.json", lsproto.FileChangeTypeChanged) + for _, name := range []string{main, otherURI.FileName()} { + service, err := session.GetLanguageService(context.Background(), lsconv.FileNameToDocumentURI(name)) + assert.NilError(t, err) + assert.Equal(t, service.GetProgram().Options().Strict, core.TSTrue) + assert.Equal(t, old.GetDefaultProject(lsconv.FileNameToDocumentURI(name)).Program.Options().Strict, core.TSFalse) + } +} + +func TestWatchAliasComparerErrorConfigDiscovery(t *testing.T) { + t.Parallel() + for _, deletion := range []bool{false, true} { + name := "creation" + if deletion { + name = "deletion" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + const main = "/src/e\u0301/main.ts" + const config = "/src/e\u0301/tsconfig.json" + files := map[string]any{main: "export const value = 1;"} + configText := `{"compilerOptions":{"noLib":true,"types":[],"strict":true},"files":["main.ts"]}` + before, after := KindInferred, KindConfigured + kind := lsproto.FileChangeTypeCreated + if deletion { + files[config] = configText + before, after = after, before + kind = lsproto.FileChangeTypeDeleted + } + session, fs := newComparerErrorConfigSession(t, files, main) + uri := lsconv.FileNameToDocumentURI(main) + assert.Equal(t, session.Snapshot().GetDefaultProject(uri).Kind, before) + if deletion { + assert.NilError(t, fs.Remove(config)) + } else { + assert.NilError(t, fs.WriteFile(config, configText)) + } + // A directory event need not identify the config added or removed beneath it. + recoverComparerWithConfigEvent(t, session, fs, "/src/\u00e9", kind) + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + assert.Equal(t, session.Snapshot().GetDefaultProject(uri).Kind, after) + if !deletion { + assert.Equal(t, session.Snapshot().GetDefaultProject(uri).Program.Options().Strict, core.TSTrue) + } + }) + } +} + +func TestWatchAliasComparerErrorContentMapperManifest(t *testing.T) { + t.Parallel() + const main = "/src/main.ts" + const manifest = "/src/e\u0301/package.json" + session, fs := newComparerErrorConfigSession(t, map[string]any{ + main: "import { version } from './app.box';", + "/src/tsconfig.json": `{ + "compilerOptions":{"noLib":true,"types":[],"target":"es2020","module":"preserve"}, + "contentMappers":[{"package":"mapper","extensions":[".box"]}], + "files":["main.ts"] + }`, + "/src/node_modules/mapper": vfstest.Symlink("/src/e\u0301"), + manifest: `{"name":"mapper","typescript":{"contentMapper":{"exec":["compiler-test-mapper"]}}}`, + "/src/app.box": "export const version = #{target};", + }, main) + oldMappers := session.Snapshot().ConfigFileRegistry.contentMappers() + assert.Assert(t, slices.Contains(oldMappers.extensions, ".box")) + oldCommandLine := session.Snapshot().GetDefaultProject(lsconv.FileNameToDocumentURI(main)).CommandLine + assert.Equal(t, oldCommandLine.ContentMappers()[0].Version, "") + assert.NilError(t, fs.WriteFile(manifest, contentmappertest.PackageJSON(contentmappertest.TransformingMapper))) + recoverComparerWithConfigEvent(t, session, fs, "/src/\u00e9/package.json", lsproto.FileChangeTypeChanged) + service, err := session.GetLanguageService(context.Background(), lsconv.FileNameToDocumentURI(main)) + assert.NilError(t, err) + mappers := session.Snapshot().GetDefaultProject(lsconv.FileNameToDocumentURI(main)).CommandLine.ContentMappers() + assert.Equal(t, mappers[0].Version, "1.0.0") + assert.DeepEqual(t, mappers[0].CompilerOptions, []string{"target", "jsx"}) + source := service.GetProgram().GetSourceFile("/src/app.box") + assert.Assert(t, source != nil) + assert.Assert(t, strings.Contains(source.Text(), "export const version = 7;"), "mapper manifest was not reloaded: %q", source.Text()) + assert.Assert(t, session.Snapshot().ConfigFileRegistry.contentMappers() != oldMappers) +} + +func TestConfigInvalidateAllPreservesClosedFiles(t *testing.T) { + t.Parallel() + const main = "/src/main.ts" + const config tspath.Path = "/src/tsconfig.json" + session, _ := newComparerErrorConfigSession(t, map[string]any{ + main: "export const value = 1;", + string(config): `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`, + }, main) + old := session.Snapshot() + newBuilder := func() *configFileRegistryBuilder { + return &configFileRegistryBuilder{ + fs: newSourceFS(false, old.fs, old.host.toPath), + configs: dirty.NewSyncMap(old.ConfigFileRegistry.configs), + configFileNames: dirty.NewMap(old.ConfigFileRegistry.configFileNames), + allConfiguredContentMappers: old.ConfigFileRegistry.contentMappers(), + } + } + builder := newBuilder() + change := FileChangeSummary{InvalidateAll: true} + change.Closed.Add(lsconv.FileNameToDocumentURI(main)) + result := builder.DidChangeFiles(change, nil) + entry, ok := builder.configs.Load(config) + assert.Assert(t, ok) + assert.Equal(t, entry.Value().pendingReload, PendingReloadFull) + assert.Equal(t, len(entry.Value().retainingOpenFiles), 0) + assert.Assert(t, builder.allConfiguredContentMappers == nil) + _, retained := builder.configFileNames.Get(old.host.toPath(main)) + assert.Assert(t, !retained) + _, affected := result.affectedFiles[old.host.toPath(main)] + assert.Assert(t, !affected, "a closed file must not be rediscovered during invalidation") + assert.Equal(t, len(old.ConfigFileRegistry.configs[config].retainingOpenFiles), 1) + assert.Equal(t, old.ConfigFileRegistry.configs[config].pendingReload, PendingReloadNone) + + // The ordinary high-volume path still avoids reparsing unchanged config text. + builder = newBuilder() + builder.invalidateCache(nil, false /*forceFullReload*/) + entry, ok = builder.configs.Load(config) + assert.Assert(t, ok) + assert.Equal(t, entry.Value().pendingReload, PendingReloadFileNames) + assert.Assert(t, builder.allConfiguredContentMappers != nil) +} + +const ( + watchLifecycleLogical = "/project/node_modules/pkg/lib" + watchLifecyclePhysical = "/packages/e\u0301" + watchLifecycleMain = `import { value } from "pkg/lib"; export { value };` + watchLifecycleInitial = `export const value: "initial";` +) + +func watchLifecycleFS(useCaseSensitiveFileNames bool) vfs.FS { + return vfstest.FromMap(map[string]any{ + "/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true,"module":"node16","moduleResolution":"node16"},"files":["main.ts"]}`, + "/project/main.ts": watchLifecycleMain, + watchLifecycleLogical: vfstest.Symlink(watchLifecyclePhysical), + watchLifecyclePhysical + "/index.d.ts": watchLifecycleInitial, + }, useCaseSensitiveFileNames) +} + +func checkWatchDirectoryRecreation(t *testing.T, fs vfs.FS, deleted, created string, editor []FileChangeKind, api bool) { + t.Helper() + ctx := context.Background() + session := NewSession(&SessionInit{ + BackgroundCtx: ctx, FS: fs, Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true}, + }) + defer session.Close() + const uri = "file:///project/main.ts" + session.DidOpenFile(ctx, uri, 1, watchLifecycleMain, lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + old := session.Snapshot() + old.ref() + defer old.Deref() + assert.Assert(t, old.GetDefaultProject(uri).Program.GetSourceFile(watchLifecycleLogical+"/index.d.ts") != nil) + + func() { + session.snapshotUpdateMu.Lock() + defer session.snapshotUpdateMu.Unlock() + events := []*lsproto.FileEvent{ + {Uri: lsconv.FileNameToDocumentURI(deleted), Type: lsproto.FileChangeTypeDeleted}, + {Uri: lsconv.FileNameToDocumentURI(created), Type: lsproto.FileChangeTypeCreated}, + } + if len(editor) == 0 { + session.DidChangeWatchedFiles(ctx, events) + return + } + session.DidChangeWatchedFiles(ctx, events[:1]) + for _, kind := range editor { + if kind == FileChangeKindSave { + saveURI := lsproto.DocumentUri(uri) + if !fs.UseCaseSensitiveFileNames() { + saveURI = lsconv.FileNameToDocumentURI(strings.ToUpper("/project/main.ts")) + } + session.DidSaveFile(ctx, saveURI) + } else { + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: watchLifecycleMain + "\n"}, + }}) + } + } + session.DidChangeWatchedFiles(ctx, events[1:]) + }() + + if api { + var changes FileChangeSummary + changes.Changed.Add(lsconv.FileNameToDocumentURI(watchLifecyclePhysical + "/index.d.ts")) + next, err := session.APIUpdate(ctx, changes, nil) + assert.NilError(t, err) + defer next.Deref() + } + service, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + source := service.GetProgram().GetSourceFile(watchLifecycleLogical + "/index.d.ts") + assert.Assert(t, source != nil, "directory recreation must not tombstone an unchanged declaration") + assert.Equal(t, source.Text(), watchLifecycleInitial) + assert.Equal(t, old.GetDefaultProject(uri).Program.GetSourceFile(watchLifecycleLogical+"/index.d.ts").Text(), watchLifecycleInitial) + if len(editor) != 0 { + overlay := session.Snapshot().fs.overlays[session.toPath("/project/main.ts")] + assert.Equal(t, overlay.Content(), watchLifecycleMain+"\n") + assert.Equal(t, overlay.Version(), int32(2)) + assert.Equal(t, overlay.MatchesDiskText(), editor[len(editor)-1] == FileChangeKindSave) + } + + assert.NilError(t, fs.WriteFile(watchLifecyclePhysical+"/index.d.ts", `export const value: "updated";`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsconv.FileNameToDocumentURI(watchLifecyclePhysical + "/index.d.ts"), Type: lsproto.FileChangeTypeChanged, + }}) + service, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Equal(t, service.GetProgram().GetSourceFile(watchLifecycleLogical+"/index.d.ts").Text(), `export const value: "updated";`) +} + +func TestWatchDirectoryRecreationCoalescesBeforeDeletion(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, deleted, created string + editor []FileChangeKind + caseInsensitive bool + api bool + }{ + {name: "physical", deleted: watchLifecyclePhysical, created: watchLifecyclePhysical}, + {name: "logical", deleted: watchLifecycleLogical, created: watchLifecycleLogical}, + {name: "physical-to-logical", deleted: watchLifecyclePhysical, created: watchLifecycleLogical}, + {name: "logical-to-physical", deleted: watchLifecycleLogical, created: watchLifecyclePhysical}, + {name: "edit-save", deleted: watchLifecyclePhysical, created: watchLifecycleLogical, editor: []FileChangeKind{FileChangeKindChange, FileChangeKindSave}}, + {name: "save-edit", deleted: watchLifecycleLogical, created: watchLifecyclePhysical, editor: []FileChangeKind{FileChangeKindSave, FileChangeKindChange}}, + {name: "case-insensitive-spellings", deleted: strings.ToUpper(watchLifecyclePhysical), created: watchLifecyclePhysical, caseInsensitive: true}, + {name: "case-insensitive-edit-save", deleted: strings.ToUpper(watchLifecyclePhysical), created: watchLifecyclePhysical, caseInsensitive: true, editor: []FileChangeKind{FileChangeKindChange, FileChangeKindSave}}, + {name: "case-insensitive-save-edit", deleted: strings.ToUpper(watchLifecyclePhysical), created: watchLifecyclePhysical, caseInsensitive: true, editor: []FileChangeKind{FileChangeKindSave, FileChangeKindChange}}, + {name: "api-merge", deleted: watchLifecyclePhysical, created: watchLifecycleLogical, api: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + checkWatchDirectoryRecreation(t, watchLifecycleFS(!test.caseInsensitive), test.deleted, test.created, test.editor, test.api) + }) + } +} + +func TestWatchDirectoryFinalDeletion(t *testing.T) { + t.Parallel() + ctx := context.Background() + fs := watchLifecycleFS(true) + session := NewSession(&SessionInit{ + BackgroundCtx: ctx, FS: fs, Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true}, + }) + defer session.Close() + const uri = "file:///project/main.ts" + session.DidOpenFile(ctx, uri, 1, watchLifecycleMain, lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + assert.NilError(t, fs.Remove(watchLifecyclePhysical+"/index.d.ts")) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{ + {Uri: lsconv.FileNameToDocumentURI(watchLifecyclePhysical), Type: lsproto.FileChangeTypeDeleted}, + {Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeCreated}, + {Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeDeleted}, + }) + service, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, service.GetProgram().GetSourceFile(watchLifecycleLogical+"/index.d.ts") == nil) +} + +func TestWatchDirectoryCanceledEventsRefreshRealpath(t *testing.T) { + t.Parallel() + ctx := context.Background() + fs := &countedWatchAliasFS{FS: watchLifecycleFS(true)} + session := NewSession(&SessionInit{ + BackgroundCtx: ctx, FS: fs, Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true}, + }) + defer session.Close() + const uri = "file:///project/main.ts" + session.DidOpenFile(ctx, uri, 1, watchLifecycleMain, lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + const target = "/packages/other/index.d.ts" + fs.FS = vfstest.FromMap(map[string]any{ + "/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true,"module":"node16","moduleResolution":"node16"},"files":["main.ts"]}`, + "/project/main.ts": watchLifecycleMain, + watchLifecycleLogical: vfstest.Symlink("/packages/other"), + target: watchLifecycleInitial, + }, true) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{ + {Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeCreated}, + {Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeDeleted}, + }) + next, err := session.APIUpdate(ctx, FileChangeSummary{}, nil) + assert.NilError(t, err) + defer next.Deref() + file := next.fs.diskFiles[session.toPath(watchLifecycleLogical+"/index.d.ts")] + assert.Assert(t, file != nil) + assert.Equal(t, file.realpathName, target, "canceled notifications must still refresh physical observations") + assert.NilError(t, fs.WriteFile(target, `export const value: "retargeted";`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsconv.FileNameToDocumentURI(target), Type: lsproto.FileChangeTypeChanged, + }}) + service, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Equal(t, service.GetProgram().GetSourceFile(watchLifecycleLogical+"/index.d.ts").Text(), `export const value: "retargeted";`) +} + +func TestWatchNotificationsUsePublishedGeneration(t *testing.T) { + t.Parallel() + const initial = `import {value} from "one"; export {value};` + fs := vfstest.FromMap(map[string]any{ + "/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true},"files":["main.ts"]}`, + "/project/main.ts": initial, + "/project/node_modules/one": vfstest.Symlink("/packages/pkg"), + "/project/node_modules/two": vfstest.Symlink("/packages/pkg"), + "/packages/pkg/index.d.ts": `export const value: "initial";`, + }, true) + session := NewSession(&SessionInit{ + BackgroundCtx: context.Background(), FS: fs, Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true}, + }) + defer session.Close() + ctx := context.Background() + session.DidOpenFile(ctx, "file:///project/main.ts", 1, initial, lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + + // A notification can arrive after a clone's input was flushed but before + // that clone is published. It must not capture the receipt-time aliases. + session.snapshotUpdateMu.Lock() + defer session.snapshotUpdateMu.Unlock() + old := session.Snapshot() + old.ref() + defer old.Deref() + assert.NilError(t, fs.WriteFile("/packages/pkg/index.d.ts", `export const value: "updated";`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: "file:///packages/pkg/index.d.ts", Type: lsproto.FileChangeTypeChanged, + }}) + session.pendingFileChangesMu.Lock() + pending := append([]FileChange(nil), session.pendingFileChanges...) + session.pendingFileChangesMu.Unlock() + assert.Equal(t, len(pending), 1, "only the raw notification belongs in the pending queue") + assert.Equal(t, pending[0].URI, lsproto.DocumentUri("file:///packages/pkg/index.d.ts")) + + edits, overlays := session.fs.processChanges([]FileChange{{ + Kind: FileChangeKindChange, URI: "file:///project/main.ts", Version: 2, + Changes: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `import {value} from "two"; export {value};`}, + }}, + }}) + session.UpdateSnapshot(ctx, overlays, SnapshotChange{ + fileChanges: edits, ResourceRequest: ResourceRequest{Documents: []lsproto.DocumentUri{"file:///project/main.ts"}}, + }) + assert.Assert(t, session.Snapshot() != old) + + session.pendingFileChangesMu.Lock() + changes, overlays := session.flushChangesLocked(ctx) + session.pendingFileChangesMu.Unlock() + assert.Assert(t, changes.Changed.Has("file:///project/node_modules/two/index.d.ts"), "preparation must use the newly published registration") + session.UpdateSnapshot(ctx, overlays, SnapshotChange{ + fileChanges: changes, ResourceRequest: ResourceRequest{Documents: []lsproto.DocumentUri{"file:///project/main.ts"}}, + }) + source := session.Snapshot().GetDefaultProject("file:///project/main.ts").Program.GetSourceFile("/project/node_modules/two/index.d.ts") + assert.Assert(t, source != nil) + assert.Equal(t, source.Text(), `export const value: "updated";`) + assert.Equal(t, old.GetDefaultProject("file:///project/main.ts").Program.GetSourceFile("/project/node_modules/one/index.d.ts").Text(), `export const value: "initial";`) +} + +func TestWatchPreparationSerializesBackgroundAdoption(t *testing.T) { + t.Parallel() + session := NewSession(&SessionInit{ + BackgroundCtx: context.Background(), FS: vfstest.FromMap(map[string]string{}, true), Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true}, + }) + defer session.Close() + base := session.Snapshot() + clone, err := session.CloneSnapshot(context.Background(), base, FileChangeSummary{}, nil) + assert.NilError(t, err) + session.snapshotUpdateMu.Lock() + started, adopted := make(chan struct{}), make(chan struct{}) + go func() { + close(started) + session.adoptSnapshotChange(base, clone) + close(adopted) + }() + <-started + var adoptedEarly bool + select { + case <-adopted: + adoptedEarly = true + case <-time.After(20 * time.Millisecond): + adoptedEarly = session.Snapshot() != base + } + session.snapshotUpdateMu.Unlock() + <-adopted + assert.Assert(t, !adoptedEarly, "background adoption changed the base inside the preparation-to-clone boundary") + assert.Assert(t, session.Snapshot() == clone) +} + +func TestWatchAliasDisabledSaveKeepsSnapshot(t *testing.T) { + t.Parallel() + for _, enabled := range []bool{false, true} { + t.Run(strconv.FormatBool(enabled), func(t *testing.T) { + t.Parallel() + const uri = "file:///src/main.ts" + const text = "export const value = 1;" + fs := vfstest.FromMap(map[string]string{ + "/src/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`, + "/src/main.ts": text, + }, true) + session := NewSession(&SessionInit{ + BackgroundCtx: context.Background(), FS: fs, Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/src", WatchEnabled: enabled}, + }) + defer session.Close() + session.DidOpenFile(context.Background(), uri, 1, text, lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + previous := session.Snapshot() + session.DidSaveFile(context.Background(), uri) + _, err = session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + assert.Assert(t, session.Snapshot() == previous, "hosts without alias state must not clone only to refresh aliases") + }) + } +} + +func TestWatchAliasSaveWithoutOverlay(t *testing.T) { + t.Parallel() + const main = `import {value} from "pkg"; export {value};` + fs := vfstest.FromMap(map[string]any{ + "/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true},"files":["main.ts"]}`, + "/project/main.ts": main, + "/project/node_modules/pkg": vfstest.Symlink("/packages/pkg"), + "/packages/pkg/index.d.ts": `export const value: "initial";`, + }, true) + session := NewSession(&SessionInit{ + BackgroundCtx: context.Background(), FS: fs, Client: &noopClient{}, + Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true}, + }) + defer session.Close() + ctx := context.Background() + session.DidOpenFile(ctx, "file:///project/main.ts", 1, main, lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + assert.NilError(t, fs.WriteFile("/packages/pkg/index.d.ts", `export const value: "saved";`)) + session.DidSaveFile(ctx, "file:///packages/pkg/index.d.ts") + service, err := session.GetLanguageService(ctx, "file:///project/main.ts") + assert.NilError(t, err) + assert.Equal(t, service.GetProgram().GetSourceFile("/project/node_modules/pkg/index.d.ts").Text(), `export const value: "saved";`) +} diff --git a/tsc/internal/project/watchalias.go b/tsc/internal/project/watchalias.go new file mode 100644 index 0000000000000..4c0a6d969cc36 --- /dev/null +++ b/tsc/internal/project/watchalias.go @@ -0,0 +1,305 @@ +package project + +import ( + "errors" + "fmt" + "iter" + "maps" + "slices" + + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project/logging" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/watchalias" +) + +func (s *Snapshot) nativeWatchAliasesEnabled() bool { + return s.host.options.WatchEnabled && watchalias.Enabled(s.host.fs) +} + +// Build after finalizing files and projects. Physical correspondence is also +// needed by direct API hosts that do not register native filesystem watches. +func (s *Snapshot) initializeWatchAliases(logger logging.Logger) { + native := s.nativeWatchAliasesEnabled() + if !native && s.fs.realpathFiles == 0 { + return + } + index := watchalias.NewExact(s.host.fs) + if native { + index = watchalias.New(s.host.fs) + } + directories := make(map[string]struct{}) + register := func(registration watchalias.Registration) bool { + if err := index.Register(registration); err != nil { + s.watchAliasesError = fmt.Errorf("indexing project watch path %q: %w", registration.Name, err) + return false + } + return true + } + for registration := range s.watchRegistrations(nil) { + registration.Name = s.normalizeWatchAliasName(registration.Name) + if registration.Name == "" { + continue + } + if !register(registration) { + break + } + if registration.Realpath == "" { + continue + } + // Requested roots can themselves be symlinks. Resolve directories + // explicitly: a file link does not imply a link between its parents. + for directory := tspath.GetDirectoryPath(registration.Name); ; { + if _, ok := directories[directory]; ok { + break + } + directories[directory] = struct{}{} + if !register(watchalias.Registration{Name: directory, Realpath: s.host.fs.Realpath(directory), Directory: true}) { + break + } + parent := tspath.GetDirectoryPath(directory) + if parent == directory || parent == "" { + break + } + directory = parent + } + if s.watchAliasesError != nil { + break + } + } + if s.watchAliasesError != nil { + if logger != nil { + logger.Warnf("Watch aliases unavailable; watch events will invalidate all cached project state until a later snapshot retries: %v", s.watchAliasesError) + } else { + s.apiError = errors.Join(s.apiError, s.watchAliasesError) + } + return + } + s.watchAliases = index +} + +func (s *Snapshot) normalizeWatchAliasName(name string) string { + if name == "" || tspath.IsDynamicFileName(name) { + return "" + } + name = tspath.GetNormalizedAbsolutePath(name, s.host.options.CurrentDirectory) + if !tspath.IsRootedDiskPath(name) { + return "" + } + return name +} + +// Registration and reuse observe the same facts. Already-shared source lookup +// collections need not be traversed again during an overlay-only edit. +func (s *Snapshot) watchRegistrations(previous *Snapshot) iter.Seq[watchalias.Registration] { + return func(yield func(watchalias.Registration) bool) { + native := s.nativeWatchAliasesEnabled() + for _, file := range s.fs.diskFiles { + if native || file.realpathName != "" { + if !yield(watchalias.Registration{Name: file.FileName(), Realpath: file.realpathName, Dependency: file.realpathName != ""}) { + return + } + } + } + if !native { + return + } + name := func(name string) bool { return yield(watchalias.Registration{Name: name}) } + for _, file := range s.fs.overlays { + if !name(file.FileName()) { + return + } + } + for _, config := range s.ConfigFileRegistry.configs { + if !name(config.fileName) { + return + } + if config.commandLine != nil { + for directory := range config.commandLine.WildcardDirectories() { + if !name(directory) { + return + } + } + } + } + for _, search := range s.ConfigFileRegistry.configFileNames { + if !name(search.fileName) { + return + } + } + for _, project := range s.ProjectCollection.Projects() { + var old *sourceFS + if previous != nil { + if p := previous.ProjectCollection.GetProjectByPath(project.configFilePath); p != nil && p.host != nil { + old = p.host.sourceFS + } + } + if project.host != nil { + for _, names := range []*collections.SyncMap[tspath.Path, string]{ + project.host.sourceFS.seenFiles, + project.host.sourceFS.missingDirectories, + } { + if names != nil && (old == nil || names != old.seenFiles && names != old.missingDirectories) { + more := true + names.Range(func(_ tspath.Path, value string) bool { + more = name(value) + return more + }) + if !more { + return + } + } + } + } + if project.contentMapperWatch != nil { + for _, value := range project.contentMapperWatch.input { + if !name(value) { + return + } + } + } + } + if s.autoImportsWatch != nil { + for _, directory := range s.autoImportsWatch.input { + if !name(directory) { + return + } + } + } + } +} + +func (s *Session) watchAliasesNeedRefresh(change FileChangeSummary) bool { + if !change.hasFileSystemChanges { + return false + } + snapshot := s.Snapshot() + return snapshot.watchAliases != nil || snapshot.watchAliasesError != nil +} + +func (s *Snapshot) watchAliasChangesAreContentOnly(change FileChangeSummary, overlays map[tspath.Path]*Overlay) bool { + if change.hasFileSystemChanges || change.InvalidateAll || change.IncludesWatchChangeOutsideNodeModules || + change.Opened != "" || change.Reopened != "" || change.Closed.Len() != 0 || change.Created.Len() != 0 || change.Deleted.Len() != 0 { + return false + } + for uri := range change.Changed.Keys() { + path := s.host.toPath(uri.FileName()) + previous, next := s.fs.overlays[path], overlays[path] + if previous == nil || next == nil || previous == next { + return false + } + } + return true +} + +func (s *Snapshot) initializeWatchAliasesFrom(previous *Snapshot, contentOnly bool, logger logging.Logger) { + if !s.nativeWatchAliasesEnabled() && s.fs.realpathFiles == 0 { + return + } + if contentOnly && previous.watchAliasesError == nil && previous.watchAliases != nil && + s.nativeWatchAliasesEnabled() == previous.nativeWatchAliasesEnabled() && s.canReuseWatchAliases(previous) { + s.watchAliases = previous.watchAliases + return + } + s.initializeWatchAliases(logger) +} + +// Surplus immutable coverage supplies candidates, never live project membership. +func (s *Snapshot) canReuseWatchAliases(previous *Snapshot) bool { + for registration := range s.watchRegistrations(previous) { + if !previous.watchAliases.Covers(registration) { + registration.Name = s.normalizeWatchAliasName(registration.Name) + if registration.Name != "" && !previous.watchAliases.Covers(registration) { + return false + } + } + } + return true +} + +func (s *Snapshot) watchNames(name string) []string { + if s.watchAliases != nil { + return s.watchAliases.Expand(name) + } + return []string{name} +} + +func (s *Snapshot) expandWatchAliases(change FileChangeSummary) FileChangeSummary { + change, _ = s.matchWatchChanges(change) + return change +} + +func (s *Snapshot) matchWatchChanges(change FileChangeSummary) (FileChangeSummary, []string) { + var affected collections.Set[string] + if prepared := change.preparedWatchChanges; prepared != nil { + if prepared.snapshotID != s.id { + panic("watch changes must be prepared for the snapshot being cloned") + } + for _, name := range prepared.affected { + affected.Add(name) + } + } + if s.watchAliasesError != nil && change.Created.Len()+change.Changed.Len()+change.Deleted.Len() != 0 { + change.InvalidateAll = true + } + if s.watchAliases == nil { + return change, nil + } + expand := func(uris collections.Set[lsproto.DocumentUri], kind fswatch.EventKind) collections.Set[lsproto.DocumentUri] { + if uris.Len() == 0 { + return uris + } + events := make(map[string]fswatch.EventKind, uris.Len()) + for uri := range uris.Keys() { + events[uri.FileName()] = kind + } + var matches watchalias.Matches + if change.preparedWatchChanges != nil { + matches = s.watchAliases.MatchExpanded(events) + } else { + matches = s.watchAliases.Match(events) + } + var result collections.Set[lsproto.DocumentUri] + for name := range matches.Changes { + result.Add(lsconv.FileNameToDocumentURI(name)) + } + for _, name := range matches.Affected { + affected.Add(name) + } + return result + } + change.Created = expand(change.Created, fswatch.EventUpdate) + change.Changed = expand(change.Changed, fswatch.EventUpdate) + change.Deleted = expand(change.Deleted, fswatch.EventDelete) + return change, slices.Collect(maps.Keys(affected.Keys())) +} + +func (s *Snapshot) watchChangesOverlapProjectState(change FileChangeSummary) bool { + for _, events := range []collections.Set[lsproto.DocumentUri]{change.Changed, change.Deleted} { + for uri := range events.Keys() { + path := s.host.toPath(uri.FileName()) + base := tspath.GetBaseFileName(string(path)) + if base == "tsconfig.json" || base == "jsconfig.json" || base == s.ConfigFileRegistry.customConfigFileName { + return true + } + if s.ConfigFileRegistry.isTracked(path) { + return true + } + if _, ok := s.fs.overlays[path]; ok { + return true + } + if _, ok := s.fs.overlayDirectories[path]; ok { + return true + } + for _, project := range s.ProjectCollection.Projects() { + if project.host != nil && project.host.sourceFS.SeenFileOrMissingParentDirectory(path) { + return true + } + } + } + } + return false +} diff --git a/tsc/internal/project/watchalias_coalescing_darwin_test.go b/tsc/internal/project/watchalias_coalescing_darwin_test.go new file mode 100644 index 0000000000000..d8a8e72a97ebc --- /dev/null +++ b/tsc/internal/project/watchalias_coalescing_darwin_test.go @@ -0,0 +1,37 @@ +package project + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "gotest.tools/v3/assert" +) + +type lifecycleNativeComparerFS struct { + vfs.FS + comparer fswatch.PathComparer +} + +func (fs *lifecycleNativeComparerFS) WatchPathComparer(string) (fswatch.PathComparer, error) { + return fs.comparer, nil +} + +func TestWatchDirectoryRecreationNativeSpellings(t *testing.T) { + t.Parallel() + comparer, err := fswatch.PathComparerForPath(t.TempDir()) + assert.NilError(t, err) + if comparer.Key("\u00e9") != comparer.Key("e\u0301") { + t.Skip("requires a normalization-insensitive volume") + } + for _, test := range []struct{ name, deleted, created string }{ + {"NFC-to-original", "/packages/\u00e9", watchLifecyclePhysical}, + {"original-to-NFC", watchLifecyclePhysical, "/packages/\u00e9"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fs := &lifecycleNativeComparerFS{FS: watchLifecycleFS(true), comparer: comparer} + checkWatchDirectoryRecreation(t, fs, test.deleted, test.created, nil, false) + }) + } +} diff --git a/tsc/internal/project/watchalias_darwin_test.go b/tsc/internal/project/watchalias_darwin_test.go new file mode 100644 index 0000000000000..1c2def1a8b0ed --- /dev/null +++ b/tsc/internal/project/watchalias_darwin_test.go @@ -0,0 +1,365 @@ +package project_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lspwatcher" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/project/logging" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" +) + +type watchAliasTestSession struct { + *project.Session + mu sync.Mutex +} + +func (s *watchAliasTestSession) WaitForBackgroundTasks() { + s.mu.Lock() + defer s.mu.Unlock() + s.Session.WaitForBackgroundTasks() +} + +func (s *watchAliasTestSession) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto.FileEvent) { + s.mu.Lock() + defer s.mu.Unlock() + s.Session.DidChangeWatchedFiles(ctx, changes) +} + +// These tests exercise registrations produced by Session, not a hand-written +// broad watch, and assert source contents rather than just Program replacement. +func TestWatchAliasesRealBackend(t *testing.T) { //nolint:paralleltest // Keep native subscriptions sequential to bound kqueue descriptors and event delays. + comparer, comparerErr := fswatch.PathComparerForPath(".") + if comparerErr != nil { + t.Fatal(comparerErr) + } + if comparer.Key("é") != comparer.Key("e\u0301") { + t.Skip("requires a case-insensitive Darwin volume") + } + for _, backend := range []struct { //nolint:paralleltest // Native subscriptions are intentionally sequential. + name string + watcher fswatch.Watcher + }{{"fsevents", fswatch.Default()}, {"kqueue", fswatch.Kqueue()}} { + for _, tc := range []struct { + name, physical, requested string + root, symlink bool + }{ + {"ascii", "ASCII", "ascii", false, false}, + {"long-s", "s", "ſ", false, false}, + {"sigma", "σ", "ς", false, false}, + {"sharp-s", "SS", "ß", false, false}, + {"dotted-i", "i\u0307", "İ", false, false}, + {"ligature", "ffi", "ffi", false, false}, + {"normalization", "é", "e\u0301", false, false}, + {"normalization-root", "é", "e\u0301", true, false}, + {"raw-nfd", "e\u0301", "e\u0301", false, false}, + {"realpath-nfd", "e\u0301", "node_modules/pkg", false, true}, + } { + t.Run(backend.name+"/"+tc.name, func(t *testing.T) { + dir := watchAliasDirectory(t) + physical, requested := filepath.Join(dir, tc.physical), filepath.Join(dir, tc.requested) + if err := os.MkdirAll(physical, 0o755); err != nil { + t.Fatal(err) + } + if tc.symlink { + if err := os.MkdirAll(filepath.Dir(requested), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(physical, requested); err != nil { + t.Fatal(err) + } + } + watchAliasWrite(t, filepath.Join(physical, "value.ts"), "export const value = 1;") + if _, err := os.Stat(filepath.Join(requested, "value.ts")); err != nil { + t.Skipf("volume does not support this filename alias: %v", err) + } + root, importName := dir, "./"+tc.requested+"/value" + if tc.root { + root, importName = requested, "./value" + } + watchAliasWrite(t, filepath.Join(root, "tsconfig.json"), `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`) + mainText := fmt.Sprintf("import { value } from %q; const n: number = value;", importName) + watchAliasWrite(t, filepath.Join(root, "main.ts"), mainText) + session, wait := watchAliasSession(t, root, backend.watcher) + uri := lsconv.FileNameToDocumentURI(filepath.Join(root, "main.ts")) + session.DidOpenFile(context.Background(), uri, 1, mainText, lsproto.LanguageKindTypeScript) + service, err := session.GetLanguageService(context.Background(), uri) + if err != nil { + t.Fatal(err) + } + expected := filepath.Join(requested, "value.ts") + if source := service.GetProgram().GetSourceFile(expected); source == nil || source.Text() != "export const value = 1;" { + t.Fatal("missing initial source") + } + session.WaitForBackgroundTasks() + const changed = `export const value = "changed";` + wait("/value.ts", func() { watchAliasWrite(t, filepath.Join(physical, "value.ts"), changed) }) + service, err = session.GetLanguageService(context.Background(), uri) + if err != nil { + t.Fatal(err) + } + source := service.GetProgram().GetSourceFile(expected) + if source == nil || source.Text() != changed { + t.Fatalf("cached alias %q did not acquire changed disk contents", expected) + } + if tc.name == "normalization" { + const bulkText = "export const value = 3;" + watchAliasWrite(t, filepath.Join(physical, "value.ts"), bulkText) + session.DidChangeWatchedFiles(context.Background(), watchAliasBulkChanges(root, filepath.Join(physical, "value.ts"))) + service, err = session.GetLanguageService(context.Background(), uri) + if err != nil { + t.Fatal(err) + } + source = service.GetProgram().GetSourceFile(expected) + if source == nil || source.Text() != bulkText { + t.Fatal("bulk overlap filtering discarded alias source change") + } + } + }) + } + t.Run(backend.name+"/config-nfd", func(t *testing.T) { + dir := watchAliasDirectory(t) + physical, requested := filepath.Join(dir, "é"), filepath.Join(dir, "e\u0301") + if err := os.MkdirAll(physical, 0o755); err != nil { + t.Fatal(err) + } + watchAliasWrite(t, filepath.Join(physical, "main.ts"), "export const x = 1;") + if _, err := os.Stat(filepath.Join(requested, "main.ts")); err != nil { + t.Skipf("volume does not support normalization aliases: %v", err) + } + session, wait := watchAliasSession(t, dir, backend.watcher) + uri := lsconv.FileNameToDocumentURI(filepath.Join(requested, "main.ts")) + session.DidOpenFile(context.Background(), uri, 1, "export const x = 1;", lsproto.LanguageKindTypeScript) + check := func(kind project.Kind) { + t.Helper() + if _, err := session.GetLanguageService(context.Background(), uri); err != nil { + t.Fatal(err) + } + if got := session.Snapshot().GetDefaultProject(uri).Kind; got != kind { + t.Fatalf("project kind = %v, want %v", got, kind) + } + session.WaitForBackgroundTasks() + } + check(project.KindInferred) + config := filepath.Join(physical, "tsconfig.json") + wait("/tsconfig.json", func() { watchAliasWrite(t, config, `{"compilerOptions":{"noLib":true},"files":["main.ts"]}`) }) + check(project.KindConfigured) + wait("/tsconfig.json", func() { + watchAliasWrite(t, config, `{"compilerOptions":{"noLib":true,"strict":true},"files":["main.ts"]}`) + }) + check(project.KindConfigured) + if !session.Snapshot().GetDefaultProject(uri).Program.Options().Strict.IsTrue() { + t.Fatal("config alias was not refreshed") + } + wait("/tsconfig.json", func() { + if err := os.Remove(config); err != nil { + t.Fatal(err) + } + }) + check(project.KindInferred) + watchAliasWrite(t, config, `{"compilerOptions":{"noLib":true},"files":["main.ts"]}`) + session.DidChangeWatchedFiles(context.Background(), watchAliasBulkChanges(dir, config)) + check(project.KindConfigured) + }) + t.Run(backend.name+"/one-to-many-and-bulk", func(t *testing.T) { + root := watchAliasDirectory(t) + watchAliasWrite(t, filepath.Join(root, "s.ts"), "export const value = 1;") + if _, err := os.Stat(filepath.Join(root, "ſ.ts")); err != nil { + t.Skipf("volume does not support long-s alias: %v", err) + } + mainText := `import { value as a } from "./s"; import { value as b } from "./ſ"; a; b;` + watchAliasWrite(t, filepath.Join(root, "main.ts"), mainText) + watchAliasWrite(t, filepath.Join(root, "tsconfig.json"), `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`) + session, wait := watchAliasSession(t, root, backend.watcher) + uri := lsconv.FileNameToDocumentURI(filepath.Join(root, "main.ts")) + session.DidOpenFile(context.Background(), uri, 1, mainText, lsproto.LanguageKindTypeScript) + check := func(text string) { + t.Helper() + service, err := session.GetLanguageService(context.Background(), uri) + if err != nil { + t.Fatal(err) + } + a := service.GetProgram().GetSourceFile(filepath.Join(root, "s.ts")) + b := service.GetProgram().GetSourceFile(filepath.Join(root, "ſ.ts")) + if a == nil || b == nil || a == b || a.Path() == b.Path() { + t.Fatal("compiler identities were merged") + } + if a.Text() != text || b.Text() != text { + t.Fatalf("one-to-many invalidation failed: %q, %q", a.Text(), b.Text()) + } + session.WaitForBackgroundTasks() + } + check("export const value = 1;") + wait("/s.ts", func() { watchAliasWrite(t, filepath.Join(root, "s.ts"), "export const value = 2;") }) + check("export const value = 2;") + watchAliasWrite(t, filepath.Join(root, "s.ts"), "export const value = 3;") + session.DidChangeWatchedFiles(context.Background(), watchAliasBulkChanges(root, filepath.Join(root, "s.ts"))) + check("export const value = 3;") + }) + t.Run(backend.name+"/unknown-newfile", func(t *testing.T) { + root := watchAliasDirectory(t) + if err := os.MkdirAll(filepath.Join(root, "s"), 0o755); err != nil { + t.Fatal(err) + } + const mainText = `import { added } from "./ſ/new"; added;` + watchAliasWrite(t, filepath.Join(root, "main.ts"), mainText) + watchAliasWrite(t, filepath.Join(root, "tsconfig.json"), `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`) + session, wait := watchAliasSession(t, root, backend.watcher) + uri := lsconv.FileNameToDocumentURI(filepath.Join(root, "main.ts")) + session.DidOpenFile(context.Background(), uri, 1, mainText, lsproto.LanguageKindTypeScript) + if _, err := session.GetLanguageService(context.Background(), uri); err != nil { + t.Fatal(err) + } + session.WaitForBackgroundTasks() + wait("/new.ts", func() { watchAliasWrite(t, filepath.Join(root, "s/new.ts"), "export const added = 1;") }) + service, err := session.GetLanguageService(context.Background(), uri) + if err != nil { + t.Fatal(err) + } + source := service.GetProgram().GetSourceFile(filepath.Join(root, "ſ/new.ts")) + if source == nil || source.Text() != "export const added = 1;" { + t.Fatal("new source was not discovered through alias directory") + } + }) + for _, symlink := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/directory-delete/symlink=%v", backend.name, symlink), func(t *testing.T) { + root := watchAliasDirectory(t) + physical, requested := filepath.Join(root, "e\u0301"), filepath.Join(root, "e\u0301") + if err := os.MkdirAll(physical, 0o755); err != nil { + t.Fatal(err) + } + if symlink { + requested = filepath.Join(root, "node_modules/pkg") + if err := os.MkdirAll(filepath.Dir(requested), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(physical, requested); err != nil { + t.Fatal(err) + } + } + watchAliasWrite(t, filepath.Join(physical, "value.ts"), "export const value = 1;") + if _, err := os.Stat(filepath.Join(requested, "value.ts")); err != nil { + t.Skipf("volume does not support normalization alias: %v", err) + } + importName := "./e\u0301/value" + if symlink { + importName = "./node_modules/pkg/value" + } + mainText := fmt.Sprintf("import { value } from %q; value;", importName) + watchAliasWrite(t, filepath.Join(root, "main.ts"), mainText) + watchAliasWrite(t, filepath.Join(root, "tsconfig.json"), `{"compilerOptions":{"noLib":true,"types":[]},"files":["main.ts"]}`) + session, _ := watchAliasSession(t, root, backend.watcher) + uri := lsconv.FileNameToDocumentURI(filepath.Join(root, "main.ts")) + session.DidOpenFile(context.Background(), uri, 1, mainText, lsproto.LanguageKindTypeScript) + service, err := session.GetLanguageService(context.Background(), uri) + if err != nil { + t.Fatal(err) + } + if service.GetProgram().GetSourceFile(filepath.Join(requested, "value.ts")) == nil { + t.Fatal("missing initial alias source") + } + session.WaitForBackgroundTasks() + if err = os.RemoveAll(physical); err != nil { + t.Fatal(err) + } + // Isolate directory-only delivery; the backend may additionally emit + // child events, but correctness must not depend on receiving them. + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsconv.FileNameToDocumentURI(filepath.Join(root, "é")), Type: lsproto.FileChangeTypeDeleted}}) + service, err = session.GetLanguageService(context.Background(), uri) + if err != nil { + t.Fatal(err) + } + if service.GetProgram().GetSourceFile(filepath.Join(requested, "value.ts")) != nil { + t.Fatal("deleted alias source survived") + } + }) + } + } +} + +func watchAliasDirectory(t *testing.T) string { + t.Helper() + return filepath.ToSlash(t.TempDir()) +} + +func watchAliasWrite(t *testing.T, name, text string) { + t.Helper() + if err := os.WriteFile(name, []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +func watchAliasBulkChanges(root, name string) []*lsproto.FileEvent { + var changes []*lsproto.FileEvent + for i := range 1001 { + changes = append(changes, &lsproto.FileEvent{Uri: lsconv.FileNameToDocumentURI(filepath.Join(root, fmt.Sprintf("unknown%d.ts", i))), Type: lsproto.FileChangeTypeChanged}) + } + return append(changes, &lsproto.FileEvent{Uri: lsconv.FileNameToDocumentURI(name), Type: lsproto.FileChangeTypeChanged}) +} + +func watchAliasSession(t *testing.T, root string, backend fswatch.Watcher) (*watchAliasTestSession, func(string, func())) { + t.Helper() + watchAliasWrite(t, filepath.Join(root, "package.json"), `{"private":true}`) + client := &projecttestutil.ClientMock{} + session := &watchAliasTestSession{Session: project.NewSession(&project.SessionInit{ + BackgroundCtx: context.Background(), FS: bundled.WrapFS(osvfs.FS()), Client: client, + Options: &project.SessionOptions{ + CurrentDirectory: root, DefaultLibraryPath: bundled.LibPath(), + PositionEncoding: lsproto.PositionEncodingKindUTF8, WatchEnabled: true, + }, + })} + delivered := make(chan []*lsproto.FileEvent, 100) + watcher := lspwatcher.NewWithFSWatcher(session.FS(), backend, func(events []*lsproto.FileEvent) { + session.DidChangeWatchedFiles(context.Background(), events) + delivered <- events + }, logging.NewNopLogger()) + t.Cleanup(func() { watcher.Close(); session.Close() }) + client.WatchFilesFunc = func(ctx context.Context, id project.WatcherID, watchers []*lsproto.FileSystemWatcher) error { + var fixtureWatchers []*lsproto.FileSystemWatcher + for _, watcher := range watchers { + // Ancestor node_modules belongs to the test runner, not the fixture. + // Avoid recursively opening the repository's dependencies with kqueue. + if watcher.GlobPattern.Pattern != nil && strings.HasPrefix(strings.ToLower(*watcher.GlobPattern.Pattern), strings.ToLower(root)+"/") { + fixtureWatchers = append(fixtureWatchers, watcher) + } + } + return watcher.WatchFiles(string(id), fixtureWatchers) + } + client.UnwatchFilesFunc = func(ctx context.Context, id project.WatcherID) error { + return watcher.UnwatchFiles(string(id)) + } + return session, func(suffix string, action func()) { + t.Helper() + time.Sleep(150 * time.Millisecond) + for len(delivered) > 0 { + <-delivered + } + action() + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case events := <-delivered: + for _, event := range events { + if strings.HasSuffix(event.Uri.FileName(), suffix) { + return + } + } + case <-timer.C: + t.Fatalf("no real backend notification for %s", suffix) + } + } + } +} diff --git a/tsc/internal/project/watchnotifications.go b/tsc/internal/project/watchnotifications.go new file mode 100644 index 0000000000000..3950bd4bfd365 --- /dev/null +++ b/tsc/internal/project/watchnotifications.go @@ -0,0 +1,81 @@ +package project + +import ( + "slices" + + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" +) + +// Spellings are expanded, but subtree effects wait until after coalescing. +// Affected observations survive even when all visible notifications disappear. +type preparedWatchChanges struct { + snapshotID uint64 + affected []string +} + +// prepareWatchNotifications is also used for receipt-time scheduling previews. +// Only the call under the snapshot-update lock supplies the clone's input. +func (s *Snapshot) prepareWatchNotifications(changes []FileChange) ([]FileChange, *preparedWatchChanges, bool) { + if s.watchAliases == nil && s.watchAliasesError == nil { + return changes, nil, false + } + if !slices.ContainsFunc(changes, func(change FileChange) bool { + return change.Kind.IsWatchKind() || change.Kind == FileChangeKindSave + }) { + return changes, nil, false + } + result := make([]FileChange, 0, len(changes)) + observations := make(map[string]fswatch.EventKind) + for _, change := range changes { + kind := change.Kind + if !kind.IsWatchKind() && kind != FileChangeKindSave { + result = append(result, change) + continue + } + if kind == FileChangeKindSave { + kind = FileChangeKindWatchChange + } + for _, name := range s.watchNames(change.URI.FileName()) { + result = append(result, FileChange{Kind: kind, URI: lsconv.FileNameToDocumentURI(name)}) + // Observe raw filesystem activity without deriving deletions that + // could outlive a canceled directory lifecycle. + observations[name] = fswatch.EventUpdate + } + if change.Kind == FileChangeKindSave { + // Only the original document is saved. Alias notifications still + // invalidate disk entries when the saved document has no overlay. + result = append(result, change) + } + } + prepared := &preparedWatchChanges{snapshotID: s.id} + if s.watchAliases != nil { + prepared.affected = s.watchAliases.MatchExpanded(observations).Affected + } + return result, prepared, s.watchAliasesError != nil +} + +func (s *Snapshot) prepareWatchSummary(change FileChangeSummary) FileChangeSummary { + if change.preparedWatchChanges != nil { + return change + } + expand := func(uris collections.Set[lsproto.DocumentUri]) collections.Set[lsproto.DocumentUri] { + if s.watchAliases == nil || uris.Len() == 0 { + return uris + } + var result collections.Set[lsproto.DocumentUri] + for uri := range uris.Keys() { + for _, name := range s.watchNames(uri.FileName()) { + result.Add(lsconv.FileNameToDocumentURI(name)) + } + } + return result + } + change.Created = expand(change.Created) + change.Changed = expand(change.Changed) + change.Deleted = expand(change.Deleted) + change.preparedWatchChanges = &preparedWatchChanges{snapshotID: s.id} + return change +} diff --git a/tsc/internal/vfs/cachedvfs/cachedvfs.go b/tsc/internal/vfs/cachedvfs/cachedvfs.go index 7128d24d6bda7..c93375acb627f 100644 --- a/tsc/internal/vfs/cachedvfs/cachedvfs.go +++ b/tsc/internal/vfs/cachedvfs/cachedvfs.go @@ -5,7 +5,9 @@ import ( "time" "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/watchalias" ) type FS struct { @@ -98,13 +100,23 @@ func (fsys *FS) ReadFile(path string) (contents string, ok bool) { } func (fsys *FS) Realpath(path string) string { + return fsys.cachedRealpath(path, fsys.fs.Realpath) +} + +func (fsys *FS) RealpathWithParent(path string, realpath func(string) string) string { + return fsys.cachedRealpath(path, func(path string) string { + return vfs.RealpathWithParent(fsys.fs, path, realpath) + }) +} + +func (fsys *FS) cachedRealpath(path string, resolve func(string) string) string { if fsys.enabled.Load() { if ret, ok := fsys.realpathCache.Load(path); ok { return ret } } - ret := fsys.fs.Realpath(path) + ret := resolve(path) if fsys.enabled.Load() { fsys.realpathCache.Store(path, ret) @@ -141,6 +153,19 @@ func (fsys *FS) UseCaseSensitiveFileNames() bool { return fsys.fs.UseCaseSensitiveFileNames() } +func (fsys *FS) WatchPathComparer(directory string) (fswatch.PathComparer, error) { + if provider, ok := fsys.fs.(interface { + WatchPathComparer(directory string) (fswatch.PathComparer, error) + }); ok { + return provider.WatchPathComparer(directory) + } + return fswatch.PathComparer{}, nil +} + +func (fsys *FS) WatchPathComparisonEnabled() bool { + return watchalias.Enabled(fsys.fs) +} + func (fsys *FS) WalkDir(root string, walkFn vfs.WalkDirFunc) error { return fsys.fs.WalkDir(root, walkFn) } diff --git a/tsc/internal/vfs/osvfs/os.go b/tsc/internal/vfs/osvfs/os.go index 21308dd967505..a5ec9435616bf 100644 --- a/tsc/internal/vfs/osvfs/os.go +++ b/tsc/internal/vfs/osvfs/os.go @@ -12,6 +12,7 @@ import ( "unicode" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/nativepath" "github.com/microsoft/TypeScript/tsc/internal/osutil" "github.com/microsoft/TypeScript/tsc/internal/tspath" @@ -91,6 +92,15 @@ func (vfs *osFS) UseCaseSensitiveFileNames() bool { return isFileSystemCaseSensitive } +func (vfs *osFS) WatchPathComparer(directory string) (fswatch.PathComparer, error) { + defer blockingOpSema.Acquire()() + return fswatch.PathComparerForPath(directory) +} + +func (vfs *osFS) WatchPathComparisonEnabled() bool { + return fswatch.NativePathComparisonAvailable +} + func (vfs *osFS) ReadFile(path string) (contents string, ok bool) { defer readSema.Acquire()() return vfs.common.ReadFile(path) diff --git a/tsc/internal/vfs/osvfs/realpath_darwin.go b/tsc/internal/vfs/osvfs/realpath_darwin.go new file mode 100644 index 0000000000000..7592627f2b56c --- /dev/null +++ b/tsc/internal/vfs/osvfs/realpath_darwin.go @@ -0,0 +1,37 @@ +package osvfs + +import ( + "path/filepath" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/internal" + "golang.org/x/sys/unix" +) + +// Darwin's Realpath preserves non-symlink component spellings. An authoritative +// lstat of the requested leaf therefore lets ordinary siblings share their +// parent's resolution, without enumerating directories or guessing from absent +// entries. Other platforms retain their native casing and reparse-point rules. +func (vfs *osFS) RealpathWithParent(path string, realpath func(string) string) string { + _ = internal.RootLength(path) // Assert path is rooted + if filepath.Clean(path) != path { + return vfs.Realpath(path) + } + release := blockingOpSema.Acquire() + var info unix.Stat_t + err := unix.Lstat(path, &info) + release() + if err != nil { + // FS.Realpath returns the entire original name on failure, including + // missing leaves beneath a symlinked parent. + return path + } + if info.Mode&unix.S_IFMT == unix.S_IFLNK { + return vfs.Realpath(path) + } + parent := tspath.GetDirectoryPath(path) + if parent == path { + return path + } + return tspath.CombinePaths(realpath(parent), tspath.GetBaseFileName(path)) +} diff --git a/tsc/internal/vfs/osvfs/realpath_darwin_test.go b/tsc/internal/vfs/osvfs/realpath_darwin_test.go new file mode 100644 index 0000000000000..3f21e27ede060 --- /dev/null +++ b/tsc/internal/vfs/osvfs/realpath_darwin_test.go @@ -0,0 +1,66 @@ +package osvfs + +import ( + "os" + "path/filepath" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/cachedvfs" + "gotest.tools/v3/assert" +) + +func TestRealpathWithParent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + assert.NilError(t, os.Mkdir(dir+"/target", 0o755)) + assert.NilError(t, os.Symlink(dir+"/target", dir+"/link")) + assert.NilError(t, os.WriteFile(dir+"/target/file.ts", nil, 0o600)) + assert.NilError(t, os.Symlink("file.ts", dir+"/target/symlink.ts")) + assert.NilError(t, os.Symlink("absent.ts", dir+"/target/dangling.ts")) + assert.NilError(t, os.Symlink("cycle.ts", dir+"/target/cycle.ts")) + assert.NilError(t, os.Mkdir(dir+"/target/denied", 0o755)) + assert.NilError(t, os.WriteFile(dir+"/target/denied/file.ts", nil, 0o600)) + assert.NilError(t, os.Chmod(dir+"/target/denied", 0)) + defer func() { assert.NilError(t, os.Chmod(dir+"/target/denied", 0o755)) }() + + fs := FS() + for _, suffix := range []string{ + "", "/", "/../target/file.ts", "/file.ts", "/symlink.ts", "/dangling.ts", "/cycle.ts", + "/absent.ts", "/absent/file.ts", "/file.ts/child.ts", "/denied/file.ts", + } { + name := dir + "/link" + suffix + want := fs.Realpath(name) + for _, filesystem := range []vfs.FS{fs, cachedvfs.From(fs), bundled.WrapFS(fs)} { + got := vfs.RealpathWithParent(filesystem, name, filesystem.Realpath) + assert.Equal(t, got, want, "requested %s", name) + } + } + for _, pair := range [][2]string{ + {"s", "\u017f"}, + {"SS", "\u00df"}, + {"i\u0307", "\u0130"}, + {"ff", "\ufb00"}, + {"\u00e9", "e\u0301"}, + } { + assert.NilError(t, os.Symlink("file.ts", dir+"/target/"+pair[0])) + name := dir + "/link/" + pair[1] + // On a sensitive volume, this is an absent path instead of an alias. + assert.Equal(t, vfs.RealpathWithParent(fs, name, fs.Realpath), fs.Realpath(name)) + } +} + +func TestRealpathWithParentRetainsOriginalMissingName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + assert.NilError(t, os.Mkdir(dir+"/target", 0o755)) + assert.NilError(t, os.Symlink(dir+"/target", dir+"/link")) + fs := FS() + name := filepath.ToSlash(dir + "/link/missing.ts") + got := vfs.RealpathWithParent(fs, name, func(string) string { + t.Fatal("a failed leaf lookup must not resolve or substitute the parent") + return "" + }) + assert.Equal(t, got, name) +} diff --git a/tsc/internal/vfs/vfs.go b/tsc/internal/vfs/vfs.go index 7690fa3d65070..df0a2e11da364 100644 --- a/tsc/internal/vfs/vfs.go +++ b/tsc/internal/vfs/vfs.go @@ -49,6 +49,18 @@ type FS interface { Realpath(path string) string } +// RealpathWithParent allows a filesystem to reuse the caller's cached parent +// resolution. It has the same result and failure semantics as FS.Realpath. +// The callback must resolve paths on this filesystem, not on the host OS. +func RealpathWithParent(fs FS, path string, realpath func(string) string) string { + if resolver, ok := fs.(interface { + RealpathWithParent(path string, realpath func(string) string) string + }); ok { + return resolver.RealpathWithParent(path, realpath) + } + return fs.Realpath(path) +} + type Entries struct { Files []string Directories []string diff --git a/tsc/internal/vfs/vfs_test.go b/tsc/internal/vfs/vfs_test.go index 8c32bdbd4c4a9..d0c8427ad5e36 100644 --- a/tsc/internal/vfs/vfs_test.go +++ b/tsc/internal/vfs/vfs_test.go @@ -4,14 +4,35 @@ import ( "testing" "testing/fstest" + "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/repo" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/cachedvfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +func TestRealpathWithParentFallback(t *testing.T) { + t.Parallel() + for _, caseSensitive := range []bool{true, false} { + disk := vfstest.FromMap(map[string]any{ + "/virtual-watch-only/target/file.ts": "", + "/virtual-watch-only/link": vfstest.Symlink("/virtual-watch-only/target"), + }, caseSensitive) + for _, fs := range []vfs.FS{disk, cachedvfs.From(disk), bundled.WrapFS(cachedvfs.From(disk))} { + for _, name := range []string{"/virtual-watch-only/link/file.ts", "/virtual-watch-only/link/missing.ts"} { + got := vfs.RealpathWithParent(fs, name, func(string) string { + t.Fatal("a filesystem without the capability must use its own resolver") + return "" + }) + assert.Equal(t, got, disk.Realpath(name)) + } + } + } +} + func BenchmarkReadFile(b *testing.B) { type bench struct { name string diff --git a/tsc/internal/watchalias/capability_test.go b/tsc/internal/watchalias/capability_test.go new file mode 100644 index 0000000000000..5c97b1ffb0a61 --- /dev/null +++ b/tsc/internal/watchalias/capability_test.go @@ -0,0 +1,35 @@ +package watchalias_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/cachedvfs" + "github.com/microsoft/TypeScript/tsc/internal/watchalias" + "gotest.tools/v3/assert" +) + +type disabledComparisonFS struct{ vfs.FS } + +func (disabledComparisonFS) WatchPathComparisonEnabled() bool { return false } + +func (disabledComparisonFS) WatchPathComparer(string) (fswatch.PathComparer, error) { + panic("disabled filesystem comparison must not query native paths") +} + +func TestDisabledComparisonSurvivesWrappers(t *testing.T) { + t.Parallel() + for _, filesystem := range []vfs.FS{ + disabledComparisonFS{}, + cachedvfs.From(disabledComparisonFS{}), + bundled.WrapFS(cachedvfs.From(disabledComparisonFS{})), + cachedvfs.From(bundled.WrapFS(disabledComparisonFS{})), + } { + assert.Assert(t, !watchalias.Enabled(filesystem)) + index := watchalias.New(filesystem) + assert.NilError(t, index.Add("/virtual/\u017f.ts")) + assert.DeepEqual(t, index.Expand("/virtual/s.ts"), []string{"/virtual/s.ts"}) + } +} diff --git a/tsc/internal/watchalias/index.go b/tsc/internal/watchalias/index.go new file mode 100644 index 0000000000000..ff5e7b9b47a43 --- /dev/null +++ b/tsc/internal/watchalias/index.go @@ -0,0 +1,195 @@ +// Package watchalias translates watch notifications into registered original +// spellings without changing compiler path identity. +package watchalias + +import ( + "errors" + "io/fs" + "strings" + "syscall" + + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" +) + +// ComparerProvider is an optional filesystem capability. Implementations query +// the supplied directory, returning fs.ErrNotExist for a missing directory. +// Filesystems without this capability use exact watch names and never probe the +// host. In particular, UseCaseSensitiveFileNames alone does not enable folding. +type ComparerProvider interface { + WatchPathComparer(directory string) (fswatch.PathComparer, error) +} + +type comparerResult struct { + comparer fswatch.PathComparer + err error +} + +// Index belongs to one watch-state generation. Register original, absolute, +// slash-normalized names, never already-canonicalized compiler paths. Discard +// the index when rebuilding that state, so directory comparers and aliases cannot +// outlive the state they describe. Callers synchronize mutations with reads. +type Index struct { + filesystem vfs.FS + provider ComparerProvider + directoryComparers map[string]comparerResult + added map[string]struct{} + aliases map[fswatch.PathComparer]map[string][]string + registrations map[string]*Registration + paths map[tspath.Path]*registeredPath +} + +func New(filesystem vfs.FS) *Index { + index := NewExact(filesystem) + if Enabled(filesystem) { + index.provider, _ = filesystem.(ComparerProvider) + } + return index +} + +// NewExact retains physical correspondence without enabling native comparison. +func NewExact(filesystem vfs.FS) *Index { + return &Index{filesystem: filesystem} +} + +// Enabled checks the host capability without querying any filesystem paths. +func Enabled(filesystem vfs.FS) bool { + if _, ok := filesystem.(ComparerProvider); !ok { + return false + } + if host, ok := filesystem.(interface{ WatchPathComparisonEnabled() bool }); ok { + return host.WatchPathComparisonEnabled() + } + return true +} + +// Contains reports whether an original spelling is already registered, either +// directly or as an ancestor. It does not query or mutate the index. +func (i *Index) Contains(original string) bool { + _, ok := i.added[original] + return ok +} + +// Add registers a spelling and its ancestors. Volume queries are cached per +// directory in this index, and only missing directories inherit their nearest +// existing ancestor's comparer. Other errors are returned, with no partial alias +// registration. Retrying Add after an error requires a new index. +func (i *Index) Add(original string) error { + if _, ok := i.added[original]; ok { + return nil + } + type entry struct { + name string + comparer fswatch.PathComparer + } + var buffer [8]entry + entries := buffer[:0] + for name := original; ; { + if _, ok := i.added[name]; ok { + break + } + parent := tspath.GetDirectoryPath(name) + var c fswatch.PathComparer + if i.provider != nil { + var err error + c, err = i.comparer(parent) + if err != nil { + return err + } + } + entries = append(entries, entry{name, c}) + if parent == name || parent == "" { + break + } + name = parent + } + if i.added == nil { + i.added = make(map[string]struct{}) + } + for _, entry := range entries { + i.added[entry.name] = struct{}{} + if entry.comparer == (fswatch.PathComparer{}) { + continue + } + if i.aliases == nil { + i.aliases = make(map[fswatch.PathComparer]map[string][]string) + } + aliases := i.aliases[entry.comparer] + if aliases == nil { + aliases = make(map[string][]string) + i.aliases[entry.comparer] = aliases + } + key := entry.comparer.Key(entry.name) + aliases[key] = append(aliases[key], entry.name) + } + return nil +} + +func (i *Index) comparer(directory string) (fswatch.PathComparer, error) { + if cached, ok := i.directoryComparers[directory]; ok { + return cached.comparer, cached.err + } + c, err := i.provider.WatchPathComparer(directory) + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + if parent := tspath.GetDirectoryPath(directory); parent != directory && parent != "" { + c, err = i.comparer(parent) + } + } + if i.directoryComparers == nil { + i.directoryComparers = make(map[string]comparerResult) + } + i.directoryComparers[directory] = comparerResult{c, err} + return c, err +} + +func (i *Index) expandNative(event string) []string { + result := []string{event} + var seen map[string]struct{} + for comparer, aliases := range i.aliases { + key := comparer.Key(event) + nameEnd, keyEnd := len(event), len(key) + for { + for _, original := range aliases[key[:keyEnd]] { + if !i.matchesSensitiveAncestors(original, event[:nameEnd]) { + continue + } + expanded := original + event[nameEnd:] + if expanded == event { + continue + } + if seen == nil { + seen = make(map[string]struct{}) + } + if _, ok := seen[expanded]; !ok { + seen[expanded] = struct{}{} + result = append(result, expanded) + } + } + nameEnd = strings.LastIndexByte(event[:nameEnd], '/') + keyEnd = strings.LastIndexByte(key[:keyEnd], '/') + if nameEnd <= 0 || keyEnd <= 0 { + break + } + } + } + return result +} + +func (i *Index) matchesSensitiveAncestors(original, event string) bool { + // A case-insensitive volume may be mounted beneath a case-sensitive + // directory. Folding the volume's full path is only a candidate lookup; + // its sensitive ancestor components must still match byte-for-byte. + for original != event { + parent := tspath.GetDirectoryPath(original) + eventParent := tspath.GetDirectoryPath(event) + if parent == original || eventParent == event { + break + } + if i.directoryComparers[parent].comparer == (fswatch.PathComparer{}) && original[len(parent):] != event[len(eventParent):] { + return false + } + original, event = parent, eventParent + } + return true +} diff --git a/tsc/internal/watchalias/index_darwin_test.go b/tsc/internal/watchalias/index_darwin_test.go new file mode 100644 index 0000000000000..674f3378f8660 --- /dev/null +++ b/tsc/internal/watchalias/index_darwin_test.go @@ -0,0 +1,287 @@ +//go:build darwin && (amd64 || arm64) + +package watchalias + +import ( + "fmt" + "reflect" + "slices" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func nativeComparer(t testing.TB) fswatch.PathComparer { + t.Helper() + c, err := fswatch.PathComparerForPath(".") + if err != nil { + t.Fatal(err) + } + if c.Key("A") == "A" { + t.Skip("test requires a case-insensitive volume") + } + return c +} + +func TestNativeAliasesAndAncestors(t *testing.T) { + t.Parallel() + c := nativeComparer(t) + f := &comparerFS{get: func(string) (fswatch.PathComparer, error) { return c, nil }} + index := New(f) + for _, path := range []string{"/work/straße/İ.ts", "/work/STRASSE/i\u0307.ts", "/work/straße/İ.ts"} { + if err := index.Add(path); err != nil { + t.Fatal(err) + } + } + before := len(f.calls) + for _, test := range []struct { + event string + want []string + }{ + {"/work/strasse/i\u0307.ts", []string{"/work/straße/İ.ts", "/work/STRASSE/i\u0307.ts"}}, + {"/work/strasse/new/tsconfig.json", []string{"/work/straße/new/tsconfig.json", "/work/STRASSE/new/tsconfig.json"}}, + {"/work/strasse/new/generated.ts", []string{"/work/straße/new/generated.ts", "/work/STRASSE/new/generated.ts"}}, + } { + got := index.Expand(test.event) + if got[0] != test.event { + t.Fatalf("original event lost: %q", got) + } + for _, want := range test.want { + if !slices.Contains(got, want) { + t.Errorf("Expand(%q) = %q, missing %q", test.event, got, want) + } + } + unique := make(map[string]bool) + for _, name := range got { + if unique[name] { + t.Errorf("duplicate expansion: %q", got) + } + unique[name] = true + } + } + for _, event := range []string{"/work/strasse2/new.ts", "/work/strasse/\u0131.ts"} { + if got := index.Expand(event); slices.Contains(got, "/work/straße/İ.ts") { + t.Errorf("unrelated file matched: %q", got) + } + } + if len(f.calls) != before { + t.Fatal("Expand probed filesystem") + } +} + +func TestVolumeComparersAreIndependent(t *testing.T) { + t.Parallel() + native := nativeComparer(t) + f := &comparerFS{get: func(path string) (fswatch.PathComparer, error) { + if path == "/sensitive" { + return fswatch.PathComparer{}, nil + } + return native, nil + }} + index := New(f) + for _, path := range []string{"/sensitive/straße.ts", "/insensitive/straße.ts"} { + if err := index.Add(path); err != nil { + t.Fatal(err) + } + } + if got := index.Expand("/sensitive/STRASSE.ts"); !reflect.DeepEqual(got, []string{"/sensitive/STRASSE.ts"}) { + t.Fatalf("sensitive volume aliased: %q", got) + } + if got := index.Expand("/insensitive/STRASSE.ts"); !slices.Contains(got, "/insensitive/straße.ts") { + t.Fatalf("insensitive volume did not alias: %q", got) + } +} + +func TestNativeVolumeDoesNotFoldSensitiveAncestors(t *testing.T) { + t.Parallel() + native := nativeComparer(t) + f := &comparerFS{get: func(path string) (fswatch.PathComparer, error) { + if path == "/" { + return fswatch.PathComparer{}, nil + } + return native, nil + }} + index := New(f) + if err := index.Add("/CaseMount/straße.ts"); err != nil { + t.Fatal(err) + } + event := "/casemount/STRASSE.ts" + if got := index.Expand(event); !reflect.DeepEqual(got, []string{event}) { + t.Fatalf("native volume folded its sensitive mount name: %q", got) + } + if got := index.Expand("/CaseMount/STRASSE.ts"); !slices.Contains(got, "/CaseMount/straße.ts") { + t.Fatalf("native leaf alias lost: %q", got) + } +} + +func TestNativeIndexKeysMatchWholePaths(t *testing.T) { + t.Parallel() + native := nativeComparer(t) + comparer := func(directory string) fswatch.PathComparer { + if directory == "/" || directory == "/Sensitive" { + return fswatch.PathComparer{} + } + return native + } + index := New(&comparerFS{get: func(directory string) (fswatch.PathComparer, error) { + return comparer(directory), nil + }}) + names := []string{ + "/Sensitive/Stra\u00dfe/FILE.ts", + "/Sensitive/Stra\u00dfe/other.ts", + "/Sensitive/STRASSE/\u0130.ts", + "/Sensitive/STRASSE/i\u0307.ts", + "/Sensitive/e\u0301/\ufb03.ts", + "/Sensitive/\u00e9/FFI.ts", + "/Sensitive/I\u0307/\u0301.ts", + "/Sensitive/plain/lower.ts", + "/Sensitive/invalid\xff/FILE.ts", + "/Sensitive/Stra\u00dfe/invalid\xff.ts", + "/Sensitive/nul\x00/FILE.ts", + "/Sensitive/Stra\u00dfe/nul\x00.ts", + } + for _, name := range names { + if err := index.Add(name); err != nil { + t.Fatal(err) + } + } + expected := make(map[fswatch.PathComparer]map[string][]string) + for name := range index.added { + c := comparer(tspath.GetDirectoryPath(name)) + if c == (fswatch.PathComparer{}) { + continue + } + if expected[c] == nil { + expected[c] = make(map[string][]string) + } + key := c.Key(name) + expected[c][key] = append(expected[c][key], name) + } + for c, aliases := range expected { + if len(index.aliases[c]) != len(aliases) { + t.Fatalf("comparison key count = %d, want %d", len(index.aliases[c]), len(aliases)) + } + for key, names := range aliases { + got := slices.Clone(index.aliases[c][key]) + slices.Sort(got) + slices.Sort(names) + if !slices.Equal(got, names) { + t.Errorf("aliases for %q = %q, want %q", key, got, names) + } + } + } +} + +func BenchmarkIndex(b *testing.B) { + c := nativeComparer(b) + for _, count := range []int{1000, 10000, 50000} { + for _, spelling := range []string{"ascii", "unicode"} { + names := make([]string, count) + for i := range names { + dir := "Package" + if spelling == "unicode" { + dir = "Straße" + } + names[i] = fmt.Sprintf("/work/%s%d/File%d.ts", dir, i/10, i) + } + makeIndex := func() *Index { + f := &comparerFS{get: func(string) (fswatch.PathComparer, error) { return c, nil }} + index := New(f) + for _, name := range names { + if err := index.Add(name); err != nil { + b.Fatal(err) + } + } + return index + } + b.Run(fmt.Sprintf("%s/%d/construct", spelling, count), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + makeIndex() + } + }) + index := makeIndex() + event := fmt.Sprintf("/work/package%d/file%d.ts", (count-1)/10, count-1) + if spelling == "unicode" { + event = fmt.Sprintf("/work/STRASSE%d/file%d.ts", (count-1)/10, count-1) + } + b.Run(fmt.Sprintf("%s/%d/match", spelling, count), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + index.Expand(event) + } + }) + if spelling == "unicode" { + unicodeEvent := fmt.Sprintf("/work/straße%d/file%d.ts", (count-1)/10, count-1) + b.Run(fmt.Sprintf("%s/%d/match-native", spelling, count), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + index.Expand(unicodeEvent) + } + }) + } + b.Run(fmt.Sprintf("%s/%d/unknown", spelling, count), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + index.Expand(event + "/new/generated.ts") + } + }) + } + } +} + +func TestPhysicalRegistrationNativeEndpoints(t *testing.T) { + t.Parallel() + native := nativeComparer(t) + f := &comparerFS{ + FS: vfstest.FromMap(map[string]string{}, false), + get: func(path string) (fswatch.PathComparer, error) { + if path == "/" { + return fswatch.PathComparer{}, nil + } + return native, nil + }, + } + index := New(f) + assert.NilError(t, index.Register(Registration{Name: "/Logical/dep.ts", Realpath: "/CaseMount/straße/ſ.ts", Dependency: true})) + assert.NilError(t, index.Register(Registration{Name: "/Logical", Realpath: "/CaseMount/straße", Directory: true})) + queries := len(f.calls) + assert.Assert(t, slices.Contains(index.Expand("/CaseMount/STRASSE/s.ts"), "/Logical/dep.ts")) + assert.Assert(t, slices.Contains(index.Expand("/CaseMount/STRASSE/new.ts"), "/Logical/new.ts")) + assert.Assert(t, !slices.Contains(index.Expand("/casemount/STRASSE/s.ts"), "/Logical/dep.ts")) + changes := index.Match(map[string]fswatch.EventKind{"/casemount/STRASSE": fswatch.EventDelete}) + _, matched := changes.Changes["/Logical/dep.ts"] + assert.Assert(t, !matched, "the physical endpoint's sensitive ancestors must match") + changes = index.Match(map[string]fswatch.EventKind{"/CaseMount/STRASSE": fswatch.EventDelete}) + assert.Equal(t, changes.Changes["/Logical/dep.ts"], fswatch.EventDelete) + assert.Equal(t, len(f.calls), queries, "matching must not probe the filesystem") +} + +func TestPhysicalRegistrationSensitiveSubtrees(t *testing.T) { + t.Parallel() + native := nativeComparer(t) + f := &comparerFS{ + FS: vfstest.FromMap(map[string]string{}, false), + get: func(path string) (fswatch.PathComparer, error) { + if path == "/" || path == "/Sensitive" { + return fswatch.PathComparer{}, nil + } + return native, nil + }, + } + index := New(f) + assert.NilError(t, index.Register(Registration{Name: "/logical/one.ts", Realpath: "/Sensitive/Mount/a.ts", Dependency: true})) + assert.NilError(t, index.Register(Registration{Name: "/logical/two.ts", Realpath: "/Sensitive/mount/a.ts", Dependency: true})) + for _, test := range []struct{ directory, affected, unaffected string }{ + {"/Sensitive/Mount", "/logical/one.ts", "/logical/two.ts"}, + {"/Sensitive/mount", "/logical/two.ts", "/logical/one.ts"}, + } { + matches := index.Match(map[string]fswatch.EventKind{test.directory: fswatch.EventDelete}) + assert.Equal(t, matches.Changes[test.affected], fswatch.EventDelete) + _, extra := matches.Changes[test.unaffected] + assert.Assert(t, !extra, "compiler keys must not merge volume-sensitive physical subtrees") + } +} diff --git a/tsc/internal/watchalias/index_test.go b/tsc/internal/watchalias/index_test.go new file mode 100644 index 0000000000000..e68a0be75a07e --- /dev/null +++ b/tsc/internal/watchalias/index_test.go @@ -0,0 +1,203 @@ +package watchalias + +import ( + "errors" + "io/fs" + "reflect" + "slices" + "syscall" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +type exactFS struct{ vfs.FS } + +type comparerFS struct { + vfs.FS + calls []string + get func(string) (fswatch.PathComparer, error) +} + +func (f *comparerFS) WatchPathComparer(path string) (fswatch.PathComparer, error) { + f.calls = append(f.calls, path) + return f.get(path) +} + +func TestExactFilesystemDoesNotProbe(t *testing.T) { + t.Parallel() + index := New(exactFS{}) + for _, path := range []string{"/project/A.ts", "/project/straße.ts"} { + if err := index.Add(path); err != nil { + t.Fatal(err) + } + } + for _, event := range []string{"/project/a.ts", "/project/STRASSE.ts", "/project/new.ts"} { + if got := index.Expand(event); !reflect.DeepEqual(got, []string{event}) { + t.Fatalf("Expand(%q) = %q", event, got) + } + } +} + +func TestExistingAncestorComparer(t *testing.T) { + t.Parallel() + f := &comparerFS{get: func(path string) (fswatch.PathComparer, error) { + if path == "/project/new" { + return fswatch.PathComparer{}, &fs.PathError{Op: "pathconf", Path: path, Err: fs.ErrNotExist} + } + return fswatch.PathComparer{}, nil + }} + index := New(f) + for _, path := range []string{"/project/new/a.ts", "/project/new/b.ts", "/project/new/a.ts"} { + if err := index.Add(path); err != nil { + t.Fatal(err) + } + } + if want := []string{"/project/new", "/project", "/"}; !reflect.DeepEqual(f.calls, want) { + t.Fatalf("probes = %q, want %q", f.calls, want) + } + before := len(f.calls) + for _, name := range []string{"/", "/project", "/project/new", "/project/new/a.ts"} { + if !index.Contains(name) { + t.Fatalf("missing original registration %q", name) + } + } + if index.Contains("/project/new/c.ts") || index.Contains("/project/NEW/a.ts") { + t.Fatal("unregistered original spelling was considered covered") + } + index.Expand("/project/new/deleted.ts") + if len(f.calls) != before { + t.Fatal("event expansion must not access the filesystem") + } +} + +func TestProbeFailureIsNotSilentlyIgnored(t *testing.T) { + t.Parallel() + f := &comparerFS{get: func(path string) (fswatch.PathComparer, error) { + return fswatch.PathComparer{}, &fs.PathError{Op: "pathconf", Path: path, Err: fs.ErrPermission} + }} + index := New(f) + if err := index.Add("/project/a.ts"); !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Add error = %v", err) + } + + if got := index.Expand("/project/a.ts"); !reflect.DeepEqual(got, []string{"/project/a.ts"}) { + t.Fatalf("failed Add changed index: %q", got) + } +} + +func TestNonDirectoryLookupUsesExistingAncestor(t *testing.T) { + t.Parallel() + f := &comparerFS{get: func(path string) (fswatch.PathComparer, error) { + if path == "/project/package.json/child" { + return fswatch.PathComparer{}, &fs.PathError{Op: "pathconf", Path: path, Err: syscall.ENOTDIR} + } + return fswatch.PathComparer{}, nil + }} + if err := New(f).Add("/project/package.json/child/module.ts"); err != nil { + t.Fatal(err) + } +} + +func TestPhysicalRegistrations(t *testing.T) { + t.Parallel() + for _, sensitive := range []bool{false, true} { + index := New(vfstest.FromMap(map[string]string{}, sensitive)) + for _, registration := range []Registration{ + {Name: "/var", Realpath: "/private", Directory: true}, + {Name: "/var/project/node_modules/pkg", Realpath: "/packages/one", Directory: true}, + {Name: "/var/project/node_modules/pkg/a.ts", Realpath: "/packages/one/a.ts", Dependency: true}, + {Name: "/other/link.ts", Realpath: "/packages/one/a.ts", Dependency: true}, + {Name: "/unrelated/a.ts", Realpath: "/unrelated/a.ts", Dependency: true}, + } { + assert.NilError(t, index.Register(registration)) + assert.Assert(t, index.Covers(registration)) + } + assert.Assert(t, !index.Covers(Registration{Name: "/other/link.ts", Realpath: "/packages/two/a.ts", Dependency: true})) + for _, name := range []string{"/packages/one/a.ts", "/var/project/node_modules/pkg/a.ts", "/private/project/node_modules/pkg/a.ts"} { + result := index.Match(map[string]fswatch.EventKind{name: fswatch.EventUpdate}) + assert.Equal(t, result.Changes["/var/project/node_modules/pkg/a.ts"], fswatch.EventUpdate) + assert.Equal(t, result.Changes["/other/link.ts"], fswatch.EventUpdate) + assert.Assert(t, !slices.Contains(result.Affected, "/unrelated/a.ts")) + } + result := index.Match(map[string]fswatch.EventKind{ + "/packages": fswatch.EventDelete, + "/packages/one": fswatch.EventDelete, + "/packages/one/a.ts": fswatch.EventUpdate, + }) + assert.Equal(t, result.Changes["/other/link.ts"], fswatch.EventDelete) + assert.Equal(t, result.Changes["/var/project/node_modules/pkg/a.ts"], fswatch.EventDelete) + assert.Assert(t, !slices.Contains(result.Affected, "/unrelated/a.ts")) + assert.Assert(t, !slices.Contains(index.Expand("/packages/one-other/a.ts"), "/other/link.ts")) + assert.Assert(t, slices.Contains(index.Expand("/packages/one/new.ts"), "/var/project/node_modules/pkg/new.ts")) + got := index.Expand("/PACKAGES/ONE/a.ts") + assert.Equal(t, slices.Contains(got, "/other/link.ts"), !sensitive) + } +} + +func TestPhysicalRegistrationsDoNotInferDirectoryLinks(t *testing.T) { + t.Parallel() + index := New(vfstest.FromMap(map[string]string{}, true)) + assert.NilError(t, index.Register(Registration{Name: "/logical/file.ts", Realpath: "/physical/other.ts", Dependency: true})) + assert.DeepEqual(t, index.Expand("/physical/new.ts"), []string{"/physical/new.ts"}) + result := index.Match(map[string]fswatch.EventKind{"/physical": fswatch.EventDelete}) + assert.Equal(t, result.Changes["/logical/file.ts"], fswatch.EventDelete) +} + +func TestPhysicalRegistrationExpansionIsFinite(t *testing.T) { + t.Parallel() + index := New(vfstest.FromMap(map[string]string{}, true)) + assert.NilError(t, index.Register(Registration{Name: "/a/link", Realpath: "/a", Directory: true})) + result := index.Expand("/a/link/link/file.ts") + assert.Assert(t, len(result) < 10, "explicit endpoint matching must not recursively rewrite directory links") +} + +func TestPhysicalRegistrationRootEndpoints(t *testing.T) { + t.Parallel() + for _, test := range []struct { + logical, physical, event, expected string + }{ + {"/link", "/", "/new.ts", "/link/new.ts"}, + {"/", "/physical", "/physical/new.ts", "/new.ts"}, + {"C:/link", "D:/", "D:/new.ts", "C:/link/new.ts"}, + {"C:/", "D:/physical", "D:/physical/new.ts", "C:/new.ts"}, + } { + t.Run(test.logical+"->"+test.physical, func(t *testing.T) { + t.Parallel() + index := New(vfstest.FromMap(map[string]string{}, true)) + assert.NilError(t, index.Register(Registration{Name: test.logical, Realpath: test.physical, Directory: true})) + assert.Assert(t, slices.Contains(index.Expand(test.event), test.expected)) + }) + } +} + +func TestPhysicalRegistrationEmptyDirectoryUpdate(t *testing.T) { + t.Parallel() + index := New(vfstest.FromMap(map[string]string{}, true)) + assert.NilError(t, index.Register(Registration{Name: "/logical", Realpath: "/physical", Directory: true})) + result := index.Match(map[string]fswatch.EventKind{"/physical": fswatch.EventUpdate}) + assert.Assert(t, result.NamespaceChanged) + assert.DeepEqual(t, result.Affected, []string{"/logical"}) +} + +func TestPhysicalRegistrationMatchExpanded(t *testing.T) { + t.Parallel() + index := New(vfstest.FromMap(map[string]string{}, true)) + assert.NilError(t, index.Register(Registration{Name: "/a/link", Realpath: "/a", Directory: true})) + assert.NilError(t, index.Register(Registration{Name: "/a/link/file.ts", Realpath: "/a/file.ts", Dependency: true})) + events := make(map[string]fswatch.EventKind) + for _, name := range index.Expand("/a/file.ts") { + events[name] = fswatch.EventUpdate + } + result := index.MatchExpanded(events) + assert.DeepEqual(t, result.Changes, events) + assert.Assert(t, slices.Contains(result.Affected, "/a/link/file.ts")) + + events = map[string]fswatch.EventKind{"/a": fswatch.EventDelete} + result = index.MatchExpanded(events) + assert.Equal(t, result.Changes["/a/link/file.ts"], fswatch.EventDelete) + assert.Equal(t, len(events), 1, "matching must not mutate its input") +} diff --git a/tsc/internal/watchalias/registration.go b/tsc/internal/watchalias/registration.go new file mode 100644 index 0000000000000..17f9932316bef --- /dev/null +++ b/tsc/internal/watchalias/registration.go @@ -0,0 +1,250 @@ +package watchalias + +import ( + "fmt" + "maps" + "strings" + + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +// Registration describes an observed name, not a compiler identity. Realpath is +// an explicit filesystem observation; an empty value registers only spelling. +// Only explicit directory observations may rebase unknown descendants. +type Registration struct { + Name string + Realpath string + Dependency bool + Directory bool +} + +type registeredPath struct { + logical []*Registration + physical []*Registration + children []*registeredPath +} + +func (i *Index) toPath(name string) tspath.Path { + // Native equivalence is supplied by expandNative, including volume-sensitive + // ancestors. Compiler canonicalization must not merge those endpoint trees. + return tspath.ToPath(name, "", i.provider != nil || i.filesystem == nil || i.filesystem.UseCaseSensitiveFileNames()) +} + +func (i *Index) path(name string) *registeredPath { + key := i.toPath(name) + if node := i.paths[key]; node != nil { + return node + } + if i.paths == nil { + i.paths = make(map[tspath.Path]*registeredPath) + } + node := ®isteredPath{} + i.paths[key] = node + parent := tspath.GetDirectoryPath(name) + if parent != name && parent != "" { + p := i.path(parent) + p.children = append(p.children, node) + } + return node +} + +func (i *Index) Register(registration Registration) error { + if err := i.Add(registration.Name); err != nil { + return err + } + if registration.Realpath == "" { + return nil + } + if err := i.Add(registration.Realpath); err != nil { + return err + } + if previous := i.registrations[registration.Name]; previous != nil { + if previous.Realpath != registration.Realpath { + return fmt.Errorf("conflicting watch resolutions for %q: %q and %q", registration.Name, previous.Realpath, registration.Realpath) + } + previous.Dependency = previous.Dependency || registration.Dependency + previous.Directory = previous.Directory || registration.Directory + return nil + } + if i.registrations == nil { + i.registrations = make(map[string]*Registration) + } + i.registrations[registration.Name] = ®istration + logical := i.path(registration.Name) + logical.logical = append(logical.logical, ®istration) + physical := i.path(registration.Realpath) + physical.physical = append(physical.physical, ®istration) + return nil +} + +func (i *Index) Covers(registration Registration) bool { + if registration.Realpath == "" { + return i.Contains(registration.Name) + } + previous := i.registrations[registration.Name] + return previous != nil && previous.Realpath == registration.Realpath && + (!registration.Dependency || previous.Dependency) && (!registration.Directory || previous.Directory) +} + +func (i *Index) rebase(name string, physical bool, add func(string)) { + for ancestor := name; ; { + if node := i.paths[i.toPath(ancestor)]; node != nil { + registrations := node.logical + if !physical { + registrations = node.physical + } + for _, registration := range registrations { + if registration.Name == registration.Realpath || ancestor != name && !registration.Directory { + continue + } + target := registration.Realpath + if !physical { + target = registration.Name + } + add(tspath.CombinePaths(target, strings.TrimPrefix(name[len(ancestor):], "/"))) + } + } + parent := tspath.GetDirectoryPath(ancestor) + if parent == ancestor || parent == "" { + return + } + ancestor = parent + } +} + +// Expand matches finite, explicit endpoints. Requested watch roots may first +// supply a logical spelling; logical paths then map to resolved endpoints, and +// resolved endpoints fan out to original names. Returned names are never fed +// back into an unrestricted symlink-rewrite loop. +func (i *Index) Expand(event string) []string { + if len(i.registrations) == 0 { + return i.expandNative(event) + } + names := i.expandNative(event) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + seen[name] = struct{}{} + } + add := func(name string) { + for _, expanded := range i.expandNative(name) { + if _, ok := seen[expanded]; !ok { + seen[expanded] = struct{}{} + names = append(names, expanded) + } + } + } + for _, name := range names { + i.rebase(name, false, add) + } + for _, name := range names { + i.rebase(name, true, add) + } + for _, name := range names { + i.rebase(name, false, add) + } + return names +} + +type Matches struct { + Changes map[string]fswatch.EventKind + Affected []string + NamespaceChanged bool +} + +// Match returns original-name candidates and affected resolution observations. +// Physical deletions use the same endpoint tree as resolution invalidation. +// Logical cache directory traversal remains the caller's responsibility when +// it registers only spellings, rather than resolved dependencies. +func (i *Index) Match(events map[string]fswatch.EventKind) Matches { + result := Matches{Changes: make(map[string]fswatch.EventKind, len(events))} + for name, kind := range events { + knownLeaf := false + for _, name := range i.Expand(name) { + if previous, ok := result.Changes[name]; !ok || previous != fswatch.EventDelete { + result.Changes[name] = kind + } + leaf, directory := i.classifyPath(name) + knownLeaf = knownLeaf || leaf + result.NamespaceChanged = result.NamespaceChanged || directory + } + result.NamespaceChanged = result.NamespaceChanged || kind != fswatch.EventUpdate || !knownLeaf + } + return i.matchSubtrees(result) +} + +// MatchExpanded applies subtree effects to already-expanded event spellings. +// Ordered callers can coalesce directory lifecycles before deriving deletions, +// without expanding aliases again and reviving canceled notifications. +func (i *Index) MatchExpanded(events map[string]fswatch.EventKind) Matches { + result := Matches{Changes: maps.Clone(events)} + for name, kind := range events { + leaf, directory := i.classifyPath(name) + result.NamespaceChanged = result.NamespaceChanged || directory || kind != fswatch.EventUpdate || !leaf + } + return i.matchSubtrees(result) +} + +func (i *Index) classifyPath(name string) (leaf, directory bool) { + if node := i.paths[i.toPath(name)]; node != nil { + if len(node.children) != 0 { + return false, true + } + for _, registrations := range [][]*Registration{node.logical, node.physical} { + for _, registration := range registrations { + directory = directory || registration.Directory + leaf = leaf || !registration.Directory + } + } + } + return leaf, directory +} + +func (i *Index) matchSubtrees(result Matches) Matches { + add := func(name string, kind fswatch.EventKind) { + if previous, ok := result.Changes[name]; !ok || previous != fswatch.EventDelete { + result.Changes[name] = kind + } + } + affected := make(map[*Registration]struct{}) + visited := make(map[*registeredPath]struct{}) + var visit func(*registeredPath, bool, bool) + visit = func(node *registeredPath, subtree, deleted bool) { + if node == nil { + return + } + if _, ok := visited[node]; ok { + return + } + visited[node] = struct{}{} + for _, registrations := range [][]*Registration{node.logical, node.physical} { + for _, registration := range registrations { + if _, ok := affected[registration]; !ok { + affected[registration] = struct{}{} + result.Affected = append(result.Affected, registration.Name) + } + if deleted && registration.Dependency { + add(registration.Name, fswatch.EventDelete) + } + } + } + if subtree { + for _, child := range node.children { + visit(child, true, deleted) + } + } + } + // Process deletes first so overlapping update/delete notifications cannot + // cause a matched subtree to be revisited. + for name, kind := range result.Changes { + if kind == fswatch.EventDelete { + visit(i.paths[i.toPath(name)], true, true) + } + } + for name, kind := range result.Changes { + if kind != fswatch.EventDelete { + visit(i.paths[i.toPath(name)], result.NamespaceChanged, false) + } + } + return result +} diff --git a/tsc/testdata/baselines/reference/tsbuildWatch/dependencyUpdate/watches-absolute-non-root-dependency-updates.js b/tsc/testdata/baselines/reference/tsbuildWatch/dependencyUpdate/watches-absolute-non-root-dependency-updates.js index 71a19baf1bcb5..a5f57b0545358 100644 --- a/tsc/testdata/baselines/reference/tsbuildWatch/dependencyUpdate/watches-absolute-non-root-dependency-updates.js +++ b/tsc/testdata/baselines/reference/tsbuildWatch/dependencyUpdate/watches-absolute-non-root-dependency-updates.js @@ -133,10 +133,10 @@ export const value = myValue; Watch Registrations:: Directory watches:: - c:/home/src/tslibs/ts/lib - c:/work/project - c:/work/project/src (recursive) - d:/work/deps + C:/home/src/tslibs/TS/Lib + C:/work/project + C:/work/project/src (recursive) + D:/work/deps tsconfig.json:: SemanticDiagnostics:: *refresh* C:/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts @@ -257,10 +257,10 @@ Output:: Watch Registrations:: Directory watches:: - c:/home/src/tslibs/ts/lib - c:/work/project - c:/work/project/src (recursive) - d:/work/deps + C:/home/src/tslibs/TS/Lib + C:/work/project + C:/work/project/src (recursive) + D:/work/deps tsconfig.json:: SemanticDiagnostics:: *refresh* D:/work/deps/dep.d.ts