watcher.go

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