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