Files
kjol/tools/tsgo/internal/execute/watcher.go
2026-07-09 16:50:43 -04:00

444 lines
14 KiB
Go

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
}