global_watcher.go

  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}