mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
1049 lines
30 KiB
Go
1049 lines
30 KiB
Go
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"
|
|
}
|