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/fsnotify/fsnotify"
 14	"github.com/opencode-ai/opencode/internal/config"
 15	"github.com/opencode-ai/opencode/internal/logging"
 16	"github.com/opencode-ai/opencode/internal/lsp"
 17	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 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
407			// Check if this path should be watched according to server registrations
408			if watched, watchKind := w.isPathWatched(event.Name); watched {
409				switch {
410				case event.Op&fsnotify.Write != 0:
411					if watchKind&protocol.WatchChange != 0 {
412						w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
413					}
414				case event.Op&fsnotify.Create != 0:
415					// Already handled earlier in the event loop
416					// Just send the notification if needed
417					info, err := os.Stat(event.Name)
418					if err != nil {
419						logging.Error("Error getting file info", "path", event.Name, "error", err)
420						return
421					}
422					if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
423						w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
424					}
425				case event.Op&fsnotify.Remove != 0:
426					if watchKind&protocol.WatchDelete != 0 {
427						w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
428					}
429				case event.Op&fsnotify.Rename != 0:
430					// For renames, first delete
431					if watchKind&protocol.WatchDelete != 0 {
432						w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
433					}
434
435					// Then check if the new file exists and create an event
436					if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
437						if watchKind&protocol.WatchCreate != 0 {
438							w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
439						}
440					}
441				}
442			}
443		case err, ok := <-watcher.Errors:
444			if !ok {
445				return
446			}
447			logging.Error("Error watching file", "error", err)
448		}
449	}
450}
451
452// isPathWatched checks if a path should be watched based on server registrations
453func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
454	w.registrationMu.RLock()
455	defer w.registrationMu.RUnlock()
456
457	// If no explicit registrations, watch everything
458	if len(w.registrations) == 0 {
459		return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
460	}
461
462	// Check each registration
463	for _, reg := range w.registrations {
464		isMatch := w.matchesPattern(path, reg.GlobPattern)
465		if isMatch {
466			kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
467			if reg.Kind != nil {
468				kind = *reg.Kind
469			}
470			return true, kind
471		}
472	}
473
474	return false, 0
475}
476
477// matchesGlob handles advanced glob patterns including ** and alternatives
478func matchesGlob(pattern, path string) bool {
479	// Handle file extension patterns with braces like *.{go,mod,sum}
480	if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
481		// Extract extensions from pattern like "*.{go,mod,sum}"
482		parts := strings.SplitN(pattern, "{", 2)
483		if len(parts) == 2 {
484			prefix := parts[0]
485			extPart := strings.SplitN(parts[1], "}", 2)
486			if len(extPart) == 2 {
487				extensions := strings.Split(extPart[0], ",")
488				suffix := extPart[1]
489
490				// Check if the path matches any of the extensions
491				for _, ext := range extensions {
492					extPattern := prefix + ext + suffix
493					isMatch := matchesSimpleGlob(extPattern, path)
494					if isMatch {
495						return true
496					}
497				}
498				return false
499			}
500		}
501	}
502
503	return matchesSimpleGlob(pattern, path)
504}
505
506// matchesSimpleGlob handles glob patterns with ** wildcards
507func matchesSimpleGlob(pattern, path string) bool {
508	// Handle special case for **/*.ext pattern (common in LSP)
509	if strings.HasPrefix(pattern, "**/") {
510		rest := strings.TrimPrefix(pattern, "**/")
511
512		// If the rest is a simple file extension pattern like *.go
513		if strings.HasPrefix(rest, "*.") {
514			ext := strings.TrimPrefix(rest, "*")
515			isMatch := strings.HasSuffix(path, ext)
516			return isMatch
517		}
518
519		// Otherwise, try to check if the path ends with the rest part
520		isMatch := strings.HasSuffix(path, rest)
521
522		// If it matches directly, great!
523		if isMatch {
524			return true
525		}
526
527		// Otherwise, check if any path component matches
528		pathComponents := strings.Split(path, "/")
529		for i := range pathComponents {
530			subPath := strings.Join(pathComponents[i:], "/")
531			if strings.HasSuffix(subPath, rest) {
532				return true
533			}
534		}
535
536		return false
537	}
538
539	// Handle other ** wildcard pattern cases
540	if strings.Contains(pattern, "**") {
541		parts := strings.Split(pattern, "**")
542
543		// Validate the path starts with the first part
544		if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
545			return false
546		}
547
548		// For patterns like "**/*.go", just check the suffix
549		if len(parts) == 2 && parts[0] == "" {
550			isMatch := strings.HasSuffix(path, parts[1])
551			return isMatch
552		}
553
554		// For other patterns, handle middle part
555		remaining := strings.TrimPrefix(path, parts[0])
556		if len(parts) == 2 {
557			isMatch := strings.HasSuffix(remaining, parts[1])
558			return isMatch
559		}
560	}
561
562	// Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
563	if strings.HasPrefix(pattern, "*.") {
564		ext := strings.TrimPrefix(pattern, "*")
565		isMatch := strings.HasSuffix(path, ext)
566		return isMatch
567	}
568
569	// Fall back to simple matching for simpler patterns
570	matched, err := filepath.Match(pattern, path)
571	if err != nil {
572		logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
573		return false
574	}
575
576	return matched
577}
578
579// matchesPattern checks if a path matches the glob pattern
580func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
581	patternInfo, err := pattern.AsPattern()
582	if err != nil {
583		logging.Error("Error parsing pattern", "pattern", pattern, "error", err)
584		return false
585	}
586
587	basePath := patternInfo.GetBasePath()
588	patternText := patternInfo.GetPattern()
589
590	path = filepath.ToSlash(path)
591
592	// For simple patterns without base path
593	if basePath == "" {
594		// Check if the pattern matches the full path or just the file extension
595		fullPathMatch := matchesGlob(patternText, path)
596		baseNameMatch := matchesGlob(patternText, filepath.Base(path))
597
598		return fullPathMatch || baseNameMatch
599	}
600
601	// For relative patterns
602	basePath = strings.TrimPrefix(basePath, "file://")
603	basePath = filepath.ToSlash(basePath)
604
605	// Make path relative to basePath for matching
606	relPath, err := filepath.Rel(basePath, path)
607	if err != nil {
608		logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
609		return false
610	}
611	relPath = filepath.ToSlash(relPath)
612
613	isMatch := matchesGlob(patternText, relPath)
614
615	return isMatch
616}
617
618// debounceHandleFileEvent handles file events with debouncing to reduce notifications
619func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
620	w.debounceMu.Lock()
621	defer w.debounceMu.Unlock()
622
623	// Create a unique key based on URI and change type
624	key := fmt.Sprintf("%s:%d", uri, changeType)
625
626	// Cancel existing timer if any
627	if timer, exists := w.debounceMap[key]; exists {
628		timer.Stop()
629	}
630
631	// Create new timer
632	w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
633		w.handleFileEvent(ctx, uri, changeType)
634
635		// Cleanup timer after execution
636		w.debounceMu.Lock()
637		delete(w.debounceMap, key)
638		w.debounceMu.Unlock()
639	})
640}
641
642// handleFileEvent sends file change notifications
643func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
644	// If the file is open and it's a change event, use didChange notification
645	filePath := uri[7:] // Remove "file://" prefix
646	if changeType == protocol.FileChangeType(protocol.Deleted) {
647		w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri))
648	} else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
649		err := w.client.NotifyChange(ctx, filePath)
650		if err != nil {
651			logging.Error("Error notifying change", "error", err)
652		}
653		return
654	}
655
656	// Notify LSP server about the file event using didChangeWatchedFiles
657	if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
658		logging.Error("Error notifying LSP server about file event", "error", err)
659	}
660}
661
662// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
663func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
664	cnf := config.Get()
665	if cnf.DebugLSP {
666		logging.Debug("Notifying file event",
667			"uri", uri,
668			"changeType", changeType,
669		)
670	}
671
672	params := protocol.DidChangeWatchedFilesParams{
673		Changes: []protocol.FileEvent{
674			{
675				URI:  protocol.DocumentUri(uri),
676				Type: changeType,
677			},
678		},
679	}
680
681	return w.client.DidChangeWatchedFiles(ctx, params)
682}
683
684// getServerNameFromContext extracts the server name from the context
685// This is a best-effort function that tries to identify which LSP server we're dealing with
686func getServerNameFromContext(ctx context.Context) string {
687	// First check if the server name is directly stored in the context
688	if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
689		return strings.ToLower(serverName)
690	}
691
692	// Otherwise, try to extract server name from the client command path
693	if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
694		path := strings.ToLower(w.client.Cmd.Path)
695
696		// Extract server name from path
697		if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
698			return "typescript"
699		} else if strings.Contains(path, "gopls") {
700			return "gopls"
701		} else if strings.Contains(path, "rust-analyzer") {
702			return "rust-analyzer"
703		} else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
704			return "python"
705		} else if strings.Contains(path, "clangd") {
706			return "clangd"
707		} else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
708			return "java"
709		}
710
711		// Return the base name as fallback
712		return filepath.Base(path)
713	}
714
715	return "unknown"
716}
717
718// shouldPreloadFiles determines if we should preload files for a specific language server
719// Some servers work better with preloaded files, others don't need it
720func shouldPreloadFiles(serverName string) bool {
721	// TypeScript/JavaScript servers typically need some files preloaded
722	// to properly resolve imports and provide intellisense
723	switch serverName {
724	case "typescript", "typescript-language-server", "tsserver", "vtsls":
725		return true
726	case "java", "jdtls":
727		// Java servers often need to see source files to build the project model
728		return true
729	default:
730		// For most servers, we'll use lazy loading by default
731		return false
732	}
733}
734
735// Common patterns for directories and files to exclude
736// TODO: make configurable
737var (
738	excludedDirNames = map[string]bool{
739		".git":         true,
740		"node_modules": true,
741		"dist":         true,
742		"build":        true,
743		"out":          true,
744		"bin":          true,
745		".idea":        true,
746		".vscode":      true,
747		".cache":       true,
748		"coverage":     true,
749		"target":       true, // Rust build output
750		"vendor":       true, // Go vendor directory
751	}
752
753	excludedFileExtensions = map[string]bool{
754		".swp":   true,
755		".swo":   true,
756		".tmp":   true,
757		".temp":  true,
758		".bak":   true,
759		".log":   true,
760		".o":     true, // Object files
761		".so":    true, // Shared libraries
762		".dylib": true, // macOS shared libraries
763		".dll":   true, // Windows shared libraries
764		".a":     true, // Static libraries
765		".exe":   true, // Windows executables
766		".lock":  true, // Lock files
767	}
768
769	// Large binary files that shouldn't be opened
770	largeBinaryExtensions = map[string]bool{
771		".png":  true,
772		".jpg":  true,
773		".jpeg": true,
774		".gif":  true,
775		".bmp":  true,
776		".ico":  true,
777		".zip":  true,
778		".tar":  true,
779		".gz":   true,
780		".rar":  true,
781		".7z":   true,
782		".pdf":  true,
783		".mp3":  true,
784		".mp4":  true,
785		".mov":  true,
786		".wav":  true,
787		".wasm": true,
788	}
789
790	// Maximum file size to open (5MB)
791	maxFileSize int64 = 5 * 1024 * 1024
792)
793
794// shouldExcludeDir returns true if the directory should be excluded from watching/opening
795func shouldExcludeDir(dirPath string) bool {
796	dirName := filepath.Base(dirPath)
797
798	// Skip dot directories
799	if strings.HasPrefix(dirName, ".") {
800		return true
801	}
802
803	// Skip common excluded directories
804	if excludedDirNames[dirName] {
805		return true
806	}
807
808	return false
809}
810
811// shouldExcludeFile returns true if the file should be excluded from opening
812func shouldExcludeFile(filePath string) bool {
813	fileName := filepath.Base(filePath)
814	cnf := config.Get()
815	// Skip dot files
816	if strings.HasPrefix(fileName, ".") {
817		return true
818	}
819
820	// Check file extension
821	ext := strings.ToLower(filepath.Ext(filePath))
822	if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
823		return true
824	}
825
826	// Skip temporary files
827	if strings.HasSuffix(filePath, "~") {
828		return true
829	}
830
831	// Check file size
832	info, err := os.Stat(filePath)
833	if err != nil {
834		// If we can't stat the file, skip it
835		return true
836	}
837
838	// Skip large files
839	if info.Size() > maxFileSize {
840		if cnf.DebugLSP {
841			logging.Debug("Skipping large file",
842				"path", filePath,
843				"size", info.Size(),
844				"maxSize", maxFileSize,
845				"debug", cnf.Debug,
846				"sizeMB", float64(info.Size())/(1024*1024),
847				"maxSizeMB", float64(maxFileSize)/(1024*1024),
848			)
849		}
850		return true
851	}
852
853	return false
854}
855
856// openMatchingFile opens a file if it matches any of the registered patterns
857func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
858	cnf := config.Get()
859	// Skip directories
860	info, err := os.Stat(path)
861	if err != nil || info.IsDir() {
862		return
863	}
864
865	// Skip excluded files
866	if shouldExcludeFile(path) {
867		return
868	}
869
870	// Check if this path should be watched according to server registrations
871	if watched, _ := w.isPathWatched(path); watched {
872		// Get server name for specialized handling
873		serverName := getServerNameFromContext(ctx)
874
875		// Check if the file is a high-priority file that should be opened immediately
876		// This helps with project initialization for certain language servers
877		if isHighPriorityFile(path, serverName) {
878			if cnf.DebugLSP {
879				logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
880			}
881			if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
882				logging.Error("Error opening high-priority file", "path", path, "error", err)
883			}
884			return
885		}
886
887		// For non-high-priority files, we'll use different strategies based on server type
888		if shouldPreloadFiles(serverName) {
889			// For servers that benefit from preloading, open files but with limits
890
891			// Check file size - for preloading we're more conservative
892			if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
893				if cnf.DebugLSP {
894					logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
895				}
896				return
897			}
898
899			// Check file extension for common source files
900			ext := strings.ToLower(filepath.Ext(path))
901
902			// Only preload source files for the specific language
903			shouldOpen := false
904
905			switch serverName {
906			case "typescript", "typescript-language-server", "tsserver", "vtsls":
907				shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
908			case "gopls":
909				shouldOpen = ext == ".go"
910			case "rust-analyzer":
911				shouldOpen = ext == ".rs"
912			case "python", "pyright", "pylsp":
913				shouldOpen = ext == ".py"
914			case "clangd":
915				shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
916			case "java", "jdtls":
917				shouldOpen = ext == ".java"
918			default:
919				// For unknown servers, be conservative
920				shouldOpen = false
921			}
922
923			if shouldOpen {
924				// Don't need to check if it's already open - the client.OpenFile handles that
925				if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
926					logging.Error("Error opening file", "path", path, "error", err)
927				}
928			}
929		}
930	}
931}
932
933// isHighPriorityFile determines if a file should be opened immediately
934// regardless of the preloading strategy
935func isHighPriorityFile(path string, serverName string) bool {
936	fileName := filepath.Base(path)
937	ext := filepath.Ext(path)
938
939	switch serverName {
940	case "typescript", "typescript-language-server", "tsserver", "vtsls":
941		// For TypeScript, we want to open configuration files immediately
942		return fileName == "tsconfig.json" ||
943			fileName == "package.json" ||
944			fileName == "jsconfig.json" ||
945			// Also open main entry points
946			fileName == "index.ts" ||
947			fileName == "index.js" ||
948			fileName == "main.ts" ||
949			fileName == "main.js"
950	case "gopls":
951		// For Go, we want to open go.mod files immediately
952		return fileName == "go.mod" ||
953			fileName == "go.sum" ||
954			// Also open main.go files
955			fileName == "main.go"
956	case "rust-analyzer":
957		// For Rust, we want to open Cargo.toml files immediately
958		return fileName == "Cargo.toml" ||
959			fileName == "Cargo.lock" ||
960			// Also open lib.rs and main.rs
961			fileName == "lib.rs" ||
962			fileName == "main.rs"
963	case "python", "pyright", "pylsp":
964		// For Python, open key project files
965		return fileName == "pyproject.toml" ||
966			fileName == "setup.py" ||
967			fileName == "requirements.txt" ||
968			fileName == "__init__.py" ||
969			fileName == "__main__.py"
970	case "clangd":
971		// For C/C++, open key project files
972		return fileName == "CMakeLists.txt" ||
973			fileName == "Makefile" ||
974			fileName == "compile_commands.json"
975	case "java", "jdtls":
976		// For Java, open key project files
977		return fileName == "pom.xml" ||
978			fileName == "build.gradle" ||
979			ext == ".java" // Java servers often need to see source files
980	}
981
982	// For unknown servers, prioritize common configuration files
983	return fileName == "package.json" ||
984		fileName == "Makefile" ||
985		fileName == "CMakeLists.txt" ||
986		fileName == ".editorconfig"
987}