@@ -7,7 +7,6 @@ import (
8 "path/filepath"
8 "path/filepath"
13 "github.com/bmatcuk/doublestar/v4"
12 "github.com/bmatcuk/doublestar/v4"
@@ -16,58 +15,39 @@ import (
17 "github.com/charmbracelet/crush/internal/lsp"
16 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
19− "github.com/fsnotify/fsnotify"
22−// WorkspaceWatcher manages LSP file watching
20+// Client manages LSP file watching for a specific client
21+// It now delegates actual file watching to the GlobalWatcher
23−type WorkspaceWatcher struct {
22+type Client struct {
24 client *lsp.Client
23 client *lsp.Client
25 name string
24 name string
26 workspacePath string
25 workspacePath string
28− debounceTime time.Duration
29− debounceMap *csync.Map[string, *time.Timer]
31 // File watchers registered by the server
27 // File watchers registered by the server
32− registrations []protocol.FileSystemWatcher
28+ registrations *csync.Slice[protocol.FileSystemWatcher]
33− registrationMu sync.RWMutex
37− // Ensure the watcher is initialized with a reasonable file limit
38− if _, err := Ulimit(); err != nil {
39− slog.Error("Error setting file limit", "error", err)
43−// NewWorkspaceWatcher creates a new workspace watcher
31+// New creates a new workspace watcher for the given client.
44−func NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
32+func New(name string, client *lsp.Client) *Client {
45− return &WorkspaceWatcher{
33+ return &Client{
46 name: name,
34 name: name,
47 client: client,
35 client: client,
48− debounceTime: 300 * time.Millisecond,
36+ registrations: csync.NewSlice[protocol.FileSystemWatcher](),
49− debounceMap: csync.NewMap[string, *time.Timer](),
50− registrations: []protocol.FileSystemWatcher{},
54−// AddRegistrations adds file watchers to track
40+// register adds file watchers to track
55−func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
41+func (w *Client) register(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
56 cfg := config.Get()
42 cfg := config.Get()
58− slog.Debug("Adding file watcher registrations")
44+ w.registrations.Append(watchers...)
59− w.registrationMu.Lock()
60− defer w.registrationMu.Unlock()
63− w.registrations = append(w.registrations, watchers...)
65− // Print detailed registration information for debugging
66 if cfg.Options.DebugLSP {
46 if cfg.Options.DebugLSP {
67 slog.Debug("Adding file watcher registrations",
47 slog.Debug("Adding file watcher registrations",
69 "watchers", len(watchers),
49 "watchers", len(watchers),
70− "total", len(w.registrations),
50+ "total", w.registrations.Len(),
73 for i, watcher := range watchers {
53 for i, watcher := range watchers {
@@ -103,116 +83,29 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
106− // Determine server type for specialized handling
107− serverName := w.name
108− slog.Debug("Server type detected", "serverName", serverName)
110− // Check if this server has sent file watchers
111− hasFileWatchers := len(watchers) > 0
113− // For servers that need file preloading, we'll use a smart approach
86+ // For servers that need file preloading, open high-priority files only
114− if shouldPreloadFiles(serverName) || !hasFileWatchers {
87+ if shouldPreloadFiles(w.name) {
115 go func() {
88 go func() {
116− startTime := time.Now()
119− // Determine max files to open based on server type
120− maxFilesToOpen := 50 // Default conservative limit
123− case "typescript", "typescript-language-server", "tsserver", "vtsls":
124− // TypeScript servers benefit from seeing more files
125− maxFilesToOpen = 100
126− case "java", "jdtls":
127− // Java servers need to see many files for project model
128− maxFilesToOpen = 200
131− // First, open high-priority files
132− highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
89+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, w.name)
133− filesOpened += highPriorityFilesOpened
135 if cfg.Options.DebugLSP {
90 if cfg.Options.DebugLSP {
136 slog.Debug("Opened high-priority files",
91 slog.Debug("Opened high-priority files",
137 "count", highPriorityFilesOpened,
92 "count", highPriorityFilesOpened,
138− "serverName", serverName)
93+ "serverName", w.name)
141− // If we've already opened enough high-priority files, we might not need more
142− if filesOpened >= maxFilesToOpen {
143− if cfg.Options.DebugLSP {
144− slog.Debug("Reached file limit with high-priority files",
145− "filesOpened", filesOpened,
146− "maxFiles", maxFilesToOpen)
151− // For the remaining slots, walk the directory and open matching files
153− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
158− // Skip directories that should be excluded
160− if path != w.workspacePath && shouldExcludeDir(path) {
161− if cfg.Options.DebugLSP {
162− slog.Debug("Skipping excluded directory", "path", path)
164− return filepath.SkipDir
167− // Process files, but limit the total number
168− if filesOpened < maxFilesToOpen {
169− // Only process if it's not already open (high-priority files were opened earlier)
170− if !w.client.IsFileOpen(path) {
171− w.openMatchingFile(ctx, path)
174− // Add a small delay after every 10 files to prevent overwhelming the server
175− if filesOpened%10 == 0 {
176− time.Sleep(50 * time.Millisecond)
180− // We've reached our limit, stop walking
181− return filepath.SkipAll
188− elapsedTime := time.Since(startTime)
189− if cfg.Options.DebugLSP {
190− slog.Debug("Limited workspace scan complete",
191− "filesOpened", filesOpened,
192− "maxFiles", maxFilesToOpen,
193− "elapsedTime", elapsedTime.Seconds(),
194− "workspacePath", w.workspacePath,
198− if err != nil && cfg.Options.DebugLSP {
199− slog.Debug("Error scanning workspace for files to open", "error", err)
202− } else if cfg.Options.DebugLSP {
203− slog.Debug("Using on-demand file loading for server", "server", serverName)
207// openHighPriorityFiles opens important files for the server type
99// openHighPriorityFiles opens important files for the server type
208// Returns the number of files opened
100// Returns the number of files opened
209−func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
101+func (w *Client) openHighPriorityFiles(ctx context.Context, serverName string) int {
210 cfg := config.Get()
102 cfg := config.Get()
211 filesOpened := 0
103 filesOpened := 0
213 // Define patterns for high-priority files based on server type
105 // Define patterns for high-priority files based on server type
214 var patterns []string
106 var patterns []string
108+ // TODO: move this to LSP config
216 switch serverName {
109 switch serverName {
217 case "typescript", "typescript-language-server", "tsserver", "vtsls":
110 case "typescript", "typescript-language-server", "tsserver", "vtsls":
218 patterns = []string{
111 patterns = []string{
@@ -329,160 +222,35 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
329 return filesOpened
222 return filesOpened
332−// WatchWorkspace sets up file watching for a workspace
225+// Watch sets up file watching for a workspace using the global watcher
333−func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
226+func (w *Client) Watch(ctx context.Context, workspacePath string) {
335 w.workspacePath = workspacePath
227 w.workspacePath = workspacePath
337 slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
229 slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
231+ // Register this workspace watcher with the global watcher
232+ instance().register(w.name, w)
233+ defer instance().unregister(w.name)
339 // Register handler for file watcher registrations from the server
235 // Register handler for file watcher registrations from the server
340 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
236 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
341− w.AddRegistrations(ctx, id, watchers)
237+ w.register(ctx, id, watchers)
344− watcher, err := fsnotify.NewWatcher()
240+ // Wait for context cancellation
346− slog.Error("Error creating watcher", "error", err)
348− defer watcher.Close()
350− // Watch the workspace recursively
351− err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
356− // Skip excluded directories (except workspace root)
357− if d.IsDir() && path != workspacePath {
358− if shouldExcludeDir(path) {
359− if cfg.Options.DebugLSP {
360− slog.Debug("Skipping excluded directory", "path", path)
362− return filepath.SkipDir
366− // Add directories to watcher
368− err = watcher.Add(path)
370− slog.Error("Error watching path", "path", path, "error", err)
377− slog.Error("Error walking workspace", "error", err)
383− case <-ctx.Done():
241+ <-ctx.Done()
384− return
242+ slog.Debug("Workspace watcher stopped", "name", w.name)
385− case event, ok := <-watcher.Events:
390− if !w.client.HandlesFile(event.Name) {
391− continue // client doesn't handle this filetype
394− uri := string(protocol.URIFromPath(event.Name))
396− // Add new directories to the watcher
397− if event.Op&fsnotify.Create != 0 {
398− if info, err := os.Stat(event.Name); err == nil {
400− // Skip excluded directories
401− if !shouldExcludeDir(event.Name) {
402− if err := watcher.Add(event.Name); err != nil {
403− slog.Error("Error adding directory to watcher", "path", event.Name, "error", err)
407− // For newly created files
408− if !shouldExcludeFile(event.Name) {
409− w.openMatchingFile(ctx, event.Name)
416− if cfg.Options.DebugLSP {
417− matched, kind := w.isPathWatched(event.Name)
418− slog.Debug("File event",
420− "operation", event.Op.String(),
426− // Check if this path should be watched according to server registrations
427− if watched, watchKind := w.isPathWatched(event.Name); watched {
429− case event.Op&fsnotify.Write != 0:
430− if watchKind&protocol.WatchChange != 0 {
431− w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
433− case event.Op&fsnotify.Create != 0:
434− // Already handled earlier in the event loop
435− // Just send the notification if needed
436− info, err := os.Stat(event.Name)
438− if !os.IsNotExist(err) {
439− // Only log if it's not a "file not found" error
440− slog.Debug("Error getting file info", "path", event.Name, "error", err)
444− if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
445− w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
447− case event.Op&fsnotify.Remove != 0:
448− if watchKind&protocol.WatchDelete != 0 {
449− w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
451− case event.Op&fsnotify.Rename != 0:
452− // For renames, first delete
453− if watchKind&protocol.WatchDelete != 0 {
454− w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
457− // Then check if the new file exists and create an event
458− if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
459− if watchKind&protocol.WatchCreate != 0 {
460− w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
465− case err, ok := <-watcher.Errors:
469− slog.Error("Error watching file", "error", err)
474// isPathWatched checks if a path should be watched based on server registrations
245// isPathWatched checks if a path should be watched based on server registrations
246+// If no explicit registrations, watch everything
475−func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
247+func (w *Client) isPathWatched(path string) (bool, protocol.WatchKind) {
476− w.registrationMu.RLock()
477− defer w.registrationMu.RUnlock()
479− // If no explicit registrations, watch everything
480− if len(w.registrations) == 0 {
248+ if w.registrations.Len() == 0 {
481 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
249 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
484 // Check each registration
252 // Check each registration
485− for _, reg := range w.registrations {
253+ for reg := range w.registrations.Seq() {
486 isMatch := w.matchesPattern(path, reg.GlobPattern)
254 isMatch := w.matchesPattern(path, reg.GlobPattern)
487 if isMatch {
255 if isMatch {
488 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
256 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
@@ -496,110 +264,19 @@ func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind)
496 return false, 0
264 return false, 0
499−// matchesGlob handles advanced glob patterns including ** and alternatives
267+// matchesGlob handles glob patterns using the doublestar library
500func matchesGlob(pattern, path string) bool {
268func matchesGlob(pattern, path string) bool {
501− // Handle file extension patterns with braces like *.{go,mod,sum}
269+ // Use doublestar for all glob matching - it handles ** and other complex patterns
502− if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
503− // Extract extensions from pattern like "*.{go,mod,sum}"
504− parts := strings.SplitN(pattern, "{", 2)
505− if len(parts) == 2 {
507− extPart := strings.SplitN(parts[1], "}", 2)
508− if len(extPart) == 2 {
509− extensions := strings.Split(extPart[0], ",")
510− suffix := extPart[1]
512− // Check if the path matches any of the extensions
513− for _, ext := range extensions {
514− extPattern := prefix + ext + suffix
515− isMatch := matchesSimpleGlob(extPattern, path)
525− return matchesSimpleGlob(pattern, path)
528−// matchesSimpleGlob handles glob patterns with ** wildcards
529−func matchesSimpleGlob(pattern, path string) bool {
530− // Handle special case for **/*.ext pattern (common in LSP)
531− if after, ok := strings.CutPrefix(pattern, "**/"); ok {
534− // If the rest is a simple file extension pattern like *.go
535− if strings.HasPrefix(rest, "*.") {
536− ext := strings.TrimPrefix(rest, "*")
537− isMatch := strings.HasSuffix(path, ext)
541− // Otherwise, try to check if the path ends with the rest part
542− isMatch := strings.HasSuffix(path, rest)
544− // If it matches directly, great!
549− // Otherwise, check if any path component matches
550− pathComponents := strings.Split(path, "/")
551− for i := range pathComponents {
552− subPath := strings.Join(pathComponents[i:], "/")
553− if strings.HasSuffix(subPath, rest) {
561− // Handle other ** wildcard pattern cases
562− if strings.Contains(pattern, "**") {
563− parts := strings.Split(pattern, "**")
565− // Validate the path starts with the first part
566− if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
570− // For patterns like "**/*.go", just check the suffix
571− if len(parts) == 2 && parts[0] == "" {
572− isMatch := strings.HasSuffix(path, parts[1])
576− // For other patterns, handle middle part
577− remaining := strings.TrimPrefix(path, parts[0])
578− if len(parts) == 2 {
579− isMatch := strings.HasSuffix(remaining, parts[1])
584− // Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
585− if strings.HasPrefix(pattern, "*.") {
586− ext := strings.TrimPrefix(pattern, "*")
587− isMatch := strings.HasSuffix(path, ext)
591− // Fall back to simple matching for simpler patterns
592− matched, err := filepath.Match(pattern, path)
270+ matched, err := doublestar.Match(pattern, path)
593 if err != nil {
271 if err != nil {
594 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
272 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
595 return false
273 return false
598 return matched
275 return matched
601// matchesPattern checks if a path matches the glob pattern
278// matchesPattern checks if a path matches the glob pattern
602−func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
279+func (w *Client) matchesPattern(path string, pattern protocol.GlobPattern) bool {
603 patternInfo, err := pattern.AsPattern()
280 patternInfo, err := pattern.AsPattern()
604 if err != nil {
281 if err != nil {
605 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
282 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
@@ -637,53 +314,8 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
637 return isMatch
314 return isMatch
640−// debounceHandleFileEvent handles file events with debouncing to reduce notifications
641−func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
642− // Create a unique key based on URI and change type
643− key := fmt.Sprintf("%s:%d", uri, changeType)
645− // Cancel existing timer if any
646− if timer, exists := w.debounceMap.Get(key); exists {
651− w.debounceMap.Set(key, time.AfterFunc(w.debounceTime, func() {
652− w.handleFileEvent(ctx, uri, changeType)
654− // Cleanup timer after execution
655− w.debounceMap.Del(key)
659−// handleFileEvent sends file change notifications
660−func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
661− // If the file is open and it's a change event, use didChange notification
662− filePath, err := protocol.DocumentURI(uri).Path()
664− // XXX: Do we want to return here, or send the error up the stack?
665− slog.Error("Error converting URI to path", "uri", uri, "error", err)
669− if changeType == protocol.FileChangeType(protocol.Deleted) {
670− w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
671− } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
672− err := w.client.NotifyChange(ctx, filePath)
674− slog.Error("Error notifying change", "error", err)
679− // Notify LSP server about the file event using didChangeWatchedFiles
680− if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
681− slog.Error("Error notifying LSP server about file event", "error", err)
685// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
317// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
686−func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
318+func (w *Client) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
687 cfg := config.Get()
319 cfg := config.Get()
688 if cfg.Options.DebugLSP {
320 if cfg.Options.DebugLSP {
689 slog.Debug("Notifying file event",
321 slog.Debug("Notifying file event",
@@ -724,21 +356,6 @@ func shouldPreloadFiles(serverName string) bool {
724// Common patterns for directories and files to exclude
356// Common patterns for directories and files to exclude
725// TODO: make configurable
357// TODO: make configurable
727− excludedDirNames = map[string]bool{
729− "node_modules": true,
738− "target": true, // Rust build output
739− "vendor": true, // Go vendor directory
742 excludedFileExtensions = map[string]bool{
359 excludedFileExtensions = map[string]bool{
743 ".swp": true,
360 ".swp": true,
744 ".swo": true,
361 ".swo": true,
@@ -780,23 +397,6 @@ var (
780 maxFileSize int64 = 5 * 1024 * 1024
397 maxFileSize int64 = 5 * 1024 * 1024
783−// shouldExcludeDir returns true if the directory should be excluded from watching/opening
784−func shouldExcludeDir(dirPath string) bool {
785− dirName := filepath.Base(dirPath)
787− // Skip dot directories
788− if strings.HasPrefix(dirName, ".") {
792− // Skip common excluded directories
793− if excludedDirNames[dirName] {
800// shouldExcludeFile returns true if the file should be excluded from opening
400// shouldExcludeFile returns true if the file should be excluded from opening
801func shouldExcludeFile(filePath string) bool {
401func shouldExcludeFile(filePath string) bool {
802 fileName := filepath.Base(filePath)
402 fileName := filepath.Base(filePath)
@@ -838,7 +438,7 @@ func shouldExcludeFile(filePath string) bool {
840// openMatchingFile opens a file if it matches any of the registered patterns
440// openMatchingFile opens a file if it matches any of the registered patterns
841−func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
441+func (w *Client) openMatchingFile(ctx context.Context, path string) {
842 cfg := config.Get()
442 cfg := config.Get()
843 // Skip directories
443 // Skip directories
844 info, err := os.Stat(path)
444 info, err := os.Stat(path)
@@ -885,31 +485,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
888− // Check file extension for common source files
488+ // File type is already validated by HandlesFile() and isPathWatched() checks earlier,
889− ext := strings.ToLower(filepath.Ext(path))
489+ // so we know this client handles this file type. Just open it.
891− // Only preload source files for the specific language
894− case "typescript", "typescript-language-server", "tsserver", "vtsls":
895− shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
897− shouldOpen = ext == ".go"
898− case "rust-analyzer":
899− shouldOpen = ext == ".rs"
900− case "python", "pyright", "pylsp":
901− shouldOpen = ext == ".py"
903− shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
904− case "java", "jdtls":
905− shouldOpen = ext == ".java"
909− // Don't need to check if it's already open - the client.OpenFile handles that
910− if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
490+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
911− slog.Error("Error opening file", "path", path, "error", err)
491+ slog.Error("Error opening file", "path", path, "error", err)