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