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