watcher.go 17 KB

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