watcher.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987
  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.Deleted) {
  555. w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri))
  556. } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
  557. err := w.client.NotifyChange(ctx, filePath)
  558. if err != nil {
  559. logging.Error("Error notifying change", "error", err)
  560. }
  561. return
  562. }
  563. // Notify LSP server about the file event using didChangeWatchedFiles
  564. if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
  565. logging.Error("Error notifying LSP server about file event", "error", err)
  566. }
  567. }
  568. // notifyFileEvent sends a didChangeWatchedFiles notification for a file event
  569. func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
  570. cnf := config.Get()
  571. if cnf.DebugLSP {
  572. logging.Debug("Notifying file event",
  573. "uri", uri,
  574. "changeType", changeType,
  575. )
  576. }
  577. params := protocol.DidChangeWatchedFilesParams{
  578. Changes: []protocol.FileEvent{
  579. {
  580. URI: protocol.DocumentUri(uri),
  581. Type: changeType,
  582. },
  583. },
  584. }
  585. return w.client.DidChangeWatchedFiles(ctx, params)
  586. }
  587. // getServerNameFromContext extracts the server name from the context
  588. // This is a best-effort function that tries to identify which LSP server we're dealing with
  589. func getServerNameFromContext(ctx context.Context) string {
  590. // First check if the server name is directly stored in the context
  591. if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
  592. return strings.ToLower(serverName)
  593. }
  594. // Otherwise, try to extract server name from the client command path
  595. if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
  596. path := strings.ToLower(w.client.Cmd.Path)
  597. // Extract server name from path
  598. if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
  599. return "typescript"
  600. } else if strings.Contains(path, "gopls") {
  601. return "gopls"
  602. } else if strings.Contains(path, "rust-analyzer") {
  603. return "rust-analyzer"
  604. } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
  605. return "python"
  606. } else if strings.Contains(path, "clangd") {
  607. return "clangd"
  608. } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
  609. return "java"
  610. }
  611. // Return the base name as fallback
  612. return filepath.Base(path)
  613. }
  614. return "unknown"
  615. }
  616. // shouldPreloadFiles determines if we should preload files for a specific language server
  617. // Some servers work better with preloaded files, others don't need it
  618. func shouldPreloadFiles(serverName string) bool {
  619. // TypeScript/JavaScript servers typically need some files preloaded
  620. // to properly resolve imports and provide intellisense
  621. switch serverName {
  622. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  623. return true
  624. case "java", "jdtls":
  625. // Java servers often need to see source files to build the project model
  626. return true
  627. default:
  628. // For most servers, we'll use lazy loading by default
  629. return false
  630. }
  631. }
  632. // Common patterns for directories and files to exclude
  633. // TODO: make configurable
  634. var (
  635. excludedDirNames = map[string]bool{
  636. ".git": true,
  637. "node_modules": true,
  638. "dist": true,
  639. "build": true,
  640. "out": true,
  641. "bin": true,
  642. ".idea": true,
  643. ".vscode": true,
  644. ".cache": true,
  645. "coverage": true,
  646. "target": true, // Rust build output
  647. "vendor": true, // Go vendor directory
  648. }
  649. excludedFileExtensions = map[string]bool{
  650. ".swp": true,
  651. ".swo": true,
  652. ".tmp": true,
  653. ".temp": true,
  654. ".bak": true,
  655. ".log": true,
  656. ".o": true, // Object files
  657. ".so": true, // Shared libraries
  658. ".dylib": true, // macOS shared libraries
  659. ".dll": true, // Windows shared libraries
  660. ".a": true, // Static libraries
  661. ".exe": true, // Windows executables
  662. ".lock": true, // Lock files
  663. }
  664. // Large binary files that shouldn't be opened
  665. largeBinaryExtensions = map[string]bool{
  666. ".png": true,
  667. ".jpg": true,
  668. ".jpeg": true,
  669. ".gif": true,
  670. ".bmp": true,
  671. ".ico": true,
  672. ".zip": true,
  673. ".tar": true,
  674. ".gz": true,
  675. ".rar": true,
  676. ".7z": true,
  677. ".pdf": true,
  678. ".mp3": true,
  679. ".mp4": true,
  680. ".mov": true,
  681. ".wav": true,
  682. ".wasm": true,
  683. }
  684. // Maximum file size to open (5MB)
  685. maxFileSize int64 = 5 * 1024 * 1024
  686. )
  687. // shouldExcludeDir returns true if the directory should be excluded from watching/opening
  688. func shouldExcludeDir(dirPath string) bool {
  689. dirName := filepath.Base(dirPath)
  690. // Skip dot directories
  691. if strings.HasPrefix(dirName, ".") {
  692. return true
  693. }
  694. // Skip common excluded directories
  695. if excludedDirNames[dirName] {
  696. return true
  697. }
  698. return false
  699. }
  700. // shouldExcludeFile returns true if the file should be excluded from opening
  701. func shouldExcludeFile(filePath string) bool {
  702. fileName := filepath.Base(filePath)
  703. cnf := config.Get()
  704. // Skip dot files
  705. if strings.HasPrefix(fileName, ".") {
  706. return true
  707. }
  708. // Check file extension
  709. ext := strings.ToLower(filepath.Ext(filePath))
  710. if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
  711. return true
  712. }
  713. // Skip temporary files
  714. if strings.HasSuffix(filePath, "~") {
  715. return true
  716. }
  717. // Check file size
  718. info, err := os.Stat(filePath)
  719. if err != nil {
  720. // If we can't stat the file, skip it
  721. return true
  722. }
  723. // Skip large files
  724. if info.Size() > maxFileSize {
  725. if cnf.DebugLSP {
  726. logging.Debug("Skipping large file",
  727. "path", filePath,
  728. "size", info.Size(),
  729. "maxSize", maxFileSize,
  730. "debug", cnf.Debug,
  731. "sizeMB", float64(info.Size())/(1024*1024),
  732. "maxSizeMB", float64(maxFileSize)/(1024*1024),
  733. )
  734. }
  735. return true
  736. }
  737. return false
  738. }
  739. // openMatchingFile opens a file if it matches any of the registered patterns
  740. func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
  741. cnf := config.Get()
  742. // Skip directories
  743. info, err := os.Stat(path)
  744. if err != nil || info.IsDir() {
  745. return
  746. }
  747. // Skip excluded files
  748. if shouldExcludeFile(path) {
  749. return
  750. }
  751. // Check if this path should be watched according to server registrations
  752. if watched, _ := w.isPathWatched(path); watched {
  753. // Get server name for specialized handling
  754. serverName := getServerNameFromContext(ctx)
  755. // Check if the file is a high-priority file that should be opened immediately
  756. // This helps with project initialization for certain language servers
  757. if isHighPriorityFile(path, serverName) {
  758. if cnf.DebugLSP {
  759. logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
  760. }
  761. if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
  762. logging.Error("Error opening high-priority file", "path", path, "error", err)
  763. }
  764. return
  765. }
  766. // For non-high-priority files, we'll use different strategies based on server type
  767. if shouldPreloadFiles(serverName) {
  768. // For servers that benefit from preloading, open files but with limits
  769. // Check file size - for preloading we're more conservative
  770. if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
  771. if cnf.DebugLSP {
  772. logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
  773. }
  774. return
  775. }
  776. // Check file extension for common source files
  777. ext := strings.ToLower(filepath.Ext(path))
  778. // Only preload source files for the specific language
  779. shouldOpen := false
  780. switch serverName {
  781. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  782. shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
  783. case "gopls":
  784. shouldOpen = ext == ".go"
  785. case "rust-analyzer":
  786. shouldOpen = ext == ".rs"
  787. case "python", "pyright", "pylsp":
  788. shouldOpen = ext == ".py"
  789. case "clangd":
  790. shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
  791. case "java", "jdtls":
  792. shouldOpen = ext == ".java"
  793. default:
  794. // For unknown servers, be conservative
  795. shouldOpen = false
  796. }
  797. if shouldOpen {
  798. // Don't need to check if it's already open - the client.OpenFile handles that
  799. if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
  800. logging.Error("Error opening file", "path", path, "error", err)
  801. }
  802. }
  803. }
  804. }
  805. }
  806. // isHighPriorityFile determines if a file should be opened immediately
  807. // regardless of the preloading strategy
  808. func isHighPriorityFile(path string, serverName string) bool {
  809. fileName := filepath.Base(path)
  810. ext := filepath.Ext(path)
  811. switch serverName {
  812. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  813. // For TypeScript, we want to open configuration files immediately
  814. return fileName == "tsconfig.json" ||
  815. fileName == "package.json" ||
  816. fileName == "jsconfig.json" ||
  817. // Also open main entry points
  818. fileName == "index.ts" ||
  819. fileName == "index.js" ||
  820. fileName == "main.ts" ||
  821. fileName == "main.js"
  822. case "gopls":
  823. // For Go, we want to open go.mod files immediately
  824. return fileName == "go.mod" ||
  825. fileName == "go.sum" ||
  826. // Also open main.go files
  827. fileName == "main.go"
  828. case "rust-analyzer":
  829. // For Rust, we want to open Cargo.toml files immediately
  830. return fileName == "Cargo.toml" ||
  831. fileName == "Cargo.lock" ||
  832. // Also open lib.rs and main.rs
  833. fileName == "lib.rs" ||
  834. fileName == "main.rs"
  835. case "python", "pyright", "pylsp":
  836. // For Python, open key project files
  837. return fileName == "pyproject.toml" ||
  838. fileName == "setup.py" ||
  839. fileName == "requirements.txt" ||
  840. fileName == "__init__.py" ||
  841. fileName == "__main__.py"
  842. case "clangd":
  843. // For C/C++, open key project files
  844. return fileName == "CMakeLists.txt" ||
  845. fileName == "Makefile" ||
  846. fileName == "compile_commands.json"
  847. case "java", "jdtls":
  848. // For Java, open key project files
  849. return fileName == "pom.xml" ||
  850. fileName == "build.gradle" ||
  851. ext == ".java" // Java servers often need to see source files
  852. }
  853. // For unknown servers, prioritize common configuration files
  854. return fileName == "package.json" ||
  855. fileName == "Makefile" ||
  856. fileName == "CMakeLists.txt" ||
  857. fileName == ".editorconfig"
  858. }