watcher.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  1. package watcher
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "sync"
  10. "time"
  11. "github.com/bmatcuk/doublestar/v4"
  12. "github.com/charmbracelet/crush/internal/config"
  13. "github.com/charmbracelet/crush/internal/csync"
  14. "github.com/charmbracelet/crush/internal/lsp"
  15. "github.com/charmbracelet/crush/internal/lsp/protocol"
  16. "github.com/fsnotify/fsnotify"
  17. )
  18. // WorkspaceWatcher manages LSP file watching
  19. type WorkspaceWatcher struct {
  20. client *lsp.Client
  21. name string
  22. workspacePath string
  23. debounceTime time.Duration
  24. debounceMap *csync.Map[string, *time.Timer]
  25. // File watchers registered by the server
  26. registrations []protocol.FileSystemWatcher
  27. registrationMu sync.RWMutex
  28. }
  29. func init() {
  30. // Ensure the watcher is initialized with a reasonable file limit
  31. if _, err := Ulimit(); err != nil {
  32. slog.Error("Error setting file limit", "error", err)
  33. }
  34. }
  35. // NewWorkspaceWatcher creates a new workspace watcher
  36. func NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
  37. return &WorkspaceWatcher{
  38. name: name,
  39. client: client,
  40. debounceTime: 300 * time.Millisecond,
  41. debounceMap: csync.NewMap[string, *time.Timer](),
  42. registrations: []protocol.FileSystemWatcher{},
  43. }
  44. }
  45. // AddRegistrations adds file watchers to track
  46. func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
  47. cfg := config.Get()
  48. slog.Debug("Adding file watcher registrations")
  49. w.registrationMu.Lock()
  50. defer w.registrationMu.Unlock()
  51. // Add new watchers
  52. w.registrations = append(w.registrations, watchers...)
  53. // Print detailed registration information for debugging
  54. if cfg.Options.DebugLSP {
  55. slog.Debug("Adding file watcher registrations",
  56. "id", id,
  57. "watchers", len(watchers),
  58. "total", len(w.registrations),
  59. )
  60. for i, watcher := range watchers {
  61. slog.Debug("Registration", "index", i+1)
  62. // Log the GlobPattern
  63. switch v := watcher.GlobPattern.Value.(type) {
  64. case string:
  65. slog.Debug("GlobPattern", "pattern", v)
  66. case protocol.RelativePattern:
  67. slog.Debug("GlobPattern", "pattern", v.Pattern)
  68. // Log BaseURI details
  69. switch u := v.BaseURI.Value.(type) {
  70. case string:
  71. slog.Debug("BaseURI", "baseURI", u)
  72. case protocol.DocumentURI:
  73. slog.Debug("BaseURI", "baseURI", u)
  74. default:
  75. slog.Debug("BaseURI", "baseURI", u)
  76. }
  77. default:
  78. slog.Debug("GlobPattern unknown type", "type", fmt.Sprintf("%T", v))
  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. slog.Debug("WatchKind", "kind", watchKind)
  86. }
  87. }
  88. // Determine server type for specialized handling
  89. serverName := w.name
  90. slog.Debug("Server type detected", "serverName", serverName)
  91. // Check if this server has sent file watchers
  92. hasFileWatchers := len(watchers) > 0
  93. // For servers that need file preloading, we'll use a smart approach
  94. if shouldPreloadFiles(serverName) || !hasFileWatchers {
  95. go func() {
  96. startTime := time.Now()
  97. filesOpened := 0
  98. // Determine max files to open based on server type
  99. maxFilesToOpen := 50 // Default conservative limit
  100. switch serverName {
  101. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  102. // TypeScript servers benefit from seeing more files
  103. maxFilesToOpen = 100
  104. case "java", "jdtls":
  105. // Java servers need to see many files for project model
  106. maxFilesToOpen = 200
  107. }
  108. // First, open high-priority files
  109. highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
  110. filesOpened += highPriorityFilesOpened
  111. if cfg.Options.DebugLSP {
  112. slog.Debug("Opened high-priority files",
  113. "count", highPriorityFilesOpened,
  114. "serverName", serverName)
  115. }
  116. // If we've already opened enough high-priority files, we might not need more
  117. if filesOpened >= maxFilesToOpen {
  118. if cfg.Options.DebugLSP {
  119. slog.Debug("Reached file limit with high-priority files",
  120. "filesOpened", filesOpened,
  121. "maxFiles", maxFilesToOpen)
  122. }
  123. return
  124. }
  125. // For the remaining slots, walk the directory and open matching files
  126. err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
  127. if err != nil {
  128. return err
  129. }
  130. // Skip directories that should be excluded
  131. if d.IsDir() {
  132. if path != w.workspacePath && shouldExcludeDir(path) {
  133. if cfg.Options.DebugLSP {
  134. slog.Debug("Skipping excluded directory", "path", path)
  135. }
  136. return filepath.SkipDir
  137. }
  138. } else {
  139. // Process files, but limit the total number
  140. if filesOpened < maxFilesToOpen {
  141. // Only process if it's not already open (high-priority files were opened earlier)
  142. if !w.client.IsFileOpen(path) {
  143. w.openMatchingFile(ctx, path)
  144. filesOpened++
  145. // Add a small delay after every 10 files to prevent overwhelming the server
  146. if filesOpened%10 == 0 {
  147. time.Sleep(50 * time.Millisecond)
  148. }
  149. }
  150. } else {
  151. // We've reached our limit, stop walking
  152. return filepath.SkipAll
  153. }
  154. }
  155. return nil
  156. })
  157. elapsedTime := time.Since(startTime)
  158. if cfg.Options.DebugLSP {
  159. slog.Debug("Limited workspace scan complete",
  160. "filesOpened", filesOpened,
  161. "maxFiles", maxFilesToOpen,
  162. "elapsedTime", elapsedTime.Seconds(),
  163. "workspacePath", w.workspacePath,
  164. )
  165. }
  166. if err != nil && cfg.Options.DebugLSP {
  167. slog.Debug("Error scanning workspace for files to open", "error", err)
  168. }
  169. }()
  170. } else if cfg.Options.DebugLSP {
  171. slog.Debug("Using on-demand file loading for server", "server", serverName)
  172. }
  173. }
  174. // openHighPriorityFiles opens important files for the server type
  175. // Returns the number of files opened
  176. func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
  177. cfg := config.Get()
  178. filesOpened := 0
  179. // Define patterns for high-priority files based on server type
  180. var patterns []string
  181. switch serverName {
  182. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  183. patterns = []string{
  184. "**/tsconfig.json",
  185. "**/package.json",
  186. "**/jsconfig.json",
  187. "**/index.ts",
  188. "**/index.js",
  189. "**/main.ts",
  190. "**/main.js",
  191. }
  192. case "gopls":
  193. patterns = []string{
  194. "**/go.mod",
  195. "**/go.sum",
  196. "**/main.go",
  197. }
  198. case "rust-analyzer":
  199. patterns = []string{
  200. "**/Cargo.toml",
  201. "**/Cargo.lock",
  202. "**/src/lib.rs",
  203. "**/src/main.rs",
  204. }
  205. case "python", "pyright", "pylsp":
  206. patterns = []string{
  207. "**/pyproject.toml",
  208. "**/setup.py",
  209. "**/requirements.txt",
  210. "**/__init__.py",
  211. "**/__main__.py",
  212. }
  213. case "clangd":
  214. patterns = []string{
  215. "**/CMakeLists.txt",
  216. "**/Makefile",
  217. "**/compile_commands.json",
  218. }
  219. case "java", "jdtls":
  220. patterns = []string{
  221. "**/pom.xml",
  222. "**/build.gradle",
  223. "**/src/main/java/**/*.java",
  224. }
  225. default:
  226. // For unknown servers, use common configuration files
  227. patterns = []string{
  228. "**/package.json",
  229. "**/Makefile",
  230. "**/CMakeLists.txt",
  231. "**/.editorconfig",
  232. }
  233. }
  234. // Collect all files to open first
  235. var filesToOpen []string
  236. // For each pattern, find matching files
  237. for _, pattern := range patterns {
  238. // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
  239. matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
  240. if err != nil {
  241. if cfg.Options.DebugLSP {
  242. slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
  243. }
  244. continue
  245. }
  246. for _, match := range matches {
  247. // Convert relative path to absolute
  248. fullPath := filepath.Join(w.workspacePath, match)
  249. // Skip directories and excluded files
  250. info, err := os.Stat(fullPath)
  251. if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
  252. continue
  253. }
  254. filesToOpen = append(filesToOpen, fullPath)
  255. // Limit the number of files per pattern
  256. if len(filesToOpen) >= 5 && (serverName != "java" && serverName != "jdtls") {
  257. break
  258. }
  259. }
  260. }
  261. // Open files in batches to reduce overhead
  262. batchSize := 3
  263. for i := 0; i < len(filesToOpen); i += batchSize {
  264. end := min(i+batchSize, len(filesToOpen))
  265. // Open batch of files
  266. for j := i; j < end; j++ {
  267. fullPath := filesToOpen[j]
  268. if err := w.client.OpenFile(ctx, fullPath); err != nil {
  269. if cfg.Options.DebugLSP {
  270. slog.Debug("Error opening high-priority file", "path", fullPath, "error", err)
  271. }
  272. } else {
  273. filesOpened++
  274. if cfg.Options.DebugLSP {
  275. slog.Debug("Opened high-priority file", "path", fullPath)
  276. }
  277. }
  278. }
  279. // Only add delay between batches, not individual files
  280. if end < len(filesToOpen) {
  281. time.Sleep(50 * time.Millisecond)
  282. }
  283. }
  284. return filesOpened
  285. }
  286. // WatchWorkspace sets up file watching for a workspace
  287. func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
  288. cfg := config.Get()
  289. w.workspacePath = workspacePath
  290. slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
  291. // Register handler for file watcher registrations from the server
  292. lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
  293. w.AddRegistrations(ctx, id, watchers)
  294. })
  295. watcher, err := fsnotify.NewWatcher()
  296. if err != nil {
  297. slog.Error("Error creating watcher", "error", err)
  298. }
  299. defer watcher.Close()
  300. // Watch the workspace recursively
  301. err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
  302. if err != nil {
  303. return err
  304. }
  305. // Skip excluded directories (except workspace root)
  306. if d.IsDir() && path != workspacePath {
  307. if shouldExcludeDir(path) {
  308. if cfg.Options.DebugLSP {
  309. slog.Debug("Skipping excluded directory", "path", path)
  310. }
  311. return filepath.SkipDir
  312. }
  313. }
  314. // Add directories to watcher
  315. if d.IsDir() {
  316. err = watcher.Add(path)
  317. if err != nil {
  318. slog.Error("Error watching path", "path", path, "error", err)
  319. }
  320. }
  321. return nil
  322. })
  323. if err != nil {
  324. slog.Error("Error walking workspace", "error", err)
  325. }
  326. // Event loop
  327. for {
  328. select {
  329. case <-ctx.Done():
  330. return
  331. case event, ok := <-watcher.Events:
  332. if !ok {
  333. return
  334. }
  335. uri := string(protocol.URIFromPath(event.Name))
  336. // Add new directories to the watcher
  337. if event.Op&fsnotify.Create != 0 {
  338. if info, err := os.Stat(event.Name); err == nil {
  339. if info.IsDir() {
  340. // Skip excluded directories
  341. if !shouldExcludeDir(event.Name) {
  342. if err := watcher.Add(event.Name); err != nil {
  343. slog.Error("Error adding directory to watcher", "path", event.Name, "error", err)
  344. }
  345. }
  346. } else {
  347. // For newly created files
  348. if !shouldExcludeFile(event.Name) {
  349. w.openMatchingFile(ctx, event.Name)
  350. }
  351. }
  352. }
  353. }
  354. // Debug logging
  355. if cfg.Options.DebugLSP {
  356. matched, kind := w.isPathWatched(event.Name)
  357. slog.Debug("File event",
  358. "path", event.Name,
  359. "operation", event.Op.String(),
  360. "watched", matched,
  361. "kind", kind,
  362. )
  363. }
  364. // Check if this path should be watched according to server registrations
  365. if watched, watchKind := w.isPathWatched(event.Name); watched {
  366. switch {
  367. case event.Op&fsnotify.Write != 0:
  368. if watchKind&protocol.WatchChange != 0 {
  369. w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
  370. }
  371. case event.Op&fsnotify.Create != 0:
  372. // Already handled earlier in the event loop
  373. // Just send the notification if needed
  374. info, err := os.Stat(event.Name)
  375. if err != nil {
  376. slog.Error("Error getting file info", "path", event.Name, "error", err)
  377. return
  378. }
  379. if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
  380. w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
  381. }
  382. case event.Op&fsnotify.Remove != 0:
  383. if watchKind&protocol.WatchDelete != 0 {
  384. w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
  385. }
  386. case event.Op&fsnotify.Rename != 0:
  387. // For renames, first delete
  388. if watchKind&protocol.WatchDelete != 0 {
  389. w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
  390. }
  391. // Then check if the new file exists and create an event
  392. if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
  393. if watchKind&protocol.WatchCreate != 0 {
  394. w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
  395. }
  396. }
  397. }
  398. }
  399. case err, ok := <-watcher.Errors:
  400. if !ok {
  401. return
  402. }
  403. slog.Error("Error watching file", "error", err)
  404. }
  405. }
  406. }
  407. // isPathWatched checks if a path should be watched based on server registrations
  408. func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
  409. w.registrationMu.RLock()
  410. defer w.registrationMu.RUnlock()
  411. // If no explicit registrations, watch everything
  412. if len(w.registrations) == 0 {
  413. return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
  414. }
  415. // Check each registration
  416. for _, reg := range w.registrations {
  417. isMatch := w.matchesPattern(path, reg.GlobPattern)
  418. if isMatch {
  419. kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
  420. if reg.Kind != nil {
  421. kind = *reg.Kind
  422. }
  423. return true, kind
  424. }
  425. }
  426. return false, 0
  427. }
  428. // matchesGlob handles advanced glob patterns including ** and alternatives
  429. func matchesGlob(pattern, path string) bool {
  430. // Handle file extension patterns with braces like *.{go,mod,sum}
  431. if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
  432. // Extract extensions from pattern like "*.{go,mod,sum}"
  433. parts := strings.SplitN(pattern, "{", 2)
  434. if len(parts) == 2 {
  435. prefix := parts[0]
  436. extPart := strings.SplitN(parts[1], "}", 2)
  437. if len(extPart) == 2 {
  438. extensions := strings.Split(extPart[0], ",")
  439. suffix := extPart[1]
  440. // Check if the path matches any of the extensions
  441. for _, ext := range extensions {
  442. extPattern := prefix + ext + suffix
  443. isMatch := matchesSimpleGlob(extPattern, path)
  444. if isMatch {
  445. return true
  446. }
  447. }
  448. return false
  449. }
  450. }
  451. }
  452. return matchesSimpleGlob(pattern, path)
  453. }
  454. // matchesSimpleGlob handles glob patterns with ** wildcards
  455. func matchesSimpleGlob(pattern, path string) bool {
  456. // Handle special case for **/*.ext pattern (common in LSP)
  457. if after, ok := strings.CutPrefix(pattern, "**/"); ok {
  458. rest := after
  459. // If the rest is a simple file extension pattern like *.go
  460. if strings.HasPrefix(rest, "*.") {
  461. ext := strings.TrimPrefix(rest, "*")
  462. isMatch := strings.HasSuffix(path, ext)
  463. return isMatch
  464. }
  465. // Otherwise, try to check if the path ends with the rest part
  466. isMatch := strings.HasSuffix(path, rest)
  467. // If it matches directly, great!
  468. if isMatch {
  469. return true
  470. }
  471. // Otherwise, check if any path component matches
  472. pathComponents := strings.Split(path, "/")
  473. for i := range pathComponents {
  474. subPath := strings.Join(pathComponents[i:], "/")
  475. if strings.HasSuffix(subPath, rest) {
  476. return true
  477. }
  478. }
  479. return false
  480. }
  481. // Handle other ** wildcard pattern cases
  482. if strings.Contains(pattern, "**") {
  483. parts := strings.Split(pattern, "**")
  484. // Validate the path starts with the first part
  485. if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
  486. return false
  487. }
  488. // For patterns like "**/*.go", just check the suffix
  489. if len(parts) == 2 && parts[0] == "" {
  490. isMatch := strings.HasSuffix(path, parts[1])
  491. return isMatch
  492. }
  493. // For other patterns, handle middle part
  494. remaining := strings.TrimPrefix(path, parts[0])
  495. if len(parts) == 2 {
  496. isMatch := strings.HasSuffix(remaining, parts[1])
  497. return isMatch
  498. }
  499. }
  500. // Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
  501. if strings.HasPrefix(pattern, "*.") {
  502. ext := strings.TrimPrefix(pattern, "*")
  503. isMatch := strings.HasSuffix(path, ext)
  504. return isMatch
  505. }
  506. // Fall back to simple matching for simpler patterns
  507. matched, err := filepath.Match(pattern, path)
  508. if err != nil {
  509. slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
  510. return false
  511. }
  512. return matched
  513. }
  514. // matchesPattern checks if a path matches the glob pattern
  515. func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
  516. patternInfo, err := pattern.AsPattern()
  517. if err != nil {
  518. slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
  519. return false
  520. }
  521. basePath := patternInfo.GetBasePath()
  522. patternText := patternInfo.GetPattern()
  523. path = filepath.ToSlash(path)
  524. // For simple patterns without base path
  525. if basePath == "" {
  526. // Check if the pattern matches the full path or just the file extension
  527. fullPathMatch := matchesGlob(patternText, path)
  528. baseNameMatch := matchesGlob(patternText, filepath.Base(path))
  529. return fullPathMatch || baseNameMatch
  530. }
  531. if basePath == "" {
  532. return false
  533. }
  534. // For relative patterns
  535. if basePath, err = protocol.DocumentURI(basePath).Path(); err != nil {
  536. // XXX: Do we want to return here, or send the error up the stack?
  537. slog.Error("Error converting base path to URI", "basePath", basePath, "error", err)
  538. }
  539. basePath = filepath.ToSlash(basePath)
  540. // Make path relative to basePath for matching
  541. relPath, err := filepath.Rel(basePath, path)
  542. if err != nil {
  543. slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
  544. return false
  545. }
  546. relPath = filepath.ToSlash(relPath)
  547. isMatch := matchesGlob(patternText, relPath)
  548. return isMatch
  549. }
  550. // debounceHandleFileEvent handles file events with debouncing to reduce notifications
  551. func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
  552. // Create a unique key based on URI and change type
  553. key := fmt.Sprintf("%s:%d", uri, changeType)
  554. // Cancel existing timer if any
  555. if timer, exists := w.debounceMap.Get(key); exists {
  556. timer.Stop()
  557. }
  558. // Create new timer
  559. w.debounceMap.Set(key, time.AfterFunc(w.debounceTime, func() {
  560. w.handleFileEvent(ctx, uri, changeType)
  561. // Cleanup timer after execution
  562. w.debounceMap.Del(key)
  563. }))
  564. }
  565. // handleFileEvent sends file change notifications
  566. func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
  567. // If the file is open and it's a change event, use didChange notification
  568. filePath, err := protocol.DocumentURI(uri).Path()
  569. if err != nil {
  570. // XXX: Do we want to return here, or send the error up the stack?
  571. slog.Error("Error converting URI to path", "uri", uri, "error", err)
  572. return
  573. }
  574. if changeType == protocol.FileChangeType(protocol.Deleted) {
  575. w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
  576. } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
  577. err := w.client.NotifyChange(ctx, filePath)
  578. if err != nil {
  579. slog.Error("Error notifying change", "error", err)
  580. }
  581. return
  582. }
  583. // Notify LSP server about the file event using didChangeWatchedFiles
  584. if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
  585. slog.Error("Error notifying LSP server about file event", "error", err)
  586. }
  587. }
  588. // notifyFileEvent sends a didChangeWatchedFiles notification for a file event
  589. func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
  590. cfg := config.Get()
  591. if cfg.Options.DebugLSP {
  592. slog.Debug("Notifying file event",
  593. "uri", uri,
  594. "changeType", changeType,
  595. )
  596. }
  597. params := protocol.DidChangeWatchedFilesParams{
  598. Changes: []protocol.FileEvent{
  599. {
  600. URI: protocol.DocumentURI(uri),
  601. Type: changeType,
  602. },
  603. },
  604. }
  605. return w.client.DidChangeWatchedFiles(ctx, params)
  606. }
  607. // shouldPreloadFiles determines if we should preload files for a specific language server
  608. // Some servers work better with preloaded files, others don't need it
  609. func shouldPreloadFiles(serverName string) bool {
  610. // TypeScript/JavaScript servers typically need some files preloaded
  611. // to properly resolve imports and provide intellisense
  612. switch serverName {
  613. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  614. return true
  615. case "java", "jdtls":
  616. // Java servers often need to see source files to build the project model
  617. return true
  618. default:
  619. // For most servers, we'll use lazy loading by default
  620. return false
  621. }
  622. }
  623. // Common patterns for directories and files to exclude
  624. // TODO: make configurable
  625. var (
  626. excludedDirNames = map[string]bool{
  627. ".git": true,
  628. "node_modules": true,
  629. "dist": true,
  630. "build": true,
  631. "out": true,
  632. "bin": true,
  633. ".idea": true,
  634. ".vscode": true,
  635. ".cache": true,
  636. "coverage": true,
  637. "target": true, // Rust build output
  638. "vendor": true, // Go vendor directory
  639. }
  640. excludedFileExtensions = map[string]bool{
  641. ".swp": true,
  642. ".swo": true,
  643. ".tmp": true,
  644. ".temp": true,
  645. ".bak": true,
  646. ".log": true,
  647. ".o": true, // Object files
  648. ".so": true, // Shared libraries
  649. ".dylib": true, // macOS shared libraries
  650. ".dll": true, // Windows shared libraries
  651. ".a": true, // Static libraries
  652. ".exe": true, // Windows executables
  653. ".lock": true, // Lock files
  654. }
  655. // Large binary files that shouldn't be opened
  656. largeBinaryExtensions = map[string]bool{
  657. ".png": true,
  658. ".jpg": true,
  659. ".jpeg": true,
  660. ".gif": true,
  661. ".bmp": true,
  662. ".ico": true,
  663. ".zip": true,
  664. ".tar": true,
  665. ".gz": true,
  666. ".rar": true,
  667. ".7z": true,
  668. ".pdf": true,
  669. ".mp3": true,
  670. ".mp4": true,
  671. ".mov": true,
  672. ".wav": true,
  673. ".wasm": true,
  674. }
  675. // Maximum file size to open (5MB)
  676. maxFileSize int64 = 5 * 1024 * 1024
  677. )
  678. // shouldExcludeDir returns true if the directory should be excluded from watching/opening
  679. func shouldExcludeDir(dirPath string) bool {
  680. dirName := filepath.Base(dirPath)
  681. // Skip dot directories
  682. if strings.HasPrefix(dirName, ".") {
  683. return true
  684. }
  685. // Skip common excluded directories
  686. if excludedDirNames[dirName] {
  687. return true
  688. }
  689. return false
  690. }
  691. // shouldExcludeFile returns true if the file should be excluded from opening
  692. func shouldExcludeFile(filePath string) bool {
  693. fileName := filepath.Base(filePath)
  694. cfg := config.Get()
  695. // Skip dot files
  696. if strings.HasPrefix(fileName, ".") {
  697. return true
  698. }
  699. // Check file extension
  700. ext := strings.ToLower(filepath.Ext(filePath))
  701. if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
  702. return true
  703. }
  704. // Skip temporary files
  705. if strings.HasSuffix(filePath, "~") {
  706. return true
  707. }
  708. // Check file size
  709. info, err := os.Stat(filePath)
  710. if err != nil {
  711. // If we can't stat the file, skip it
  712. return true
  713. }
  714. // Skip large files
  715. if info.Size() > maxFileSize {
  716. if cfg.Options.DebugLSP {
  717. slog.Debug("Skipping large file",
  718. "path", filePath,
  719. "size", info.Size(),
  720. "maxSize", maxFileSize,
  721. "debug", cfg.Options.Debug,
  722. "sizeMB", float64(info.Size())/(1024*1024),
  723. "maxSizeMB", float64(maxFileSize)/(1024*1024),
  724. )
  725. }
  726. return true
  727. }
  728. return false
  729. }
  730. // openMatchingFile opens a file if it matches any of the registered patterns
  731. func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
  732. cfg := config.Get()
  733. // Skip directories
  734. info, err := os.Stat(path)
  735. if err != nil || info.IsDir() {
  736. return
  737. }
  738. // Skip excluded files
  739. if shouldExcludeFile(path) {
  740. return
  741. }
  742. // Check if this path should be watched according to server registrations
  743. if watched, _ := w.isPathWatched(path); !watched {
  744. return
  745. }
  746. serverName := w.name
  747. // Get server name for specialized handling
  748. // Check if the file is a high-priority file that should be opened immediately
  749. // This helps with project initialization for certain language servers
  750. if isHighPriorityFile(path, serverName) {
  751. if cfg.Options.DebugLSP {
  752. slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
  753. }
  754. if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
  755. slog.Error("Error opening high-priority file", "path", path, "error", err)
  756. }
  757. return
  758. }
  759. // For non-high-priority files, we'll use different strategies based on server type
  760. if !shouldPreloadFiles(serverName) {
  761. return
  762. }
  763. // For servers that benefit from preloading, open files but with limits
  764. // Check file size - for preloading we're more conservative
  765. if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
  766. if cfg.Options.DebugLSP {
  767. slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
  768. }
  769. return
  770. }
  771. // Check file extension for common source files
  772. ext := strings.ToLower(filepath.Ext(path))
  773. // Only preload source files for the specific language
  774. var shouldOpen bool
  775. switch serverName {
  776. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  777. shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
  778. case "gopls":
  779. shouldOpen = ext == ".go"
  780. case "rust-analyzer":
  781. shouldOpen = ext == ".rs"
  782. case "python", "pyright", "pylsp":
  783. shouldOpen = ext == ".py"
  784. case "clangd":
  785. shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
  786. case "java", "jdtls":
  787. shouldOpen = ext == ".java"
  788. }
  789. if shouldOpen {
  790. // Don't need to check if it's already open - the client.OpenFile handles that
  791. if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
  792. slog.Error("Error opening file", "path", path, "error", err)
  793. }
  794. }
  795. }
  796. // isHighPriorityFile determines if a file should be opened immediately
  797. // regardless of the preloading strategy
  798. func isHighPriorityFile(path string, serverName string) bool {
  799. fileName := filepath.Base(path)
  800. ext := filepath.Ext(path)
  801. switch serverName {
  802. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  803. // For TypeScript, we want to open configuration files immediately
  804. return fileName == "tsconfig.json" ||
  805. fileName == "package.json" ||
  806. fileName == "jsconfig.json" ||
  807. // Also open main entry points
  808. fileName == "index.ts" ||
  809. fileName == "index.js" ||
  810. fileName == "main.ts" ||
  811. fileName == "main.js"
  812. case "gopls":
  813. // For Go, we want to open go.mod files immediately
  814. return fileName == "go.mod" ||
  815. fileName == "go.sum" ||
  816. // Also open main.go files
  817. fileName == "main.go"
  818. case "rust-analyzer":
  819. // For Rust, we want to open Cargo.toml files immediately
  820. return fileName == "Cargo.toml" ||
  821. fileName == "Cargo.lock" ||
  822. // Also open lib.rs and main.rs
  823. fileName == "lib.rs" ||
  824. fileName == "main.rs"
  825. case "python", "pyright", "pylsp":
  826. // For Python, open key project files
  827. return fileName == "pyproject.toml" ||
  828. fileName == "setup.py" ||
  829. fileName == "requirements.txt" ||
  830. fileName == "__init__.py" ||
  831. fileName == "__main__.py"
  832. case "clangd":
  833. // For C/C++, open key project files
  834. return fileName == "CMakeLists.txt" ||
  835. fileName == "Makefile" ||
  836. fileName == "compile_commands.json"
  837. case "java", "jdtls":
  838. // For Java, open key project files
  839. return fileName == "pom.xml" ||
  840. fileName == "build.gradle" ||
  841. ext == ".java" // Java servers often need to see source files
  842. }
  843. // For unknown servers, prioritize common configuration files
  844. return fileName == "package.json" ||
  845. fileName == "Makefile" ||
  846. fileName == "CMakeLists.txt" ||
  847. fileName == ".editorconfig"
  848. }