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