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