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/kujtimiihoxha/opencode/internal/config"
 15	"github.com/kujtimiihoxha/opencode/internal/logging"
 16	"github.com/kujtimiihoxha/opencode/internal/lsp"
 17	"github.com/kujtimiihoxha/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, _ := os.Stat(event.Name)
418					if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
419						w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
420					}
421				case event.Op&fsnotify.Remove != 0:
422					if watchKind&protocol.WatchDelete != 0 {
423						w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
424					}
425				case event.Op&fsnotify.Rename != 0:
426					// For renames, first delete
427					if watchKind&protocol.WatchDelete != 0 {
428						w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
429					}
430
431					// Then check if the new file exists and create an event
432					if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
433						if watchKind&protocol.WatchCreate != 0 {
434							w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
435						}
436					}
437				}
438			}
439		case err, ok := <-watcher.Errors:
440			if !ok {
441				return
442			}
443			logging.Error("Error watching file", "error", err)
444		}
445	}
446}
447
448// isPathWatched checks if a path should be watched based on server registrations
449func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
450	w.registrationMu.RLock()
451	defer w.registrationMu.RUnlock()
452
453	// If no explicit registrations, watch everything
454	if len(w.registrations) == 0 {
455		return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
456	}
457
458	// Check each registration
459	for _, reg := range w.registrations {
460		isMatch := w.matchesPattern(path, reg.GlobPattern)
461		if isMatch {
462			kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
463			if reg.Kind != nil {
464				kind = *reg.Kind
465			}
466			return true, kind
467		}
468	}
469
470	return false, 0
471}
472
473// matchesGlob handles advanced glob patterns including ** and alternatives
474func matchesGlob(pattern, path string) bool {
475	// Handle file extension patterns with braces like *.{go,mod,sum}
476	if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
477		// Extract extensions from pattern like "*.{go,mod,sum}"
478		parts := strings.SplitN(pattern, "{", 2)
479		if len(parts) == 2 {
480			prefix := parts[0]
481			extPart := strings.SplitN(parts[1], "}", 2)
482			if len(extPart) == 2 {
483				extensions := strings.Split(extPart[0], ",")
484				suffix := extPart[1]
485
486				// Check if the path matches any of the extensions
487				for _, ext := range extensions {
488					extPattern := prefix + ext + suffix
489					isMatch := matchesSimpleGlob(extPattern, path)
490					if isMatch {
491						return true
492					}
493				}
494				return false
495			}
496		}
497	}
498
499	return matchesSimpleGlob(pattern, path)
500}
501
502// matchesSimpleGlob handles glob patterns with ** wildcards
503func matchesSimpleGlob(pattern, path string) bool {
504	// Handle special case for **/*.ext pattern (common in LSP)
505	if strings.HasPrefix(pattern, "**/") {
506		rest := strings.TrimPrefix(pattern, "**/")
507
508		// If the rest is a simple file extension pattern like *.go
509		if strings.HasPrefix(rest, "*.") {
510			ext := strings.TrimPrefix(rest, "*")
511			isMatch := strings.HasSuffix(path, ext)
512			return isMatch
513		}
514
515		// Otherwise, try to check if the path ends with the rest part
516		isMatch := strings.HasSuffix(path, rest)
517
518		// If it matches directly, great!
519		if isMatch {
520			return true
521		}
522
523		// Otherwise, check if any path component matches
524		pathComponents := strings.Split(path, "/")
525		for i := range pathComponents {
526			subPath := strings.Join(pathComponents[i:], "/")
527			if strings.HasSuffix(subPath, rest) {
528				return true
529			}
530		}
531
532		return false
533	}
534
535	// Handle other ** wildcard pattern cases
536	if strings.Contains(pattern, "**") {
537		parts := strings.Split(pattern, "**")
538
539		// Validate the path starts with the first part
540		if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
541			return false
542		}
543
544		// For patterns like "**/*.go", just check the suffix
545		if len(parts) == 2 && parts[0] == "" {
546			isMatch := strings.HasSuffix(path, parts[1])
547			return isMatch
548		}
549
550		// For other patterns, handle middle part
551		remaining := strings.TrimPrefix(path, parts[0])
552		if len(parts) == 2 {
553			isMatch := strings.HasSuffix(remaining, parts[1])
554			return isMatch
555		}
556	}
557
558	// Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
559	if strings.HasPrefix(pattern, "*.") {
560		ext := strings.TrimPrefix(pattern, "*")
561		isMatch := strings.HasSuffix(path, ext)
562		return isMatch
563	}
564
565	// Fall back to simple matching for simpler patterns
566	matched, err := filepath.Match(pattern, path)
567	if err != nil {
568		logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
569		return false
570	}
571
572	return matched
573}
574
575// matchesPattern checks if a path matches the glob pattern
576func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
577	patternInfo, err := pattern.AsPattern()
578	if err != nil {
579		logging.Error("Error parsing pattern", "pattern", pattern, "error", err)
580		return false
581	}
582
583	basePath := patternInfo.GetBasePath()
584	patternText := patternInfo.GetPattern()
585
586	path = filepath.ToSlash(path)
587
588	// For simple patterns without base path
589	if basePath == "" {
590		// Check if the pattern matches the full path or just the file extension
591		fullPathMatch := matchesGlob(patternText, path)
592		baseNameMatch := matchesGlob(patternText, filepath.Base(path))
593
594		return fullPathMatch || baseNameMatch
595	}
596
597	// For relative patterns
598	basePath = strings.TrimPrefix(basePath, "file://")
599	basePath = filepath.ToSlash(basePath)
600
601	// Make path relative to basePath for matching
602	relPath, err := filepath.Rel(basePath, path)
603	if err != nil {
604		logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
605		return false
606	}
607	relPath = filepath.ToSlash(relPath)
608
609	isMatch := matchesGlob(patternText, relPath)
610
611	return isMatch
612}
613
614// debounceHandleFileEvent handles file events with debouncing to reduce notifications
615func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
616	w.debounceMu.Lock()
617	defer w.debounceMu.Unlock()
618
619	// Create a unique key based on URI and change type
620	key := fmt.Sprintf("%s:%d", uri, changeType)
621
622	// Cancel existing timer if any
623	if timer, exists := w.debounceMap[key]; exists {
624		timer.Stop()
625	}
626
627	// Create new timer
628	w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
629		w.handleFileEvent(ctx, uri, changeType)
630
631		// Cleanup timer after execution
632		w.debounceMu.Lock()
633		delete(w.debounceMap, key)
634		w.debounceMu.Unlock()
635	})
636}
637
638// handleFileEvent sends file change notifications
639func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
640	// If the file is open and it's a change event, use didChange notification
641	filePath := uri[7:] // Remove "file://" prefix
642	if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
643		err := w.client.NotifyChange(ctx, filePath)
644		if err != nil {
645			logging.Error("Error notifying change", "error", err)
646		}
647		return
648	}
649
650	// Notify LSP server about the file event using didChangeWatchedFiles
651	if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
652		logging.Error("Error notifying LSP server about file event", "error", err)
653	}
654}
655
656// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
657func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
658	cnf := config.Get()
659	if cnf.DebugLSP {
660		logging.Debug("Notifying file event",
661			"uri", uri,
662			"changeType", changeType,
663		)
664	}
665
666	params := protocol.DidChangeWatchedFilesParams{
667		Changes: []protocol.FileEvent{
668			{
669				URI:  protocol.DocumentUri(uri),
670				Type: changeType,
671			},
672		},
673	}
674
675	return w.client.DidChangeWatchedFiles(ctx, params)
676}
677
678// getServerNameFromContext extracts the server name from the context
679// This is a best-effort function that tries to identify which LSP server we're dealing with
680func getServerNameFromContext(ctx context.Context) string {
681	// First check if the server name is directly stored in the context
682	if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
683		return strings.ToLower(serverName)
684	}
685	
686	// Otherwise, try to extract server name from the client command path
687	if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
688		path := strings.ToLower(w.client.Cmd.Path)
689
690		// Extract server name from path
691		if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
692			return "typescript"
693		} else if strings.Contains(path, "gopls") {
694			return "gopls"
695		} else if strings.Contains(path, "rust-analyzer") {
696			return "rust-analyzer"
697		} else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
698			return "python"
699		} else if strings.Contains(path, "clangd") {
700			return "clangd"
701		} else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
702			return "java"
703		}
704
705		// Return the base name as fallback
706		return filepath.Base(path)
707	}
708
709	return "unknown"
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	cnf := 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 cnf.DebugLSP {
835			logging.Debug("Skipping large file",
836				"path", filePath,
837				"size", info.Size(),
838				"maxSize", maxFileSize,
839				"debug", cnf.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	cnf := 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		// Get server name for specialized handling
867		serverName := getServerNameFromContext(ctx)
868		
869		// Check if the file is a high-priority file that should be opened immediately
870		// This helps with project initialization for certain language servers
871		if isHighPriorityFile(path, serverName) {
872			if cnf.DebugLSP {
873				logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
874			}
875			if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
876				logging.Error("Error opening high-priority file", "path", path, "error", err)
877			}
878			return
879		}
880
881		// For non-high-priority files, we'll use different strategies based on server type
882		if shouldPreloadFiles(serverName) {
883			// For servers that benefit from preloading, open files but with limits
884			
885			// Check file size - for preloading we're more conservative
886			if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
887				if cnf.DebugLSP {
888					logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
889				}
890				return
891			}
892			
893			// Check file extension for common source files
894			ext := strings.ToLower(filepath.Ext(path))
895			
896			// Only preload source files for the specific language
897			shouldOpen := false
898			
899			switch serverName {
900			case "typescript", "typescript-language-server", "tsserver", "vtsls":
901				shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
902			case "gopls":
903				shouldOpen = ext == ".go"
904			case "rust-analyzer":
905				shouldOpen = ext == ".rs"
906			case "python", "pyright", "pylsp":
907				shouldOpen = ext == ".py"
908			case "clangd":
909				shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
910			case "java", "jdtls":
911				shouldOpen = ext == ".java"
912			default:
913				// For unknown servers, be conservative
914				shouldOpen = false
915			}
916			
917			if shouldOpen {
918				// Don't need to check if it's already open - the client.OpenFile handles that
919				if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
920					logging.Error("Error opening file", "path", path, "error", err)
921				}
922			}
923		}
924	}
925}
926
927// isHighPriorityFile determines if a file should be opened immediately
928// regardless of the preloading strategy
929func isHighPriorityFile(path string, serverName string) bool {
930	fileName := filepath.Base(path)
931	ext := filepath.Ext(path)
932
933	switch serverName {
934	case "typescript", "typescript-language-server", "tsserver", "vtsls":
935		// For TypeScript, we want to open configuration files immediately
936		return fileName == "tsconfig.json" ||
937			fileName == "package.json" ||
938			fileName == "jsconfig.json" ||
939			// Also open main entry points
940			fileName == "index.ts" ||
941			fileName == "index.js" ||
942			fileName == "main.ts" ||
943			fileName == "main.js"
944	case "gopls":
945		// For Go, we want to open go.mod files immediately
946		return fileName == "go.mod" || 
947			fileName == "go.sum" ||
948			// Also open main.go files
949			fileName == "main.go"
950	case "rust-analyzer":
951		// For Rust, we want to open Cargo.toml files immediately
952		return fileName == "Cargo.toml" || 
953			fileName == "Cargo.lock" ||
954			// Also open lib.rs and main.rs
955			fileName == "lib.rs" ||
956			fileName == "main.rs"
957	case "python", "pyright", "pylsp":
958		// For Python, open key project files
959		return fileName == "pyproject.toml" ||
960			fileName == "setup.py" ||
961			fileName == "requirements.txt" ||
962			fileName == "__init__.py" ||
963			fileName == "__main__.py"
964	case "clangd":
965		// For C/C++, open key project files
966		return fileName == "CMakeLists.txt" ||
967			fileName == "Makefile" ||
968			fileName == "compile_commands.json"
969	case "java", "jdtls":
970		// For Java, open key project files
971		return fileName == "pom.xml" ||
972			fileName == "build.gradle" ||
973			ext == ".java" // Java servers often need to see source files
974	}
975
976	// For unknown servers, prioritize common configuration files
977	return fileName == "package.json" ||
978		fileName == "Makefile" ||
979		fileName == "CMakeLists.txt" ||
980		fileName == ".editorconfig"
981}