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}