1package watcher
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/bmatcuk/doublestar/v4"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/lsp"
16 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
17)
18
19// Client manages LSP file watching for a specific client
20// It now delegates actual file watching to the GlobalWatcher
21type Client struct {
22 client *lsp.Client
23 name string
24 workspacePath string
25
26 // File watchers registered by the server
27 registrations *csync.Slice[protocol.FileSystemWatcher]
28}
29
30// New creates a new workspace watcher for the given client.
31func New(name string, client *lsp.Client) *Client {
32 return &Client{
33 name: name,
34 client: client,
35 registrations: csync.NewSlice[protocol.FileSystemWatcher](),
36 }
37}
38
39// register adds file watchers to track
40func (w *Client) register(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
41 cfg := config.Get()
42
43 w.registrations.Append(watchers...)
44
45 if cfg.Options.DebugLSP {
46 slog.Debug("Adding file watcher registrations",
47 "id", id,
48 "watchers", len(watchers),
49 "total", w.registrations.Len(),
50 )
51
52 for i, watcher := range watchers {
53 slog.Debug("Registration", "index", i+1)
54
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
62 // Log BaseURI details
63 switch u := v.BaseURI.Value.(type) {
64 case string:
65 slog.Debug("BaseURI", "baseURI", u)
66 case protocol.DocumentURI:
67 slog.Debug("BaseURI", "baseURI", u)
68 default:
69 slog.Debug("BaseURI", "baseURI", u)
70 }
71 default:
72 slog.Debug("GlobPattern unknown type", "type", fmt.Sprintf("%T", v))
73 }
74
75 // Log WatchKind
76 watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
77 if watcher.Kind != nil {
78 watchKind = *watcher.Kind
79 }
80
81 slog.Debug("WatchKind", "kind", watchKind)
82 }
83 }
84
85 // For servers that need file preloading, open high-priority files only
86 if shouldPreloadFiles(w.name) {
87 go func() {
88 highPriorityFilesOpened := w.openHighPriorityFiles(ctx, w.name)
89 if cfg.Options.DebugLSP {
90 slog.Debug("Opened high-priority files",
91 "count", highPriorityFilesOpened,
92 "serverName", w.name)
93 }
94 }()
95 }
96}
97
98// openHighPriorityFiles opens important files for the server type
99// Returns the number of files opened
100func (w *Client) openHighPriorityFiles(ctx context.Context, serverName string) int {
101 cfg := config.Get()
102 filesOpened := 0
103
104 // Define patterns for high-priority files based on server type
105 var patterns []string
106
107 // TODO: move this to LSP config
108 switch serverName {
109 case "typescript", "typescript-language-server", "tsserver", "vtsls":
110 patterns = []string{
111 "**/tsconfig.json",
112 "**/package.json",
113 "**/jsconfig.json",
114 "**/index.ts",
115 "**/index.js",
116 "**/main.ts",
117 "**/main.js",
118 }
119 case "gopls":
120 patterns = []string{
121 "**/go.mod",
122 "**/go.sum",
123 "**/main.go",
124 }
125 case "rust-analyzer":
126 patterns = []string{
127 "**/Cargo.toml",
128 "**/Cargo.lock",
129 "**/src/lib.rs",
130 "**/src/main.rs",
131 }
132 case "python", "pyright", "pylsp":
133 patterns = []string{
134 "**/pyproject.toml",
135 "**/setup.py",
136 "**/requirements.txt",
137 "**/__init__.py",
138 "**/__main__.py",
139 }
140 case "clangd":
141 patterns = []string{
142 "**/CMakeLists.txt",
143 "**/Makefile",
144 "**/compile_commands.json",
145 }
146 case "java", "jdtls":
147 patterns = []string{
148 "**/pom.xml",
149 "**/build.gradle",
150 "**/src/main/java/**/*.java",
151 }
152 default:
153 // For unknown servers, use common configuration files
154 patterns = []string{
155 "**/package.json",
156 "**/Makefile",
157 "**/CMakeLists.txt",
158 "**/.editorconfig",
159 }
160 }
161
162 // Collect all files to open first
163 var filesToOpen []string
164
165 // For each pattern, find matching files
166 for _, pattern := range patterns {
167 // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
168 matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
169 if err != nil {
170 if cfg.Options.DebugLSP {
171 slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
172 }
173 continue
174 }
175
176 for _, match := range matches {
177 // Convert relative path to absolute
178 fullPath := filepath.Join(w.workspacePath, match)
179
180 // Skip directories and excluded files
181 info, err := os.Stat(fullPath)
182 if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
183 continue
184 }
185
186 filesToOpen = append(filesToOpen, fullPath)
187
188 // Limit the number of files per pattern
189 if len(filesToOpen) >= 5 && (serverName != "java" && serverName != "jdtls") {
190 break
191 }
192 }
193 }
194
195 // Open files in batches to reduce overhead
196 batchSize := 3
197 for i := 0; i < len(filesToOpen); i += batchSize {
198 end := min(i+batchSize, len(filesToOpen))
199
200 // Open batch of files
201 for j := i; j < end; j++ {
202 fullPath := filesToOpen[j]
203 if err := w.client.OpenFile(ctx, fullPath); err != nil {
204 if cfg.Options.DebugLSP {
205 slog.Debug("Error opening high-priority file", "path", fullPath, "error", err)
206 }
207 } else {
208 filesOpened++
209 if cfg.Options.DebugLSP {
210 slog.Debug("Opened high-priority file", "path", fullPath)
211 }
212 }
213 }
214
215 // Only add delay between batches, not individual files
216 if end < len(filesToOpen) {
217 time.Sleep(50 * time.Millisecond)
218 }
219 }
220
221 return filesOpened
222}
223
224// Watch sets up file watching for a workspace using the global watcher
225func (w *Client) Watch(ctx context.Context, workspacePath string) {
226 w.workspacePath = workspacePath
227
228 slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
229
230 // Register this workspace watcher with the global watcher
231 instance().register(w.name, w)
232 defer instance().unregister(w.name)
233
234 // Register handler for file watcher registrations from the server
235 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
236 w.register(ctx, id, watchers)
237 })
238
239 // Wait for context cancellation
240 <-ctx.Done()
241 slog.Debug("Workspace watcher stopped", "name", w.name)
242}
243
244// isPathWatched checks if a path should be watched based on server registrations
245// If no explicit registrations, watch everything
246func (w *Client) isPathWatched(path string) (bool, protocol.WatchKind) {
247 if w.registrations.Len() == 0 {
248 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
249 }
250
251 // Check each registration
252 for reg := range w.registrations.Seq() {
253 isMatch := w.matchesPattern(path, reg.GlobPattern)
254 if isMatch {
255 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
256 if reg.Kind != nil {
257 kind = *reg.Kind
258 }
259 return true, kind
260 }
261 }
262
263 return false, 0
264}
265
266// matchesGlob handles glob patterns using the doublestar library
267func matchesGlob(pattern, path string) bool {
268 // Use doublestar for all glob matching - it handles ** and other complex patterns
269 matched, err := doublestar.Match(pattern, path)
270 if err != nil {
271 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
272 return false
273 }
274 return matched
275}
276
277// matchesPattern checks if a path matches the glob pattern
278func (w *Client) matchesPattern(path string, pattern protocol.GlobPattern) bool {
279 patternInfo, err := pattern.AsPattern()
280 if err != nil {
281 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
282 return false
283 }
284
285 basePath := patternInfo.GetBasePath()
286 patternText := patternInfo.GetPattern()
287
288 path = filepath.ToSlash(path)
289
290 // For simple patterns without base path
291 if basePath == "" {
292 // Check if the pattern matches the full path or just the file extension
293 fullPathMatch := matchesGlob(patternText, path)
294 baseNameMatch := matchesGlob(patternText, filepath.Base(path))
295
296 return fullPathMatch || baseNameMatch
297 }
298
299 if basePath == "" {
300 return false
301 }
302
303 // Make path relative to basePath for matching
304 relPath, err := filepath.Rel(basePath, path)
305 if err != nil {
306 slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err, "server", w.name)
307 return false
308 }
309 relPath = filepath.ToSlash(relPath)
310
311 isMatch := matchesGlob(patternText, relPath)
312
313 return isMatch
314}
315
316// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
317func (w *Client) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
318 cfg := config.Get()
319 if cfg.Options.DebugLSP {
320 slog.Debug("Notifying file event",
321 "uri", uri,
322 "changeType", changeType,
323 )
324 }
325
326 params := protocol.DidChangeWatchedFilesParams{
327 Changes: []protocol.FileEvent{
328 {
329 URI: protocol.DocumentURI(uri),
330 Type: changeType,
331 },
332 },
333 }
334
335 return w.client.DidChangeWatchedFiles(ctx, params)
336}
337
338// shouldPreloadFiles determines if we should preload files for a specific language server
339// Some servers work better with preloaded files, others don't need it
340func shouldPreloadFiles(serverName string) bool {
341 // TypeScript/JavaScript servers typically need some files preloaded
342 // to properly resolve imports and provide intellisense
343 switch serverName {
344 case "typescript", "typescript-language-server", "tsserver", "vtsls":
345 return true
346 case "java", "jdtls":
347 // Java servers often need to see source files to build the project model
348 return true
349 default:
350 // For most servers, we'll use lazy loading by default
351 return false
352 }
353}
354
355// Common patterns for directories and files to exclude
356// TODO: make configurable
357var (
358 excludedFileExtensions = map[string]bool{
359 ".swp": true,
360 ".swo": true,
361 ".tmp": true,
362 ".temp": true,
363 ".bak": true,
364 ".log": true,
365 ".o": true, // Object files
366 ".so": true, // Shared libraries
367 ".dylib": true, // macOS shared libraries
368 ".dll": true, // Windows shared libraries
369 ".a": true, // Static libraries
370 ".exe": true, // Windows executables
371 ".lock": true, // Lock files
372 }
373
374 // Large binary files that shouldn't be opened
375 largeBinaryExtensions = map[string]bool{
376 ".png": true,
377 ".jpg": true,
378 ".jpeg": true,
379 ".gif": true,
380 ".bmp": true,
381 ".ico": true,
382 ".zip": true,
383 ".tar": true,
384 ".gz": true,
385 ".rar": true,
386 ".7z": true,
387 ".pdf": true,
388 ".mp3": true,
389 ".mp4": true,
390 ".mov": true,
391 ".wav": true,
392 ".wasm": true,
393 }
394
395 // Maximum file size to open (5MB)
396 maxFileSize int64 = 5 * 1024 * 1024
397)
398
399// shouldExcludeFile returns true if the file should be excluded from opening
400func shouldExcludeFile(filePath string) bool {
401 fileName := filepath.Base(filePath)
402 cfg := config.Get()
403
404 // Skip dot files
405 if strings.HasPrefix(fileName, ".") {
406 return true
407 }
408
409 // Check file extension
410 ext := strings.ToLower(filepath.Ext(filePath))
411 if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
412 return true
413 }
414
415 info, err := os.Stat(filePath)
416 if err != nil {
417 // If we can't stat the file, skip it
418 return true
419 }
420
421 // Skip large files
422 if info.Size() > maxFileSize {
423 if cfg.Options.DebugLSP {
424 slog.Debug("Skipping large file",
425 "path", filePath,
426 "size", info.Size(),
427 "maxSize", maxFileSize,
428 "debug", cfg.Options.Debug,
429 "sizeMB", float64(info.Size())/(1024*1024),
430 "maxSizeMB", float64(maxFileSize)/(1024*1024),
431 )
432 }
433 return true
434 }
435
436 return false
437}
438
439// openMatchingFile opens a file if it matches any of the registered patterns
440func (w *Client) openMatchingFile(ctx context.Context, path string) {
441 cfg := config.Get()
442 // Skip directories
443 info, err := os.Stat(path)
444 if err != nil || info.IsDir() {
445 return
446 }
447
448 // Skip excluded files
449 if shouldExcludeFile(path) {
450 return
451 }
452
453 // Check if this path should be watched according to server registrations
454 if watched, _ := w.isPathWatched(path); !watched {
455 return
456 }
457
458 serverName := w.name
459
460 // Get server name for specialized handling
461 // Check if the file is a high-priority file that should be opened immediately
462 // This helps with project initialization for certain language servers
463 if isHighPriorityFile(path, serverName) {
464 if cfg.Options.DebugLSP {
465 slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
466 }
467 if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
468 slog.Error("Error opening high-priority file", "path", path, "error", err)
469 }
470 return
471 }
472
473 // For non-high-priority files, we'll use different strategies based on server type
474 if !shouldPreloadFiles(serverName) {
475 return
476 }
477 // For servers that benefit from preloading, open files but with limits
478
479 // Check file size - for preloading we're more conservative
480 if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
481 if cfg.Options.DebugLSP {
482 slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
483 }
484 return
485 }
486
487 // File type is already validated by HandlesFile() and isPathWatched() checks earlier,
488 // so we know this client handles this file type. Just open it.
489 if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
490 slog.Error("Error opening file", "path", path, "error", err)
491 }
492}
493
494// isHighPriorityFile determines if a file should be opened immediately
495// regardless of the preloading strategy
496func isHighPriorityFile(path string, serverName string) bool {
497 fileName := filepath.Base(path)
498 ext := filepath.Ext(path)
499
500 switch serverName {
501 case "typescript", "typescript-language-server", "tsserver", "vtsls":
502 // For TypeScript, we want to open configuration files immediately
503 return fileName == "tsconfig.json" ||
504 fileName == "package.json" ||
505 fileName == "jsconfig.json" ||
506 // Also open main entry points
507 fileName == "index.ts" ||
508 fileName == "index.js" ||
509 fileName == "main.ts" ||
510 fileName == "main.js"
511 case "gopls":
512 // For Go, we want to open go.mod files immediately
513 return fileName == "go.mod" ||
514 fileName == "go.sum" ||
515 // Also open main.go files
516 fileName == "main.go"
517 case "rust-analyzer":
518 // For Rust, we want to open Cargo.toml files immediately
519 return fileName == "Cargo.toml" ||
520 fileName == "Cargo.lock" ||
521 // Also open lib.rs and main.rs
522 fileName == "lib.rs" ||
523 fileName == "main.rs"
524 case "python", "pyright", "pylsp":
525 // For Python, open key project files
526 return fileName == "pyproject.toml" ||
527 fileName == "setup.py" ||
528 fileName == "requirements.txt" ||
529 fileName == "__init__.py" ||
530 fileName == "__main__.py"
531 case "clangd":
532 // For C/C++, open key project files
533 return fileName == "CMakeLists.txt" ||
534 fileName == "Makefile" ||
535 fileName == "compile_commands.json"
536 case "java", "jdtls":
537 // For Java, open key project files
538 return fileName == "pom.xml" ||
539 fileName == "build.gradle" ||
540 ext == ".java" // Java servers often need to see source files
541 }
542
543 // For unknown servers, prioritize common configuration files
544 return fileName == "package.json" ||
545 fileName == "Makefile" ||
546 fileName == "CMakeLists.txt" ||
547 fileName == ".editorconfig"
548}