1package watcher
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "sync"
9 "sync/atomic"
10 "time"
11
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/home"
16 "github.com/charmbracelet/crush/internal/lsp/protocol"
17 "github.com/fsnotify/fsnotify"
18)
19
20// global manages a single fsnotify.Watcher instance shared across all LSP clients.
21//
22// IMPORTANT: This implementation only watches directories, not individual files.
23// The fsnotify library automatically provides events for all files within watched
24// directories, making this approach much more efficient than watching individual files.
25//
26// Key benefits of directory-only watching:
27// - Significantly fewer file descriptors used
28// - Automatic coverage of new files created in watched directories
29// - Better performance with large codebases
30// - fsnotify handles deduplication internally (no need to track watched dirs)
31type global struct {
32 watcher *fsnotify.Watcher
33
34 // Map of workspace watchers by client name
35 watchers *csync.Map[string, *Client]
36
37 // Single workspace root directory for ignore checking
38 root string
39
40 started atomic.Bool
41
42 // Debouncing for file events (shared across all clients)
43 debounceTime time.Duration
44 debounceMap *csync.Map[string, *time.Timer]
45
46 // Context for shutdown
47 ctx context.Context
48 cancel context.CancelFunc
49
50 // Wait group for cleanup
51 wg sync.WaitGroup
52}
53
54// instance returns the singleton global watcher instance
55var instance = sync.OnceValue(func() *global {
56 ctx, cancel := context.WithCancel(context.Background())
57 gw := &global{
58 watchers: csync.NewMap[string, *Client](),
59 debounceTime: 300 * time.Millisecond,
60 debounceMap: csync.NewMap[string, *time.Timer](),
61 ctx: ctx,
62 cancel: cancel,
63 }
64
65 // Initialize the fsnotify watcher
66 watcher, err := fsnotify.NewWatcher()
67 if err != nil {
68 slog.Error("lsp watcher: Failed to create global file watcher", "error", err)
69 return gw
70 }
71
72 gw.watcher = watcher
73
74 return gw
75})
76
77// register registers a workspace watcher with the global watcher
78func (gw *global) register(name string, watcher *Client) {
79 gw.watchers.Set(name, watcher)
80 slog.Debug("lsp watcher: Registered workspace watcher", "name", name)
81}
82
83// unregister removes a workspace watcher from the global watcher
84func (gw *global) unregister(name string) {
85 gw.watchers.Del(name)
86 slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
87}
88
89// Start walks the given path and sets up the watcher on it.
90//
91// Note: We only watch directories, not individual files. fsnotify automatically provides
92// events for all files within watched directories. Multiple calls with the same workspace
93// are safe since fsnotify handles directory deduplication internally.
94func Start() error {
95 cfg := config.Get()
96 root := cfg.WorkingDir()
97
98 // Skip file watching entirely when running in home directory
99 // Home directories typically contain too many files and directories
100 // which can overwhelm the file watcher and cause "too many open files" errors
101 if root == home.Dir() {
102 slog.Info("lsp watcher: Skipping file watcher in home directory to prevent resource exhaustion", "path", root)
103 return nil
104 }
105
106 gw := instance()
107
108 // If watcher creation failed, skip file watching entirely
109 if gw.watcher == nil {
110 slog.Warn("lsp watcher: File watcher not available, file watching disabled")
111 return nil
112 }
113
114 // technically workspace root is always the same...
115 if gw.started.Load() {
116 slog.Debug("lsp watcher: watcher already set up, skipping")
117 return nil
118 }
119
120 slog.Debug("lsp watcher: set workspace directory to global watcher", "path", root)
121
122 // Store the workspace root for hierarchical ignore checking
123 gw.root = root
124 gw.started.Store(true)
125
126 // Start the event processing goroutine now that we're initialized
127 gw.wg.Add(1)
128 go gw.processEvents()
129
130 // Walk the workspace and add only directories to the watcher
131 // fsnotify will automatically provide events for all files within these directories
132 // Multiple calls with the same directories are safe (fsnotify deduplicates)
133 err := fsext.WalkDirectories(root, func(path string, d os.DirEntry, err error) error {
134 if err != nil {
135 // Skip directories we don't have permission to access
136 slog.Debug("lsp watcher: Skipping directory due to error", "path", path, "error", err)
137 return nil
138 }
139
140 // Add directory to watcher (fsnotify handles deduplication automatically)
141 if err := gw.addDirectoryToWatcher(path); err != nil {
142 slog.Error("lsp watcher: Error watching directory", "path", path, "error", err)
143 }
144
145 return nil
146 })
147 if err != nil {
148 return fmt.Errorf("lsp watcher: error walking workspace %s: %w", root, err)
149 }
150
151 return nil
152}
153
154// addDirectoryToWatcher adds a directory to the fsnotify watcher.
155// fsnotify handles deduplication internally, so we don't need to track watched directories.
156func (gw *global) addDirectoryToWatcher(dirPath string) error {
157 if gw.watcher == nil {
158 // File watching is disabled, skip silently
159 return nil
160 }
161
162 // Add directory to fsnotify watcher - fsnotify handles deduplication
163 // "A path can only be watched once; watching it more than once is a no-op"
164 err := gw.watcher.Add(dirPath)
165 if err != nil {
166 return fmt.Errorf("lsp watcher: failed to watch directory %s: %w", dirPath, err)
167 }
168
169 slog.Debug("lsp watcher: watching directory", "path", dirPath)
170 return nil
171}
172
173// processEvents processes file system events and handles them centrally.
174// Since we only watch directories, we automatically get events for all files
175// within those directories. When new directories are created, we add them
176// to the watcher to ensure complete coverage.
177func (gw *global) processEvents() {
178 defer gw.wg.Done()
179 cfg := config.Get()
180
181 if gw.watcher == nil || !gw.started.Load() {
182 slog.Debug("lsp watcher: Global watcher not available or not started")
183 return
184 }
185
186 for {
187 select {
188 case <-gw.ctx.Done():
189 return
190
191 case event, ok := <-gw.watcher.Events:
192 if !ok {
193 return
194 }
195
196 // Handle directory creation globally (only once)
197 // When new directories are created, we need to add them to the watcher
198 // to ensure we get events for files created within them
199 if event.Op&fsnotify.Create != 0 {
200 if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
201 if !fsext.ShouldExcludeFile(gw.root, event.Name) {
202 if err := gw.addDirectoryToWatcher(event.Name); err != nil {
203 slog.Error("lsp watcher: Error adding new directory to watcher", "path", event.Name, "error", err)
204 }
205 } else if cfg != nil && cfg.Options.DebugLSP {
206 slog.Debug("lsp watcher: Skipping ignored new directory", "path", event.Name)
207 }
208 }
209 }
210
211 if cfg != nil && cfg.Options.DebugLSP {
212 slog.Debug("lsp watcher: Global watcher received event", "path", event.Name, "op", event.Op.String())
213 }
214
215 // Process the event centrally
216 gw.handleFileEvent(event)
217
218 case err, ok := <-gw.watcher.Errors:
219 if !ok {
220 return
221 }
222 slog.Error("lsp watcher: Global watcher error", "error", err)
223 }
224 }
225}
226
227// handleFileEvent processes a file system event and distributes notifications to relevant clients
228func (gw *global) handleFileEvent(event fsnotify.Event) {
229 cfg := config.Get()
230 uri := string(protocol.URIFromPath(event.Name))
231
232 // Handle file creation for all relevant clients (only once)
233 if event.Op&fsnotify.Create != 0 {
234 if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
235 if !fsext.ShouldExcludeFile(gw.root, event.Name) {
236 gw.openMatchingFileForClients(event.Name)
237 }
238 }
239 }
240
241 // Process the event for each relevant client
242 for client, watcher := range gw.watchers.Seq2() {
243 if !watcher.client.HandlesFile(event.Name) {
244 continue // client doesn't handle this filetype
245 }
246
247 // Debug logging per client
248 if cfg.Options.DebugLSP {
249 matched, kind := watcher.isPathWatched(event.Name)
250 slog.Debug("lsp watcher: File event for client",
251 "path", event.Name,
252 "operation", event.Op.String(),
253 "watched", matched,
254 "kind", kind,
255 "client", client,
256 )
257 }
258
259 // Check if this path should be watched according to server registrations
260 if watched, watchKind := watcher.isPathWatched(event.Name); watched {
261 switch {
262 case event.Op&fsnotify.Write != 0:
263 if watchKind&protocol.WatchChange != 0 {
264 gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Changed))
265 }
266 case event.Op&fsnotify.Create != 0:
267 // File creation was already handled globally above
268 // Just send the notification if needed
269 info, err := os.Stat(event.Name)
270 if err != nil {
271 if !os.IsNotExist(err) {
272 slog.Debug("lsp watcher: Error getting file info", "path", event.Name, "error", err)
273 }
274 continue
275 }
276 if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
277 gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
278 }
279 case event.Op&fsnotify.Remove != 0:
280 if watchKind&protocol.WatchDelete != 0 {
281 gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
282 }
283 case event.Op&fsnotify.Rename != 0:
284 // For renames, first delete
285 if watchKind&protocol.WatchDelete != 0 {
286 gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
287 }
288
289 // Then check if the new file exists and create an event
290 if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
291 if watchKind&protocol.WatchCreate != 0 {
292 gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
293 }
294 }
295 }
296 }
297 }
298}
299
300// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
301func (gw *global) openMatchingFileForClients(path string) {
302 // Skip directories
303 info, err := os.Stat(path)
304 if err != nil || info.IsDir() {
305 return
306 }
307
308 // Skip excluded files
309 if fsext.ShouldExcludeFile(gw.root, path) {
310 return
311 }
312
313 // Open the file for each client that handles it and has matching patterns
314 for _, watcher := range gw.watchers.Seq2() {
315 if watcher.client.HandlesFile(path) {
316 watcher.openMatchingFile(gw.ctx, path)
317 }
318 }
319}
320
321// debounceHandleFileEventForClient handles file events with debouncing for a specific client
322func (gw *global) debounceHandleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
323 // Create a unique key based on URI, change type, and client name
324 key := fmt.Sprintf("%s:%d:%s", uri, changeType, watcher.name)
325
326 // Cancel existing timer if any
327 if timer, exists := gw.debounceMap.Get(key); exists {
328 timer.Stop()
329 }
330
331 // Create new timer
332 gw.debounceMap.Set(key, time.AfterFunc(gw.debounceTime, func() {
333 gw.handleFileEventForClient(watcher, uri, changeType)
334
335 // Cleanup timer after execution
336 gw.debounceMap.Del(key)
337 }))
338}
339
340// handleFileEventForClient sends file change notifications to a specific client
341func (gw *global) handleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
342 // If the file is open and it's a change event, use didChange notification
343 filePath, err := protocol.DocumentURI(uri).Path()
344 if err != nil {
345 slog.Error("lsp watcher: Error converting URI to path", "uri", uri, "error", err)
346 return
347 }
348
349 if changeType == protocol.FileChangeType(protocol.Deleted) {
350 watcher.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
351 } else if changeType == protocol.FileChangeType(protocol.Changed) && watcher.client.IsFileOpen(filePath) {
352 err := watcher.client.NotifyChange(gw.ctx, filePath)
353 if err != nil {
354 slog.Error("lsp watcher: Error notifying change", "error", err)
355 }
356 return
357 }
358
359 // Notify LSP server about the file event using didChangeWatchedFiles
360 if err := watcher.notifyFileEvent(gw.ctx, uri, changeType); err != nil {
361 slog.Error("lsp watcher: Error notifying LSP server about file event", "error", err)
362 }
363}
364
365// shutdown gracefully shuts down the global watcher
366func (gw *global) shutdown() {
367 if gw.cancel != nil {
368 gw.cancel()
369 }
370
371 if gw.watcher != nil {
372 gw.watcher.Close()
373 gw.watcher = nil
374 }
375
376 gw.wg.Wait()
377 slog.Debug("lsp watcher: Global watcher shutdown complete")
378}
379
380// Shutdown shuts down the singleton global watcher
381func Shutdown() {
382 instance().shutdown()
383}