Skip to main content
Glama

MCP Language Server

watcher.go19.5 kB
package watcher import ( "context" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/isaacphi/mcp-language-server/internal/logging" "github.com/isaacphi/mcp-language-server/internal/lsp" "github.com/isaacphi/mcp-language-server/internal/protocol" ) // Create a logger for the watcher component var watcherLogger = logging.NewLogger(logging.Watcher) // WorkspaceWatcher manages LSP file watching type WorkspaceWatcher struct { client LSPClient workspacePath string config *WatcherConfig debounceMap map[string]*time.Timer debounceMu sync.Mutex // File watchers registered by the server registrations []protocol.FileSystemWatcher registrationMu sync.RWMutex // Gitignore matcher gitignore *GitignoreMatcher } // NewWorkspaceWatcher creates a new workspace watcher with default configuration func NewWorkspaceWatcher(client LSPClient) *WorkspaceWatcher { return NewWorkspaceWatcherWithConfig(client, DefaultWatcherConfig()) } // NewWorkspaceWatcherWithConfig creates a new workspace watcher with custom configuration func NewWorkspaceWatcherWithConfig(client LSPClient, config *WatcherConfig) *WorkspaceWatcher { return &WorkspaceWatcher{ client: client, config: config, 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) { w.registrationMu.Lock() defer w.registrationMu.Unlock() // Add new watchers w.registrations = append(w.registrations, watchers...) // Log registration information watcherLogger.Info("Added %d file watcher registrations (id: %s), total: %d", len(watchers), id, len(w.registrations)) // Detailed debug information about registrations if watcherLogger.IsLevelEnabled(logging.LevelDebug) { for i, watcher := range watchers { watcherLogger.Debug("Registration #%d raw data:", i+1) // Log the GlobPattern switch v := watcher.GlobPattern.Value.(type) { case string: watcherLogger.Debug(" GlobPattern: string pattern '%s'", v) case protocol.RelativePattern: watcherLogger.Debug(" GlobPattern: RelativePattern with pattern '%s'", v.Pattern) // Log BaseURI details switch u := v.BaseURI.Value.(type) { case string: watcherLogger.Debug(" BaseURI: string '%s'", u) case protocol.DocumentUri: watcherLogger.Debug(" BaseURI: DocumentUri '%s'", u) default: watcherLogger.Debug(" BaseURI: unknown type %T", u) } default: watcherLogger.Debug(" GlobPattern: unknown type %T", v) } // Log WatchKind watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete) if watcher.Kind != nil { watchKind = *watcher.Kind } watcherLogger.Debug(" WatchKind: %d (Create:%v, Change:%v, Delete:%v)", watchKind, watchKind&protocol.WatchCreate != 0, watchKind&protocol.WatchChange != 0, watchKind&protocol.WatchDelete != 0) // Test match against some example paths testPaths := []string{ "/Users/phil/dev/mcp-language-server/internal/watcher/watcher.go", "/Users/phil/dev/mcp-language-server/go.mod", } for _, testPath := range testPaths { isMatch := w.matchesPattern(testPath, watcher.GlobPattern) watcherLogger.Debug(" Test path '%s': %v", testPath, isMatch) } } } // Find and open all existing files that match the newly registered patterns // TODO: not all language servers require this, but typescript does. Make this configurable go func() { startTime := time.Now() filesOpened := 0 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() { watcherLogger.Debug("Processing directory: %s", path) if path != w.workspacePath && w.shouldExcludeDir(path) { watcherLogger.Debug("Skipping excluded directory: %s", path) return filepath.SkipDir } } else { // Process files w.openMatchingFile(ctx, path) filesOpened++ // Add a small delay after every 100 files to prevent overwhelming the server if filesOpened%100 == 0 { time.Sleep(10 * time.Millisecond) } } return nil }) elapsedTime := time.Since(startTime) watcherLogger.Info("Workspace scan complete: processed %d files in %.2f seconds", filesOpened, elapsedTime.Seconds()) if err != nil { watcherLogger.Error("Error scanning workspace for files to open: %v", err) } }() } // WatchWorkspace sets up file watching for a workspace func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) { w.workspacePath = workspacePath // Initialize gitignore matcher gitignore, err := NewGitignoreMatcher(workspacePath) if err != nil { watcherLogger.Error("Error initializing gitignore matcher: %v", err) } else { w.gitignore = gitignore watcherLogger.Info("Initialized gitignore matcher for %s", workspacePath) } // 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 { watcherLogger.Fatal("Error creating watcher: %v", err) } defer func() { if err := watcher.Close(); err != nil { watcherLogger.Error("Error closing watcher: %v", err) } }() // 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 w.shouldExcludeDir(path) { watcherLogger.Debug("Skipping watching excluded directory: %s", path) return filepath.SkipDir } } // Add directories to watcher if d.IsDir() { err = watcher.Add(path) if err != nil { watcherLogger.Error("Error watching path %s: %v", path, err) } } return nil }) if err != nil { watcherLogger.Fatal("Error walking workspace: %v", err) } // Event loop for { select { case <-ctx.Done(): return case event, ok := <-watcher.Events: if !ok { return } uri := fmt.Sprintf("file://%s", event.Name) // Check if this is a file (not a directory) and should be excluded isFile := false isExcluded := false if info, err := os.Stat(event.Name); err == nil { isFile = !info.IsDir() if isFile { isExcluded = w.shouldExcludeFile(event.Name) if isExcluded { watcherLogger.Debug("Skipping excluded file: %s", event.Name) } } else { // It's a directory isExcluded = w.shouldExcludeDir(event.Name) if isExcluded { watcherLogger.Debug("Skipping excluded directory: %s", event.Name) } } } // Add new directories to the watcher if event.Op&fsnotify.Create != 0 { if info, err := os.Stat(event.Name); err == nil { if info.IsDir() { // Skip excluded directories if !w.shouldExcludeDir(event.Name) { if err := watcher.Add(event.Name); err != nil { watcherLogger.Error("Error watching new directory: %v", err) } } } else { // For newly created files if !w.shouldExcludeFile(event.Name) { w.openMatchingFile(ctx, event.Name) } } } } // Debug logging if watcherLogger.IsLevelEnabled(logging.LevelDebug) { matched, kind := w.isPathWatched(event.Name) watcherLogger.Debug("Event: %s, Op: %s, Watched: %v, Kind: %d, Excluded: %v", event.Name, event.Op.String(), matched, kind, isExcluded) } // Skip excluded files from further processing if isExcluded { continue } // 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, _ := os.Stat(event.Name) if info != nil && !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 } watcherLogger.Error("Watcher error: %v", 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 { watcherLogger.Error("Error matching pattern %s: %v", pattern, 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 { watcherLogger.Error("Error parsing pattern: %v", err) return false } basePath := patternInfo.GetBasePath() patternText := patternInfo.GetPattern() // watcherLogger.Debug("Matching path %s against pattern %s (base: %s)", path, patternText, basePath) path = filepath.ToSlash(path) // Special handling for wildcard patterns like "**/*" if patternText == "**/*" { // This should match any file // watcherLogger.Debug("Using special matching for **/* pattern") return true } // Special handling for wildcard patterns like "**/*.ext" if strings.HasPrefix(patternText, "**/") { if strings.HasPrefix(strings.TrimPrefix(patternText, "**/"), "*.") { // Extension pattern like **/*.go ext := strings.TrimPrefix(strings.TrimPrefix(patternText, "**/"), "*") // watcherLogger.Debug("Using extension matching for **/*.ext pattern: checking if %s ends with %s", path, ext) return strings.HasSuffix(path, ext) } else { // Any other pattern starting with **/ should match any path // watcherLogger.Debug("Using path substring matching for **/ pattern") return true } } // 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)) watcherLogger.Debug("No base path, fullPathMatch: %v, baseNameMatch: %v", fullPathMatch, baseNameMatch) 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 { watcherLogger.Error("Error getting relative path for %s: %v", path, err) return false } relPath = filepath.ToSlash(relPath) isMatch := matchesGlob(patternText, relPath) watcherLogger.Debug("Relative path matching: %s against %s = %v", relPath, patternText, isMatch) 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.config.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.Changed) && w.client.IsFileOpen(filePath) { err := w.client.NotifyChange(ctx, filePath) if err != nil { watcherLogger.Error("Error notifying change: %v", err) } return } // Notify LSP server about the file event using didChangeWatchedFiles if err := w.notifyFileEvent(ctx, uri, changeType); err != nil { watcherLogger.Error("Error notifying LSP server about file event: %v", err) } } // notifyFileEvent sends a didChangeWatchedFiles notification for a file event func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error { watcherLogger.Debug("Notifying file event: %s (type: %d)", uri, changeType) params := protocol.DidChangeWatchedFilesParams{ Changes: []protocol.FileEvent{ { URI: protocol.DocumentUri(uri), Type: changeType, }, }, } return w.client.DidChangeWatchedFiles(ctx, params) } // shouldExcludeDir returns true if the directory should be excluded from watching/opening func (w *WorkspaceWatcher) shouldExcludeDir(dirPath string) bool { dirName := filepath.Base(dirPath) // Skip dot directories if strings.HasPrefix(dirName, ".") { return true } // Skip common excluded directories if w.config.ExcludedDirs[dirName] { return true } // Check gitignore patterns if w.gitignore != nil && w.gitignore.ShouldIgnore(dirPath, true) { watcherLogger.Debug("Directory %s excluded by gitignore pattern", dirPath) return true } return false } // shouldExcludeFile returns true if the file should be excluded from opening func (w *WorkspaceWatcher) shouldExcludeFile(filePath string) bool { fileName := filepath.Base(filePath) // Skip dot files if strings.HasPrefix(fileName, ".") { return true } // Check file extension ext := strings.ToLower(filepath.Ext(filePath)) if w.config.ExcludedFileExtensions[ext] || w.config.LargeBinaryExtensions[ext] { return true } // Skip temporary files if strings.HasSuffix(filePath, "~") { return true } // Check gitignore patterns if w.gitignore != nil && w.gitignore.ShouldIgnore(filePath, false) { watcherLogger.Debug("File %s excluded by gitignore pattern", filePath) 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() > w.config.MaxFileSize { watcherLogger.Debug("Skipping large file: %s (%.2f MB)", filePath, float64(info.Size())/(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) { // Skip directories info, err := os.Stat(path) if err != nil || info.IsDir() { return } // Skip excluded files if w.shouldExcludeFile(path) { return } // Check if this path should be watched according to server registrations if watched, _ := w.isPathWatched(path); watched { // Don't need to check if it's already open - the client.OpenFile handles that if err := w.client.OpenFile(ctx, path); err != nil && watcherLogger.IsLevelEnabled(logging.LevelDebug) { watcherLogger.Debug("Error opening file %s: %v", path, err) } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/isaacphi/mcp-language-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server