@@ -1,548 +0,0 @@
1−package watcher
2−
3−import (
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
21−type 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.
31−func 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
40−func (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
100−func (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
225−func (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
246−func (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
267−func 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
278−func (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
317−func (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
340−func 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
357−var (
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
400−func 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
440−func (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
496−func 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−}