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}