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, debug bool) *WorkspaceWatcher {
 45	return &WorkspaceWatcher{
 46		name:          name,
 47		client:        client,
 48		debug:         debug,
 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}