diff options
| author | adamdottv <[email protected]> | 2025-05-29 15:10:44 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-05-29 15:10:44 -0500 |
| commit | 37c0c1f358cadbc918319500cd2b1b3fcbe41a9e (patch) | |
| tree | f012aad2481ea47b062c630420ecc008287a65dc /internal/lsp/watcher | |
| parent | 2a132f86d687be767df4a7657e9c4441b8a4058d (diff) | |
| download | opencode-37c0c1f358cadbc918319500cd2b1b3fcbe41a9e.tar.gz opencode-37c0c1f358cadbc918319500cd2b1b3fcbe41a9e.zip | |
wip: refactoring tui
Diffstat (limited to 'internal/lsp/watcher')
| -rw-r--r-- | internal/lsp/watcher/watcher.go | 1049 |
1 files changed, 0 insertions, 1049 deletions
diff --git a/internal/lsp/watcher/watcher.go b/internal/lsp/watcher/watcher.go deleted file mode 100644 index 3b8342976..000000000 --- a/internal/lsp/watcher/watcher.go +++ /dev/null @@ -1,1049 +0,0 @@ -package watcher - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - - "github.com/bmatcuk/doublestar/v4" - "github.com/fsnotify/fsnotify" - "github.com/sst/opencode/internal/config" - "github.com/sst/opencode/internal/lsp" - "github.com/sst/opencode/internal/lsp/protocol" - "log/slog" -) - -// WorkspaceWatcher manages LSP file watching -type WorkspaceWatcher struct { - client *lsp.Client - workspacePath string - - debounceTime time.Duration - debounceMap map[string]*time.Timer - debounceMu sync.Mutex - - // File watchers registered by the server - registrations []protocol.FileSystemWatcher - registrationMu sync.RWMutex -} - -// NewWorkspaceWatcher creates a new workspace watcher -func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher { - return &WorkspaceWatcher{ - client: client, - debounceTime: 300 * time.Millisecond, - debounceMap: make(map[string]*time.Timer), - registrations: []protocol.FileSystemWatcher{}, - } -} - -// AddRegistrations adds file watchers to track -func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) { - cnf := config.Get() - - slog.Debug("Adding file watcher registrations") - w.registrationMu.Lock() - defer w.registrationMu.Unlock() - - // Add new watchers - w.registrations = append(w.registrations, watchers...) - - // Print detailed registration information for debugging - if cnf.DebugLSP { - slog.Debug("Adding file watcher registrations", - "id", id, - "watchers", len(watchers), - "total", len(w.registrations), - ) - - for i, watcher := range watchers { - slog.Debug("Registration", "index", i+1) - - // Log the GlobPattern - switch v := watcher.GlobPattern.Value.(type) { - case string: - slog.Debug("GlobPattern", "pattern", v) - case protocol.RelativePattern: - slog.Debug("GlobPattern", "pattern", v.Pattern) - - // Log BaseURI details - switch u := v.BaseURI.Value.(type) { - case string: - slog.Debug("BaseURI", "baseURI", u) - case protocol.DocumentUri: - slog.Debug("BaseURI", "baseURI", u) - default: - slog.Debug("BaseURI", "baseURI", u) - } - default: - slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v)) - } - - // Log WatchKind - watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete) - if watcher.Kind != nil { - watchKind = *watcher.Kind - } - - slog.Debug("WatchKind", "kind", watchKind) - } - } - - // Determine server type for specialized handling - serverName := getServerNameFromContext(ctx) - slog.Debug("Server type detected", "serverName", serverName) - - // Check if this server has sent file watchers - hasFileWatchers := len(watchers) > 0 - - // For servers that need file preloading, we'll use a smart approach - if shouldPreloadFiles(serverName) || !hasFileWatchers { - go func() { - startTime := time.Now() - filesOpened := 0 - - // Determine max files to open based on server type - maxFilesToOpen := 50 // Default conservative limit - - switch serverName { - case "typescript", "typescript-language-server", "tsserver", "vtsls": - // TypeScript servers benefit from seeing more files - maxFilesToOpen = 100 - case "java", "jdtls": - // Java servers need to see many files for project model - maxFilesToOpen = 200 - } - - // First, open high-priority files - highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName) - filesOpened += highPriorityFilesOpened - - if cnf.DebugLSP { - slog.Debug("Opened high-priority files", - "count", highPriorityFilesOpened, - "serverName", serverName) - } - - // If we've already opened enough high-priority files, we might not need more - if filesOpened >= maxFilesToOpen { - if cnf.DebugLSP { - slog.Debug("Reached file limit with high-priority files", - "filesOpened", filesOpened, - "maxFiles", maxFilesToOpen) - } - return - } - - // For the remaining slots, walk the directory and open matching files - - err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip directories that should be excluded - if d.IsDir() { - if path != w.workspacePath && shouldExcludeDir(path) { - if cnf.DebugLSP { - slog.Debug("Skipping excluded directory", "path", path) - } - return filepath.SkipDir - } - } else { - // Process files, but limit the total number - if filesOpened < maxFilesToOpen { - // Only process if it's not already open (high-priority files were opened earlier) - if !w.client.IsFileOpen(path) { - w.openMatchingFile(ctx, path) - filesOpened++ - - // Add a small delay after every 10 files to prevent overwhelming the server - if filesOpened%10 == 0 { - time.Sleep(50 * time.Millisecond) - } - } - } else { - // We've reached our limit, stop walking - return filepath.SkipAll - } - } - - return nil - }) - - elapsedTime := time.Since(startTime) - if cnf.DebugLSP { - slog.Debug("Limited workspace scan complete", - "filesOpened", filesOpened, - "maxFiles", maxFilesToOpen, - "elapsedTime", elapsedTime.Seconds(), - "workspacePath", w.workspacePath, - ) - } - - if err != nil && cnf.DebugLSP { - slog.Debug("Error scanning workspace for files to open", "error", err) - } - }() - } else if cnf.DebugLSP { - slog.Debug("Using on-demand file loading for server", "server", serverName) - } -} - -// openHighPriorityFiles opens important files for the server type -// Returns the number of files opened -func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int { - cnf := config.Get() - filesOpened := 0 - - // Define patterns for high-priority files based on server type - var patterns []string - - switch serverName { - case "typescript", "typescript-language-server", "tsserver", "vtsls": - patterns = []string{ - "**/tsconfig.json", - "**/package.json", - "**/jsconfig.json", - "**/index.ts", - "**/index.js", - "**/main.ts", - "**/main.js", - } - case "gopls": - patterns = []string{ - "**/go.mod", - "**/go.sum", - "**/main.go", - } - case "rust-analyzer": - patterns = []string{ - "**/Cargo.toml", - "**/Cargo.lock", - "**/src/lib.rs", - "**/src/main.rs", - } - case "python", "pyright", "pylsp": - patterns = []string{ - "**/pyproject.toml", - "**/setup.py", - "**/requirements.txt", - "**/__init__.py", - "**/__main__.py", - } - case "clangd": - patterns = []string{ - "**/CMakeLists.txt", - "**/Makefile", - "**/compile_commands.json", - } - case "java", "jdtls": - patterns = []string{ - "**/pom.xml", - "**/build.gradle", - "**/src/main/java/**/*.java", - } - default: - // For unknown servers, use common configuration files - patterns = []string{ - "**/package.json", - "**/Makefile", - "**/CMakeLists.txt", - "**/.editorconfig", - } - } - - // For each pattern, find and open matching files - for _, pattern := range patterns { - // Use doublestar.Glob to find files matching the pattern (supports ** patterns) - matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern) - if err != nil { - if cnf.DebugLSP { - slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err) - } - continue - } - - for _, match := range matches { - // Convert relative path to absolute - fullPath := filepath.Join(w.workspacePath, match) - - // Skip directories and excluded files - info, err := os.Stat(fullPath) - if err != nil || info.IsDir() || shouldExcludeFile(fullPath) { - continue - } - - // Open the file - if err := w.client.OpenFile(ctx, fullPath); err != nil { - if cnf.DebugLSP { - slog.Debug("Error opening high-priority file", "path", fullPath, "error", err) - } - } else { - filesOpened++ - if cnf.DebugLSP { - slog.Debug("Opened high-priority file", "path", fullPath) - } - } - - // Add a small delay to prevent overwhelming the server - time.Sleep(20 * time.Millisecond) - - // Limit the number of files opened per pattern - if filesOpened >= 5 && (serverName != "java" && serverName != "jdtls") { - break - } - } - } - - return filesOpened -} - -// WatchWorkspace sets up file watching for a workspace -func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) { - cnf := config.Get() - w.workspacePath = workspacePath - - // Store the watcher in the context for later use - ctx = context.WithValue(ctx, "workspaceWatcher", w) - - // If the server name isn't already in the context, try to detect it - if _, ok := ctx.Value("serverName").(string); !ok { - serverName := getServerNameFromContext(ctx) - ctx = context.WithValue(ctx, "serverName", serverName) - } - - serverName := getServerNameFromContext(ctx) - slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName) - - // Register handler for file watcher registrations from the server - lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) { - w.AddRegistrations(ctx, id, watchers) - }) - - watcher, err := fsnotify.NewWatcher() - if err != nil { - slog.Error("Error creating watcher", "error", err) - } - defer watcher.Close() - - // Watch the workspace recursively - err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip excluded directories (except workspace root) - if d.IsDir() && path != workspacePath { - if shouldExcludeDir(path) { - if cnf.DebugLSP { - slog.Debug("Skipping excluded directory", "path", path) - } - return filepath.SkipDir - } - } - - // Add directories to watcher - if d.IsDir() { - err = watcher.Add(path) - if err != nil { - slog.Error("Error watching path", "path", path, "error", err) - } - } - - return nil - }) - if err != nil { - slog.Error("Error walking workspace", "error", err) - } - - // Event loop - for { - select { - case <-ctx.Done(): - return - case event, ok := <-watcher.Events: - if !ok { - return - } - - uri := fmt.Sprintf("file://%s", event.Name) - - // Add new directories to the watcher - if event.Op&fsnotify.Create != 0 { - // Check if the file/directory still exists before processing - info, err := os.Stat(event.Name) - if err != nil { - if os.IsNotExist(err) { - // File was deleted between event and processing - ignore - slog.Debug("File deleted between create event and stat", "path", event.Name) - continue - } - slog.Error("Error getting file info", "path", event.Name, "error", err) - continue - } - - if info.IsDir() { - // Skip excluded directories - if !shouldExcludeDir(event.Name) { - if err := watcher.Add(event.Name); err != nil { - slog.Error("Error adding directory to watcher", "path", event.Name, "error", err) - } - } - } else { - // For newly created files - if !shouldExcludeFile(event.Name) { - w.openMatchingFile(ctx, event.Name) - } - } - } - - // Debug logging - if cnf.DebugLSP { - matched, kind := w.isPathWatched(event.Name) - slog.Debug("File event", - "path", event.Name, - "operation", event.Op.String(), - "watched", matched, - "kind", kind, - ) - - } - - // Check if this path should be watched according to server registrations - if watched, watchKind := w.isPathWatched(event.Name); watched { - switch { - case event.Op&fsnotify.Write != 0: - if watchKind&protocol.WatchChange != 0 { - w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed)) - } - case event.Op&fsnotify.Create != 0: - // Already handled earlier in the event loop - // Just send the notification if needed - info, err := os.Stat(event.Name) - if err != nil { - slog.Error("Error getting file info", "path", event.Name, "error", err) - return - } - if !info.IsDir() && watchKind&protocol.WatchCreate != 0 { - w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created)) - } - case event.Op&fsnotify.Remove != 0: - if watchKind&protocol.WatchDelete != 0 { - w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted)) - } - case event.Op&fsnotify.Rename != 0: - // For renames, first delete - if watchKind&protocol.WatchDelete != 0 { - w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted)) - } - - // Then check if the new file exists and create an event - if info, err := os.Stat(event.Name); err == nil && !info.IsDir() { - if watchKind&protocol.WatchCreate != 0 { - w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created)) - } - } - } - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - slog.Error("Error watching file", "error", err) - } - } -} - -// isPathWatched checks if a path should be watched based on server registrations -func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) { - w.registrationMu.RLock() - defer w.registrationMu.RUnlock() - - // If no explicit registrations, watch everything - if len(w.registrations) == 0 { - return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete) - } - - // Check each registration - for _, reg := range w.registrations { - isMatch := w.matchesPattern(path, reg.GlobPattern) - if isMatch { - kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete) - if reg.Kind != nil { - kind = *reg.Kind - } - return true, kind - } - } - - return false, 0 -} - -// matchesGlob handles advanced glob patterns including ** and alternatives -func matchesGlob(pattern, path string) bool { - // Handle file extension patterns with braces like *.{go,mod,sum} - if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") { - // Extract extensions from pattern like "*.{go,mod,sum}" - parts := strings.SplitN(pattern, "{", 2) - if len(parts) == 2 { - prefix := parts[0] - extPart := strings.SplitN(parts[1], "}", 2) - if len(extPart) == 2 { - extensions := strings.Split(extPart[0], ",") - suffix := extPart[1] - - // Check if the path matches any of the extensions - for _, ext := range extensions { - extPattern := prefix + ext + suffix - isMatch := matchesSimpleGlob(extPattern, path) - if isMatch { - return true - } - } - return false - } - } - } - - return matchesSimpleGlob(pattern, path) -} - -// matchesSimpleGlob handles glob patterns with ** wildcards -func matchesSimpleGlob(pattern, path string) bool { - // Handle special case for **/*.ext pattern (common in LSP) - if strings.HasPrefix(pattern, "**/") { - rest := strings.TrimPrefix(pattern, "**/") - - // If the rest is a simple file extension pattern like *.go - if strings.HasPrefix(rest, "*.") { - ext := strings.TrimPrefix(rest, "*") - isMatch := strings.HasSuffix(path, ext) - return isMatch - } - - // Otherwise, try to check if the path ends with the rest part - isMatch := strings.HasSuffix(path, rest) - - // If it matches directly, great! - if isMatch { - return true - } - - // Otherwise, check if any path component matches - pathComponents := strings.Split(path, "/") - for i := range pathComponents { - subPath := strings.Join(pathComponents[i:], "/") - if strings.HasSuffix(subPath, rest) { - return true - } - } - - return false - } - - // Handle other ** wildcard pattern cases - if strings.Contains(pattern, "**") { - parts := strings.Split(pattern, "**") - - // Validate the path starts with the first part - if !strings.HasPrefix(path, parts[0]) && parts[0] != "" { - return false - } - - // For patterns like "**/*.go", just check the suffix - if len(parts) == 2 && parts[0] == "" { - isMatch := strings.HasSuffix(path, parts[1]) - return isMatch - } - - // For other patterns, handle middle part - remaining := strings.TrimPrefix(path, parts[0]) - if len(parts) == 2 { - isMatch := strings.HasSuffix(remaining, parts[1]) - return isMatch - } - } - - // Handle simple * wildcard for file extension patterns (*.go, *.sum, etc) - if strings.HasPrefix(pattern, "*.") { - ext := strings.TrimPrefix(pattern, "*") - isMatch := strings.HasSuffix(path, ext) - return isMatch - } - - // Fall back to simple matching for simpler patterns - matched, err := filepath.Match(pattern, path) - if err != nil { - slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err) - return false - } - - return matched -} - -// matchesPattern checks if a path matches the glob pattern -func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool { - patternInfo, err := pattern.AsPattern() - if err != nil { - slog.Error("Error parsing pattern", "pattern", pattern, "error", err) - return false - } - - basePath := patternInfo.GetBasePath() - patternText := patternInfo.GetPattern() - - path = filepath.ToSlash(path) - - // For simple patterns without base path - if basePath == "" { - // Check if the pattern matches the full path or just the file extension - fullPathMatch := matchesGlob(patternText, path) - baseNameMatch := matchesGlob(patternText, filepath.Base(path)) - - return fullPathMatch || baseNameMatch - } - - // For relative patterns - basePath = strings.TrimPrefix(basePath, "file://") - basePath = filepath.ToSlash(basePath) - - // Make path relative to basePath for matching - relPath, err := filepath.Rel(basePath, path) - if err != nil { - slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err) - return false - } - relPath = filepath.ToSlash(relPath) - - isMatch := matchesGlob(patternText, relPath) - - return isMatch -} - -// debounceHandleFileEvent handles file events with debouncing to reduce notifications -func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) { - w.debounceMu.Lock() - defer w.debounceMu.Unlock() - - // Create a unique key based on URI and change type - key := fmt.Sprintf("%s:%d", uri, changeType) - - // Cancel existing timer if any - if timer, exists := w.debounceMap[key]; exists { - timer.Stop() - } - - // Create new timer - w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() { - w.handleFileEvent(ctx, uri, changeType) - - // Cleanup timer after execution - w.debounceMu.Lock() - delete(w.debounceMap, key) - w.debounceMu.Unlock() - }) -} - -// handleFileEvent sends file change notifications -func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) { - // If the file is open and it's a change event, use didChange notification - filePath := uri[7:] // Remove "file://" prefix - - if changeType == protocol.FileChangeType(protocol.Deleted) { - // Always clear diagnostics for deleted files - w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri)) - - // If the file was open, close it in the LSP client - if w.client.IsFileOpen(filePath) { - if err := w.client.CloseFile(ctx, filePath); err != nil { - slog.Debug("Error closing deleted file in LSP client", "file", filePath, "error", err) - // Continue anyway - the file is gone - } - } - } else if changeType == protocol.FileChangeType(protocol.Changed) { - // For changed files, verify the file still exists before notifying - if _, err := os.Stat(filePath); err != nil { - if os.IsNotExist(err) { - // File was deleted between the event and now - treat as delete - slog.Debug("File deleted between change event and processing", "file", filePath) - w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted)) - return - } - slog.Error("Error getting file info", "path", filePath, "error", err) - return - } - - // File exists and is open, notify change - if w.client.IsFileOpen(filePath) { - err := w.client.NotifyChange(ctx, filePath) - if err != nil { - slog.Error("Error notifying change", "error", err) - } - return - } - } else if changeType == protocol.FileChangeType(protocol.Created) { - // For created files, verify the file still exists before notifying - if _, err := os.Stat(filePath); err != nil { - if os.IsNotExist(err) { - // File was deleted between the event and now - ignore - slog.Debug("File deleted between create event and processing", "file", filePath) - return - } - slog.Error("Error getting file info", "path", filePath, "error", err) - return - } - } - - // Notify LSP server about the file event using didChangeWatchedFiles - if err := w.notifyFileEvent(ctx, uri, changeType); err != nil { - slog.Error("Error notifying LSP server about file event", "error", err) - } -} - -// notifyFileEvent sends a didChangeWatchedFiles notification for a file event -func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error { - cnf := config.Get() - if cnf.DebugLSP { - slog.Debug("Notifying file event", - "uri", uri, - "changeType", changeType, - ) - } - - params := protocol.DidChangeWatchedFilesParams{ - Changes: []protocol.FileEvent{ - { - URI: protocol.DocumentUri(uri), - Type: changeType, - }, - }, - } - - return w.client.DidChangeWatchedFiles(ctx, params) -} - -// getServerNameFromContext extracts the server name from the context -// This is a best-effort function that tries to identify which LSP server we're dealing with -func getServerNameFromContext(ctx context.Context) string { - // First check if the server name is directly stored in the context - if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" { - return strings.ToLower(serverName) - } - - // Otherwise, try to extract server name from the client command path - if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil { - path := strings.ToLower(w.client.Cmd.Path) - - // Extract server name from path - if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") { - return "typescript" - } else if strings.Contains(path, "gopls") { - return "gopls" - } else if strings.Contains(path, "rust-analyzer") { - return "rust-analyzer" - } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") { - return "python" - } else if strings.Contains(path, "clangd") { - return "clangd" - } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") { - return "java" - } - - // Return the base name as fallback - return filepath.Base(path) - } - - return "unknown" -} - -// shouldPreloadFiles determines if we should preload files for a specific language server -// Some servers work better with preloaded files, others don't need it -func shouldPreloadFiles(serverName string) bool { - // TypeScript/JavaScript servers typically need some files preloaded - // to properly resolve imports and provide intellisense - switch serverName { - case "typescript", "typescript-language-server", "tsserver", "vtsls": - return true - case "java", "jdtls": - // Java servers often need to see source files to build the project model - return true - default: - // For most servers, we'll use lazy loading by default - return false - } -} - -// Common patterns for directories and files to exclude -// TODO: make configurable -var ( - excludedDirNames = map[string]bool{ - ".git": true, - "node_modules": true, - "dist": true, - "build": true, - "out": true, - "bin": true, - ".idea": true, - ".vscode": true, - ".cache": true, - "coverage": true, - "target": true, // Rust build output - "vendor": true, // Go vendor directory - } - - excludedFileExtensions = map[string]bool{ - ".swp": true, - ".swo": true, - ".tmp": true, - ".temp": true, - ".bak": true, - ".log": true, - ".o": true, // Object files - ".so": true, // Shared libraries - ".dylib": true, // macOS shared libraries - ".dll": true, // Windows shared libraries - ".a": true, // Static libraries - ".exe": true, // Windows executables - ".lock": true, // Lock files - } - - // Large binary files that shouldn't be opened - largeBinaryExtensions = map[string]bool{ - ".png": true, - ".jpg": true, - ".jpeg": true, - ".gif": true, - ".bmp": true, - ".ico": true, - ".zip": true, - ".tar": true, - ".gz": true, - ".rar": true, - ".7z": true, - ".pdf": true, - ".mp3": true, - ".mp4": true, - ".mov": true, - ".wav": true, - ".wasm": true, - } - - // Maximum file size to open (5MB) - maxFileSize int64 = 5 * 1024 * 1024 -) - -// shouldExcludeDir returns true if the directory should be excluded from watching/opening -func shouldExcludeDir(dirPath string) bool { - dirName := filepath.Base(dirPath) - - // Skip dot directories - if strings.HasPrefix(dirName, ".") { - return true - } - - // Skip common excluded directories - if excludedDirNames[dirName] { - return true - } - - return false -} - -// shouldExcludeFile returns true if the file should be excluded from opening -func shouldExcludeFile(filePath string) bool { - fileName := filepath.Base(filePath) - cnf := config.Get() - // Skip dot files - if strings.HasPrefix(fileName, ".") { - return true - } - - // Check file extension - ext := strings.ToLower(filepath.Ext(filePath)) - if excludedFileExtensions[ext] || largeBinaryExtensions[ext] { - return true - } - - // Skip temporary files - if strings.HasSuffix(filePath, "~") { - return true - } - - // Skip numeric temporary files (often created by editors) - if _, err := strconv.Atoi(fileName); err == nil { - return true - } - - // Check file size - info, err := os.Stat(filePath) - if err != nil { - // If we can't stat the file, skip it - return true - } - - // Skip large files - if info.Size() > maxFileSize { - if cnf.DebugLSP { - slog.Debug("Skipping large file", - "path", filePath, - "size", info.Size(), - "maxSize", maxFileSize, - "debug", cnf.Debug, - "sizeMB", float64(info.Size())/(1024*1024), - "maxSizeMB", float64(maxFileSize)/(1024*1024), - ) - } - return true - } - - return false -} - -// openMatchingFile opens a file if it matches any of the registered patterns -func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) { - cnf := config.Get() - // Skip directories and verify file exists - info, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - // File was deleted between event and processing - ignore - slog.Debug("File deleted between event and openMatchingFile", "path", path) - return - } - slog.Error("Error getting file info", "path", path, "error", err) - return - } - - if info.IsDir() { - return - } - - // Skip excluded files - if shouldExcludeFile(path) { - return - } - - // Check if this path should be watched according to server registrations - if watched, _ := w.isPathWatched(path); watched { - // Get server name for specialized handling - serverName := getServerNameFromContext(ctx) - - // Check if the file is a high-priority file that should be opened immediately - // This helps with project initialization for certain language servers - if isHighPriorityFile(path, serverName) { - if cnf.DebugLSP { - slog.Debug("Opening high-priority file", "path", path, "serverName", serverName) - } - if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP { - slog.Error("Error opening high-priority file", "path", path, "error", err) - } - return - } - - // For non-high-priority files, we'll use different strategies based on server type - if shouldPreloadFiles(serverName) { - // For servers that benefit from preloading, open files but with limits - - // Check file size - for preloading we're more conservative - if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files - if cnf.DebugLSP { - slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size()) - } - return - } - - // Check file extension for common source files - ext := strings.ToLower(filepath.Ext(path)) - - // Only preload source files for the specific language - shouldOpen := false - - switch serverName { - case "typescript", "typescript-language-server", "tsserver", "vtsls": - shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" - case "gopls": - shouldOpen = ext == ".go" - case "rust-analyzer": - shouldOpen = ext == ".rs" - case "python", "pyright", "pylsp": - shouldOpen = ext == ".py" - case "clangd": - shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp" - case "java", "jdtls": - shouldOpen = ext == ".java" - default: - // For unknown servers, be conservative - shouldOpen = false - } - - if shouldOpen { - // Don't need to check if it's already open - the client.OpenFile handles that - if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP { - slog.Error("Error opening file", "path", path, "error", err) - } - } - } - } -} - -// isHighPriorityFile determines if a file should be opened immediately -// regardless of the preloading strategy -func isHighPriorityFile(path string, serverName string) bool { - fileName := filepath.Base(path) - ext := filepath.Ext(path) - - switch serverName { - case "typescript", "typescript-language-server", "tsserver", "vtsls": - // For TypeScript, we want to open configuration files immediately - return fileName == "tsconfig.json" || - fileName == "package.json" || - fileName == "jsconfig.json" || - // Also open main entry points - fileName == "index.ts" || - fileName == "index.js" || - fileName == "main.ts" || - fileName == "main.js" - case "gopls": - // For Go, we want to open go.mod files immediately - return fileName == "go.mod" || - fileName == "go.sum" || - // Also open main.go files - fileName == "main.go" - case "rust-analyzer": - // For Rust, we want to open Cargo.toml files immediately - return fileName == "Cargo.toml" || - fileName == "Cargo.lock" || - // Also open lib.rs and main.rs - fileName == "lib.rs" || - fileName == "main.rs" - case "python", "pyright", "pylsp": - // For Python, open key project files - return fileName == "pyproject.toml" || - fileName == "setup.py" || - fileName == "requirements.txt" || - fileName == "__init__.py" || - fileName == "__main__.py" - case "clangd": - // For C/C++, open key project files - return fileName == "CMakeLists.txt" || - fileName == "Makefile" || - fileName == "compile_commands.json" - case "java", "jdtls": - // For Java, open key project files - return fileName == "pom.xml" || - fileName == "build.gradle" || - ext == ".java" // Java servers often need to see source files - } - - // For unknown servers, prioritize common configuration files - return fileName == "package.json" || - fileName == "Makefile" || - fileName == "CMakeLists.txt" || - fileName == ".editorconfig" -} |
