MCP Language Server

package watcher import ( "context" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/isaacphi/mcp-language-server/internal/lsp" gitignore "github.com/sabhiram/go-gitignore" ) var debug = os.Getenv("DEBUG") != "" // WorkspaceWatcher manages file watching and version tracking type WorkspaceWatcher struct { client *lsp.Client ignore *gitignore.GitIgnore workspacePath string debounceTime time.Duration debounceMap map[string]*time.Timer debounceMu sync.Mutex } func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher { return &WorkspaceWatcher{ client: client, debounceTime: 1000 * time.Millisecond, debounceMap: make(map[string]*time.Timer), } } func (w *WorkspaceWatcher) loadGitIgnore(workspacePath string) error { gitignorePath := filepath.Join(workspacePath, ".gitignore") // Read and log the content of .gitignore content, err := os.ReadFile(gitignorePath) if err != nil { if debug { log.Printf("DEBUG: Error reading .gitignore: %v", err) } return fmt.Errorf("error reading gitignore: %w", err) } log.Printf("DEBUG: .gitignore content:\n%s", string(content)) ignore, err := gitignore.CompileIgnoreFile(gitignorePath) if err != nil { return fmt.Errorf("error compiling gitignore: %w", err) } w.ignore = ignore if debug { log.Printf("DEBUG: Successfully loaded .gitignore") } return nil } func (w *WorkspaceWatcher) shouldIgnorePath(path string, workspacePath string) bool { // Always ignore .git directory if filepath.Base(path) == ".git" { return true } // If we have a gitignore file, check against its patterns if w.ignore != nil { // Convert to relative path for gitignore matching relPath, err := filepath.Rel(workspacePath, path) if err != nil { log.Printf("DEBUG: Error getting relative path for %s: %v", path, err) return false } // Convert path separators to forward slashes relPath = filepath.ToSlash(relPath) // Remove leading ./ if present relPath = strings.TrimPrefix(relPath, "./") matches, pattern := w.ignore.MatchesPathHow(relPath) if debug { log.Printf("DEBUG: Path check details:") log.Printf(" Original path: %s", path) log.Printf(" Workspace: %s", workspacePath) log.Printf(" Relative path: %s", relPath) log.Printf(" Matches gitignore? %v", matches) if pattern != nil { log.Printf(" Matched pattern: %s (line %d)", pattern.Line, pattern.LineNo) } } return matches } return false } func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) { w.workspacePath = workspacePath // Load gitignore patterns if err := w.loadGitIgnore(workspacePath); err != nil { log.Printf("Error loading gitignore: %v", err) } watcher, err := fsnotify.NewWatcher() if err != nil { log.Fatalf("Error creating watcher: %v", err) } defer watcher.Close() // Watch all subdirectories except ignored ones err = filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { if w.shouldIgnorePath(path, workspacePath) { return filepath.SkipDir } err = watcher.Add(path) if err != nil { log.Printf("Error watching path %s: %v", path, err) } } else { if w.shouldIgnorePath(path, workspacePath) { return nil } } return nil }) if err != nil { log.Fatalf("Error walking workspace: %v", err) } for { select { case <-ctx.Done(): return case event, ok := <-watcher.Events: log.Println("EVENT::", event, ok) if !ok { return } // Skip temporary files and backup files if strings.HasSuffix(event.Name, "~") || strings.HasSuffix(event.Name, ".swp") { if debug { log.Println("Skipping temporary file") } continue } // Skip ignored paths if w.shouldIgnorePath(event.Name, workspacePath) { if debug { log.Println("Skipping", event.Name) } continue } uri := fmt.Sprintf("file://%s", event.Name) switch { case event.Op&fsnotify.Write != 0: w.debounceHandleChange(ctx, uri) case event.Op&fsnotify.Create != 0: // Also watch new directories if info, err := os.Stat(event.Name); err == nil && info.IsDir() { if !w.shouldIgnorePath(event.Name, workspacePath) { err = watcher.Add(event.Name) if err != nil { log.Printf("Error watching new directory: %v", err) } } } w.debounceHandleCreate(ctx, uri) case event.Op&fsnotify.Remove != 0: w.handleDelete(ctx, uri) case event.Op&fsnotify.Rename != 0: w.handleDelete(ctx, uri) if info, err := os.Stat(event.Name); err == nil { if info.IsDir() && !w.shouldIgnorePath(event.Name, workspacePath) { err = watcher.Add(event.Name) if err != nil { log.Printf("Error watching new directory: %v", err) } } w.debounceHandleCreate(ctx, uri) } } case err, ok := <-watcher.Errors: if !ok { return } log.Printf("Watcher error: %v\n", err) } } } func (w *WorkspaceWatcher) debounceHandleChange(ctx context.Context, uri string) { w.debounceMu.Lock() defer w.debounceMu.Unlock() // Cancel existing timer if any if timer, exists := w.debounceMap[uri]; exists { timer.Stop() } // Create new timer w.debounceMap[uri] = time.AfterFunc(w.debounceTime, func() { w.handleChange(ctx, uri) // Cleanup timer after execution w.debounceMu.Lock() delete(w.debounceMap, uri) w.debounceMu.Unlock() }) } func (w *WorkspaceWatcher) debounceHandleCreate(ctx context.Context, uri string) { w.debounceMu.Lock() defer w.debounceMu.Unlock() // Cancel existing timer if any if timer, exists := w.debounceMap[uri]; exists { timer.Stop() } // Create new timer w.debounceMap[uri] = time.AfterFunc(w.debounceTime, func() { w.handleCreate(ctx, uri) // Cleanup timer after execution w.debounceMu.Lock() delete(w.debounceMap, uri) w.debounceMu.Unlock() }) } func (w *WorkspaceWatcher) handleCreate(ctx context.Context, uri string) { _, err := os.ReadFile(uri[7:]) // Remove "file://" prefix if err != nil { log.Printf("Error reading file: %v", err) return } // Temporarily open the file to trigger analysis if err := w.client.OpenFile(ctx, uri[7:]); err != nil { log.Printf("Error opening file for analysis: %v", err) return } // Close it right after to not keep it in memory log.Printf("Error closing file after analysis: %v", err) if err := w.client.CloseFile(ctx, uri[7:]); err != nil { log.Printf("Error closing file: %v", err) } } func (w *WorkspaceWatcher) handleChange(ctx context.Context, uri string) { // Only handle changes for files that aren't "open" // If a file is open, changes should come through the client's NotifyChange if w.client.IsFileOpen(uri[7:]) { // Remove "file://" prefix err := w.client.NotifyChange(ctx, uri[7:]) if err != nil { log.Printf("Error notifying change: %v", err) } return } // Skip temporary files and backup files if strings.HasSuffix(uri, "~") || strings.HasSuffix(uri, ".swp") { return } // Temporarily open file to trigger analysis if err := w.client.OpenFile(ctx, uri[7:]); err != nil { log.Printf("Error opening file for analysis: %v", err) return } // And immediately close it if err := w.client.CloseFile(ctx, uri[7:]); err != nil { log.Printf("Error closing file after analysis: %v", err) } } func (w *WorkspaceWatcher) handleDelete(ctx context.Context, uri string) { // If the file is open in the client, close it properly if w.client.IsFileOpen(uri[7:]) { if err := w.client.CloseFile(ctx, uri[7:]); err != nil { log.Printf("Error closing file: %v", err) } } }