summaryrefslogtreecommitdiffhomepage
path: root/internal/lsp/watcher
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-05-29 15:10:44 -0500
committeradamdottv <[email protected]>2025-05-29 15:10:44 -0500
commit37c0c1f358cadbc918319500cd2b1b3fcbe41a9e (patch)
treef012aad2481ea47b062c630420ecc008287a65dc /internal/lsp/watcher
parent2a132f86d687be767df4a7657e9c4441b8a4058d (diff)
downloadopencode-37c0c1f358cadbc918319500cd2b1b3fcbe41a9e.tar.gz
opencode-37c0c1f358cadbc918319500cd2b1b3fcbe41a9e.zip
wip: refactoring tui
Diffstat (limited to 'internal/lsp/watcher')
-rw-r--r--internal/lsp/watcher/watcher.go1049
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"
-}