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