watcher.go

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