watcher.go

  1package watcher
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"sync"
 10	"time"
 11
 12	"github.com/fsnotify/fsnotify"
 13	"github.com/kujtimiihoxha/termai/internal/config"
 14	"github.com/kujtimiihoxha/termai/internal/logging"
 15	"github.com/kujtimiihoxha/termai/internal/lsp"
 16	"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
 17)
 18
 19// WorkspaceWatcher manages LSP file watching
 20type WorkspaceWatcher struct {
 21	client        *lsp.Client
 22	workspacePath string
 23
 24	debounceTime time.Duration
 25	debounceMap  map[string]*time.Timer
 26	debounceMu   sync.Mutex
 27
 28	// File watchers registered by the server
 29	registrations  []protocol.FileSystemWatcher
 30	registrationMu sync.RWMutex
 31}
 32
 33// NewWorkspaceWatcher creates a new workspace watcher
 34func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
 35	return &WorkspaceWatcher{
 36		client:        client,
 37		debounceTime:  300 * time.Millisecond,
 38		debounceMap:   make(map[string]*time.Timer),
 39		registrations: []protocol.FileSystemWatcher{},
 40	}
 41}
 42
 43// AddRegistrations adds file watchers to track
 44func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
 45	cnf := config.Get()
 46	w.registrationMu.Lock()
 47	defer w.registrationMu.Unlock()
 48
 49	// Add new watchers
 50	w.registrations = append(w.registrations, watchers...)
 51
 52	// Print detailed registration information for debugging
 53	if cnf.DebugLSP {
 54		logging.Debug("Adding file watcher registrations",
 55			"id", id,
 56			"watchers", len(watchers),
 57			"total", len(w.registrations),
 58			"watchers", watchers,
 59		)
 60
 61		for i, watcher := range watchers {
 62			logging.Debug("Registration", "index", i+1)
 63
 64			// Log the GlobPattern
 65			switch v := watcher.GlobPattern.Value.(type) {
 66			case string:
 67				logging.Debug("GlobPattern", "pattern", v)
 68			case protocol.RelativePattern:
 69				logging.Debug("GlobPattern", "pattern", v.Pattern)
 70
 71				// Log BaseURI details
 72				switch u := v.BaseURI.Value.(type) {
 73				case string:
 74					logging.Debug("BaseURI", "baseURI", u)
 75				case protocol.DocumentUri:
 76					logging.Debug("BaseURI", "baseURI", u)
 77				default:
 78					logging.Debug("BaseURI", "baseURI", u)
 79				}
 80			default:
 81				logging.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
 82			}
 83
 84			// Log WatchKind
 85			watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
 86			if watcher.Kind != nil {
 87				watchKind = *watcher.Kind
 88			}
 89
 90			logging.Debug("WatchKind", "kind", watchKind)
 91
 92			// Test match against some example paths
 93			testPaths := []string{
 94				"/Users/phil/dev/mcp-language-server/internal/watcher/watcher.go",
 95				"/Users/phil/dev/mcp-language-server/go.mod",
 96			}
 97
 98			for _, testPath := range testPaths {
 99				isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
100				logging.Debug("Test path", "path", testPath, "matches", isMatch)
101			}
102		}
103	}
104
105	// Find and open all existing files that match the newly registered patterns
106	// TODO: not all language servers require this, but typescript does. Make this configurable
107	go func() {
108		startTime := time.Now()
109		filesOpened := 0
110
111		err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
112			if err != nil {
113				return err
114			}
115
116			// Skip directories that should be excluded
117			if d.IsDir() {
118				if path != w.workspacePath && shouldExcludeDir(path) {
119					if cnf.DebugLSP {
120						logging.Debug("Skipping excluded directory", "path", path)
121					}
122					return filepath.SkipDir
123				}
124			} else {
125				// Process files
126				w.openMatchingFile(ctx, path)
127				filesOpened++
128
129				// Add a small delay after every 100 files to prevent overwhelming the server
130				if filesOpened%100 == 0 {
131					time.Sleep(10 * time.Millisecond)
132				}
133			}
134
135			return nil
136		})
137
138		elapsedTime := time.Since(startTime)
139		if cnf.DebugLSP {
140			logging.Debug("Workspace scan complete",
141				"filesOpened", filesOpened,
142				"elapsedTime", elapsedTime.Seconds(),
143				"workspacePath", w.workspacePath,
144			)
145		}
146
147		if err != nil && cnf.DebugLSP {
148			logging.Debug("Error scanning workspace for files to open", "error", err)
149		}
150	}()
151}
152
153// WatchWorkspace sets up file watching for a workspace
154func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
155	cnf := config.Get()
156	w.workspacePath = workspacePath
157
158	// Register handler for file watcher registrations from the server
159	lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
160		w.AddRegistrations(ctx, id, watchers)
161	})
162
163	watcher, err := fsnotify.NewWatcher()
164	if err != nil {
165		logging.Error("Error creating watcher", "error", err)
166	}
167	defer watcher.Close()
168
169	// Watch the workspace recursively
170	err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
171		if err != nil {
172			return err
173		}
174
175		// Skip excluded directories (except workspace root)
176		if d.IsDir() && path != workspacePath {
177			if shouldExcludeDir(path) {
178				if cnf.DebugLSP {
179					logging.Debug("Skipping excluded directory", "path", path)
180				}
181				return filepath.SkipDir
182			}
183		}
184
185		// Add directories to watcher
186		if d.IsDir() {
187			err = watcher.Add(path)
188			if err != nil {
189				logging.Error("Error watching path", "path", path, "error", err)
190			}
191		}
192
193		return nil
194	})
195	if err != nil {
196		logging.Error("Error walking workspace", "error", err)
197	}
198
199	// Event loop
200	for {
201		select {
202		case <-ctx.Done():
203			return
204		case event, ok := <-watcher.Events:
205			if !ok {
206				return
207			}
208
209			uri := fmt.Sprintf("file://%s", event.Name)
210
211			// Add new directories to the watcher
212			if event.Op&fsnotify.Create != 0 {
213				if info, err := os.Stat(event.Name); err == nil {
214					if info.IsDir() {
215						// Skip excluded directories
216						if !shouldExcludeDir(event.Name) {
217							if err := watcher.Add(event.Name); err != nil {
218								logging.Error("Error adding directory to watcher", "path", event.Name, "error", err)
219							}
220						}
221					} else {
222						// For newly created files
223						if !shouldExcludeFile(event.Name) {
224							w.openMatchingFile(ctx, event.Name)
225						}
226					}
227				}
228			}
229
230			// Debug logging
231			if cnf.DebugLSP {
232				matched, kind := w.isPathWatched(event.Name)
233				logging.Debug("File event",
234					"path", event.Name,
235					"operation", event.Op.String(),
236					"watched", matched,
237					"kind", kind,
238				)
239
240			}
241
242			// Check if this path should be watched according to server registrations
243			if watched, watchKind := w.isPathWatched(event.Name); watched {
244				switch {
245				case event.Op&fsnotify.Write != 0:
246					if watchKind&protocol.WatchChange != 0 {
247						w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
248					}
249				case event.Op&fsnotify.Create != 0:
250					// Already handled earlier in the event loop
251					// Just send the notification if needed
252					info, _ := os.Stat(event.Name)
253					if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
254						w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
255					}
256				case event.Op&fsnotify.Remove != 0:
257					if watchKind&protocol.WatchDelete != 0 {
258						w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
259					}
260				case event.Op&fsnotify.Rename != 0:
261					// For renames, first delete
262					if watchKind&protocol.WatchDelete != 0 {
263						w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
264					}
265
266					// Then check if the new file exists and create an event
267					if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
268						if watchKind&protocol.WatchCreate != 0 {
269							w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
270						}
271					}
272				}
273			}
274		case err, ok := <-watcher.Errors:
275			if !ok {
276				return
277			}
278			logging.Error("Error watching file", "error", err)
279		}
280	}
281}
282
283// isPathWatched checks if a path should be watched based on server registrations
284func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
285	w.registrationMu.RLock()
286	defer w.registrationMu.RUnlock()
287
288	// If no explicit registrations, watch everything
289	if len(w.registrations) == 0 {
290		return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
291	}
292
293	// Check each registration
294	for _, reg := range w.registrations {
295		isMatch := w.matchesPattern(path, reg.GlobPattern)
296		if isMatch {
297			kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
298			if reg.Kind != nil {
299				kind = *reg.Kind
300			}
301			return true, kind
302		}
303	}
304
305	return false, 0
306}
307
308// matchesGlob handles advanced glob patterns including ** and alternatives
309func matchesGlob(pattern, path string) bool {
310	// Handle file extension patterns with braces like *.{go,mod,sum}
311	if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
312		// Extract extensions from pattern like "*.{go,mod,sum}"
313		parts := strings.SplitN(pattern, "{", 2)
314		if len(parts) == 2 {
315			prefix := parts[0]
316			extPart := strings.SplitN(parts[1], "}", 2)
317			if len(extPart) == 2 {
318				extensions := strings.Split(extPart[0], ",")
319				suffix := extPart[1]
320
321				// Check if the path matches any of the extensions
322				for _, ext := range extensions {
323					extPattern := prefix + ext + suffix
324					isMatch := matchesSimpleGlob(extPattern, path)
325					if isMatch {
326						return true
327					}
328				}
329				return false
330			}
331		}
332	}
333
334	return matchesSimpleGlob(pattern, path)
335}
336
337// matchesSimpleGlob handles glob patterns with ** wildcards
338func matchesSimpleGlob(pattern, path string) bool {
339	// Handle special case for **/*.ext pattern (common in LSP)
340	if strings.HasPrefix(pattern, "**/") {
341		rest := strings.TrimPrefix(pattern, "**/")
342
343		// If the rest is a simple file extension pattern like *.go
344		if strings.HasPrefix(rest, "*.") {
345			ext := strings.TrimPrefix(rest, "*")
346			isMatch := strings.HasSuffix(path, ext)
347			return isMatch
348		}
349
350		// Otherwise, try to check if the path ends with the rest part
351		isMatch := strings.HasSuffix(path, rest)
352
353		// If it matches directly, great!
354		if isMatch {
355			return true
356		}
357
358		// Otherwise, check if any path component matches
359		pathComponents := strings.Split(path, "/")
360		for i := range pathComponents {
361			subPath := strings.Join(pathComponents[i:], "/")
362			if strings.HasSuffix(subPath, rest) {
363				return true
364			}
365		}
366
367		return false
368	}
369
370	// Handle other ** wildcard pattern cases
371	if strings.Contains(pattern, "**") {
372		parts := strings.Split(pattern, "**")
373
374		// Validate the path starts with the first part
375		if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
376			return false
377		}
378
379		// For patterns like "**/*.go", just check the suffix
380		if len(parts) == 2 && parts[0] == "" {
381			isMatch := strings.HasSuffix(path, parts[1])
382			return isMatch
383		}
384
385		// For other patterns, handle middle part
386		remaining := strings.TrimPrefix(path, parts[0])
387		if len(parts) == 2 {
388			isMatch := strings.HasSuffix(remaining, parts[1])
389			return isMatch
390		}
391	}
392
393	// Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
394	if strings.HasPrefix(pattern, "*.") {
395		ext := strings.TrimPrefix(pattern, "*")
396		isMatch := strings.HasSuffix(path, ext)
397		return isMatch
398	}
399
400	// Fall back to simple matching for simpler patterns
401	matched, err := filepath.Match(pattern, path)
402	if err != nil {
403		logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
404		return false
405	}
406
407	return matched
408}
409
410// matchesPattern checks if a path matches the glob pattern
411func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
412	patternInfo, err := pattern.AsPattern()
413	if err != nil {
414		logging.Error("Error parsing pattern", "pattern", pattern, "error", err)
415		return false
416	}
417
418	basePath := patternInfo.GetBasePath()
419	patternText := patternInfo.GetPattern()
420
421	path = filepath.ToSlash(path)
422
423	// For simple patterns without base path
424	if basePath == "" {
425		// Check if the pattern matches the full path or just the file extension
426		fullPathMatch := matchesGlob(patternText, path)
427		baseNameMatch := matchesGlob(patternText, filepath.Base(path))
428
429		return fullPathMatch || baseNameMatch
430	}
431
432	// For relative patterns
433	basePath = strings.TrimPrefix(basePath, "file://")
434	basePath = filepath.ToSlash(basePath)
435
436	// Make path relative to basePath for matching
437	relPath, err := filepath.Rel(basePath, path)
438	if err != nil {
439		logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
440		return false
441	}
442	relPath = filepath.ToSlash(relPath)
443
444	isMatch := matchesGlob(patternText, relPath)
445
446	return isMatch
447}
448
449// debounceHandleFileEvent handles file events with debouncing to reduce notifications
450func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
451	w.debounceMu.Lock()
452	defer w.debounceMu.Unlock()
453
454	// Create a unique key based on URI and change type
455	key := fmt.Sprintf("%s:%d", uri, changeType)
456
457	// Cancel existing timer if any
458	if timer, exists := w.debounceMap[key]; exists {
459		timer.Stop()
460	}
461
462	// Create new timer
463	w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
464		w.handleFileEvent(ctx, uri, changeType)
465
466		// Cleanup timer after execution
467		w.debounceMu.Lock()
468		delete(w.debounceMap, key)
469		w.debounceMu.Unlock()
470	})
471}
472
473// handleFileEvent sends file change notifications
474func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
475	// If the file is open and it's a change event, use didChange notification
476	filePath := uri[7:] // Remove "file://" prefix
477	if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
478		err := w.client.NotifyChange(ctx, filePath)
479		if err != nil {
480			logging.Error("Error notifying change", "error", err)
481		}
482		return
483	}
484
485	// Notify LSP server about the file event using didChangeWatchedFiles
486	if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
487		logging.Error("Error notifying LSP server about file event", "error", err)
488	}
489}
490
491// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
492func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
493	cnf := config.Get()
494	if cnf.DebugLSP {
495		logging.Debug("Notifying file event",
496			"uri", uri,
497			"changeType", changeType,
498		)
499	}
500
501	params := protocol.DidChangeWatchedFilesParams{
502		Changes: []protocol.FileEvent{
503			{
504				URI:  protocol.DocumentUri(uri),
505				Type: changeType,
506			},
507		},
508	}
509
510	return w.client.DidChangeWatchedFiles(ctx, params)
511}
512
513// Common patterns for directories and files to exclude
514// TODO: make configurable
515var (
516	excludedDirNames = map[string]bool{
517		".git":         true,
518		"node_modules": true,
519		"dist":         true,
520		"build":        true,
521		"out":          true,
522		"bin":          true,
523		".idea":        true,
524		".vscode":      true,
525		".cache":       true,
526		"coverage":     true,
527		"target":       true, // Rust build output
528		"vendor":       true, // Go vendor directory
529	}
530
531	excludedFileExtensions = map[string]bool{
532		".swp":   true,
533		".swo":   true,
534		".tmp":   true,
535		".temp":  true,
536		".bak":   true,
537		".log":   true,
538		".o":     true, // Object files
539		".so":    true, // Shared libraries
540		".dylib": true, // macOS shared libraries
541		".dll":   true, // Windows shared libraries
542		".a":     true, // Static libraries
543		".exe":   true, // Windows executables
544		".lock":  true, // Lock files
545	}
546
547	// Large binary files that shouldn't be opened
548	largeBinaryExtensions = map[string]bool{
549		".png":  true,
550		".jpg":  true,
551		".jpeg": true,
552		".gif":  true,
553		".bmp":  true,
554		".ico":  true,
555		".zip":  true,
556		".tar":  true,
557		".gz":   true,
558		".rar":  true,
559		".7z":   true,
560		".pdf":  true,
561		".mp3":  true,
562		".mp4":  true,
563		".mov":  true,
564		".wav":  true,
565		".wasm": true,
566	}
567
568	// Maximum file size to open (5MB)
569	maxFileSize int64 = 5 * 1024 * 1024
570)
571
572// shouldExcludeDir returns true if the directory should be excluded from watching/opening
573func shouldExcludeDir(dirPath string) bool {
574	dirName := filepath.Base(dirPath)
575
576	// Skip dot directories
577	if strings.HasPrefix(dirName, ".") {
578		return true
579	}
580
581	// Skip common excluded directories
582	if excludedDirNames[dirName] {
583		return true
584	}
585
586	return false
587}
588
589// shouldExcludeFile returns true if the file should be excluded from opening
590func shouldExcludeFile(filePath string) bool {
591	fileName := filepath.Base(filePath)
592	cnf := config.Get()
593	// Skip dot files
594	if strings.HasPrefix(fileName, ".") {
595		return true
596	}
597
598	// Check file extension
599	ext := strings.ToLower(filepath.Ext(filePath))
600	if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
601		return true
602	}
603
604	// Skip temporary files
605	if strings.HasSuffix(filePath, "~") {
606		return true
607	}
608
609	// Check file size
610	info, err := os.Stat(filePath)
611	if err != nil {
612		// If we can't stat the file, skip it
613		return true
614	}
615
616	// Skip large files
617	if info.Size() > maxFileSize {
618		if cnf.DebugLSP {
619			logging.Debug("Skipping large file",
620				"path", filePath,
621				"size", info.Size(),
622				"maxSize", maxFileSize,
623				"debug", cnf.Debug,
624				"sizeMB", float64(info.Size())/(1024*1024),
625				"maxSizeMB", float64(maxFileSize)/(1024*1024),
626			)
627		}
628		return true
629	}
630
631	return false
632}
633
634// openMatchingFile opens a file if it matches any of the registered patterns
635func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
636	cnf := config.Get()
637	// Skip directories
638	info, err := os.Stat(path)
639	if err != nil || info.IsDir() {
640		return
641	}
642
643	// Skip excluded files
644	if shouldExcludeFile(path) {
645		return
646	}
647
648	// Check if this path should be watched according to server registrations
649	if watched, _ := w.isPathWatched(path); watched {
650		// Don't need to check if it's already open - the client.OpenFile handles that
651		if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
652			logging.Error("Error opening file", "path", path, "error", err)
653		}
654	}
655}