1package watcher
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/bmatcuk/doublestar/v4"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/logging"
15 "github.com/charmbracelet/crush/internal/lsp"
16 "github.com/charmbracelet/crush/internal/lsp/protocol"
17 "github.com/fsnotify/fsnotify"
18)
19
20// WorkspaceWatcher manages LSP file watching
21type WorkspaceWatcher struct {
22 client *lsp.Client
23 workspacePath string
24
25 debounceTime time.Duration
26 debounceMap map[string]*time.Timer
27 debounceMu sync.Mutex
28
29 // File watchers registered by the server
30 registrations []protocol.FileSystemWatcher
31 registrationMu sync.RWMutex
32}
33
34// NewWorkspaceWatcher creates a new workspace watcher
35func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
36 return &WorkspaceWatcher{
37 client: client,
38 debounceTime: 300 * time.Millisecond,
39 debounceMap: make(map[string]*time.Timer),
40 registrations: []protocol.FileSystemWatcher{},
41 }
42}
43
44// AddRegistrations adds file watchers to track
45func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
46 cnf := config.Get()
47
48 logging.Debug("Adding file watcher registrations")
49 w.registrationMu.Lock()
50 defer w.registrationMu.Unlock()
51
52 // Add new watchers
53 w.registrations = append(w.registrations, watchers...)
54
55 // Print detailed registration information for debugging
56 if cnf.DebugLSP {
57 logging.Debug("Adding file watcher registrations",
58 "id", id,
59 "watchers", len(watchers),
60 "total", len(w.registrations),
61 )
62
63 for i, watcher := range watchers {
64 logging.Debug("Registration", "index", i+1)
65
66 // Log the GlobPattern
67 switch v := watcher.GlobPattern.Value.(type) {
68 case string:
69 logging.Debug("GlobPattern", "pattern", v)
70 case protocol.RelativePattern:
71 logging.Debug("GlobPattern", "pattern", v.Pattern)
72
73 // Log BaseURI details
74 switch u := v.BaseURI.Value.(type) {
75 case string:
76 logging.Debug("BaseURI", "baseURI", u)
77 case protocol.DocumentUri:
78 logging.Debug("BaseURI", "baseURI", u)
79 default:
80 logging.Debug("BaseURI", "baseURI", u)
81 }
82 default:
83 logging.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
84 }
85
86 // Log WatchKind
87 watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
88 if watcher.Kind != nil {
89 watchKind = *watcher.Kind
90 }
91
92 logging.Debug("WatchKind", "kind", watchKind)
93 }
94 }
95
96 // Determine server type for specialized handling
97 serverName := getServerNameFromContext(ctx)
98 logging.Debug("Server type detected", "serverName", serverName)
99
100 // Check if this server has sent file watchers
101 hasFileWatchers := len(watchers) > 0
102
103 // For servers that need file preloading, we'll use a smart approach
104 if shouldPreloadFiles(serverName) || !hasFileWatchers {
105 go func() {
106 startTime := time.Now()
107 filesOpened := 0
108
109 // Determine max files to open based on server type
110 maxFilesToOpen := 50 // Default conservative limit
111
112 switch serverName {
113 case "typescript", "typescript-language-server", "tsserver", "vtsls":
114 // TypeScript servers benefit from seeing more files
115 maxFilesToOpen = 100
116 case "java", "jdtls":
117 // Java servers need to see many files for project model
118 maxFilesToOpen = 200
119 }
120
121 // First, open high-priority files
122 highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
123 filesOpened += highPriorityFilesOpened
124
125 if cnf.DebugLSP {
126 logging.Debug("Opened high-priority files",
127 "count", highPriorityFilesOpened,
128 "serverName", serverName)
129 }
130
131 // If we've already opened enough high-priority files, we might not need more
132 if filesOpened >= maxFilesToOpen {
133 if cnf.DebugLSP {
134 logging.Debug("Reached file limit with high-priority files",
135 "filesOpened", filesOpened,
136 "maxFiles", maxFilesToOpen)
137 }
138 return
139 }
140
141 // For the remaining slots, walk the directory and open matching files
142
143 err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
144 if err != nil {
145 return err
146 }
147
148 // Skip directories that should be excluded
149 if d.IsDir() {
150 if path != w.workspacePath && shouldExcludeDir(path) {
151 if cnf.DebugLSP {
152 logging.Debug("Skipping excluded directory", "path", path)
153 }
154 return filepath.SkipDir
155 }
156 } else {
157 // Process files, but limit the total number
158 if filesOpened < maxFilesToOpen {
159 // Only process if it's not already open (high-priority files were opened earlier)
160 if !w.client.IsFileOpen(path) {
161 w.openMatchingFile(ctx, path)
162 filesOpened++
163
164 // Add a small delay after every 10 files to prevent overwhelming the server
165 if filesOpened%10 == 0 {
166 time.Sleep(50 * time.Millisecond)
167 }
168 }
169 } else {
170 // We've reached our limit, stop walking
171 return filepath.SkipAll
172 }
173 }
174
175 return nil
176 })
177
178 elapsedTime := time.Since(startTime)
179 if cnf.DebugLSP {
180 logging.Debug("Limited workspace scan complete",
181 "filesOpened", filesOpened,
182 "maxFiles", maxFilesToOpen,
183 "elapsedTime", elapsedTime.Seconds(),
184 "workspacePath", w.workspacePath,
185 )
186 }
187
188 if err != nil && cnf.DebugLSP {
189 logging.Debug("Error scanning workspace for files to open", "error", err)
190 }
191 }()
192 } else if cnf.DebugLSP {
193 logging.Debug("Using on-demand file loading for server", "server", serverName)
194 }
195}
196
197// openHighPriorityFiles opens important files for the server type
198// Returns the number of files opened
199func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
200 cnf := config.Get()
201 filesOpened := 0
202
203 // Define patterns for high-priority files based on server type
204 var patterns []string
205
206 switch serverName {
207 case "typescript", "typescript-language-server", "tsserver", "vtsls":
208 patterns = []string{
209 "**/tsconfig.json",
210 "**/package.json",
211 "**/jsconfig.json",
212 "**/index.ts",
213 "**/index.js",
214 "**/main.ts",
215 "**/main.js",
216 }
217 case "gopls":
218 patterns = []string{
219 "**/go.mod",
220 "**/go.sum",
221 "**/main.go",
222 }
223 case "rust-analyzer":
224 patterns = []string{
225 "**/Cargo.toml",
226 "**/Cargo.lock",
227 "**/src/lib.rs",
228 "**/src/main.rs",
229 }
230 case "python", "pyright", "pylsp":
231 patterns = []string{
232 "**/pyproject.toml",
233 "**/setup.py",
234 "**/requirements.txt",
235 "**/__init__.py",
236 "**/__main__.py",
237 }
238 case "clangd":
239 patterns = []string{
240 "**/CMakeLists.txt",
241 "**/Makefile",
242 "**/compile_commands.json",
243 }
244 case "java", "jdtls":
245 patterns = []string{
246 "**/pom.xml",
247 "**/build.gradle",
248 "**/src/main/java/**/*.java",
249 }
250 default:
251 // For unknown servers, use common configuration files
252 patterns = []string{
253 "**/package.json",
254 "**/Makefile",
255 "**/CMakeLists.txt",
256 "**/.editorconfig",
257 }
258 }
259
260 // Collect all files to open first
261 var filesToOpen []string
262
263 // For each pattern, find matching files
264 for _, pattern := range patterns {
265 // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
266 matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
267 if err != nil {
268 if cnf.DebugLSP {
269 logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
270 }
271 continue
272 }
273
274 for _, match := range matches {
275 // Convert relative path to absolute
276 fullPath := filepath.Join(w.workspacePath, match)
277
278 // Skip directories and excluded files
279 info, err := os.Stat(fullPath)
280 if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
281 continue
282 }
283
284 filesToOpen = append(filesToOpen, fullPath)
285
286 // Limit the number of files per pattern
287 if len(filesToOpen) >= 5 && (serverName != "java" && serverName != "jdtls") {
288 break
289 }
290 }
291 }
292
293 // Open files in batches to reduce overhead
294 batchSize := 3
295 for i := 0; i < len(filesToOpen); i += batchSize {
296 end := min(i+batchSize, len(filesToOpen))
297
298 // Open batch of files
299 for j := i; j < end; j++ {
300 fullPath := filesToOpen[j]
301 if err := w.client.OpenFile(ctx, fullPath); err != nil {
302 if cnf.DebugLSP {
303 logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
304 }
305 } else {
306 filesOpened++
307 if cnf.DebugLSP {
308 logging.Debug("Opened high-priority file", "path", fullPath)
309 }
310 }
311 }
312
313 // Only add delay between batches, not individual files
314 if end < len(filesToOpen) {
315 time.Sleep(50 * time.Millisecond)
316 }
317 }
318
319 return filesOpened
320}
321
322// WatchWorkspace sets up file watching for a workspace
323func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
324 cnf := config.Get()
325 w.workspacePath = workspacePath
326
327 // Store the watcher in the context for later use
328 ctx = context.WithValue(ctx, "workspaceWatcher", w)
329
330 // If the server name isn't already in the context, try to detect it
331 if _, ok := ctx.Value("serverName").(string); !ok {
332 serverName := getServerNameFromContext(ctx)
333 ctx = context.WithValue(ctx, "serverName", serverName)
334 }
335
336 serverName := getServerNameFromContext(ctx)
337 logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
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 logging.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 cnf.DebugLSP {
360 logging.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 logging.Error("Error watching path", "path", path, "error", err)
371 }
372 }
373
374 return nil
375 })
376 if err != nil {
377 logging.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 := fmt.Sprintf("file://%s", 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 logging.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 cnf.DebugLSP {
413 matched, kind := w.isPathWatched(event.Name)
414 logging.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 logging.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 logging.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 strings.HasPrefix(pattern, "**/") {
525 rest := strings.TrimPrefix(pattern, "**/")
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 logging.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 logging.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 // For relative patterns
617 basePath = strings.TrimPrefix(basePath, "file://")
618 basePath = filepath.ToSlash(basePath)
619
620 // Make path relative to basePath for matching
621 relPath, err := filepath.Rel(basePath, path)
622 if err != nil {
623 logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
624 return false
625 }
626 relPath = filepath.ToSlash(relPath)
627
628 isMatch := matchesGlob(patternText, relPath)
629
630 return isMatch
631}
632
633// debounceHandleFileEvent handles file events with debouncing to reduce notifications
634func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
635 w.debounceMu.Lock()
636 defer w.debounceMu.Unlock()
637
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[key]; exists {
643 timer.Stop()
644 }
645
646 // Create new timer
647 w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
648 w.handleFileEvent(ctx, uri, changeType)
649
650 // Cleanup timer after execution
651 w.debounceMu.Lock()
652 delete(w.debounceMap, key)
653 w.debounceMu.Unlock()
654 })
655}
656
657// handleFileEvent sends file change notifications
658func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
659 // If the file is open and it's a change event, use didChange notification
660 filePath := uri[7:] // Remove "file://" prefix
661 if changeType == protocol.FileChangeType(protocol.Deleted) {
662 w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri))
663 } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
664 err := w.client.NotifyChange(ctx, filePath)
665 if err != nil {
666 logging.Error("Error notifying change", "error", err)
667 }
668 return
669 }
670
671 // Notify LSP server about the file event using didChangeWatchedFiles
672 if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
673 logging.Error("Error notifying LSP server about file event", "error", err)
674 }
675}
676
677// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
678func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
679 cnf := config.Get()
680 if cnf.DebugLSP {
681 logging.Debug("Notifying file event",
682 "uri", uri,
683 "changeType", changeType,
684 )
685 }
686
687 params := protocol.DidChangeWatchedFilesParams{
688 Changes: []protocol.FileEvent{
689 {
690 URI: protocol.DocumentUri(uri),
691 Type: changeType,
692 },
693 },
694 }
695
696 return w.client.DidChangeWatchedFiles(ctx, params)
697}
698
699// getServerNameFromContext extracts the server name from the context
700// This is a best-effort function that tries to identify which LSP server we're dealing with
701func getServerNameFromContext(ctx context.Context) string {
702 // First check if the server name is directly stored in the context
703 if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
704 return strings.ToLower(serverName)
705 }
706
707 // Otherwise, try to extract server name from the client command path
708 if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
709 path := strings.ToLower(w.client.Cmd.Path)
710
711 // Extract server name from path
712 if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
713 return "typescript"
714 } else if strings.Contains(path, "gopls") {
715 return "gopls"
716 } else if strings.Contains(path, "rust-analyzer") {
717 return "rust-analyzer"
718 } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
719 return "python"
720 } else if strings.Contains(path, "clangd") {
721 return "clangd"
722 } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
723 return "java"
724 }
725
726 // Return the base name as fallback
727 return filepath.Base(path)
728 }
729
730 return "unknown"
731}
732
733// shouldPreloadFiles determines if we should preload files for a specific language server
734// Some servers work better with preloaded files, others don't need it
735func shouldPreloadFiles(serverName string) bool {
736 // TypeScript/JavaScript servers typically need some files preloaded
737 // to properly resolve imports and provide intellisense
738 switch serverName {
739 case "typescript", "typescript-language-server", "tsserver", "vtsls":
740 return true
741 case "java", "jdtls":
742 // Java servers often need to see source files to build the project model
743 return true
744 default:
745 // For most servers, we'll use lazy loading by default
746 return false
747 }
748}
749
750// Common patterns for directories and files to exclude
751// TODO: make configurable
752var (
753 excludedDirNames = map[string]bool{
754 ".git": true,
755 "node_modules": true,
756 "dist": true,
757 "build": true,
758 "out": true,
759 "bin": true,
760 ".idea": true,
761 ".vscode": true,
762 ".cache": true,
763 "coverage": true,
764 "target": true, // Rust build output
765 "vendor": true, // Go vendor directory
766 }
767
768 excludedFileExtensions = map[string]bool{
769 ".swp": true,
770 ".swo": true,
771 ".tmp": true,
772 ".temp": true,
773 ".bak": true,
774 ".log": true,
775 ".o": true, // Object files
776 ".so": true, // Shared libraries
777 ".dylib": true, // macOS shared libraries
778 ".dll": true, // Windows shared libraries
779 ".a": true, // Static libraries
780 ".exe": true, // Windows executables
781 ".lock": true, // Lock files
782 }
783
784 // Large binary files that shouldn't be opened
785 largeBinaryExtensions = map[string]bool{
786 ".png": true,
787 ".jpg": true,
788 ".jpeg": true,
789 ".gif": true,
790 ".bmp": true,
791 ".ico": true,
792 ".zip": true,
793 ".tar": true,
794 ".gz": true,
795 ".rar": true,
796 ".7z": true,
797 ".pdf": true,
798 ".mp3": true,
799 ".mp4": true,
800 ".mov": true,
801 ".wav": true,
802 ".wasm": true,
803 }
804
805 // Maximum file size to open (5MB)
806 maxFileSize int64 = 5 * 1024 * 1024
807)
808
809// shouldExcludeDir returns true if the directory should be excluded from watching/opening
810func shouldExcludeDir(dirPath string) bool {
811 dirName := filepath.Base(dirPath)
812
813 // Skip dot directories
814 if strings.HasPrefix(dirName, ".") {
815 return true
816 }
817
818 // Skip common excluded directories
819 if excludedDirNames[dirName] {
820 return true
821 }
822
823 return false
824}
825
826// shouldExcludeFile returns true if the file should be excluded from opening
827func shouldExcludeFile(filePath string) bool {
828 fileName := filepath.Base(filePath)
829 cnf := config.Get()
830 // Skip dot files
831 if strings.HasPrefix(fileName, ".") {
832 return true
833 }
834
835 // Check file extension
836 ext := strings.ToLower(filepath.Ext(filePath))
837 if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
838 return true
839 }
840
841 // Skip temporary files
842 if strings.HasSuffix(filePath, "~") {
843 return true
844 }
845
846 // Check file size
847 info, err := os.Stat(filePath)
848 if err != nil {
849 // If we can't stat the file, skip it
850 return true
851 }
852
853 // Skip large files
854 if info.Size() > maxFileSize {
855 if cnf.DebugLSP {
856 logging.Debug("Skipping large file",
857 "path", filePath,
858 "size", info.Size(),
859 "maxSize", maxFileSize,
860 "debug", cnf.Debug,
861 "sizeMB", float64(info.Size())/(1024*1024),
862 "maxSizeMB", float64(maxFileSize)/(1024*1024),
863 )
864 }
865 return true
866 }
867
868 return false
869}
870
871// openMatchingFile opens a file if it matches any of the registered patterns
872func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
873 cnf := config.Get()
874 // Skip directories
875 info, err := os.Stat(path)
876 if err != nil || info.IsDir() {
877 return
878 }
879
880 // Skip excluded files
881 if shouldExcludeFile(path) {
882 return
883 }
884
885 // Check if this path should be watched according to server registrations
886 if watched, _ := w.isPathWatched(path); watched {
887 // Get server name for specialized handling
888 serverName := getServerNameFromContext(ctx)
889
890 // Check if the file is a high-priority file that should be opened immediately
891 // This helps with project initialization for certain language servers
892 if isHighPriorityFile(path, serverName) {
893 if cnf.DebugLSP {
894 logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
895 }
896 if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
897 logging.Error("Error opening high-priority file", "path", path, "error", err)
898 }
899 return
900 }
901
902 // For non-high-priority files, we'll use different strategies based on server type
903 if shouldPreloadFiles(serverName) {
904 // For servers that benefit from preloading, open files but with limits
905
906 // Check file size - for preloading we're more conservative
907 if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
908 if cnf.DebugLSP {
909 logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
910 }
911 return
912 }
913
914 // Check file extension for common source files
915 ext := strings.ToLower(filepath.Ext(path))
916
917 // Only preload source files for the specific language
918 shouldOpen := false
919
920 switch serverName {
921 case "typescript", "typescript-language-server", "tsserver", "vtsls":
922 shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
923 case "gopls":
924 shouldOpen = ext == ".go"
925 case "rust-analyzer":
926 shouldOpen = ext == ".rs"
927 case "python", "pyright", "pylsp":
928 shouldOpen = ext == ".py"
929 case "clangd":
930 shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
931 case "java", "jdtls":
932 shouldOpen = ext == ".java"
933 default:
934 // For unknown servers, be conservative
935 shouldOpen = false
936 }
937
938 if shouldOpen {
939 // Don't need to check if it's already open - the client.OpenFile handles that
940 if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
941 logging.Error("Error opening file", "path", path, "error", err)
942 }
943 }
944 }
945 }
946}
947
948// isHighPriorityFile determines if a file should be opened immediately
949// regardless of the preloading strategy
950func isHighPriorityFile(path string, serverName string) bool {
951 fileName := filepath.Base(path)
952 ext := filepath.Ext(path)
953
954 switch serverName {
955 case "typescript", "typescript-language-server", "tsserver", "vtsls":
956 // For TypeScript, we want to open configuration files immediately
957 return fileName == "tsconfig.json" ||
958 fileName == "package.json" ||
959 fileName == "jsconfig.json" ||
960 // Also open main entry points
961 fileName == "index.ts" ||
962 fileName == "index.js" ||
963 fileName == "main.ts" ||
964 fileName == "main.js"
965 case "gopls":
966 // For Go, we want to open go.mod files immediately
967 return fileName == "go.mod" ||
968 fileName == "go.sum" ||
969 // Also open main.go files
970 fileName == "main.go"
971 case "rust-analyzer":
972 // For Rust, we want to open Cargo.toml files immediately
973 return fileName == "Cargo.toml" ||
974 fileName == "Cargo.lock" ||
975 // Also open lib.rs and main.rs
976 fileName == "lib.rs" ||
977 fileName == "main.rs"
978 case "python", "pyright", "pylsp":
979 // For Python, open key project files
980 return fileName == "pyproject.toml" ||
981 fileName == "setup.py" ||
982 fileName == "requirements.txt" ||
983 fileName == "__init__.py" ||
984 fileName == "__main__.py"
985 case "clangd":
986 // For C/C++, open key project files
987 return fileName == "CMakeLists.txt" ||
988 fileName == "Makefile" ||
989 fileName == "compile_commands.json"
990 case "java", "jdtls":
991 // For Java, open key project files
992 return fileName == "pom.xml" ||
993 fileName == "build.gradle" ||
994 ext == ".java" // Java servers often need to see source files
995 }
996
997 // For unknown servers, prioritize common configuration files
998 return fileName == "package.json" ||
999 fileName == "Makefile" ||
1000 fileName == "CMakeLists.txt" ||
1001 fileName == ".editorconfig"
1002}