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