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 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 }
472}
473
474// isPathWatched checks if a path should be watched based on server registrations
475func (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 {
481 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
482 }
483
484 // Check each registration
485 for _, reg := range w.registrations {
486 isMatch := w.matchesPattern(path, reg.GlobPattern)
487 if isMatch {
488 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
489 if reg.Kind != nil {
490 kind = *reg.Kind
491 }
492 return true, kind
493 }
494 }
495
496 return false, 0
497}
498
499// matchesGlob handles advanced glob patterns including ** and alternatives
500func 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
529func 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)
593 if err != nil {
594 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
595 return false
596 }
597
598 return matched
599}
600
601// matchesPattern checks if a path matches the glob pattern
602func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
603 patternInfo, err := pattern.AsPattern()
604 if err != nil {
605 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
606 return false
607 }
608
609 basePath := patternInfo.GetBasePath()
610 patternText := patternInfo.GetPattern()
611
612 path = filepath.ToSlash(path)
613
614 // For simple patterns without base path
615 if basePath == "" {
616 // Check if the pattern matches the full path or just the file extension
617 fullPathMatch := matchesGlob(patternText, path)
618 baseNameMatch := matchesGlob(patternText, filepath.Base(path))
619
620 return fullPathMatch || baseNameMatch
621 }
622
623 if basePath == "" {
624 return false
625 }
626
627 // Make path relative to basePath for matching
628 relPath, err := filepath.Rel(basePath, path)
629 if err != nil {
630 slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err, "server", w.name)
631 return false
632 }
633 relPath = filepath.ToSlash(relPath)
634
635 isMatch := matchesGlob(patternText, relPath)
636
637 return isMatch
638}
639
640// debounceHandleFileEvent handles file events with debouncing to reduce notifications
641func (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
660func (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
685// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
686func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
687 cfg := config.Get()
688 if cfg.Options.DebugLSP {
689 slog.Debug("Notifying file event",
690 "uri", uri,
691 "changeType", changeType,
692 )
693 }
694
695 params := protocol.DidChangeWatchedFilesParams{
696 Changes: []protocol.FileEvent{
697 {
698 URI: protocol.DocumentURI(uri),
699 Type: changeType,
700 },
701 },
702 }
703
704 return w.client.DidChangeWatchedFiles(ctx, params)
705}
706
707// shouldPreloadFiles determines if we should preload files for a specific language server
708// Some servers work better with preloaded files, others don't need it
709func shouldPreloadFiles(serverName string) bool {
710 // TypeScript/JavaScript servers typically need some files preloaded
711 // to properly resolve imports and provide intellisense
712 switch serverName {
713 case "typescript", "typescript-language-server", "tsserver", "vtsls":
714 return true
715 case "java", "jdtls":
716 // Java servers often need to see source files to build the project model
717 return true
718 default:
719 // For most servers, we'll use lazy loading by default
720 return false
721 }
722}
723
724// Common patterns for directories and files to exclude
725// TODO: make configurable
726var (
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
742 excludedFileExtensions = map[string]bool{
743 ".swp": true,
744 ".swo": true,
745 ".tmp": true,
746 ".temp": true,
747 ".bak": true,
748 ".log": true,
749 ".o": true, // Object files
750 ".so": true, // Shared libraries
751 ".dylib": true, // macOS shared libraries
752 ".dll": true, // Windows shared libraries
753 ".a": true, // Static libraries
754 ".exe": true, // Windows executables
755 ".lock": true, // Lock files
756 }
757
758 // Large binary files that shouldn't be opened
759 largeBinaryExtensions = map[string]bool{
760 ".png": true,
761 ".jpg": true,
762 ".jpeg": true,
763 ".gif": true,
764 ".bmp": true,
765 ".ico": true,
766 ".zip": true,
767 ".tar": true,
768 ".gz": true,
769 ".rar": true,
770 ".7z": true,
771 ".pdf": true,
772 ".mp3": true,
773 ".mp4": true,
774 ".mov": true,
775 ".wav": true,
776 ".wasm": true,
777 }
778
779 // Maximum file size to open (5MB)
780 maxFileSize int64 = 5 * 1024 * 1024
781)
782
783// shouldExcludeDir returns true if the directory should be excluded from watching/opening
784func 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
800// shouldExcludeFile returns true if the file should be excluded from opening
801func shouldExcludeFile(filePath string) bool {
802 fileName := filepath.Base(filePath)
803 cfg := config.Get()
804
805 // Skip dot files
806 if strings.HasPrefix(fileName, ".") {
807 return true
808 }
809
810 // Check file extension
811 ext := strings.ToLower(filepath.Ext(filePath))
812 if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
813 return true
814 }
815
816 info, err := os.Stat(filePath)
817 if err != nil {
818 // If we can't stat the file, skip it
819 return true
820 }
821
822 // Skip large files
823 if info.Size() > maxFileSize {
824 if cfg.Options.DebugLSP {
825 slog.Debug("Skipping large file",
826 "path", filePath,
827 "size", info.Size(),
828 "maxSize", maxFileSize,
829 "debug", cfg.Options.Debug,
830 "sizeMB", float64(info.Size())/(1024*1024),
831 "maxSizeMB", float64(maxFileSize)/(1024*1024),
832 )
833 }
834 return true
835 }
836
837 return false
838}
839
840// openMatchingFile opens a file if it matches any of the registered patterns
841func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
842 cfg := config.Get()
843 // Skip directories
844 info, err := os.Stat(path)
845 if err != nil || info.IsDir() {
846 return
847 }
848
849 // Skip excluded files
850 if shouldExcludeFile(path) {
851 return
852 }
853
854 // Check if this path should be watched according to server registrations
855 if watched, _ := w.isPathWatched(path); !watched {
856 return
857 }
858
859 serverName := w.name
860
861 // Get server name for specialized handling
862 // Check if the file is a high-priority file that should be opened immediately
863 // This helps with project initialization for certain language servers
864 if isHighPriorityFile(path, serverName) {
865 if cfg.Options.DebugLSP {
866 slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
867 }
868 if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
869 slog.Error("Error opening high-priority file", "path", path, "error", err)
870 }
871 return
872 }
873
874 // For non-high-priority files, we'll use different strategies based on server type
875 if !shouldPreloadFiles(serverName) {
876 return
877 }
878 // For servers that benefit from preloading, open files but with limits
879
880 // Check file size - for preloading we're more conservative
881 if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
882 if cfg.Options.DebugLSP {
883 slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
884 }
885 return
886 }
887
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 }
913 }
914}
915
916// isHighPriorityFile determines if a file should be opened immediately
917// regardless of the preloading strategy
918func isHighPriorityFile(path string, serverName string) bool {
919 fileName := filepath.Base(path)
920 ext := filepath.Ext(path)
921
922 switch serverName {
923 case "typescript", "typescript-language-server", "tsserver", "vtsls":
924 // For TypeScript, we want to open configuration files immediately
925 return fileName == "tsconfig.json" ||
926 fileName == "package.json" ||
927 fileName == "jsconfig.json" ||
928 // Also open main entry points
929 fileName == "index.ts" ||
930 fileName == "index.js" ||
931 fileName == "main.ts" ||
932 fileName == "main.js"
933 case "gopls":
934 // For Go, we want to open go.mod files immediately
935 return fileName == "go.mod" ||
936 fileName == "go.sum" ||
937 // Also open main.go files
938 fileName == "main.go"
939 case "rust-analyzer":
940 // For Rust, we want to open Cargo.toml files immediately
941 return fileName == "Cargo.toml" ||
942 fileName == "Cargo.lock" ||
943 // Also open lib.rs and main.rs
944 fileName == "lib.rs" ||
945 fileName == "main.rs"
946 case "python", "pyright", "pylsp":
947 // For Python, open key project files
948 return fileName == "pyproject.toml" ||
949 fileName == "setup.py" ||
950 fileName == "requirements.txt" ||
951 fileName == "__init__.py" ||
952 fileName == "__main__.py"
953 case "clangd":
954 // For C/C++, open key project files
955 return fileName == "CMakeLists.txt" ||
956 fileName == "Makefile" ||
957 fileName == "compile_commands.json"
958 case "java", "jdtls":
959 // For Java, open key project files
960 return fileName == "pom.xml" ||
961 fileName == "build.gradle" ||
962 ext == ".java" // Java servers often need to see source files
963 }
964
965 // For unknown servers, prioritize common configuration files
966 return fileName == "package.json" ||
967 fileName == "Makefile" ||
968 fileName == "CMakeLists.txt" ||
969 fileName == ".editorconfig"
970}