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}