Skip to content

Commit bb2240e

Browse files
johnfav03Copilot
andauthored
Rebuilt CLI watcher around new fswatch package (#4026)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4cf361f commit bb2240e

52 files changed

Lines changed: 1526 additions & 724 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/tsgo/main.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package main
22

33
import (
4+
"context"
45
"os"
6+
"os/signal"
7+
"syscall"
58

69
"github.com/microsoft/typescript-go/internal/core"
710
"github.com/microsoft/typescript-go/internal/execute"
@@ -22,6 +25,8 @@ func runMain() int {
2225
return runAPI(args[1:])
2326
}
2427
}
25-
result := execute.CommandLine(newSystem(), args, nil)
28+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
29+
defer stop()
30+
result := execute.CommandLine(ctx, newSystem(), args, nil)
2631
return int(result.Status)
2732
}

internal/execute/build/orchestrator.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package build
22

33
import (
4+
"context"
45
"io"
56
"strings"
67
"sync/atomic"
@@ -208,30 +209,35 @@ func (o *Orchestrator) GenerateGraph(oldTasks *collections.SyncMap[tspath.Path,
208209
}
209210
}
210211

211-
func (o *Orchestrator) Start() tsc.CommandLineResult {
212+
func (o *Orchestrator) Start(ctx context.Context) tsc.CommandLineResult {
212213
if o.opts.Command.CompilerOptions.Watch.IsTrue() {
213214
o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode))
214215
}
215216
o.GenerateGraph(nil)
216217
result := o.buildOrClean()
217218
if o.opts.Command.CompilerOptions.Watch.IsTrue() {
218-
o.Watch()
219+
o.Watch(ctx)
219220
result.Watcher = o
220221
}
221222
return result
222223
}
223224

224-
func (o *Orchestrator) Watch() {
225+
func (o *Orchestrator) Watch(ctx context.Context) {
225226
o.updateWatch()
226227
o.resetCaches()
227228

228229
// Start watching for file changes
229230
if o.opts.Testing == nil {
230231
watchInterval := o.opts.Command.WatchOptions.WatchInterval()
232+
ticker := time.NewTicker(watchInterval)
233+
defer ticker.Stop()
231234
for {
232-
// Testing mode: run a single cycle and exit
233-
time.Sleep(watchInterval)
234-
o.DoCycle()
235+
select {
236+
case <-ctx.Done():
237+
return
238+
case <-ticker.C:
239+
o.DoCycle()
240+
}
235241
}
236242
}
237243
}

internal/execute/tsc.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@ func stopTracing(sys tsc.System, tr *tracing.Tracing) {
4949
}
5050
}
5151

52-
func CommandLine(sys tsc.System, commandLineArgs []string, testing tsc.CommandLineTesting) tsc.CommandLineResult {
52+
func CommandLine(ctx context.Context, sys tsc.System, commandLineArgs []string, testing tsc.CommandLineTesting) tsc.CommandLineResult {
5353
if len(commandLineArgs) > 0 {
5454
switch strings.ToLower(commandLineArgs[0]) {
5555
case "-b", "--b", "-build", "--build":
56-
return tscBuildCompilation(sys, tsoptions.ParseBuildCommandLine(commandLineArgs, sys), testing)
56+
return tscBuildCompilation(ctx, sys, tsoptions.ParseBuildCommandLine(commandLineArgs, sys), testing)
5757
// case "-f":
5858
// return fmtMain(sys, commandLineArgs[1], commandLineArgs[1])
5959
}
6060
}
6161

62-
return tscCompilation(sys, tsoptions.ParseCommandLine(commandLineArgs, sys), testing)
62+
return tscCompilation(ctx, sys, tsoptions.ParseCommandLine(commandLineArgs, sys), testing)
6363
}
6464

6565
func fmtMain(sys tsc.System, input, output string) tsc.ExitStatus {
@@ -87,7 +87,7 @@ func fmtMain(sys tsc.System, input, output string) tsc.ExitStatus {
8787
return tsc.ExitStatusSuccess
8888
}
8989

90-
func tscBuildCompilation(sys tsc.System, buildCommand *tsoptions.ParsedBuildCommandLine, testing tsc.CommandLineTesting) tsc.CommandLineResult {
90+
func tscBuildCompilation(ctx context.Context, sys tsc.System, buildCommand *tsoptions.ParsedBuildCommandLine, testing tsc.CommandLineTesting) tsc.CommandLineResult {
9191
locale := buildCommand.Locale()
9292
reportDiagnostic := tsc.CreateDiagnosticReporter(sys, sys.Writer(), locale, buildCommand.CompilerOptions)
9393

@@ -115,10 +115,10 @@ func tscBuildCompilation(sys tsc.System, buildCommand *tsoptions.ParsedBuildComm
115115
Command: buildCommand,
116116
Testing: testing,
117117
})
118-
return orchestrator.Start()
118+
return orchestrator.Start(ctx)
119119
}
120120

121-
func tscCompilation(sys tsc.System, commandLine *tsoptions.ParsedCommandLine, testing tsc.CommandLineTesting) tsc.CommandLineResult {
121+
func tscCompilation(ctx context.Context, sys tsc.System, commandLine *tsoptions.ParsedCommandLine, testing tsc.CommandLineTesting) tsc.CommandLineResult {
122122
configFileName := ""
123123
locale := commandLine.Locale()
124124
reportDiagnostic := tsc.CreateDiagnosticReporter(sys, sys.Writer(), locale, commandLine.CompilerOptions())
@@ -201,9 +201,9 @@ func tscCompilation(sys tsc.System, commandLine *tsoptions.ParsedCommandLine, te
201201
configForCompilation := commandLine
202202
extendedConfigCache := &tsc.ExtendedConfigCache{}
203203
var compileTimes tsc.CompileTimes
204+
var commandLineRaw *collections.OrderedMap[string, any]
204205
if configFileName != "" {
205206
configStart := sys.Now()
206-
var commandLineRaw *collections.OrderedMap[string, any]
207207
if raw, ok := commandLine.Raw.(*collections.OrderedMap[string, any]); ok {
208208
// Wrap command line options in a "compilerOptions" key to match tsconfig.json structure
209209
wrapped := &collections.OrderedMap[string, any]{}
@@ -234,11 +234,12 @@ func tscCompilation(sys tsc.System, commandLine *tsoptions.ParsedCommandLine, te
234234
sys,
235235
configForCompilation,
236236
compilerOptionsFromCommandLine,
237+
commandLineRaw,
237238
reportDiagnostic,
238239
reportErrorSummary,
239240
testing,
240241
)
241-
watcher.start()
242+
watcher.start(ctx)
242243
return tsc.CommandLineResult{Status: tsc.ExitStatusSuccess, Watcher: watcher}
243244
} else if configForCompilation.CompilerOptions().IsIncremental() {
244245
return performIncrementalCompilation(
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package tsctests
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"path"
7+
"sort"
8+
"strings"
9+
"sync"
10+
11+
"github.com/microsoft/typescript-go/internal/execute"
12+
"github.com/microsoft/typescript-go/internal/fswatch"
13+
"github.com/microsoft/typescript-go/internal/testutil/fsbaselineutil"
14+
)
15+
16+
// MockWatchBackend implements execute.WatchBackend for testing. It
17+
// records all WatchDirectory calls so tests can verify that
18+
// the correct watches are registered. Events can be delivered through
19+
// SendEvents, which routes them only through watches whose paths
20+
// match, enforcing that tests fail if the wrong watches are set up.
21+
type MockWatchBackend struct {
22+
mu sync.Mutex
23+
Dirs map[string]*MockWatch
24+
DirectoryExists func(string) bool // if set, WatchDirectory fails for non-existent dirs
25+
}
26+
27+
var _ execute.WatchBackend = (*MockWatchBackend)(nil)
28+
29+
// NewMockWatchBackend creates a ready-to-use mock backend.
30+
func NewMockWatchBackend() *MockWatchBackend {
31+
return &MockWatchBackend{
32+
Dirs: make(map[string]*MockWatch),
33+
}
34+
}
35+
36+
// HasWatches reports whether any watches have been registered.
37+
func (m *MockWatchBackend) HasWatches() bool {
38+
m.mu.Lock()
39+
defer m.mu.Unlock()
40+
return len(m.Dirs) > 0
41+
}
42+
43+
// MockWatch records a single registered watch.
44+
type MockWatch struct {
45+
Path string
46+
Callback fswatch.WatchCallback
47+
Recursive bool
48+
Ignore func(string) bool
49+
Closed bool
50+
}
51+
52+
func (w *MockWatch) Close() error {
53+
w.Closed = true
54+
return nil
55+
}
56+
57+
func (m *MockWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, recursive bool, ignore func(string) bool) (io.Closer, error) {
58+
m.mu.Lock()
59+
defer m.mu.Unlock()
60+
if m.DirectoryExists != nil && !m.DirectoryExists(dir) {
61+
return nil, fmt.Errorf("directory does not exist: %s", dir)
62+
}
63+
w := &MockWatch{Path: dir, Callback: fn, Recursive: recursive, Ignore: ignore}
64+
m.Dirs[dir] = w
65+
return w, nil
66+
}
67+
68+
// SendEvents routes events through the registered watch callbacks
69+
// that match each event's path. Directory watches match if the event
70+
// path is a child (or recursive descendant) of the watched directory.
71+
// Events that match no watch are silently dropped — this is by design
72+
// so that tests fail when the production code doesn't register the
73+
// needed watches.
74+
func (m *MockWatchBackend) SendEvents(events []fswatch.Event) {
75+
// Snapshot callbacks under the lock, then invoke outside the lock
76+
// to avoid deadlock if the callback re-enters the mock.
77+
m.mu.Lock()
78+
type target struct {
79+
cb fswatch.WatchCallback
80+
events []fswatch.Event
81+
}
82+
targets := make(map[*MockWatch]*target)
83+
84+
for _, e := range events {
85+
// Check directory watches.
86+
for _, w := range m.Dirs {
87+
if w.Closed {
88+
continue
89+
}
90+
if w.Ignore != nil && w.Ignore(e.Path) {
91+
continue
92+
}
93+
if !pathIsUnder(e.Path, w.Path, w.Recursive) {
94+
continue
95+
}
96+
if t, ok := targets[w]; ok {
97+
t.events = append(t.events, e)
98+
} else {
99+
targets[w] = &target{cb: w.Callback, events: []fswatch.Event{e}}
100+
}
101+
}
102+
}
103+
m.mu.Unlock()
104+
105+
for _, t := range targets {
106+
t.cb(t.events, nil)
107+
}
108+
}
109+
110+
// SendChangedPaths converts a list of file changes into fswatch
111+
// events with appropriate event kinds and routes them through
112+
// registered watches via SendEvents. For new/modified files, it also
113+
// emits update events for their parent directories, simulating how
114+
// real filesystem watchers report directory events.
115+
func (m *MockWatchBackend) SendChangedPaths(changes []fsbaselineutil.FileChange) {
116+
events := make([]fswatch.Event, 0, len(changes)*2)
117+
seenDirs := make(map[string]struct{})
118+
for _, c := range changes {
119+
kind := fswatch.EventUpdate
120+
if c.Deleted {
121+
kind = fswatch.EventDelete
122+
}
123+
events = append(events, fswatch.Event{Kind: kind, Path: c.Path})
124+
// Emit update events for parent directories of changed files.
125+
// Real filesystem watchers deliver events to non-recursive watches
126+
// when a child directory is created, which the mock must replicate.
127+
dir := path.Dir(c.Path)
128+
for dir != "" && dir != "/" && dir != "." {
129+
if _, seen := seenDirs[dir]; seen {
130+
break
131+
}
132+
seenDirs[dir] = struct{}{}
133+
events = append(events, fswatch.Event{Kind: fswatch.EventUpdate, Path: dir})
134+
parent := path.Dir(dir)
135+
if parent == dir {
136+
break
137+
}
138+
dir = parent
139+
}
140+
}
141+
m.SendEvents(events)
142+
}
143+
144+
// pathIsUnder reports whether eventPath is inside dir. If recursive is
145+
// false, only direct children match.
146+
func pathIsUnder(eventPath, dir string, recursive bool) bool {
147+
if !strings.HasPrefix(eventPath, dir) {
148+
return false
149+
}
150+
rest := eventPath[len(dir):]
151+
if len(rest) == 0 {
152+
return false // exact match = the dir itself, not a child
153+
}
154+
if rest[0] != '/' {
155+
return false // e.g. dir="/foo", path="/foobar"
156+
}
157+
if !recursive {
158+
// Direct child only: no further '/' after the separator.
159+
return !strings.Contains(rest[1:], "/")
160+
}
161+
return true
162+
}
163+
164+
// WatchState returns a deterministic, human-readable summary of all
165+
// active watches. This is intended to be included in test baselines
166+
// so that watch registration correctness is verified via snapshot diffs.
167+
func (m *MockWatchBackend) WatchState() string {
168+
m.mu.Lock()
169+
defer m.mu.Unlock()
170+
171+
var b strings.Builder
172+
b.WriteString("Watch Registrations::\n")
173+
174+
// Directory watches, sorted by path.
175+
var dirs []string
176+
for dir, w := range m.Dirs {
177+
if !w.Closed {
178+
dirs = append(dirs, dir)
179+
}
180+
}
181+
sort.Strings(dirs)
182+
183+
b.WriteString("Directory watches::\n")
184+
if len(dirs) == 0 {
185+
b.WriteString(" (none)\n")
186+
}
187+
for _, d := range dirs {
188+
w := m.Dirs[d]
189+
if w.Recursive {
190+
fmt.Fprintf(&b, " %s (recursive)\n", d)
191+
} else {
192+
fmt.Fprintf(&b, " %s\n", d)
193+
}
194+
}
195+
196+
return b.String()
197+
}

internal/execute/tsctests/runner.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tsctests
22

33
import (
4+
"context"
45
"fmt"
56
"path/filepath"
67
"slices"
@@ -42,7 +43,7 @@ type tscInput struct {
4243

4344
func (test *tscInput) executeCommand(sys *TestSys, baselineBuilder *strings.Builder, commandLineArgs []string) tsc.CommandLineResult {
4445
fmt.Fprint(baselineBuilder, "tsgo ", strings.Join(commandLineArgs, " "), "\n")
45-
result := execute.CommandLine(sys, commandLineArgs, sys)
46+
result := execute.CommandLine(context.Background(), sys, commandLineArgs, sys)
4647
switch result.Status {
4748
case tsc.ExitStatusSuccess:
4849
baselineBuilder.WriteString("ExitStatus:: Success")
@@ -80,6 +81,9 @@ func (test *tscInput) run(t *testing.T, scenario string) {
8081
sys.baselineFSwithDiff(baselineBuilder)
8182
result := test.executeCommand(sys, baselineBuilder, test.commandLineArgs)
8283
sys.serializeState(baselineBuilder)
84+
if result.Watcher != nil && sys.mockWatchBackend.HasWatches() {
85+
baselineBuilder.WriteString(sys.mockWatchBackend.WatchState())
86+
}
8387
var unexpectedDiff strings.Builder
8488
unexpectedDiff.WriteString(sys.baselinePrograms(baselineBuilder, "Initial build"))
8589

@@ -93,14 +97,19 @@ func (test *tscInput) run(t *testing.T, scenario string) {
9397
if do.edit != nil {
9498
do.edit(sys)
9599
}
100+
changedPaths := sys.fsDiffer.ChangedPaths()
96101
sys.baselineFSwithDiff(baselineBuilder)
97102

98103
if result.Watcher == nil {
99104
test.executeCommand(sys, baselineBuilder, commandLineArgs)
100105
} else {
106+
sys.mockWatchBackend.SendChangedPaths(changedPaths)
101107
result.Watcher.DoCycle()
102108
}
103109
sys.serializeState(baselineBuilder)
110+
if result.Watcher != nil && sys.mockWatchBackend.HasWatches() {
111+
baselineBuilder.WriteString(sys.mockWatchBackend.WatchState())
112+
}
104113
unexpectedDiff.WriteString(sys.baselinePrograms(baselineBuilder, fmt.Sprintf("Edit [%d]:: %s\n", index, do.caption)))
105114
})
106115
wg.Queue(func() {
@@ -111,7 +120,7 @@ func (test *tscInput) run(t *testing.T, scenario string) {
111120
test.edits[i].edit(nonIncrementalSys)
112121
}
113122
}
114-
execute.CommandLine(nonIncrementalSys, commandLineArgs, nonIncrementalSys)
123+
execute.CommandLine(context.Background(), nonIncrementalSys, commandLineArgs, nonIncrementalSys)
115124
})
116125
wg.RunAndWait()
117126

0 commit comments

Comments
 (0)