global_watcher.go

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