1package watcher
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "sync"
10 "sync/atomic"
11 "time"
12
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/fsext"
16 "github.com/charmbracelet/crush/internal/lsp/protocol"
17 "github.com/rjeczalik/notify"
18)
19
20// global manages file watching shared across all LSP clients.
21//
22// IMPORTANT: This implementation uses github.com/rjeczalik/notify which provides
23// recursive watching on all platforms. On macOS it uses FSEvents, on Linux it
24// uses inotify (with recursion handled by the library), and on Windows it uses
25// ReadDirectoryChangesW.
26//
27// Key benefits:
28// - Single watch point for entire directory tree
29// - Automatic recursive watching without manually adding subdirectories
30// - No file descriptor exhaustion issues
31type global struct {
32 // Channel for receiving file system events
33 events chan notify.EventInfo
34
35 // Map of workspace watchers by client name
36 watchers *csync.Map[string, *Client]
37
38 // Single workspace root directory for ignore checking
39 root string
40
41 started atomic.Bool
42
43 // Debouncing for file events (shared across all clients)
44 debounceTime time.Duration
45 debounceMap *csync.Map[string, *time.Timer]
46
47 // Context for shutdown
48 ctx context.Context
49 cancel context.CancelFunc
50
51 // Wait group for cleanup
52 wg sync.WaitGroup
53}
54
55// instance returns the singleton global watcher instance
56var instance = sync.OnceValue(func() *global {
57 ctx, cancel := context.WithCancel(context.Background())
58 gw := &global{
59 events: make(chan notify.EventInfo, 4096), // Large buffer to prevent dropping events
60 watchers: csync.NewMap[string, *Client](),
61 debounceTime: 300 * time.Millisecond,
62 debounceMap: csync.NewMap[string, *time.Timer](),
63 ctx: ctx,
64 cancel: cancel,
65 }
66
67 return gw
68})
69
70// register registers a workspace watcher with the global watcher
71func (gw *global) register(name string, watcher *Client) {
72 gw.watchers.Set(name, watcher)
73 slog.Debug("lsp watcher: Registered workspace watcher", "name", name)
74}
75
76// unregister removes a workspace watcher from the global watcher
77func (gw *global) unregister(name string) {
78 gw.watchers.Del(name)
79 slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
80}
81
82// Start sets up recursive watching on the workspace root.
83//
84// Note: We use github.com/rjeczalik/notify which provides recursive watching
85// with a single watch point. The "..." suffix means watch recursively.
86// This is much more efficient than manually walking and watching each directory.
87func Start() error {
88 gw := instance()
89
90 // technically workspace root is always the same...
91 if gw.started.Load() {
92 slog.Debug("lsp watcher: watcher already set up, skipping")
93 return nil
94 }
95
96 cfg := config.Get()
97 root := cfg.WorkingDir()
98 slog.Debug("lsp watcher: set workspace directory to global watcher", "path", root)
99
100 // Store the workspace root for hierarchical ignore checking
101 gw.root = root
102 gw.started.Store(true)
103
104 // Start the event processing goroutine
105 gw.wg.Add(1)
106 go gw.processEvents()
107
108 // Set up recursive watching on the root directory
109 // The "..." suffix tells notify to watch recursively
110 watchPath := filepath.Join(root, "...")
111
112 // Watch for all event types we care about
113 events := notify.Create | notify.Write | notify.Remove | notify.Rename
114
115 if err := notify.Watch(watchPath, gw.events, events); err != nil {
116 return fmt.Errorf("lsp watcher: error setting up recursive watch on %s: %w", root, err)
117 }
118
119 slog.Info("lsp watcher: Started recursive watching", "root", root)
120 return nil
121}
122
123// processEvents processes file system events from the notify library.
124// Since notify handles recursive watching for us, we don't need to manually
125// add new directories - they're automatically included.
126func (gw *global) processEvents() {
127 defer gw.wg.Done()
128 cfg := config.Get()
129
130 if !gw.started.Load() {
131 slog.Error("lsp watcher: Global watcher not initialized")
132 return
133 }
134
135 for {
136 select {
137 case <-gw.ctx.Done():
138 return
139
140 case event, ok := <-gw.events:
141 if !ok {
142 return
143 }
144
145 path := event.Path()
146
147 // Skip ignored files
148 if fsext.ShouldExcludeFile(gw.root, path) {
149 continue
150 }
151
152 if cfg != nil && cfg.Options.DebugLSP {
153 slog.Debug("lsp watcher: Global watcher received event", "path", path, "event", event.Event().String())
154 }
155
156 // Convert notify event to our internal format and handle it
157 gw.handleFileEvent(event)
158 }
159 }
160}
161
162// handleFileEvent processes a file system event and distributes notifications to relevant clients
163func (gw *global) handleFileEvent(event notify.EventInfo) {
164 cfg := config.Get()
165 path := event.Path()
166 uri := string(protocol.URIFromPath(path))
167
168 // Map notify events to our change types
169 var changeType protocol.FileChangeType
170 var watchKindNeeded protocol.WatchKind
171
172 switch event.Event() {
173 case notify.Create:
174 changeType = protocol.FileChangeType(protocol.Created)
175 watchKindNeeded = protocol.WatchCreate
176 // Handle file creation for all relevant clients
177 if !isDir(path) && !fsext.ShouldExcludeFile(gw.root, path) {
178 gw.openMatchingFileForClients(path)
179 }
180 case notify.Write:
181 changeType = protocol.FileChangeType(protocol.Changed)
182 watchKindNeeded = protocol.WatchChange
183 case notify.Remove:
184 changeType = protocol.FileChangeType(protocol.Deleted)
185 watchKindNeeded = protocol.WatchDelete
186 case notify.Rename:
187 // Treat rename as delete + create
188 // First handle as delete
189 for _, watcher := range gw.watchers.Seq2() {
190 if !watcher.client.HandlesFile(path) {
191 continue
192 }
193 if watched, watchKind := watcher.isPathWatched(path); watched {
194 if watchKind&protocol.WatchDelete != 0 {
195 gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
196 }
197 }
198 }
199 // Then check if renamed file exists and treat as create
200 if !isDir(path) {
201 changeType = protocol.FileChangeType(protocol.Created)
202 watchKindNeeded = protocol.WatchCreate
203 } else {
204 return // Already handled delete, nothing more to do for directories
205 }
206 default:
207 // Unknown event type, skip
208 return
209 }
210
211 // Process the event for each relevant client
212 for client, watcher := range gw.watchers.Seq2() {
213 if !watcher.client.HandlesFile(path) {
214 continue // client doesn't handle this filetype
215 }
216
217 // Debug logging per client
218 if cfg.Options.DebugLSP {
219 matched, kind := watcher.isPathWatched(path)
220 slog.Debug("lsp watcher: File event for client",
221 "path", path,
222 "event", event.Event().String(),
223 "watched", matched,
224 "kind", kind,
225 "client", client,
226 )
227 }
228
229 // Check if this path should be watched according to server registrations
230 if watched, watchKind := watcher.isPathWatched(path); watched {
231 if watchKind&watchKindNeeded != 0 {
232 // Skip directory events for non-delete operations
233 if changeType != protocol.FileChangeType(protocol.Deleted) && isDir(path) {
234 continue
235 }
236
237 if changeType == protocol.FileChangeType(protocol.Deleted) {
238 // Don't debounce deletes
239 gw.handleFileEventForClient(watcher, uri, changeType)
240 } else {
241 // Debounce creates and changes
242 gw.debounceHandleFileEventForClient(watcher, uri, changeType)
243 }
244 }
245 }
246 }
247}
248
249// isDir checks if a path is a directory
250func isDir(path string) bool {
251 info, err := os.Stat(path)
252 return err == nil && info.IsDir()
253}
254
255// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
256func (gw *global) openMatchingFileForClients(path string) {
257 // Skip directories
258 info, err := os.Stat(path)
259 if err != nil || info.IsDir() {
260 return
261 }
262
263 // Skip excluded files
264 if fsext.ShouldExcludeFile(gw.root, path) {
265 return
266 }
267
268 // Open the file for each client that handles it and has matching patterns
269 for _, watcher := range gw.watchers.Seq2() {
270 if watcher.client.HandlesFile(path) {
271 watcher.openMatchingFile(gw.ctx, path)
272 }
273 }
274}
275
276// debounceHandleFileEventForClient handles file events with debouncing for a specific client
277func (gw *global) debounceHandleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
278 // Create a unique key based on URI, change type, and client name
279 key := fmt.Sprintf("%s:%d:%s", uri, changeType, watcher.name)
280
281 // Cancel existing timer if any
282 if timer, exists := gw.debounceMap.Get(key); exists {
283 timer.Stop()
284 }
285
286 // Create new timer
287 gw.debounceMap.Set(key, time.AfterFunc(gw.debounceTime, func() {
288 gw.handleFileEventForClient(watcher, uri, changeType)
289
290 // Cleanup timer after execution
291 gw.debounceMap.Del(key)
292 }))
293}
294
295// handleFileEventForClient sends file change notifications to a specific client
296func (gw *global) handleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
297 // If the file is open and it's a change event, use didChange notification
298 filePath, err := protocol.DocumentURI(uri).Path()
299 if err != nil {
300 slog.Error("lsp watcher: Error converting URI to path", "uri", uri, "error", err)
301 return
302 }
303
304 if changeType == protocol.FileChangeType(protocol.Deleted) {
305 watcher.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
306 } else if changeType == protocol.FileChangeType(protocol.Changed) && watcher.client.IsFileOpen(filePath) {
307 err := watcher.client.NotifyChange(gw.ctx, filePath)
308 if err != nil {
309 slog.Error("lsp watcher: Error notifying change", "error", err)
310 }
311 return
312 }
313
314 // Notify LSP server about the file event using didChangeWatchedFiles
315 if err := watcher.notifyFileEvent(gw.ctx, uri, changeType); err != nil {
316 slog.Error("lsp watcher: Error notifying LSP server about file event", "error", err)
317 }
318}
319
320// shutdown gracefully shuts down the global watcher
321func (gw *global) shutdown() {
322 if gw.cancel != nil {
323 gw.cancel()
324 }
325
326 // Stop watching and close the event channel
327 notify.Stop(gw.events)
328 close(gw.events)
329
330 gw.wg.Wait()
331 slog.Debug("lsp watcher: Global watcher shutdown complete")
332}
333
334// Shutdown shuts down the singleton global watcher
335func Shutdown() {
336 instance().shutdown()
337}