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