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	"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
 21type 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.
 31func 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
 40func (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
100func (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
225func (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
246func (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
267func 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
278func (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
317func (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
340func 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
357var (
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
400func 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
440func (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
496func 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}