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}