1package watcher
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/bmatcuk/doublestar/v4"
14 "github.com/charmbracelet/crush/internal/config"
15
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
18 "github.com/fsnotify/fsnotify"
19)
20
21// WorkspaceWatcher manages LSP file watching
22type WorkspaceWatcher struct {
23 client *lsp.Client
24 workspacePath string
25
26 debounceTime time.Duration
27 debounceMap map[string]*time.Timer
28 debounceMu sync.Mutex
29
30 // File watchers registered by the server
31 registrations []protocol.FileSystemWatcher
32 registrationMu sync.RWMutex
33}
34
35// NewWorkspaceWatcher creates a new workspace watcher
36func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
37 return &WorkspaceWatcher{
38 client: client,
39 debounceTime: 300 * time.Millisecond,
40 debounceMap: make(map[string]*time.Timer),
41 registrations: []protocol.FileSystemWatcher{},
42 }
43}
44
45// AddRegistrations adds file watchers to track
46func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
47 cfg := config.Get()
48
49 slog.Debug("Adding file watcher registrations")
50 w.registrationMu.Lock()
51 defer w.registrationMu.Unlock()
52
53 // Add new watchers
54 w.registrations = append(w.registrations, watchers...)
55
56 // Print detailed registration information for debugging
57 if cfg.Options.DebugLSP {
58 slog.Debug("Adding file watcher registrations",
59 "id", id,
60 "watchers", len(watchers),
61 "total", len(w.registrations),
62 )
63
64 for i, watcher := range watchers {
65 slog.Debug("Registration", "index", i+1)
66
67 // Log the GlobPattern
68 switch v := watcher.GlobPattern.Value.(type) {
69 case string:
70 slog.Debug("GlobPattern", "pattern", v)
71 case protocol.RelativePattern:
72 slog.Debug("GlobPattern", "pattern", v.Pattern)
73
74 // Log BaseURI details
75 switch u := v.BaseURI.Value.(type) {
76 case string:
77 slog.Debug("BaseURI", "baseURI", u)
78 case protocol.DocumentUri:
79 slog.Debug("BaseURI", "baseURI", u)
80 default:
81 slog.Debug("BaseURI", "baseURI", u)
82 }
83 default:
84 slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
85 }
86
87 // Log WatchKind
88 watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
89 if watcher.Kind != nil {
90 watchKind = *watcher.Kind
91 }
92
93 slog.Debug("WatchKind", "kind", watchKind)
94 }
95 }
96
97 // Determine server type for specialized handling
98 serverName := getServerNameFromContext(ctx)
99 slog.Debug("Server type detected", "serverName", serverName)
100
101 // Check if this server has sent file watchers
102 hasFileWatchers := len(watchers) > 0
103
104 // For servers that need file preloading, we'll use a smart approach
105 if shouldPreloadFiles(serverName) || !hasFileWatchers {
106 go func() {
107 startTime := time.Now()
108 filesOpened := 0
109
110 // Determine max files to open based on server type
111 maxFilesToOpen := 50 // Default conservative limit
112
113 switch serverName {
114 case "typescript", "typescript-language-server", "tsserver", "vtsls":
115 // TypeScript servers benefit from seeing more files
116 maxFilesToOpen = 100
117 case "java", "jdtls":
118 // Java servers need to see many files for project model
119 maxFilesToOpen = 200
120 }
121
122 // First, open high-priority files
123 highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
124 filesOpened += highPriorityFilesOpened
125
126 if cfg.Options.DebugLSP {
127 slog.Debug("Opened high-priority files",
128 "count", highPriorityFilesOpened,
129 "serverName", serverName)
130 }
131
132 // If we've already opened enough high-priority files, we might not need more
133 if filesOpened >= maxFilesToOpen {
134 if cfg.Options.DebugLSP {
135 slog.Debug("Reached file limit with high-priority files",
136 "filesOpened", filesOpened,
137 "maxFiles", maxFilesToOpen)
138 }
139 return
140 }
141
142 // For the remaining slots, walk the directory and open matching files
143
144 err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
145 if err != nil {
146 return err
147 }
148
149 // Skip directories that should be excluded
150 if d.IsDir() {
151 if path != w.workspacePath && shouldExcludeDir(path) {
152 if cfg.Options.DebugLSP {
153 slog.Debug("Skipping excluded directory", "path", path)
154 }
155 return filepath.SkipDir
156 }
157 } else {
158 // Process files, but limit the total number
159 if filesOpened < maxFilesToOpen {
160 // Only process if it's not already open (high-priority files were opened earlier)
161 if !w.client.IsFileOpen(path) {
162 w.openMatchingFile(ctx, path)
163 filesOpened++
164
165 // Add a small delay after every 10 files to prevent overwhelming the server
166 if filesOpened%10 == 0 {
167 time.Sleep(50 * time.Millisecond)
168 }
169 }
170 } else {
171 // We've reached our limit, stop walking
172 return filepath.SkipAll
173 }
174 }
175
176 return nil
177 })
178
179 elapsedTime := time.Since(startTime)
180 if cfg.Options.DebugLSP {
181 slog.Debug("Limited workspace scan complete",
182 "filesOpened", filesOpened,
183 "maxFiles", maxFilesToOpen,
184 "elapsedTime", elapsedTime.Seconds(),
185 "workspacePath", w.workspacePath,
186 )
187 }
188
189 if err != nil && cfg.Options.DebugLSP {
190 slog.Debug("Error scanning workspace for files to open", "error", err)
191 }
192 }()
193 } else if cfg.Options.DebugLSP {
194 slog.Debug("Using on-demand file loading for server", "server", serverName)
195 }
196}
197
198// openHighPriorityFiles opens important files for the server type
199// Returns the number of files opened
200func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
201 cfg := config.Get()
202 filesOpened := 0
203
204 // Define patterns for high-priority files based on server type
205 var patterns []string
206
207 switch serverName {
208 case "typescript", "typescript-language-server", "tsserver", "vtsls":
209 patterns = []string{
210 "**/tsconfig.json",
211 "**/package.json",
212 "**/jsconfig.json",
213 "**/index.ts",
214 "**/index.js",
215 "**/main.ts",
216 "**/main.js",
217 }
218 case "gopls":
219 patterns = []string{
220 "**/go.mod",
221 "**/go.sum",
222 "**/main.go",
223 }
224 case "rust-analyzer":
225 patterns = []string{
226 "**/Cargo.toml",
227 "**/Cargo.lock",
228 "**/src/lib.rs",
229 "**/src/main.rs",
230 }
231 case "python", "pyright", "pylsp":
232 patterns = []string{
233 "**/pyproject.toml",
234 "**/setup.py",
235 "**/requirements.txt",
236 "**/__init__.py",
237 "**/__main__.py",
238 }
239 case "clangd":
240 patterns = []string{
241 "**/CMakeLists.txt",
242 "**/Makefile",
243 "**/compile_commands.json",
244 }
245 case "java", "jdtls":
246 patterns = []string{
247 "**/pom.xml",
248 "**/build.gradle",
249 "**/src/main/java/**/*.java",
250 }
251 default:
252 // For unknown servers, use common configuration files
253 patterns = []string{
254 "**/package.json",
255 "**/Makefile",
256 "**/CMakeLists.txt",
257 "**/.editorconfig",
258 }
259 }
260
261 // Collect all files to open first
262 var filesToOpen []string
263
264 // For each pattern, find matching files
265 for _, pattern := range patterns {
266 // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
267 matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
268 if err != nil {
269 if cfg.Options.DebugLSP {
270 slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
271 }
272 continue
273 }
274
275 for _, match := range matches {
276 // Convert relative path to absolute
277 fullPath := filepath.Join(w.workspacePath, match)
278
279 // Skip directories and excluded files
280 info, err := os.Stat(fullPath)
281 if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
282 continue
283 }
284
285 filesToOpen = append(filesToOpen, fullPath)
286
287 // Limit the number of files per pattern
288 if len(filesToOpen) >= 5 && (serverName != "java" && serverName != "jdtls") {
289 break
290 }
291 }
292 }
293
294 // Open files in batches to reduce overhead
295 batchSize := 3
296 for i := 0; i < len(filesToOpen); i += batchSize {
297 end := min(i+batchSize, len(filesToOpen))
298
299 // Open batch of files
300 for j := i; j < end; j++ {
301 fullPath := filesToOpen[j]
302 if err := w.client.OpenFile(ctx, fullPath); err != nil {
303 if cfg.Options.DebugLSP {
304 slog.Debug("Error opening high-priority file", "path", fullPath, "error", err)
305 }
306 } else {
307 filesOpened++
308 if cfg.Options.DebugLSP {
309 slog.Debug("Opened high-priority file", "path", fullPath)
310 }
311 }
312 }
313
314 // Only add delay between batches, not individual files
315 if end < len(filesToOpen) {
316 time.Sleep(50 * time.Millisecond)
317 }
318 }
319
320 return filesOpened
321}
322
323// WatchWorkspace sets up file watching for a workspace
324func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
325 cfg := config.Get()
326 w.workspacePath = workspacePath
327
328 // Store the watcher in the context for later use
329 ctx = context.WithValue(ctx, "workspaceWatcher", w)
330
331 // If the server name isn't already in the context, try to detect it
332 if _, ok := ctx.Value("serverName").(string); !ok {
333 serverName := getServerNameFromContext(ctx)
334 ctx = context.WithValue(ctx, "serverName", serverName)
335 }
336
337 serverName := getServerNameFromContext(ctx)
338 slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
339
340 // Register handler for file watcher registrations from the server
341 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
342 w.AddRegistrations(ctx, id, watchers)
343 })
344
345 watcher, err := fsnotify.NewWatcher()
346 if err != nil {
347 slog.Error("Error creating watcher", "error", err)
348 }
349 defer watcher.Close()
350
351 // Watch the workspace recursively
352 err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
353 if err != nil {
354 return err
355 }
356
357 // Skip excluded directories (except workspace root)
358 if d.IsDir() && path != workspacePath {
359 if shouldExcludeDir(path) {
360 if cfg.Options.DebugLSP {
361 slog.Debug("Skipping excluded directory", "path", path)
362 }
363 return filepath.SkipDir
364 }
365 }
366
367 // Add directories to watcher
368 if d.IsDir() {
369 err = watcher.Add(path)
370 if err != nil {
371 slog.Error("Error watching path", "path", path, "error", err)
372 }
373 }
374
375 return nil
376 })
377 if err != nil {
378 slog.Error("Error walking workspace", "error", err)
379 }
380
381 // Event loop
382 for {
383 select {
384 case <-ctx.Done():
385 return
386 case event, ok := <-watcher.Events:
387 if !ok {
388 return
389 }
390
391 uri := string(protocol.URIFromPath(event.Name))
392
393 // Add new directories to the watcher
394 if event.Op&fsnotify.Create != 0 {
395 if info, err := os.Stat(event.Name); err == nil {
396 if info.IsDir() {
397 // Skip excluded directories
398 if !shouldExcludeDir(event.Name) {
399 if err := watcher.Add(event.Name); err != nil {
400 slog.Error("Error adding directory to watcher", "path", event.Name, "error", err)
401 }
402 }
403 } else {
404 // For newly created files
405 if !shouldExcludeFile(event.Name) {
406 w.openMatchingFile(ctx, event.Name)
407 }
408 }
409 }
410 }
411
412 // Debug logging
413 if cfg.Options.DebugLSP {
414 matched, kind := w.isPathWatched(event.Name)
415 slog.Debug("File event",
416 "path", event.Name,
417 "operation", event.Op.String(),
418 "watched", matched,
419 "kind", kind,
420 )
421 }
422
423 // Check if this path should be watched according to server registrations
424 if watched, watchKind := w.isPathWatched(event.Name); watched {
425 switch {
426 case event.Op&fsnotify.Write != 0:
427 if watchKind&protocol.WatchChange != 0 {
428 w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
429 }
430 case event.Op&fsnotify.Create != 0:
431 // Already handled earlier in the event loop
432 // Just send the notification if needed
433 info, err := os.Stat(event.Name)
434 if err != nil {
435 slog.Error("Error getting file info", "path", event.Name, "error", err)
436 return
437 }
438 if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
439 w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
440 }
441 case event.Op&fsnotify.Remove != 0:
442 if watchKind&protocol.WatchDelete != 0 {
443 w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
444 }
445 case event.Op&fsnotify.Rename != 0:
446 // For renames, first delete
447 if watchKind&protocol.WatchDelete != 0 {
448 w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
449 }
450
451 // Then check if the new file exists and create an event
452 if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
453 if watchKind&protocol.WatchCreate != 0 {
454 w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
455 }
456 }
457 }
458 }
459 case err, ok := <-watcher.Errors:
460 if !ok {
461 return
462 }
463 slog.Error("Error watching file", "error", err)
464 }
465 }
466}
467
468// isPathWatched checks if a path should be watched based on server registrations
469func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
470 w.registrationMu.RLock()
471 defer w.registrationMu.RUnlock()
472
473 // If no explicit registrations, watch everything
474 if len(w.registrations) == 0 {
475 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
476 }
477
478 // Check each registration
479 for _, reg := range w.registrations {
480 isMatch := w.matchesPattern(path, reg.GlobPattern)
481 if isMatch {
482 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
483 if reg.Kind != nil {
484 kind = *reg.Kind
485 }
486 return true, kind
487 }
488 }
489
490 return false, 0
491}
492
493// matchesGlob handles advanced glob patterns including ** and alternatives
494func matchesGlob(pattern, path string) bool {
495 // Handle file extension patterns with braces like *.{go,mod,sum}
496 if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
497 // Extract extensions from pattern like "*.{go,mod,sum}"
498 parts := strings.SplitN(pattern, "{", 2)
499 if len(parts) == 2 {
500 prefix := parts[0]
501 extPart := strings.SplitN(parts[1], "}", 2)
502 if len(extPart) == 2 {
503 extensions := strings.Split(extPart[0], ",")
504 suffix := extPart[1]
505
506 // Check if the path matches any of the extensions
507 for _, ext := range extensions {
508 extPattern := prefix + ext + suffix
509 isMatch := matchesSimpleGlob(extPattern, path)
510 if isMatch {
511 return true
512 }
513 }
514 return false
515 }
516 }
517 }
518
519 return matchesSimpleGlob(pattern, path)
520}
521
522// matchesSimpleGlob handles glob patterns with ** wildcards
523func matchesSimpleGlob(pattern, path string) bool {
524 // Handle special case for **/*.ext pattern (common in LSP)
525 if strings.HasPrefix(pattern, "**/") {
526 rest := strings.TrimPrefix(pattern, "**/")
527
528 // If the rest is a simple file extension pattern like *.go
529 if strings.HasPrefix(rest, "*.") {
530 ext := strings.TrimPrefix(rest, "*")
531 isMatch := strings.HasSuffix(path, ext)
532 return isMatch
533 }
534
535 // Otherwise, try to check if the path ends with the rest part
536 isMatch := strings.HasSuffix(path, rest)
537
538 // If it matches directly, great!
539 if isMatch {
540 return true
541 }
542
543 // Otherwise, check if any path component matches
544 pathComponents := strings.Split(path, "/")
545 for i := range pathComponents {
546 subPath := strings.Join(pathComponents[i:], "/")
547 if strings.HasSuffix(subPath, rest) {
548 return true
549 }
550 }
551
552 return false
553 }
554
555 // Handle other ** wildcard pattern cases
556 if strings.Contains(pattern, "**") {
557 parts := strings.Split(pattern, "**")
558
559 // Validate the path starts with the first part
560 if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
561 return false
562 }
563
564 // For patterns like "**/*.go", just check the suffix
565 if len(parts) == 2 && parts[0] == "" {
566 isMatch := strings.HasSuffix(path, parts[1])
567 return isMatch
568 }
569
570 // For other patterns, handle middle part
571 remaining := strings.TrimPrefix(path, parts[0])
572 if len(parts) == 2 {
573 isMatch := strings.HasSuffix(remaining, parts[1])
574 return isMatch
575 }
576 }
577
578 // Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
579 if strings.HasPrefix(pattern, "*.") {
580 ext := strings.TrimPrefix(pattern, "*")
581 isMatch := strings.HasSuffix(path, ext)
582 return isMatch
583 }
584
585 // Fall back to simple matching for simpler patterns
586 matched, err := filepath.Match(pattern, path)
587 if err != nil {
588 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
589 return false
590 }
591
592 return matched
593}
594
595// matchesPattern checks if a path matches the glob pattern
596func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
597 patternInfo, err := pattern.AsPattern()
598 if err != nil {
599 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
600 return false
601 }
602
603 basePath := patternInfo.GetBasePath()
604 patternText := patternInfo.GetPattern()
605
606 path = filepath.ToSlash(path)
607
608 // For simple patterns without base path
609 if basePath == "" {
610 // Check if the pattern matches the full path or just the file extension
611 fullPathMatch := matchesGlob(patternText, path)
612 baseNameMatch := matchesGlob(patternText, filepath.Base(path))
613
614 return fullPathMatch || baseNameMatch
615 }
616
617 // For relative patterns
618 basePath = protocol.DocumentUri(basePath).Path()
619 basePath = filepath.ToSlash(basePath)
620
621 // Make path relative to basePath for matching
622 relPath, err := filepath.Rel(basePath, path)
623 if err != nil {
624 slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
625 return false
626 }
627 relPath = filepath.ToSlash(relPath)
628
629 isMatch := matchesGlob(patternText, relPath)
630
631 return isMatch
632}
633
634// debounceHandleFileEvent handles file events with debouncing to reduce notifications
635func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
636 w.debounceMu.Lock()
637 defer w.debounceMu.Unlock()
638
639 // Create a unique key based on URI and change type
640 key := fmt.Sprintf("%s:%d", uri, changeType)
641
642 // Cancel existing timer if any
643 if timer, exists := w.debounceMap[key]; exists {
644 timer.Stop()
645 }
646
647 // Create new timer
648 w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
649 w.handleFileEvent(ctx, uri, changeType)
650
651 // Cleanup timer after execution
652 w.debounceMu.Lock()
653 delete(w.debounceMap, key)
654 w.debounceMu.Unlock()
655 })
656}
657
658// handleFileEvent sends file change notifications
659func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
660 // If the file is open and it's a change event, use didChange notification
661 filePath := protocol.DocumentUri(uri).Path()
662 if changeType == protocol.FileChangeType(protocol.Deleted) {
663 w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri))
664 } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
665 err := w.client.NotifyChange(ctx, filePath)
666 if err != nil {
667 slog.Error("Error notifying change", "error", err)
668 }
669 return
670 }
671
672 // Notify LSP server about the file event using didChangeWatchedFiles
673 if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
674 slog.Error("Error notifying LSP server about file event", "error", err)
675 }
676}
677
678// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
679func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
680 cfg := config.Get()
681 if cfg.Options.DebugLSP {
682 slog.Debug("Notifying file event",
683 "uri", uri,
684 "changeType", changeType,
685 )
686 }
687
688 params := protocol.DidChangeWatchedFilesParams{
689 Changes: []protocol.FileEvent{
690 {
691 URI: protocol.DocumentUri(uri),
692 Type: changeType,
693 },
694 },
695 }
696
697 return w.client.DidChangeWatchedFiles(ctx, params)
698}
699
700// getServerNameFromContext extracts the server name from the context
701// This is a best-effort function that tries to identify which LSP server we're dealing with
702func getServerNameFromContext(ctx context.Context) string {
703 // First check if the server name is directly stored in the context
704 if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
705 return strings.ToLower(serverName)
706 }
707
708 // Otherwise, try to extract server name from the client command path
709 if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
710 path := strings.ToLower(w.client.Cmd.Path)
711
712 // Extract server name from path
713 if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
714 return "typescript"
715 } else if strings.Contains(path, "gopls") {
716 return "gopls"
717 } else if strings.Contains(path, "rust-analyzer") {
718 return "rust-analyzer"
719 } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
720 return "python"
721 } else if strings.Contains(path, "clangd") {
722 return "clangd"
723 } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
724 return "java"
725 }
726
727 // Return the base name as fallback
728 return filepath.Base(path)
729 }
730
731 return "unknown"
732}
733
734// shouldPreloadFiles determines if we should preload files for a specific language server
735// Some servers work better with preloaded files, others don't need it
736func shouldPreloadFiles(serverName string) bool {
737 // TypeScript/JavaScript servers typically need some files preloaded
738 // to properly resolve imports and provide intellisense
739 switch serverName {
740 case "typescript", "typescript-language-server", "tsserver", "vtsls":
741 return true
742 case "java", "jdtls":
743 // Java servers often need to see source files to build the project model
744 return true
745 default:
746 // For most servers, we'll use lazy loading by default
747 return false
748 }
749}
750
751// Common patterns for directories and files to exclude
752// TODO: make configurable
753var (
754 excludedDirNames = map[string]bool{
755 ".git": true,
756 "node_modules": true,
757 "dist": true,
758 "build": true,
759 "out": true,
760 "bin": true,
761 ".idea": true,
762 ".vscode": true,
763 ".cache": true,
764 "coverage": true,
765 "target": true, // Rust build output
766 "vendor": true, // Go vendor directory
767 }
768
769 excludedFileExtensions = map[string]bool{
770 ".swp": true,
771 ".swo": true,
772 ".tmp": true,
773 ".temp": true,
774 ".bak": true,
775 ".log": true,
776 ".o": true, // Object files
777 ".so": true, // Shared libraries
778 ".dylib": true, // macOS shared libraries
779 ".dll": true, // Windows shared libraries
780 ".a": true, // Static libraries
781 ".exe": true, // Windows executables
782 ".lock": true, // Lock files
783 }
784
785 // Large binary files that shouldn't be opened
786 largeBinaryExtensions = map[string]bool{
787 ".png": true,
788 ".jpg": true,
789 ".jpeg": true,
790 ".gif": true,
791 ".bmp": true,
792 ".ico": true,
793 ".zip": true,
794 ".tar": true,
795 ".gz": true,
796 ".rar": true,
797 ".7z": true,
798 ".pdf": true,
799 ".mp3": true,
800 ".mp4": true,
801 ".mov": true,
802 ".wav": true,
803 ".wasm": true,
804 }
805
806 // Maximum file size to open (5MB)
807 maxFileSize int64 = 5 * 1024 * 1024
808)
809
810// shouldExcludeDir returns true if the directory should be excluded from watching/opening
811func shouldExcludeDir(dirPath string) bool {
812 dirName := filepath.Base(dirPath)
813
814 // Skip dot directories
815 if strings.HasPrefix(dirName, ".") {
816 return true
817 }
818
819 // Skip common excluded directories
820 if excludedDirNames[dirName] {
821 return true
822 }
823
824 return false
825}
826
827// shouldExcludeFile returns true if the file should be excluded from opening
828func shouldExcludeFile(filePath string) bool {
829 fileName := filepath.Base(filePath)
830 cfg := config.Get()
831 // Skip dot files
832 if strings.HasPrefix(fileName, ".") {
833 return true
834 }
835
836 // Check file extension
837 ext := strings.ToLower(filepath.Ext(filePath))
838 if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
839 return true
840 }
841
842 // Skip temporary files
843 if strings.HasSuffix(filePath, "~") {
844 return true
845 }
846
847 // Check file size
848 info, err := os.Stat(filePath)
849 if err != nil {
850 // If we can't stat the file, skip it
851 return true
852 }
853
854 // Skip large files
855 if info.Size() > maxFileSize {
856 if cfg.Options.DebugLSP {
857 slog.Debug("Skipping large file",
858 "path", filePath,
859 "size", info.Size(),
860 "maxSize", maxFileSize,
861 "debug", cfg.Options.Debug,
862 "sizeMB", float64(info.Size())/(1024*1024),
863 "maxSizeMB", float64(maxFileSize)/(1024*1024),
864 )
865 }
866 return true
867 }
868
869 return false
870}
871
872// openMatchingFile opens a file if it matches any of the registered patterns
873func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
874 cfg := config.Get()
875 // Skip directories
876 info, err := os.Stat(path)
877 if err != nil || info.IsDir() {
878 return
879 }
880
881 // Skip excluded files
882 if shouldExcludeFile(path) {
883 return
884 }
885
886 // Check if this path should be watched according to server registrations
887 if watched, _ := w.isPathWatched(path); watched {
888 // Get server name for specialized handling
889 serverName := getServerNameFromContext(ctx)
890
891 // Check if the file is a high-priority file that should be opened immediately
892 // This helps with project initialization for certain language servers
893 if isHighPriorityFile(path, serverName) {
894 if cfg.Options.DebugLSP {
895 slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
896 }
897 if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
898 slog.Error("Error opening high-priority file", "path", path, "error", err)
899 }
900 return
901 }
902
903 // For non-high-priority files, we'll use different strategies based on server type
904 if shouldPreloadFiles(serverName) {
905 // For servers that benefit from preloading, open files but with limits
906
907 // Check file size - for preloading we're more conservative
908 if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
909 if cfg.Options.DebugLSP {
910 slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
911 }
912 return
913 }
914
915 // Check file extension for common source files
916 ext := strings.ToLower(filepath.Ext(path))
917
918 // Only preload source files for the specific language
919 shouldOpen := false
920
921 switch serverName {
922 case "typescript", "typescript-language-server", "tsserver", "vtsls":
923 shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
924 case "gopls":
925 shouldOpen = ext == ".go"
926 case "rust-analyzer":
927 shouldOpen = ext == ".rs"
928 case "python", "pyright", "pylsp":
929 shouldOpen = ext == ".py"
930 case "clangd":
931 shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
932 case "java", "jdtls":
933 shouldOpen = ext == ".java"
934 default:
935 // For unknown servers, be conservative
936 shouldOpen = false
937 }
938
939 if shouldOpen {
940 // Don't need to check if it's already open - the client.OpenFile handles that
941 if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
942 slog.Error("Error opening file", "path", path, "error", err)
943 }
944 }
945 }
946 }
947}
948
949// isHighPriorityFile determines if a file should be opened immediately
950// regardless of the preloading strategy
951func isHighPriorityFile(path string, serverName string) bool {
952 fileName := filepath.Base(path)
953 ext := filepath.Ext(path)
954
955 switch serverName {
956 case "typescript", "typescript-language-server", "tsserver", "vtsls":
957 // For TypeScript, we want to open configuration files immediately
958 return fileName == "tsconfig.json" ||
959 fileName == "package.json" ||
960 fileName == "jsconfig.json" ||
961 // Also open main entry points
962 fileName == "index.ts" ||
963 fileName == "index.js" ||
964 fileName == "main.ts" ||
965 fileName == "main.js"
966 case "gopls":
967 // For Go, we want to open go.mod files immediately
968 return fileName == "go.mod" ||
969 fileName == "go.sum" ||
970 // Also open main.go files
971 fileName == "main.go"
972 case "rust-analyzer":
973 // For Rust, we want to open Cargo.toml files immediately
974 return fileName == "Cargo.toml" ||
975 fileName == "Cargo.lock" ||
976 // Also open lib.rs and main.rs
977 fileName == "lib.rs" ||
978 fileName == "main.rs"
979 case "python", "pyright", "pylsp":
980 // For Python, open key project files
981 return fileName == "pyproject.toml" ||
982 fileName == "setup.py" ||
983 fileName == "requirements.txt" ||
984 fileName == "__init__.py" ||
985 fileName == "__main__.py"
986 case "clangd":
987 // For C/C++, open key project files
988 return fileName == "CMakeLists.txt" ||
989 fileName == "Makefile" ||
990 fileName == "compile_commands.json"
991 case "java", "jdtls":
992 // For Java, open key project files
993 return fileName == "pom.xml" ||
994 fileName == "build.gradle" ||
995 ext == ".java" // Java servers often need to see source files
996 }
997
998 // For unknown servers, prioritize common configuration files
999 return fileName == "package.json" ||
1000 fileName == "Makefile" ||
1001 fileName == "CMakeLists.txt" ||
1002 fileName == ".editorconfig"
1003}