global_watcher.go

  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}