watcher.go 17 KB

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