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/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}