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