watcher.go

  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}