@@ -0,0 +1,364 @@
+package watcher
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/fsext"
+ "github.com/charmbracelet/crush/internal/lsp/protocol"
+ "github.com/fsnotify/fsnotify"
+)
+
+// global manages a single fsnotify.Watcher instance shared across all LSP clients.
+//
+// IMPORTANT: This implementation only watches directories, not individual files.
+// The fsnotify library automatically provides events for all files within watched
+// directories, making this approach much more efficient than watching individual files.
+//
+// Key benefits of directory-only watching:
+// - Significantly fewer file descriptors used
+// - Automatic coverage of new files created in watched directories
+// - Better performance with large codebases
+// - fsnotify handles deduplication internally (no need to track watched dirs)
+type global struct {
+ watcher *fsnotify.Watcher
+
+ // Map of workspace watchers by client name
+ watchers *csync.Map[string, *Client]
+
+ // Single workspace root directory for ignore checking
+ root string
+
+ started atomic.Bool
+
+ // Debouncing for file events (shared across all clients)
+ debounceTime time.Duration
+ debounceMap *csync.Map[string, *time.Timer]
+
+ // Context for shutdown
+ ctx context.Context
+ cancel context.CancelFunc
+
+ // Wait group for cleanup
+ wg sync.WaitGroup
+}
+
+// instance returns the singleton global watcher instance
+var instance = sync.OnceValue(func() *global {
+ ctx, cancel := context.WithCancel(context.Background())
+ gw := &global{
+ watchers: csync.NewMap[string, *Client](),
+ debounceTime: 300 * time.Millisecond,
+ debounceMap: csync.NewMap[string, *time.Timer](),
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ // Initialize the fsnotify watcher
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ slog.Error("lsp watcher: Failed to create global file watcher", "error", err)
+ return gw
+ }
+
+ gw.watcher = watcher
+
+ return gw
+})
+
+// register registers a workspace watcher with the global watcher
+func (gw *global) register(name string, watcher *Client) {
+ gw.watchers.Set(name, watcher)
+ slog.Debug("lsp watcher: Registered workspace watcher", "name", name)
+}
+
+// unregister removes a workspace watcher from the global watcher
+func (gw *global) unregister(name string) {
+ gw.watchers.Del(name)
+ slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
+}
+
+// Start walks the given path and sets up the watcher on it.
+//
+// Note: We only watch directories, not individual files. fsnotify automatically provides
+// events for all files within watched directories. Multiple calls with the same workspace
+// are safe since fsnotify handles directory deduplication internally.
+func Start() error {
+ gw := instance()
+
+ // technically workspace root is always the same...
+ if gw.started.Load() {
+ slog.Debug("lsp watcher: watcher already set up, skipping")
+ return nil
+ }
+
+ cfg := config.Get()
+ root := cfg.WorkingDir()
+ slog.Debug("lsp watcher: set workspace directory to global watcher", "path", root)
+
+ // Store the workspace root for hierarchical ignore checking
+ gw.root = root
+ gw.started.Store(true)
+
+ // Start the event processing goroutine now that we're initialized
+ gw.wg.Add(1)
+ go gw.processEvents()
+
+ // Walk the workspace and add only directories to the watcher
+ // fsnotify will automatically provide events for all files within these directories
+ // Multiple calls with the same directories are safe (fsnotify deduplicates)
+ err := fsext.WalkDirectories(root, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Add directory to watcher (fsnotify handles deduplication automatically)
+ if err := gw.addDirectoryToWatcher(path); err != nil {
+ slog.Error("lsp watcher: Error watching directory", "path", path, "error", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("lsp watcher: error walking workspace %s: %w", root, err)
+ }
+
+ return nil
+}
+
+// addDirectoryToWatcher adds a directory to the fsnotify watcher.
+// fsnotify handles deduplication internally, so we don't need to track watched directories.
+func (gw *global) addDirectoryToWatcher(dirPath string) error {
+ if gw.watcher == nil {
+ return fmt.Errorf("lsp watcher: global watcher not initialized")
+ }
+
+ // Add directory to fsnotify watcher - fsnotify handles deduplication
+ // "A path can only be watched once; watching it more than once is a no-op"
+ err := gw.watcher.Add(dirPath)
+ if err != nil {
+ return fmt.Errorf("lsp watcher: failed to watch directory %s: %w", dirPath, err)
+ }
+
+ slog.Debug("lsp watcher: watching directory", "path", dirPath)
+ return nil
+}
+
+// processEvents processes file system events and handles them centrally.
+// Since we only watch directories, we automatically get events for all files
+// within those directories. When new directories are created, we add them
+// to the watcher to ensure complete coverage.
+func (gw *global) processEvents() {
+ defer gw.wg.Done()
+ cfg := config.Get()
+
+ if gw.watcher == nil || !gw.started.Load() {
+ slog.Error("lsp watcher: Global watcher not initialized")
+ return
+ }
+
+ for {
+ select {
+ case <-gw.ctx.Done():
+ return
+
+ case event, ok := <-gw.watcher.Events:
+ if !ok {
+ return
+ }
+
+ // Handle directory creation globally (only once)
+ // When new directories are created, we need to add them to the watcher
+ // to ensure we get events for files created within them
+ if event.Op&fsnotify.Create != 0 {
+ if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
+ if !fsext.ShouldExcludeFile(gw.root, event.Name) {
+ if err := gw.addDirectoryToWatcher(event.Name); err != nil {
+ slog.Error("lsp watcher: Error adding new directory to watcher", "path", event.Name, "error", err)
+ }
+ } else if cfg != nil && cfg.Options.DebugLSP {
+ slog.Debug("lsp watcher: Skipping ignored new directory", "path", event.Name)
+ }
+ }
+ }
+
+ if cfg != nil && cfg.Options.DebugLSP {
+ slog.Debug("lsp watcher: Global watcher received event", "path", event.Name, "op", event.Op.String())
+ }
+
+ // Process the event centrally
+ gw.handleFileEvent(event)
+
+ case err, ok := <-gw.watcher.Errors:
+ if !ok {
+ return
+ }
+ slog.Error("lsp watcher: Global watcher error", "error", err)
+ }
+ }
+}
+
+// handleFileEvent processes a file system event and distributes notifications to relevant clients
+func (gw *global) handleFileEvent(event fsnotify.Event) {
+ cfg := config.Get()
+ uri := string(protocol.URIFromPath(event.Name))
+
+ // Handle file creation for all relevant clients (only once)
+ if event.Op&fsnotify.Create != 0 {
+ if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
+ if !fsext.ShouldExcludeFile(gw.root, event.Name) {
+ gw.openMatchingFileForClients(event.Name)
+ }
+ }
+ }
+
+ // Process the event for each relevant client
+ for client, watcher := range gw.watchers.Seq2() {
+ if !watcher.client.HandlesFile(event.Name) {
+ continue // client doesn't handle this filetype
+ }
+
+ // Debug logging per client
+ if cfg.Options.DebugLSP {
+ matched, kind := watcher.isPathWatched(event.Name)
+ slog.Debug("lsp watcher: File event for client",
+ "path", event.Name,
+ "operation", event.Op.String(),
+ "watched", matched,
+ "kind", kind,
+ "client", client,
+ )
+ }
+
+ // Check if this path should be watched according to server registrations
+ if watched, watchKind := watcher.isPathWatched(event.Name); watched {
+ switch {
+ case event.Op&fsnotify.Write != 0:
+ if watchKind&protocol.WatchChange != 0 {
+ gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Changed))
+ }
+ case event.Op&fsnotify.Create != 0:
+ // File creation was already handled globally above
+ // Just send the notification if needed
+ info, err := os.Stat(event.Name)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ slog.Debug("lsp watcher: Error getting file info", "path", event.Name, "error", err)
+ }
+ continue
+ }
+ if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
+ gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
+ }
+ case event.Op&fsnotify.Remove != 0:
+ if watchKind&protocol.WatchDelete != 0 {
+ gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
+ }
+ case event.Op&fsnotify.Rename != 0:
+ // For renames, first delete
+ if watchKind&protocol.WatchDelete != 0 {
+ gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
+ }
+
+ // Then check if the new file exists and create an event
+ if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
+ if watchKind&protocol.WatchCreate != 0 {
+ gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
+ }
+ }
+ }
+ }
+ }
+}
+
+// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
+func (gw *global) openMatchingFileForClients(path string) {
+ // Skip directories
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ return
+ }
+
+ // Skip excluded files
+ if fsext.ShouldExcludeFile(gw.root, path) {
+ return
+ }
+
+ // Open the file for each client that handles it and has matching patterns
+ for _, watcher := range gw.watchers.Seq2() {
+ if watcher.client.HandlesFile(path) {
+ watcher.openMatchingFile(gw.ctx, path)
+ }
+ }
+}
+
+// debounceHandleFileEventForClient handles file events with debouncing for a specific client
+func (gw *global) debounceHandleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
+ // Create a unique key based on URI, change type, and client name
+ key := fmt.Sprintf("%s:%d:%s", uri, changeType, watcher.name)
+
+ // Cancel existing timer if any
+ if timer, exists := gw.debounceMap.Get(key); exists {
+ timer.Stop()
+ }
+
+ // Create new timer
+ gw.debounceMap.Set(key, time.AfterFunc(gw.debounceTime, func() {
+ gw.handleFileEventForClient(watcher, uri, changeType)
+
+ // Cleanup timer after execution
+ gw.debounceMap.Del(key)
+ }))
+}
+
+// handleFileEventForClient sends file change notifications to a specific client
+func (gw *global) handleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
+ // If the file is open and it's a change event, use didChange notification
+ filePath, err := protocol.DocumentURI(uri).Path()
+ if err != nil {
+ slog.Error("lsp watcher: Error converting URI to path", "uri", uri, "error", err)
+ return
+ }
+
+ if changeType == protocol.FileChangeType(protocol.Deleted) {
+ watcher.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
+ } else if changeType == protocol.FileChangeType(protocol.Changed) && watcher.client.IsFileOpen(filePath) {
+ err := watcher.client.NotifyChange(gw.ctx, filePath)
+ if err != nil {
+ slog.Error("lsp watcher: Error notifying change", "error", err)
+ }
+ return
+ }
+
+ // Notify LSP server about the file event using didChangeWatchedFiles
+ if err := watcher.notifyFileEvent(gw.ctx, uri, changeType); err != nil {
+ slog.Error("lsp watcher: Error notifying LSP server about file event", "error", err)
+ }
+}
+
+// shutdown gracefully shuts down the global watcher
+func (gw *global) shutdown() {
+ if gw.cancel != nil {
+ gw.cancel()
+ }
+
+ if gw.watcher != nil {
+ gw.watcher.Close()
+ gw.watcher = nil
+ }
+
+ gw.wg.Wait()
+ slog.Debug("lsp watcher: Global watcher shutdown complete")
+}
+
+// Shutdown shuts down the singleton global watcher
+func Shutdown() {
+ instance().shutdown()
+}
@@ -0,0 +1,297 @@
+package watcher
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/csync"
+ "github.com/fsnotify/fsnotify"
+)
+
+func TestGlobalWatcher(t *testing.T) {
+ t.Parallel()
+
+ // Test that we can get the global watcher instance
+ gw1 := instance()
+ if gw1 == nil {
+ t.Fatal("Expected global watcher instance, got nil")
+ }
+
+ // Test that subsequent calls return the same instance (singleton)
+ gw2 := instance()
+ if gw1 != gw2 {
+ t.Fatal("Expected same global watcher instance, got different instances")
+ }
+
+ // Test registration and unregistration
+ mockWatcher := &Client{
+ name: "test-watcher",
+ }
+
+ gw1.register("test", mockWatcher)
+
+ // Check that it was registered
+ registered, _ := gw1.watchers.Get("test")
+
+ if registered != mockWatcher {
+ t.Fatal("Expected workspace watcher to be registered")
+ }
+
+ // Test unregistration
+ gw1.unregister("test")
+
+ unregistered, _ := gw1.watchers.Get("test")
+
+ if unregistered != nil {
+ t.Fatal("Expected workspace watcher to be unregistered")
+ }
+}
+
+func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
+ t.Parallel()
+
+ // Create a temporary directory for testing
+ tempDir := t.TempDir()
+
+ // Create a new global watcher instance for this test
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Create a real fsnotify watcher for testing
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
+ }
+ defer watcher.Close()
+
+ gw := &global{
+ watcher: watcher,
+ watchers: csync.NewMap[string, *Client](),
+ debounceTime: 300 * time.Millisecond,
+ debounceMap: csync.NewMap[string, *time.Timer](),
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ // Test that watching the same workspace multiple times is safe (idempotent)
+ err1 := gw.addDirectoryToWatcher(tempDir)
+ if err1 != nil {
+ t.Fatalf("First addDirectoryToWatcher call failed: %v", err1)
+ }
+
+ err2 := gw.addDirectoryToWatcher(tempDir)
+ if err2 != nil {
+ t.Fatalf("Second addDirectoryToWatcher call failed: %v", err2)
+ }
+
+ err3 := gw.addDirectoryToWatcher(tempDir)
+ if err3 != nil {
+ t.Fatalf("Third addDirectoryToWatcher call failed: %v", err3)
+ }
+
+ // All calls should succeed - fsnotify handles deduplication internally
+ // This test verifies that multiple WatchWorkspace calls are safe
+}
+
+func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
+ t.Parallel()
+
+ // Create a temporary directory structure for testing
+ tempDir := t.TempDir()
+ subDir := filepath.Join(tempDir, "subdir")
+ if err := os.Mkdir(subDir, 0o755); err != nil {
+ t.Fatalf("Failed to create subdirectory: %v", err)
+ }
+
+ // Create some files
+ file1 := filepath.Join(tempDir, "file1.txt")
+ file2 := filepath.Join(subDir, "file2.txt")
+ if err := os.WriteFile(file1, []byte("content1"), 0o644); err != nil {
+ t.Fatalf("Failed to create file1: %v", err)
+ }
+ if err := os.WriteFile(file2, []byte("content2"), 0o644); err != nil {
+ t.Fatalf("Failed to create file2: %v", err)
+ }
+
+ // Create a new global watcher instance for this test
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Create a real fsnotify watcher for testing
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
+ }
+ defer watcher.Close()
+
+ gw := &global{
+ watcher: watcher,
+ watchers: csync.NewMap[string, *Client](),
+ debounceTime: 300 * time.Millisecond,
+ debounceMap: csync.NewMap[string, *time.Timer](),
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ // Watch the workspace
+ err = gw.addDirectoryToWatcher(tempDir)
+ if err != nil {
+ t.Fatalf("addDirectoryToWatcher failed: %v", err)
+ }
+
+ // Verify that our expected directories exist and can be watched
+ expectedDirs := []string{tempDir, subDir}
+
+ for _, expectedDir := range expectedDirs {
+ info, err := os.Stat(expectedDir)
+ if err != nil {
+ t.Fatalf("Expected directory %s doesn't exist: %v", expectedDir, err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
+ }
+
+ // Try to add it again - fsnotify should handle this gracefully
+ err = gw.addDirectoryToWatcher(expectedDir)
+ if err != nil {
+ t.Fatalf("Failed to add directory %s to watcher: %v", expectedDir, err)
+ }
+ }
+
+ // Verify that files exist but we don't try to watch them directly
+ testFiles := []string{file1, file2}
+ for _, file := range testFiles {
+ info, err := os.Stat(file)
+ if err != nil {
+ t.Fatalf("Test file %s doesn't exist: %v", file, err)
+ }
+ if info.IsDir() {
+ t.Fatalf("Expected %s to be a file, but it's a directory", file)
+ }
+ }
+}
+
+func TestFsnotifyDeduplication(t *testing.T) {
+ t.Parallel()
+
+ // Create a temporary directory for testing
+ tempDir := t.TempDir()
+
+ // Create a real fsnotify watcher
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
+ }
+ defer watcher.Close()
+
+ // Add the same directory multiple times
+ err1 := watcher.Add(tempDir)
+ if err1 != nil {
+ t.Fatalf("First Add failed: %v", err1)
+ }
+
+ err2 := watcher.Add(tempDir)
+ if err2 != nil {
+ t.Fatalf("Second Add failed: %v", err2)
+ }
+
+ err3 := watcher.Add(tempDir)
+ if err3 != nil {
+ t.Fatalf("Third Add failed: %v", err3)
+ }
+
+ // All should succeed - fsnotify handles deduplication internally
+ // This test verifies the fsnotify behavior we're relying on
+}
+
+func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
+ t.Parallel()
+
+ // Create a temporary directory structure for testing
+ tempDir := t.TempDir()
+
+ // Create directories that should be ignored
+ nodeModules := filepath.Join(tempDir, "node_modules")
+ target := filepath.Join(tempDir, "target")
+ customIgnored := filepath.Join(tempDir, "custom_ignored")
+ normalDir := filepath.Join(tempDir, "src")
+
+ for _, dir := range []string{nodeModules, target, customIgnored, normalDir} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("Failed to create directory %s: %v", dir, err)
+ }
+ }
+
+ // Create .gitignore file
+ gitignoreContent := "node_modules/\ntarget/\n"
+ if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
+ t.Fatalf("Failed to create .gitignore: %v", err)
+ }
+
+ // Create .crushignore file
+ crushignoreContent := "custom_ignored/\n"
+ if err := os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644); err != nil {
+ t.Fatalf("Failed to create .crushignore: %v", err)
+ }
+
+ // Create a new global watcher instance for this test
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Create a real fsnotify watcher for testing
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
+ }
+ defer watcher.Close()
+
+ gw := &global{
+ watcher: watcher,
+ watchers: csync.NewMap[string, *Client](),
+ debounceTime: 300 * time.Millisecond,
+ debounceMap: csync.NewMap[string, *time.Timer](),
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ // Watch the workspace
+ err = gw.addDirectoryToWatcher(tempDir)
+ if err != nil {
+ t.Fatalf("addDirectoryToWatcher failed: %v", err)
+ }
+
+ // This test verifies that the watcher can successfully add directories to fsnotify
+ // The actual ignore logic is tested in the fsext package
+ // Here we just verify that the watcher integration works
+}
+
+func TestGlobalWatcherShutdown(t *testing.T) {
+ t.Parallel()
+
+ // Create a new context for this test
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Create a temporary global watcher for testing
+ gw := &global{
+ watchers: csync.NewMap[string, *Client](),
+ debounceTime: 300 * time.Millisecond,
+ debounceMap: csync.NewMap[string, *time.Timer](),
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ // Test shutdown doesn't panic
+ gw.shutdown()
+
+ // Verify context was cancelled
+ select {
+ case <-gw.ctx.Done():
+ // Expected
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("Expected context to be cancelled after shutdown")
+ }
+}
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"strings"
- "sync"
"time"
"github.com/bmatcuk/doublestar/v4"
@@ -16,58 +15,39 @@ import (
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/lsp/protocol"
- "github.com/fsnotify/fsnotify"
)
-// WorkspaceWatcher manages LSP file watching
-type WorkspaceWatcher struct {
+// Client manages LSP file watching for a specific client
+// It now delegates actual file watching to the GlobalWatcher
+type Client struct {
client *lsp.Client
name string
workspacePath string
- debounceTime time.Duration
- debounceMap *csync.Map[string, *time.Timer]
-
// File watchers registered by the server
- registrations []protocol.FileSystemWatcher
- registrationMu sync.RWMutex
+ registrations *csync.Slice[protocol.FileSystemWatcher]
}
-func init() {
- // Ensure the watcher is initialized with a reasonable file limit
- if _, err := Ulimit(); err != nil {
- slog.Error("Error setting file limit", "error", err)
- }
-}
-
-// NewWorkspaceWatcher creates a new workspace watcher
-func NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
- return &WorkspaceWatcher{
+// New creates a new workspace watcher for the given client.
+func New(name string, client *lsp.Client) *Client {
+ return &Client{
name: name,
client: client,
- debounceTime: 300 * time.Millisecond,
- debounceMap: csync.NewMap[string, *time.Timer](),
- registrations: []protocol.FileSystemWatcher{},
+ registrations: csync.NewSlice[protocol.FileSystemWatcher](),
}
}
-// AddRegistrations adds file watchers to track
-func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
+// register adds file watchers to track
+func (w *Client) register(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
cfg := config.Get()
- slog.Debug("Adding file watcher registrations")
- w.registrationMu.Lock()
- defer w.registrationMu.Unlock()
-
- // Add new watchers
- w.registrations = append(w.registrations, watchers...)
+ w.registrations.Append(watchers...)
- // Print detailed registration information for debugging
if cfg.Options.DebugLSP {
slog.Debug("Adding file watcher registrations",
"id", id,
"watchers", len(watchers),
- "total", len(w.registrations),
+ "total", w.registrations.Len(),
)
for i, watcher := range watchers {
@@ -103,116 +83,29 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
}
}
- // Determine server type for specialized handling
- serverName := w.name
- slog.Debug("Server type detected", "serverName", serverName)
-
- // Check if this server has sent file watchers
- hasFileWatchers := len(watchers) > 0
-
- // For servers that need file preloading, we'll use a smart approach
- if shouldPreloadFiles(serverName) || !hasFileWatchers {
+ // For servers that need file preloading, open high-priority files only
+ if shouldPreloadFiles(w.name) {
go func() {
- startTime := time.Now()
- filesOpened := 0
-
- // Determine max files to open based on server type
- maxFilesToOpen := 50 // Default conservative limit
-
- switch serverName {
- case "typescript", "typescript-language-server", "tsserver", "vtsls":
- // TypeScript servers benefit from seeing more files
- maxFilesToOpen = 100
- case "java", "jdtls":
- // Java servers need to see many files for project model
- maxFilesToOpen = 200
- }
-
- // First, open high-priority files
- highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
- filesOpened += highPriorityFilesOpened
-
+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, w.name)
if cfg.Options.DebugLSP {
slog.Debug("Opened high-priority files",
"count", highPriorityFilesOpened,
- "serverName", serverName)
- }
-
- // If we've already opened enough high-priority files, we might not need more
- if filesOpened >= maxFilesToOpen {
- if cfg.Options.DebugLSP {
- slog.Debug("Reached file limit with high-priority files",
- "filesOpened", filesOpened,
- "maxFiles", maxFilesToOpen)
- }
- return
- }
-
- // For the remaining slots, walk the directory and open matching files
-
- err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return err
- }
-
- // Skip directories that should be excluded
- if d.IsDir() {
- if path != w.workspacePath && shouldExcludeDir(path) {
- if cfg.Options.DebugLSP {
- slog.Debug("Skipping excluded directory", "path", path)
- }
- return filepath.SkipDir
- }
- } else {
- // Process files, but limit the total number
- if filesOpened < maxFilesToOpen {
- // Only process if it's not already open (high-priority files were opened earlier)
- if !w.client.IsFileOpen(path) {
- w.openMatchingFile(ctx, path)
- filesOpened++
-
- // Add a small delay after every 10 files to prevent overwhelming the server
- if filesOpened%10 == 0 {
- time.Sleep(50 * time.Millisecond)
- }
- }
- } else {
- // We've reached our limit, stop walking
- return filepath.SkipAll
- }
- }
-
- return nil
- })
-
- elapsedTime := time.Since(startTime)
- if cfg.Options.DebugLSP {
- slog.Debug("Limited workspace scan complete",
- "filesOpened", filesOpened,
- "maxFiles", maxFilesToOpen,
- "elapsedTime", elapsedTime.Seconds(),
- "workspacePath", w.workspacePath,
- )
- }
-
- if err != nil && cfg.Options.DebugLSP {
- slog.Debug("Error scanning workspace for files to open", "error", err)
+ "serverName", w.name)
}
}()
- } else if cfg.Options.DebugLSP {
- slog.Debug("Using on-demand file loading for server", "server", serverName)
}
}
// openHighPriorityFiles opens important files for the server type
// Returns the number of files opened
-func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
+func (w *Client) openHighPriorityFiles(ctx context.Context, serverName string) int {
cfg := config.Get()
filesOpened := 0
// Define patterns for high-priority files based on server type
var patterns []string
+ // TODO: move this to LSP config
switch serverName {
case "typescript", "typescript-language-server", "tsserver", "vtsls":
patterns = []string{
@@ -329,160 +222,35 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
return filesOpened
}
-// WatchWorkspace sets up file watching for a workspace
-func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
- cfg := config.Get()
+// Watch sets up file watching for a workspace using the global watcher
+func (w *Client) Watch(ctx context.Context, workspacePath string) {
w.workspacePath = workspacePath
slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
+ // Register this workspace watcher with the global watcher
+ instance().register(w.name, w)
+ defer instance().unregister(w.name)
+
// Register handler for file watcher registrations from the server
lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
- w.AddRegistrations(ctx, id, watchers)
+ w.register(ctx, id, watchers)
})
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- slog.Error("Error creating watcher", "error", err)
- }
- defer watcher.Close()
-
- // Watch the workspace recursively
- err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return err
- }
-
- // Skip excluded directories (except workspace root)
- if d.IsDir() && path != workspacePath {
- if shouldExcludeDir(path) {
- if cfg.Options.DebugLSP {
- slog.Debug("Skipping excluded directory", "path", path)
- }
- return filepath.SkipDir
- }
- }
-
- // Add directories to watcher
- if d.IsDir() {
- err = watcher.Add(path)
- if err != nil {
- slog.Error("Error watching path", "path", path, "error", err)
- }
- }
-
- return nil
- })
- if err != nil {
- slog.Error("Error walking workspace", "error", err)
- }
-
- // Event loop
- for {
- select {
- case <-ctx.Done():
- return
- case event, ok := <-watcher.Events:
- if !ok {
- return
- }
-
- if !w.client.HandlesFile(event.Name) {
- continue // client doesn't handle this filetype
- }
-
- uri := string(protocol.URIFromPath(event.Name))
-
- // Add new directories to the watcher
- if event.Op&fsnotify.Create != 0 {
- if info, err := os.Stat(event.Name); err == nil {
- if info.IsDir() {
- // Skip excluded directories
- if !shouldExcludeDir(event.Name) {
- if err := watcher.Add(event.Name); err != nil {
- slog.Error("Error adding directory to watcher", "path", event.Name, "error", err)
- }
- }
- } else {
- // For newly created files
- if !shouldExcludeFile(event.Name) {
- w.openMatchingFile(ctx, event.Name)
- }
- }
- }
- }
-
- // Debug logging
- if cfg.Options.DebugLSP {
- matched, kind := w.isPathWatched(event.Name)
- slog.Debug("File event",
- "path", event.Name,
- "operation", event.Op.String(),
- "watched", matched,
- "kind", kind,
- )
- }
-
- // Check if this path should be watched according to server registrations
- if watched, watchKind := w.isPathWatched(event.Name); watched {
- switch {
- case event.Op&fsnotify.Write != 0:
- if watchKind&protocol.WatchChange != 0 {
- w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
- }
- case event.Op&fsnotify.Create != 0:
- // Already handled earlier in the event loop
- // Just send the notification if needed
- info, err := os.Stat(event.Name)
- if err != nil {
- if !os.IsNotExist(err) {
- // Only log if it's not a "file not found" error
- slog.Debug("Error getting file info", "path", event.Name, "error", err)
- }
- continue
- }
- if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
- w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
- }
- case event.Op&fsnotify.Remove != 0:
- if watchKind&protocol.WatchDelete != 0 {
- w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
- }
- case event.Op&fsnotify.Rename != 0:
- // For renames, first delete
- if watchKind&protocol.WatchDelete != 0 {
- w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
- }
-
- // Then check if the new file exists and create an event
- if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
- if watchKind&protocol.WatchCreate != 0 {
- w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
- }
- }
- }
- }
- case err, ok := <-watcher.Errors:
- if !ok {
- return
- }
- slog.Error("Error watching file", "error", err)
- }
- }
+ // Wait for context cancellation
+ <-ctx.Done()
+ slog.Debug("Workspace watcher stopped", "name", w.name)
}
// isPathWatched checks if a path should be watched based on server registrations
-func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
- w.registrationMu.RLock()
- defer w.registrationMu.RUnlock()
-
- // If no explicit registrations, watch everything
- if len(w.registrations) == 0 {
+// If no explicit registrations, watch everything
+func (w *Client) isPathWatched(path string) (bool, protocol.WatchKind) {
+ if w.registrations.Len() == 0 {
return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
}
// Check each registration
- for _, reg := range w.registrations {
+ for reg := range w.registrations.Seq() {
isMatch := w.matchesPattern(path, reg.GlobPattern)
if isMatch {
kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
@@ -496,110 +264,19 @@ func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind)
return false, 0
}
-// matchesGlob handles advanced glob patterns including ** and alternatives
+// matchesGlob handles glob patterns using the doublestar library
func matchesGlob(pattern, path string) bool {
- // Handle file extension patterns with braces like *.{go,mod,sum}
- if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
- // Extract extensions from pattern like "*.{go,mod,sum}"
- parts := strings.SplitN(pattern, "{", 2)
- if len(parts) == 2 {
- prefix := parts[0]
- extPart := strings.SplitN(parts[1], "}", 2)
- if len(extPart) == 2 {
- extensions := strings.Split(extPart[0], ",")
- suffix := extPart[1]
-
- // Check if the path matches any of the extensions
- for _, ext := range extensions {
- extPattern := prefix + ext + suffix
- isMatch := matchesSimpleGlob(extPattern, path)
- if isMatch {
- return true
- }
- }
- return false
- }
- }
- }
-
- return matchesSimpleGlob(pattern, path)
-}
-
-// matchesSimpleGlob handles glob patterns with ** wildcards
-func matchesSimpleGlob(pattern, path string) bool {
- // Handle special case for **/*.ext pattern (common in LSP)
- if after, ok := strings.CutPrefix(pattern, "**/"); ok {
- rest := after
-
- // If the rest is a simple file extension pattern like *.go
- if strings.HasPrefix(rest, "*.") {
- ext := strings.TrimPrefix(rest, "*")
- isMatch := strings.HasSuffix(path, ext)
- return isMatch
- }
-
- // Otherwise, try to check if the path ends with the rest part
- isMatch := strings.HasSuffix(path, rest)
-
- // If it matches directly, great!
- if isMatch {
- return true
- }
-
- // Otherwise, check if any path component matches
- pathComponents := strings.Split(path, "/")
- for i := range pathComponents {
- subPath := strings.Join(pathComponents[i:], "/")
- if strings.HasSuffix(subPath, rest) {
- return true
- }
- }
-
- return false
- }
-
- // Handle other ** wildcard pattern cases
- if strings.Contains(pattern, "**") {
- parts := strings.Split(pattern, "**")
-
- // Validate the path starts with the first part
- if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
- return false
- }
-
- // For patterns like "**/*.go", just check the suffix
- if len(parts) == 2 && parts[0] == "" {
- isMatch := strings.HasSuffix(path, parts[1])
- return isMatch
- }
-
- // For other patterns, handle middle part
- remaining := strings.TrimPrefix(path, parts[0])
- if len(parts) == 2 {
- isMatch := strings.HasSuffix(remaining, parts[1])
- return isMatch
- }
- }
-
- // Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
- if strings.HasPrefix(pattern, "*.") {
- ext := strings.TrimPrefix(pattern, "*")
- isMatch := strings.HasSuffix(path, ext)
- return isMatch
- }
-
- // Fall back to simple matching for simpler patterns
- matched, err := filepath.Match(pattern, path)
+ // Use doublestar for all glob matching - it handles ** and other complex patterns
+ matched, err := doublestar.Match(pattern, path)
if err != nil {
slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
return false
}
-
return matched
}
// matchesPattern checks if a path matches the glob pattern
-func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
+func (w *Client) matchesPattern(path string, pattern protocol.GlobPattern) bool {
patternInfo, err := pattern.AsPattern()
if err != nil {
slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
@@ -637,53 +314,8 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
return isMatch
}
-// debounceHandleFileEvent handles file events with debouncing to reduce notifications
-func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
- // Create a unique key based on URI and change type
- key := fmt.Sprintf("%s:%d", uri, changeType)
-
- // Cancel existing timer if any
- if timer, exists := w.debounceMap.Get(key); exists {
- timer.Stop()
- }
-
- // Create new timer
- w.debounceMap.Set(key, time.AfterFunc(w.debounceTime, func() {
- w.handleFileEvent(ctx, uri, changeType)
-
- // Cleanup timer after execution
- w.debounceMap.Del(key)
- }))
-}
-
-// handleFileEvent sends file change notifications
-func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
- // If the file is open and it's a change event, use didChange notification
- filePath, err := protocol.DocumentURI(uri).Path()
- if err != nil {
- // XXX: Do we want to return here, or send the error up the stack?
- slog.Error("Error converting URI to path", "uri", uri, "error", err)
- return
- }
-
- if changeType == protocol.FileChangeType(protocol.Deleted) {
- w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
- } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
- err := w.client.NotifyChange(ctx, filePath)
- if err != nil {
- slog.Error("Error notifying change", "error", err)
- }
- return
- }
-
- // Notify LSP server about the file event using didChangeWatchedFiles
- if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
- slog.Error("Error notifying LSP server about file event", "error", err)
- }
-}
-
// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
-func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
+func (w *Client) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
cfg := config.Get()
if cfg.Options.DebugLSP {
slog.Debug("Notifying file event",
@@ -724,21 +356,6 @@ func shouldPreloadFiles(serverName string) bool {
// Common patterns for directories and files to exclude
// TODO: make configurable
var (
- excludedDirNames = map[string]bool{
- ".git": true,
- "node_modules": true,
- "dist": true,
- "build": true,
- "out": true,
- "bin": true,
- ".idea": true,
- ".vscode": true,
- ".cache": true,
- "coverage": true,
- "target": true, // Rust build output
- "vendor": true, // Go vendor directory
- }
-
excludedFileExtensions = map[string]bool{
".swp": true,
".swo": true,
@@ -780,23 +397,6 @@ var (
maxFileSize int64 = 5 * 1024 * 1024
)
-// shouldExcludeDir returns true if the directory should be excluded from watching/opening
-func shouldExcludeDir(dirPath string) bool {
- dirName := filepath.Base(dirPath)
-
- // Skip dot directories
- if strings.HasPrefix(dirName, ".") {
- return true
- }
-
- // Skip common excluded directories
- if excludedDirNames[dirName] {
- return true
- }
-
- return false
-}
-
// shouldExcludeFile returns true if the file should be excluded from opening
func shouldExcludeFile(filePath string) bool {
fileName := filepath.Base(filePath)
@@ -838,7 +438,7 @@ func shouldExcludeFile(filePath string) bool {
}
// openMatchingFile opens a file if it matches any of the registered patterns
-func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
+func (w *Client) openMatchingFile(ctx context.Context, path string) {
cfg := config.Get()
// Skip directories
info, err := os.Stat(path)
@@ -885,31 +485,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
return
}
- // Check file extension for common source files
- ext := strings.ToLower(filepath.Ext(path))
-
- // Only preload source files for the specific language
- var shouldOpen bool
- switch serverName {
- case "typescript", "typescript-language-server", "tsserver", "vtsls":
- shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
- case "gopls":
- shouldOpen = ext == ".go"
- case "rust-analyzer":
- shouldOpen = ext == ".rs"
- case "python", "pyright", "pylsp":
- shouldOpen = ext == ".py"
- case "clangd":
- shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
- case "java", "jdtls":
- shouldOpen = ext == ".java"
- }
-
- if shouldOpen {
- // Don't need to check if it's already open - the client.OpenFile handles that
- if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
- slog.Error("Error opening file", "path", path, "error", err)
- }
+ // File type is already validated by HandlesFile() and isPathWatched() checks earlier,
+ // so we know this client handles this file type. Just open it.
+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
+ slog.Error("Error opening file", "path", path, "error", err)
}
}