watcher.go 28 KB

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