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