| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049 |
- 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"
- }
|