watcher.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981
  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/kujtimiihoxha/opencode/internal/config"
  13. "github.com/kujtimiihoxha/opencode/internal/logging"
  14. "github.com/kujtimiihoxha/opencode/internal/lsp"
  15. "github.com/kujtimiihoxha/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, _ := os.Stat(event.Name)
  362. if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
  363. w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
  364. }
  365. case event.Op&fsnotify.Remove != 0:
  366. if watchKind&protocol.WatchDelete != 0 {
  367. w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
  368. }
  369. case event.Op&fsnotify.Rename != 0:
  370. // For renames, first delete
  371. if watchKind&protocol.WatchDelete != 0 {
  372. w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
  373. }
  374. // Then check if the new file exists and create an event
  375. if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
  376. if watchKind&protocol.WatchCreate != 0 {
  377. w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
  378. }
  379. }
  380. }
  381. }
  382. case err, ok := <-watcher.Errors:
  383. if !ok {
  384. return
  385. }
  386. logging.Error("Error watching file", "error", err)
  387. }
  388. }
  389. }
  390. // isPathWatched checks if a path should be watched based on server registrations
  391. func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
  392. w.registrationMu.RLock()
  393. defer w.registrationMu.RUnlock()
  394. // If no explicit registrations, watch everything
  395. if len(w.registrations) == 0 {
  396. return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
  397. }
  398. // Check each registration
  399. for _, reg := range w.registrations {
  400. isMatch := w.matchesPattern(path, reg.GlobPattern)
  401. if isMatch {
  402. kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
  403. if reg.Kind != nil {
  404. kind = *reg.Kind
  405. }
  406. return true, kind
  407. }
  408. }
  409. return false, 0
  410. }
  411. // matchesGlob handles advanced glob patterns including ** and alternatives
  412. func matchesGlob(pattern, path string) bool {
  413. // Handle file extension patterns with braces like *.{go,mod,sum}
  414. if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
  415. // Extract extensions from pattern like "*.{go,mod,sum}"
  416. parts := strings.SplitN(pattern, "{", 2)
  417. if len(parts) == 2 {
  418. prefix := parts[0]
  419. extPart := strings.SplitN(parts[1], "}", 2)
  420. if len(extPart) == 2 {
  421. extensions := strings.Split(extPart[0], ",")
  422. suffix := extPart[1]
  423. // Check if the path matches any of the extensions
  424. for _, ext := range extensions {
  425. extPattern := prefix + ext + suffix
  426. isMatch := matchesSimpleGlob(extPattern, path)
  427. if isMatch {
  428. return true
  429. }
  430. }
  431. return false
  432. }
  433. }
  434. }
  435. return matchesSimpleGlob(pattern, path)
  436. }
  437. // matchesSimpleGlob handles glob patterns with ** wildcards
  438. func matchesSimpleGlob(pattern, path string) bool {
  439. // Handle special case for **/*.ext pattern (common in LSP)
  440. if strings.HasPrefix(pattern, "**/") {
  441. rest := strings.TrimPrefix(pattern, "**/")
  442. // If the rest is a simple file extension pattern like *.go
  443. if strings.HasPrefix(rest, "*.") {
  444. ext := strings.TrimPrefix(rest, "*")
  445. isMatch := strings.HasSuffix(path, ext)
  446. return isMatch
  447. }
  448. // Otherwise, try to check if the path ends with the rest part
  449. isMatch := strings.HasSuffix(path, rest)
  450. // If it matches directly, great!
  451. if isMatch {
  452. return true
  453. }
  454. // Otherwise, check if any path component matches
  455. pathComponents := strings.Split(path, "/")
  456. for i := range pathComponents {
  457. subPath := strings.Join(pathComponents[i:], "/")
  458. if strings.HasSuffix(subPath, rest) {
  459. return true
  460. }
  461. }
  462. return false
  463. }
  464. // Handle other ** wildcard pattern cases
  465. if strings.Contains(pattern, "**") {
  466. parts := strings.Split(pattern, "**")
  467. // Validate the path starts with the first part
  468. if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
  469. return false
  470. }
  471. // For patterns like "**/*.go", just check the suffix
  472. if len(parts) == 2 && parts[0] == "" {
  473. isMatch := strings.HasSuffix(path, parts[1])
  474. return isMatch
  475. }
  476. // For other patterns, handle middle part
  477. remaining := strings.TrimPrefix(path, parts[0])
  478. if len(parts) == 2 {
  479. isMatch := strings.HasSuffix(remaining, parts[1])
  480. return isMatch
  481. }
  482. }
  483. // Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
  484. if strings.HasPrefix(pattern, "*.") {
  485. ext := strings.TrimPrefix(pattern, "*")
  486. isMatch := strings.HasSuffix(path, ext)
  487. return isMatch
  488. }
  489. // Fall back to simple matching for simpler patterns
  490. matched, err := filepath.Match(pattern, path)
  491. if err != nil {
  492. logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
  493. return false
  494. }
  495. return matched
  496. }
  497. // matchesPattern checks if a path matches the glob pattern
  498. func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
  499. patternInfo, err := pattern.AsPattern()
  500. if err != nil {
  501. logging.Error("Error parsing pattern", "pattern", pattern, "error", err)
  502. return false
  503. }
  504. basePath := patternInfo.GetBasePath()
  505. patternText := patternInfo.GetPattern()
  506. path = filepath.ToSlash(path)
  507. // For simple patterns without base path
  508. if basePath == "" {
  509. // Check if the pattern matches the full path or just the file extension
  510. fullPathMatch := matchesGlob(patternText, path)
  511. baseNameMatch := matchesGlob(patternText, filepath.Base(path))
  512. return fullPathMatch || baseNameMatch
  513. }
  514. // For relative patterns
  515. basePath = strings.TrimPrefix(basePath, "file://")
  516. basePath = filepath.ToSlash(basePath)
  517. // Make path relative to basePath for matching
  518. relPath, err := filepath.Rel(basePath, path)
  519. if err != nil {
  520. logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
  521. return false
  522. }
  523. relPath = filepath.ToSlash(relPath)
  524. isMatch := matchesGlob(patternText, relPath)
  525. return isMatch
  526. }
  527. // debounceHandleFileEvent handles file events with debouncing to reduce notifications
  528. func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
  529. w.debounceMu.Lock()
  530. defer w.debounceMu.Unlock()
  531. // Create a unique key based on URI and change type
  532. key := fmt.Sprintf("%s:%d", uri, changeType)
  533. // Cancel existing timer if any
  534. if timer, exists := w.debounceMap[key]; exists {
  535. timer.Stop()
  536. }
  537. // Create new timer
  538. w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
  539. w.handleFileEvent(ctx, uri, changeType)
  540. // Cleanup timer after execution
  541. w.debounceMu.Lock()
  542. delete(w.debounceMap, key)
  543. w.debounceMu.Unlock()
  544. })
  545. }
  546. // handleFileEvent sends file change notifications
  547. func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
  548. // If the file is open and it's a change event, use didChange notification
  549. filePath := uri[7:] // Remove "file://" prefix
  550. if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
  551. err := w.client.NotifyChange(ctx, filePath)
  552. if err != nil {
  553. logging.Error("Error notifying change", "error", err)
  554. }
  555. return
  556. }
  557. // Notify LSP server about the file event using didChangeWatchedFiles
  558. if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
  559. logging.Error("Error notifying LSP server about file event", "error", err)
  560. }
  561. }
  562. // notifyFileEvent sends a didChangeWatchedFiles notification for a file event
  563. func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
  564. cnf := config.Get()
  565. if cnf.DebugLSP {
  566. logging.Debug("Notifying file event",
  567. "uri", uri,
  568. "changeType", changeType,
  569. )
  570. }
  571. params := protocol.DidChangeWatchedFilesParams{
  572. Changes: []protocol.FileEvent{
  573. {
  574. URI: protocol.DocumentUri(uri),
  575. Type: changeType,
  576. },
  577. },
  578. }
  579. return w.client.DidChangeWatchedFiles(ctx, params)
  580. }
  581. // getServerNameFromContext extracts the server name from the context
  582. // This is a best-effort function that tries to identify which LSP server we're dealing with
  583. func getServerNameFromContext(ctx context.Context) string {
  584. // First check if the server name is directly stored in the context
  585. if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
  586. return strings.ToLower(serverName)
  587. }
  588. // Otherwise, try to extract server name from the client command path
  589. if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
  590. path := strings.ToLower(w.client.Cmd.Path)
  591. // Extract server name from path
  592. if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
  593. return "typescript"
  594. } else if strings.Contains(path, "gopls") {
  595. return "gopls"
  596. } else if strings.Contains(path, "rust-analyzer") {
  597. return "rust-analyzer"
  598. } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
  599. return "python"
  600. } else if strings.Contains(path, "clangd") {
  601. return "clangd"
  602. } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
  603. return "java"
  604. }
  605. // Return the base name as fallback
  606. return filepath.Base(path)
  607. }
  608. return "unknown"
  609. }
  610. // shouldPreloadFiles determines if we should preload files for a specific language server
  611. // Some servers work better with preloaded files, others don't need it
  612. func shouldPreloadFiles(serverName string) bool {
  613. // TypeScript/JavaScript servers typically need some files preloaded
  614. // to properly resolve imports and provide intellisense
  615. switch serverName {
  616. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  617. return true
  618. case "java", "jdtls":
  619. // Java servers often need to see source files to build the project model
  620. return true
  621. default:
  622. // For most servers, we'll use lazy loading by default
  623. return false
  624. }
  625. }
  626. // Common patterns for directories and files to exclude
  627. // TODO: make configurable
  628. var (
  629. excludedDirNames = map[string]bool{
  630. ".git": true,
  631. "node_modules": true,
  632. "dist": true,
  633. "build": true,
  634. "out": true,
  635. "bin": true,
  636. ".idea": true,
  637. ".vscode": true,
  638. ".cache": true,
  639. "coverage": true,
  640. "target": true, // Rust build output
  641. "vendor": true, // Go vendor directory
  642. }
  643. excludedFileExtensions = map[string]bool{
  644. ".swp": true,
  645. ".swo": true,
  646. ".tmp": true,
  647. ".temp": true,
  648. ".bak": true,
  649. ".log": true,
  650. ".o": true, // Object files
  651. ".so": true, // Shared libraries
  652. ".dylib": true, // macOS shared libraries
  653. ".dll": true, // Windows shared libraries
  654. ".a": true, // Static libraries
  655. ".exe": true, // Windows executables
  656. ".lock": true, // Lock files
  657. }
  658. // Large binary files that shouldn't be opened
  659. largeBinaryExtensions = map[string]bool{
  660. ".png": true,
  661. ".jpg": true,
  662. ".jpeg": true,
  663. ".gif": true,
  664. ".bmp": true,
  665. ".ico": true,
  666. ".zip": true,
  667. ".tar": true,
  668. ".gz": true,
  669. ".rar": true,
  670. ".7z": true,
  671. ".pdf": true,
  672. ".mp3": true,
  673. ".mp4": true,
  674. ".mov": true,
  675. ".wav": true,
  676. ".wasm": true,
  677. }
  678. // Maximum file size to open (5MB)
  679. maxFileSize int64 = 5 * 1024 * 1024
  680. )
  681. // shouldExcludeDir returns true if the directory should be excluded from watching/opening
  682. func shouldExcludeDir(dirPath string) bool {
  683. dirName := filepath.Base(dirPath)
  684. // Skip dot directories
  685. if strings.HasPrefix(dirName, ".") {
  686. return true
  687. }
  688. // Skip common excluded directories
  689. if excludedDirNames[dirName] {
  690. return true
  691. }
  692. return false
  693. }
  694. // shouldExcludeFile returns true if the file should be excluded from opening
  695. func shouldExcludeFile(filePath string) bool {
  696. fileName := filepath.Base(filePath)
  697. cnf := config.Get()
  698. // Skip dot files
  699. if strings.HasPrefix(fileName, ".") {
  700. return true
  701. }
  702. // Check file extension
  703. ext := strings.ToLower(filepath.Ext(filePath))
  704. if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
  705. return true
  706. }
  707. // Skip temporary files
  708. if strings.HasSuffix(filePath, "~") {
  709. return true
  710. }
  711. // Check file size
  712. info, err := os.Stat(filePath)
  713. if err != nil {
  714. // If we can't stat the file, skip it
  715. return true
  716. }
  717. // Skip large files
  718. if info.Size() > maxFileSize {
  719. if cnf.DebugLSP {
  720. logging.Debug("Skipping large file",
  721. "path", filePath,
  722. "size", info.Size(),
  723. "maxSize", maxFileSize,
  724. "debug", cnf.Debug,
  725. "sizeMB", float64(info.Size())/(1024*1024),
  726. "maxSizeMB", float64(maxFileSize)/(1024*1024),
  727. )
  728. }
  729. return true
  730. }
  731. return false
  732. }
  733. // openMatchingFile opens a file if it matches any of the registered patterns
  734. func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
  735. cnf := config.Get()
  736. // Skip directories
  737. info, err := os.Stat(path)
  738. if err != nil || info.IsDir() {
  739. return
  740. }
  741. // Skip excluded files
  742. if shouldExcludeFile(path) {
  743. return
  744. }
  745. // Check if this path should be watched according to server registrations
  746. if watched, _ := w.isPathWatched(path); watched {
  747. // Get server name for specialized handling
  748. serverName := getServerNameFromContext(ctx)
  749. // Check if the file is a high-priority file that should be opened immediately
  750. // This helps with project initialization for certain language servers
  751. if isHighPriorityFile(path, serverName) {
  752. if cnf.DebugLSP {
  753. logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
  754. }
  755. if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
  756. logging.Error("Error opening high-priority file", "path", path, "error", err)
  757. }
  758. return
  759. }
  760. // For non-high-priority files, we'll use different strategies based on server type
  761. if shouldPreloadFiles(serverName) {
  762. // For servers that benefit from preloading, open files but with limits
  763. // Check file size - for preloading we're more conservative
  764. if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
  765. if cnf.DebugLSP {
  766. logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
  767. }
  768. return
  769. }
  770. // Check file extension for common source files
  771. ext := strings.ToLower(filepath.Ext(path))
  772. // Only preload source files for the specific language
  773. shouldOpen := false
  774. switch serverName {
  775. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  776. shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
  777. case "gopls":
  778. shouldOpen = ext == ".go"
  779. case "rust-analyzer":
  780. shouldOpen = ext == ".rs"
  781. case "python", "pyright", "pylsp":
  782. shouldOpen = ext == ".py"
  783. case "clangd":
  784. shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
  785. case "java", "jdtls":
  786. shouldOpen = ext == ".java"
  787. default:
  788. // For unknown servers, be conservative
  789. shouldOpen = false
  790. }
  791. if shouldOpen {
  792. // Don't need to check if it's already open - the client.OpenFile handles that
  793. if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
  794. logging.Error("Error opening file", "path", path, "error", err)
  795. }
  796. }
  797. }
  798. }
  799. }
  800. // isHighPriorityFile determines if a file should be opened immediately
  801. // regardless of the preloading strategy
  802. func isHighPriorityFile(path string, serverName string) bool {
  803. fileName := filepath.Base(path)
  804. ext := filepath.Ext(path)
  805. switch serverName {
  806. case "typescript", "typescript-language-server", "tsserver", "vtsls":
  807. // For TypeScript, we want to open configuration files immediately
  808. return fileName == "tsconfig.json" ||
  809. fileName == "package.json" ||
  810. fileName == "jsconfig.json" ||
  811. // Also open main entry points
  812. fileName == "index.ts" ||
  813. fileName == "index.js" ||
  814. fileName == "main.ts" ||
  815. fileName == "main.js"
  816. case "gopls":
  817. // For Go, we want to open go.mod files immediately
  818. return fileName == "go.mod" ||
  819. fileName == "go.sum" ||
  820. // Also open main.go files
  821. fileName == "main.go"
  822. case "rust-analyzer":
  823. // For Rust, we want to open Cargo.toml files immediately
  824. return fileName == "Cargo.toml" ||
  825. fileName == "Cargo.lock" ||
  826. // Also open lib.rs and main.rs
  827. fileName == "lib.rs" ||
  828. fileName == "main.rs"
  829. case "python", "pyright", "pylsp":
  830. // For Python, open key project files
  831. return fileName == "pyproject.toml" ||
  832. fileName == "setup.py" ||
  833. fileName == "requirements.txt" ||
  834. fileName == "__init__.py" ||
  835. fileName == "__main__.py"
  836. case "clangd":
  837. // For C/C++, open key project files
  838. return fileName == "CMakeLists.txt" ||
  839. fileName == "Makefile" ||
  840. fileName == "compile_commands.json"
  841. case "java", "jdtls":
  842. // For Java, open key project files
  843. return fileName == "pom.xml" ||
  844. fileName == "build.gradle" ||
  845. ext == ".java" // Java servers often need to see source files
  846. }
  847. // For unknown servers, prioritize common configuration files
  848. return fileName == "package.json" ||
  849. fileName == "Makefile" ||
  850. fileName == "CMakeLists.txt" ||
  851. fileName == ".editorconfig"
  852. }