watcher.go 30 KB

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