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}