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