diff --git a/CRUSH.md b/CRUSH.md index 5a3104b6685fb5e246c77d416d4a12adeda91734..102ad43ca5758beee6515ab9da4054ddc92b9a9f 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -59,3 +59,7 @@ func TestYourFunction(t *testing.T) { - If `goimports` is not available, use `gofmt`. - You can also use `task fmt` to run `gofumpt -w .` on the entire project, as long as `gofumpt` is on the `PATH`. + +## Committing + +- ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc). diff --git a/internal/app/app.go b/internal/app/app.go index cec42dc8610b4a6c72215766b7fd1c764381ef5a..21ddcd25eff1c9aeebb9d6700f9340ab0932e7ab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,7 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/lsp/watcher" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" @@ -85,6 +86,11 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { app.setupEvents() + // Start the global watcher + if err := watcher.Start(); err != nil { + return nil, fmt.Errorf("app: %w", err) + } + // Initialize LSP clients in the background. app.initLSPClients(ctx) @@ -352,6 +358,9 @@ func (app *App) Shutdown() { cancel() } + // Shutdown the global watcher + watcher.Shutdown() + // Call call cleanup functions. for _, cleanup := range app.cleanupFuncs { if cleanup != nil { diff --git a/internal/app/lsp.go b/internal/app/lsp.go index b1f35dedc02c9ae842a8e0d2d52b51eaf38bd2ee..8a9b06c1e784770371bc4000a2101af11aa44d64 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -71,7 +71,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config watchCtx, cancelFunc := context.WithCancel(ctx) // Create the workspace watcher. - workspaceWatcher := watcher.NewWorkspaceWatcher(name, lspClient) + workspaceWatcher := watcher.New(name, lspClient) // Store the cancel function to be called during cleanup. app.watcherCancelFuncs.Append(cancelFunc) @@ -87,14 +87,14 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config } // runWorkspaceWatcher executes the workspace watcher for an LSP client. -func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) { +func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.Client) { defer app.lspWatcherWG.Done() defer log.RecoverPanic("LSP-"+name, func() { // Try to restart the client. app.restartLSPClient(ctx, name) }) - workspaceWatcher.WatchWorkspace(ctx, app.config.WorkingDir()) + workspaceWatcher.Watch(ctx, app.config.WorkingDir()) slog.Info("Workspace watcher stopped", "client", name) } diff --git a/internal/lsp/watcher/global_watcher.go b/internal/lsp/watcher/global_watcher.go new file mode 100644 index 0000000000000000000000000000000000000000..29b19f316ba0f654ae779526b5926b1fe9785819 --- /dev/null +++ b/internal/lsp/watcher/global_watcher.go @@ -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() +} diff --git a/internal/lsp/watcher/global_watcher_test.go b/internal/lsp/watcher/global_watcher_test.go new file mode 100644 index 0000000000000000000000000000000000000000..09124cd6a570b9b46b003b06b5f76dcbcbef22ff --- /dev/null +++ b/internal/lsp/watcher/global_watcher_test.go @@ -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") + } +} diff --git a/internal/lsp/watcher/watcher.go b/internal/lsp/watcher/watcher.go index ad03099ae9a2b1e516fdcab820052c1ca858bd2a..18b790349a10f0827f45f8ccb9fb6968980a9d4e 100644 --- a/internal/lsp/watcher/watcher.go +++ b/internal/lsp/watcher/watcher.go @@ -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) } }