package execute import ( "context" "fmt" "reflect" "time" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/execute/incremental" "github.com/microsoft/typescript-go/internal/execute/tsc" "github.com/microsoft/typescript-go/internal/execute/watchmanager" "github.com/microsoft/typescript-go/internal/fswatch" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs/cachedvfs" "github.com/microsoft/typescript-go/internal/vfs/trackingvfs" ) type cachedSourceFile struct { file *ast.SourceFile modTime time.Time } type watchCompilerHost struct { compiler.CompilerHost cache *collections.SyncMap[tspath.Path, *cachedSourceFile] } func (h *watchCompilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.SourceFile { info := h.CompilerHost.FS().Stat(opts.FileName) if cached, ok := h.cache.Load(opts.Path); ok { if info != nil && info.ModTime().Equal(cached.modTime) { return cached.file } } file := h.CompilerHost.GetSourceFile(opts) if file != nil { if info != nil { h.cache.Store(opts.Path, &cachedSourceFile{ file: file, modTime: info.ModTime(), }) } } else { h.cache.Delete(opts.Path) } return file } type Watcher struct { sys tsc.System configFileName string config *tsoptions.ParsedCommandLine compilerOptionsFromCommandLine *core.CompilerOptions commandLineRaw *collections.OrderedMap[string, any] reportDiagnostic tsc.DiagnosticReporter reportErrorSummary tsc.DiagnosticsReporter reportWatchStatus tsc.DiagnosticReporter testing tsc.CommandLineTesting program *incremental.Program extendedConfigCache *tsc.ExtendedConfigCache configModified bool configHasErrors bool configFilePaths []string sourceFileCache *collections.SyncMap[tspath.Path, *cachedSourceFile] wm *watchmanager.WatchManager seenFiles *collections.Set[tspath.Path] // all build dependencies (for event filtering) configMtimes map[string]time.Time } var _ tsc.Watcher = (*Watcher)(nil) func createWatcher( sys tsc.System, configParseResult *tsoptions.ParsedCommandLine, compilerOptionsFromCommandLine *core.CompilerOptions, commandLineRaw *collections.OrderedMap[string, any], reportDiagnostic tsc.DiagnosticReporter, reportErrorSummary tsc.DiagnosticsReporter, testing tsc.CommandLineTesting, ) *Watcher { wm := watchmanager.NewWatchManager(sys.Writer(), sys.FS().DirectoryExists) if t, ok := testing.(watchmanager.CommandLineTestingWithWatchBackend); ok { wm.SetBackend(t.WatchBackend()) } w := &Watcher{ sys: sys, config: configParseResult, compilerOptionsFromCommandLine: compilerOptionsFromCommandLine, commandLineRaw: commandLineRaw, reportDiagnostic: reportDiagnostic, reportErrorSummary: reportErrorSummary, reportWatchStatus: tsc.CreateWatchStatusReporter(sys, configParseResult.Locale(), configParseResult.CompilerOptions(), testing), testing: testing, sourceFileCache: &collections.SyncMap[tspath.Path, *cachedSourceFile]{}, wm: wm, } if configParseResult.ConfigFile != nil { w.configFileName = configParseResult.ConfigFile.SourceFile.FileName() } return w } func (w *Watcher) start(ctx context.Context) { w.wm.Lock() w.extendedConfigCache = &tsc.ExtendedConfigCache{} host := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), w.sys.FS(), w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing)) w.program = incremental.ReadBuildInfoProgram(w.config, incremental.NewBuildInfoReader(host), host) if w.configFileName != "" { w.configFilePaths = append([]string{w.configFileName}, w.config.ExtendedSourceFiles()...) } if w.sys.GetEnvironmentVariable("TS_WATCH_DEBUG") != "" { w.wm.DebugLog = w.sys.Writer() } if w.testing == nil { w.wm.EnsureDefaultBackend() } w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) if err := w.doBuild(); err != nil { w.wm.ForceOverflow() } w.wm.Unlock() if w.testing == nil { w.wm.RunLoop(ctx, w.DoCycle) } } func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool { cwd := w.sys.GetCurrentDirectory() 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) 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) desiredDirs[dir] = false } // Config file parent directories as non-recursive watches for _, cfgPath := range w.configFilePaths { realPath := w.sys.FS().Realpath(cfgPath) dir := tspath.GetDirectoryPath(realPath) if _, has := desiredDirs[dir]; !has { desiredDirs[dir] = false } } // For no-config CLI mode, also watch the CLI-specified files' directories if w.config.ConfigFile == nil { for _, fileName := range w.config.FileNames() { absPath := tspath.GetNormalizedAbsolutePath(fileName, cwd) realPath := w.sys.FS().Realpath(absPath) dir := tspath.GetDirectoryPath(realPath) if _, has := desiredDirs[dir]; !has { desiredDirs[dir] = false } } } // Add parent directories for seen files not covered by existing dir watches. // Resolve ancestor fallbacks first so coverage checks use final dirs. resolvedDirs := w.wm.ResolveDesiredDirs(desiredDirs) opts := w.comparePathsOptions() for _, filePath := range seenFilePaths { dir := tspath.GetDirectoryPath(filePath) if !watchmanager.IsDirCoveredByWatch(resolvedDirs, dir, opts) { if watchmanager.CanWatchDirectory(dir) { resolvedDirs[dir] = false } } } // Re-resolve in case newly added dirs don't exist return w.wm.ResolveDesiredDirs(resolvedDirs) } func (w *Watcher) reconcileWatches(seenFilePaths []string) error { desiredDirs := w.computeDesiredWatches(seenFilePaths) return w.wm.ReconcileWatches(desiredDirs) } func (w *Watcher) comparePathsOptions() tspath.ComparePathsOptions { return tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: w.sys.FS().UseCaseSensitiveFileNames(), CurrentDirectory: w.sys.GetCurrentDirectory(), } } func (w *Watcher) DoCycle() { w.wm.Lock() defer w.wm.Unlock() changedPaths, overflow := w.wm.DrainEvents() hasEvents := len(changedPaths) > 0 || overflow if w.recheckTsConfig() { return } if hasEvents && !overflow && !w.configModified { // Filter fswatch events against known dependencies if w.isRelevantChange(changedPaths) { w.evictChangedSourceFiles(changedPaths) } else { if w.wm.DebugLog != nil { fmt.Fprintf(w.wm.DebugLog, "[watch] DoCycle: %d event(s) not relevant to compilation, skipping rebuild\n", len(changedPaths)) } if w.testing != nil { w.testing.OnProgram(w.program) } return } } else if overflow { // Overflow: evict the entire source file cache to force re-build w.sourceFileCache = &collections.SyncMap[tspath.Path, *cachedSourceFile]{} } else if !hasEvents && !w.configModified { // No events and no config change if w.wm.DebugLog != nil { fmt.Fprintf(w.wm.DebugLog, "[watch] DoCycle: no events, skipping\n") } if w.testing != nil { w.testing.OnProgram(w.program) } return } w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.File_change_detected_Starting_incremental_compilation)) if err := w.doBuild(); err != nil { // Mid-cycle watch failure; force a full rebuild on the next event w.wm.ForceOverflow() } } func (w *Watcher) isRelevantChange(changedPaths map[string]fswatch.EventKind) bool { caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() cwd := w.sys.GetCurrentDirectory() opts := w.comparePathsOptions() for eventPath := range changedPaths { p := tspath.ToPath(eventPath, cwd, caseSensitive) if w.seenFiles.Has(p) { return true } if w.config.ConfigFile != nil && w.config.PossiblyMatchesFileName(eventPath) { return true } if w.config.ConfigFile != nil && w.config.PossiblyMatchesDirectoryName(p) { return true } if w.sys.FS().DirectoryExists(eventPath) { if w.wm.IsPathUnderWatch(eventPath, opts) { return true } } } return false } func (w *Watcher) doBuild() error { if w.configModified { w.sourceFileCache = &collections.SyncMap[tspath.Path, *cachedSourceFile]{} } cached := cachedvfs.From(w.sys.FS()) tfs := &trackingvfs.FS{Inner: cached} innerHost := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), tfs, w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing)) host := &watchCompilerHost{CompilerHost: innerHost, cache: w.sourceFileCache} var wildcardDirs map[string]bool if w.config.ConfigFile != nil { wildcardDirs = w.config.WildcardDirectories() for dir := range wildcardDirs { tfs.SeenFiles.Add(dir) } if len(wildcardDirs) > 0 { w.config = w.config.ReloadFileNamesOfParsedCommandLine(w.sys.FS()) } } for _, path := range w.configFilePaths { tfs.SeenFiles.Add(path) } w.program = incremental.NewProgram(compiler.NewProgram(compiler.ProgramOptions{ Config: w.config, Host: host, }), w.program, nil, w.testing != nil) result := w.compileAndEmit() cached.DisableAndClearCache() caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() cwd := w.sys.GetCurrentDirectory() seenSlice := tfs.SeenFiles.ToSlice() w.seenFiles = collections.NewSetWithSizeHint[tspath.Path](len(seenSlice)) for _, p := range seenSlice { w.seenFiles.Add(tspath.ToPath(p, cwd, caseSensitive)) } w.configMtimes = make(map[string]time.Time, len(w.configFilePaths)) for _, cfgPath := range w.configFilePaths { if s := w.sys.FS().Stat(cfgPath); s != nil { w.configMtimes[cfgPath] = s.ModTime() } } if err := w.reconcileWatches(seenSlice); err != nil { fmt.Fprintf(w.sys.Writer(), "%v\n", err) return err } w.configModified = false programFiles := w.program.GetProgram().FilesByPath() w.sourceFileCache.Range(func(path tspath.Path, _ *cachedSourceFile) bool { if _, ok := programFiles[path]; !ok { w.sourceFileCache.Delete(path) } return true }) errorCount := len(result.Diagnostics) if errorCount == 1 { w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Found_1_error_Watching_for_file_changes)) } else { w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Found_0_errors_Watching_for_file_changes, errorCount)) } if w.testing != nil { w.testing.OnProgram(w.program) } return nil } func (w *Watcher) evictChangedSourceFiles(changedPaths map[string]fswatch.EventKind) { caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() cwd := w.sys.GetCurrentDirectory() for eventPath := range changedPaths { p := tspath.ToPath(eventPath, cwd, caseSensitive) if _, ok := w.sourceFileCache.Load(p); ok { if w.wm.DebugLog != nil { fmt.Fprintf(w.wm.DebugLog, "[watch] evicting cached source file: %s\n", p) } w.sourceFileCache.Delete(p) } } } func (w *Watcher) compileAndEmit() tsc.CompileAndEmitResult { return tsc.EmitFilesAndReportErrors(tsc.EmitInput{ Sys: w.sys, ProgramLike: w.program, Program: w.program.GetProgram(), Config: w.config, ReportDiagnostic: w.reportDiagnostic, ReportErrorSummary: w.reportErrorSummary, Writer: w.sys.Writer(), CompileTimes: &tsc.CompileTimes{}, Testing: w.testing, }) } func (w *Watcher) recheckTsConfig() bool { if w.configFileName == "" { return false } if !w.configHasErrors && len(w.configFilePaths) > 0 { changed := false for _, path := range w.configFilePaths { oldMtime, ok := w.configMtimes[path] s := w.sys.FS().Stat(path) if !ok { if s != nil { changed = true break } } else if s == nil || !s.ModTime().Equal(oldMtime) { changed = true break } } if !changed { return false } } configParseResult := w.parseConfigFile() if configParseResult == nil { return true } if w.configHasErrors { w.configModified = true } w.configHasErrors = false w.configFilePaths = append([]string{w.configFileName}, configParseResult.ExtendedSourceFiles()...) if !reflect.DeepEqual(w.config.ParsedConfig, configParseResult.ParsedConfig) { w.configModified = true } w.config = configParseResult return false } func (w *Watcher) parseConfigFile() *tsoptions.ParsedCommandLine { extendedConfigCache := &tsc.ExtendedConfigCache{} configParseResult, errors := tsoptions.GetParsedCommandLineOfConfigFile(w.configFileName, w.compilerOptionsFromCommandLine, w.commandLineRaw, w.sys, extendedConfigCache) if len(errors) > 0 { for _, e := range errors { w.reportDiagnostic(e) } w.configHasErrors = true errorCount := len(errors) if errorCount == 1 { w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Found_1_error_Watching_for_file_changes)) } else { w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Found_0_errors_Watching_for_file_changes, errorCount)) } return nil } w.extendedConfigCache = extendedConfigCache return configParseResult }