@@ -7,7 +7,6 @@ import (
77 "os"
88 "path/filepath"
99 "strings"
10โ "sync"
1110 "time"
1211
1312 "github.com/bmatcuk/doublestar/v4"
@@ -16,58 +15,39 @@ import (
1615
1716 "github.com/charmbracelet/crush/internal/lsp"
1817 "github.com/charmbracelet/crush/internal/lsp/protocol"
19โ "github.com/fsnotify/fsnotify"
2018)
2119
22โ// WorkspaceWatcher manages LSP file watching
23โtype WorkspaceWatcher struct {
20+// Client manages LSP file watching for a specific client
21+// It now delegates actual file watching to the GlobalWatcher
22+type Client struct {
2423 client *lsp.Client
2524 name string
2625 workspacePath string
2726
28โ debounceTime time.Duration
29โ debounceMap *csync.Map[string, *time.Timer]
30โ
3127 // File watchers registered by the server
32โ registrations []protocol.FileSystemWatcher
33โ registrationMu sync.RWMutex
28+ registrations *csync.Slice[protocol.FileSystemWatcher]
3429}
3530
36โfunc init() {
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)
40โ }
41โ}
42โ
43โ// NewWorkspaceWatcher creates a new workspace watcher
44โfunc NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
45โ return &WorkspaceWatcher{
31+// New creates a new workspace watcher for the given client.
32+func New(name string, client *lsp.Client) *Client {
33+ return &Client{
4634 name: name,
4735 client: client,
48โ debounceTime: 300 * time.Millisecond,
49โ debounceMap: csync.NewMap[string, *time.Timer](),
50โ registrations: []protocol.FileSystemWatcher{},
36+ registrations: csync.NewSlice[protocol.FileSystemWatcher](),
5137 }
5238}
5339
54โ// AddRegistrations adds file watchers to track
55โfunc (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
40+// register adds file watchers to track
41+func (w *Client) register(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
5642 cfg := config.Get()
5743
58โ slog.Debug("Adding file watcher registrations")
59โ w.registrationMu.Lock()
60โ defer w.registrationMu.Unlock()
61โ
62โ // Add new watchers
63โ w.registrations = append(w.registrations, watchers...)
44+ w.registrations.Append(watchers...)
6445
65โ // Print detailed registration information for debugging
6646 if cfg.Options.DebugLSP {
6747 slog.Debug("Adding file watcher registrations",
6848 "id", id,
6949 "watchers", len(watchers),
70โ "total", len(w.registrations),
50+ "total", w.registrations.Len(),
7151 )
7252
7353 for i, watcher := range watchers {
@@ -103,116 +83,29 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
10383 }
10484 }
10585
106โ // Determine server type for specialized handling
107โ serverName := w.name
108โ slog.Debug("Server type detected", "serverName", serverName)
109โ
110โ // Check if this server has sent file watchers
111โ hasFileWatchers := len(watchers) > 0
112โ
113โ // For servers that need file preloading, we'll use a smart approach
114โ if shouldPreloadFiles(serverName) || !hasFileWatchers {
86+ // For servers that need file preloading, open high-priority files only
87+ if shouldPreloadFiles(w.name) {
11588 go func() {
116โ startTime := time.Now()
117โ filesOpened := 0
118โ
119โ // Determine max files to open based on server type
120โ maxFilesToOpen := 50 // Default conservative limit
121โ
122โ switch serverName {
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
129โ }
130โ
131โ // First, open high-priority files
132โ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
133โ filesOpened += highPriorityFilesOpened
134โ
89+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, w.name)
13590 if cfg.Options.DebugLSP {
13691 slog.Debug("Opened high-priority files",
13792 "count", highPriorityFilesOpened,
138โ "serverName", serverName)
139โ }
140โ
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)
147โ }
148โ return
149โ }
150โ
151โ // For the remaining slots, walk the directory and open matching files
152โ
153โ err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
154โ if err != nil {
155โ return err
156โ }
157โ
158โ // Skip directories that should be excluded
159โ if d.IsDir() {
160โ if path != w.workspacePath && shouldExcludeDir(path) {
161โ if cfg.Options.DebugLSP {
162โ slog.Debug("Skipping excluded directory", "path", path)
163โ }
164โ return filepath.SkipDir
165โ }
166โ } else {
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)
172โ filesOpened++
173โ
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)
177โ }
178โ }
179โ } else {
180โ // We've reached our limit, stop walking
181โ return filepath.SkipAll
182โ }
183โ }
184โ
185โ return nil
186โ })
187โ
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,
195โ )
196โ }
197โ
198โ if err != nil && cfg.Options.DebugLSP {
199โ slog.Debug("Error scanning workspace for files to open", "error", err)
93+ "serverName", w.name)
20094 }
20195 }()
202โ } else if cfg.Options.DebugLSP {
203โ slog.Debug("Using on-demand file loading for server", "server", serverName)
20496 }
20597}
20698
20799// openHighPriorityFiles opens important files for the server type
208100// 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 {
210102 cfg := config.Get()
211103 filesOpened := 0
212104
213105 // Define patterns for high-priority files based on server type
214106 var patterns []string
215107
108+ // TODO: move this to LSP config
216109 switch serverName {
217110 case "typescript", "typescript-language-server", "tsserver", "vtsls":
218111 patterns = []string{
@@ -329,160 +222,35 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
329222 return filesOpened
330223}
331224
332โ// WatchWorkspace sets up file watching for a workspace
333โfunc (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
334โ cfg := config.Get()
225+// Watch sets up file watching for a workspace using the global watcher
226+func (w *Client) Watch(ctx context.Context, workspacePath string) {
335227 w.workspacePath = workspacePath
336228
337229 slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
338230
231+ // Register this workspace watcher with the global watcher
232+ instance().register(w.name, w)
233+ defer instance().unregister(w.name)
234+
339235 // Register handler for file watcher registrations from the server
340236 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
341โ w.AddRegistrations(ctx, id, watchers)
237+ w.register(ctx, id, watchers)
342238 })
343239
344โ watcher, err := fsnotify.NewWatcher()
345โ if err != nil {
346โ slog.Error("Error creating watcher", "error", err)
347โ }
348โ defer watcher.Close()
349โ
350โ // Watch the workspace recursively
351โ err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
352โ if err != nil {
353โ return err
354โ }
355โ
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)
361โ }
362โ return filepath.SkipDir
363โ }
364โ }
365โ
366โ // Add directories to watcher
367โ if d.IsDir() {
368โ err = watcher.Add(path)
369โ if err != nil {
370โ slog.Error("Error watching path", "path", path, "error", err)
371โ }
372โ }
373โ
374โ return nil
375โ })
376โ if err != nil {
377โ slog.Error("Error walking workspace", "error", err)
378โ }
379โ
380โ // Event loop
381โ for {
382โ select {
383โ case <-ctx.Done():
384โ return
385โ case event, ok := <-watcher.Events:
386โ if !ok {
387โ return
388โ }
389โ
390โ if !w.client.HandlesFile(event.Name) {
391โ continue // client doesn't handle this filetype
392โ }
393โ
394โ uri := string(protocol.URIFromPath(event.Name))
395โ
396โ // Add new directories to the watcher
397โ if event.Op&fsnotify.Create != 0 {
398โ if info, err := os.Stat(event.Name); err == nil {
399โ if info.IsDir() {
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)
404โ }
405โ }
406โ } else {
407โ // For newly created files
408โ if !shouldExcludeFile(event.Name) {
409โ w.openMatchingFile(ctx, event.Name)
410โ }
411โ }
412โ }
413โ }
414โ
415โ // Debug logging
416โ if cfg.Options.DebugLSP {
417โ matched, kind := w.isPathWatched(event.Name)
418โ slog.Debug("File event",
419โ "path", event.Name,
420โ "operation", event.Op.String(),
421โ "watched", matched,
422โ "kind", kind,
423โ )
424โ }
425โ
426โ // Check if this path should be watched according to server registrations
427โ if watched, watchKind := w.isPathWatched(event.Name); watched {
428โ switch {
429โ case event.Op&fsnotify.Write != 0:
430โ if watchKind&protocol.WatchChange != 0 {
431โ w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
432โ }
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)
437โ if err != nil {
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)
441โ }
442โ continue
443โ }
444โ if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
445โ w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
446โ }
447โ case event.Op&fsnotify.Remove != 0:
448โ if watchKind&protocol.WatchDelete != 0 {
449โ w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
450โ }
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))
455โ }
456โ
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))
461โ }
462โ }
463โ }
464โ }
465โ case err, ok := <-watcher.Errors:
466โ if !ok {
467โ return
468โ }
469โ slog.Error("Error watching file", "error", err)
470โ }
471โ }
240+ // Wait for context cancellation
241+ <-ctx.Done()
242+ slog.Debug("Workspace watcher stopped", "name", w.name)
472243}
473244
474245// isPathWatched checks if a path should be watched based on server registrations
475โfunc (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
476โ w.registrationMu.RLock()
477โ defer w.registrationMu.RUnlock()
478โ
479โ // If no explicit registrations, watch everything
480โ if len(w.registrations) == 0 {
246+// If no explicit registrations, watch everything
247+func (w *Client) isPathWatched(path string) (bool, protocol.WatchKind) {
248+ if w.registrations.Len() == 0 {
481249 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
482250 }
483251
484252 // Check each registration
485โ for _, reg := range w.registrations {
253+ for reg := range w.registrations.Seq() {
486254 isMatch := w.matchesPattern(path, reg.GlobPattern)
487255 if isMatch {
488256 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
@@ -496,110 +264,19 @@ func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind)
496264 return false, 0
497265}
498266
499โ// matchesGlob handles advanced glob patterns including ** and alternatives
267+// matchesGlob handles glob patterns using the doublestar library
500268func matchesGlob(pattern, path string) bool {
501โ // Handle file extension patterns with braces like *.{go,mod,sum}
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 {
506โ prefix := parts[0]
507โ extPart := strings.SplitN(parts[1], "}", 2)
508โ if len(extPart) == 2 {
509โ extensions := strings.Split(extPart[0], ",")
510โ suffix := extPart[1]
511โ
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)
516โ if isMatch {
517โ return true
518โ }
519โ }
520โ return false
521โ }
522โ }
523โ }
524โ
525โ return matchesSimpleGlob(pattern, path)
526โ}
527โ
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 {
532โ rest := after
533โ
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)
538โ return isMatch
539โ }
540โ
541โ // Otherwise, try to check if the path ends with the rest part
542โ isMatch := strings.HasSuffix(path, rest)
543โ
544โ // If it matches directly, great!
545โ if isMatch {
546โ return true
547โ }
548โ
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) {
554โ return true
555โ }
556โ }
557โ
558โ return false
559โ }
560โ
561โ // Handle other ** wildcard pattern cases
562โ if strings.Contains(pattern, "**") {
563โ parts := strings.Split(pattern, "**")
564โ
565โ // Validate the path starts with the first part
566โ if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
567โ return false
568โ }
569โ
570โ // For patterns like "**/*.go", just check the suffix
571โ if len(parts) == 2 && parts[0] == "" {
572โ isMatch := strings.HasSuffix(path, parts[1])
573โ return isMatch
574โ }
575โ
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])
580โ return isMatch
581โ }
582โ }
583โ
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)
588โ return isMatch
589โ }
590โ
591โ // Fall back to simple matching for simpler patterns
592โ matched, err := filepath.Match(pattern, path)
269+ // Use doublestar for all glob matching - it handles ** and other complex patterns
270+ matched, err := doublestar.Match(pattern, path)
593271 if err != nil {
594272 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
595273 return false
596274 }
597โ
598275 return matched
599276}
600277
601278// 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 {
603280 patternInfo, err := pattern.AsPattern()
604281 if err != nil {
605282 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
@@ -637,53 +314,8 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
637314 return isMatch
638315}
639316
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)
644โ
645โ // Cancel existing timer if any
646โ if timer, exists := w.debounceMap.Get(key); exists {
647โ timer.Stop()
648โ }
649โ
650โ // Create new timer
651โ w.debounceMap.Set(key, time.AfterFunc(w.debounceTime, func() {
652โ w.handleFileEvent(ctx, uri, changeType)
653โ
654โ // Cleanup timer after execution
655โ w.debounceMap.Del(key)
656โ }))
657โ}
658โ
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()
663โ if err != nil {
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)
666โ return
667โ }
668โ
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)
673โ if err != nil {
674โ slog.Error("Error notifying change", "error", err)
675โ }
676โ return
677โ }
678โ
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)
682โ }
683โ}
684โ
685317// 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 {
687319 cfg := config.Get()
688320 if cfg.Options.DebugLSP {
689321 slog.Debug("Notifying file event",
@@ -724,21 +356,6 @@ func shouldPreloadFiles(serverName string) bool {
724356// Common patterns for directories and files to exclude
725357// TODO: make configurable
726358var (
727โ excludedDirNames = map[string]bool{
728โ ".git": true,
729โ "node_modules": true,
730โ "dist": true,
731โ "build": true,
732โ "out": true,
733โ "bin": true,
734โ ".idea": true,
735โ ".vscode": true,
736โ ".cache": true,
737โ "coverage": true,
738โ "target": true, // Rust build output
739โ "vendor": true, // Go vendor directory
740โ }
741โ
742359 excludedFileExtensions = map[string]bool{
743360 ".swp": true,
744361 ".swo": true,
@@ -780,23 +397,6 @@ var (
780397 maxFileSize int64 = 5 * 1024 * 1024
781398)
782399
783โ// shouldExcludeDir returns true if the directory should be excluded from watching/opening
784โfunc shouldExcludeDir(dirPath string) bool {
785โ dirName := filepath.Base(dirPath)
786โ
787โ // Skip dot directories
788โ if strings.HasPrefix(dirName, ".") {
789โ return true
790โ }
791โ
792โ // Skip common excluded directories
793โ if excludedDirNames[dirName] {
794โ return true
795โ }
796โ
797โ return false
798โ}
799โ
800400// shouldExcludeFile returns true if the file should be excluded from opening
801401func shouldExcludeFile(filePath string) bool {
802402 fileName := filepath.Base(filePath)
@@ -838,7 +438,7 @@ func shouldExcludeFile(filePath string) bool {
838438}
839439
840440// 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) {
842442 cfg := config.Get()
843443 // Skip directories
844444 info, err := os.Stat(path)
@@ -885,31 +485,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
885485 return
886486 }
887487
888โ // Check file extension for common source files
889โ ext := strings.ToLower(filepath.Ext(path))
890โ
891โ // Only preload source files for the specific language
892โ var shouldOpen bool
893โ switch serverName {
894โ case "typescript", "typescript-language-server", "tsserver", "vtsls":
895โ shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
896โ case "gopls":
897โ shouldOpen = ext == ".go"
898โ case "rust-analyzer":
899โ shouldOpen = ext == ".rs"
900โ case "python", "pyright", "pylsp":
901โ shouldOpen = ext == ".py"
902โ case "clangd":
903โ shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
904โ case "java", "jdtls":
905โ shouldOpen = ext == ".java"
906โ }
907โ
908โ if shouldOpen {
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 {
911โ slog.Error("Error opening file", "path", path, "error", err)
912โ }
488+ // File type is already validated by HandlesFile() and isPathWatched() checks earlier,
489+ // so we know this client handles this file type. Just open it.
490+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
491+ slog.Error("Error opening file", "path", path, "error", err)
913492 }
914493}
915494