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