watcher.go

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