1package lsp
  2
  3import (
  4	"bufio"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"io"
  9	"log/slog"
 10	"maps"
 11	"os"
 12	"os/exec"
 13	"path/filepath"
 14	"slices"
 15	"strings"
 16	"sync"
 17	"sync/atomic"
 18	"time"
 19
 20	"github.com/charmbracelet/crush/internal/config"
 21	"github.com/charmbracelet/crush/internal/log"
 22	"github.com/charmbracelet/crush/internal/lsp/protocol"
 23)
 24
 25type Client struct {
 26	Cmd    *exec.Cmd
 27	stdin  io.WriteCloser
 28	stdout *bufio.Reader
 29	stderr io.ReadCloser
 30
 31	// Client name for identification
 32	name string
 33
 34	// File types this LSP server handles (e.g., .go, .rs, .py)
 35	fileTypes []string
 36
 37	// Diagnostic change callback
 38	onDiagnosticsChanged func(name string, count int)
 39
 40	// Request ID counter
 41	nextID atomic.Int32
 42
 43	// Response handlers
 44	handlers   map[int32]chan *Message
 45	handlersMu sync.RWMutex
 46
 47	// Server request handlers
 48	serverRequestHandlers map[string]ServerRequestHandler
 49	serverHandlersMu      sync.RWMutex
 50
 51	// Notification handlers
 52	notificationHandlers map[string]NotificationHandler
 53	notificationMu       sync.RWMutex
 54
 55	// Diagnostic cache
 56	diagnostics   map[protocol.DocumentURI][]protocol.Diagnostic
 57	diagnosticsMu sync.RWMutex
 58
 59	// Files are currently opened by the LSP
 60	openFiles   map[string]*OpenFileInfo
 61	openFilesMu sync.RWMutex
 62
 63	// Server state
 64	serverState atomic.Value
 65
 66	// Shutdown tracking
 67	shutdownOnce       sync.Once
 68	shutdownChan       chan struct{}
 69	stderrDone         chan struct{}
 70	messageHandlerDone chan struct{}
 71}
 72
 73// NewClient creates a new LSP client.
 74func NewClient(ctx context.Context, name string, config config.LSPConfig) (*Client, error) {
 75	cmd := exec.CommandContext(ctx, config.Command, config.Args...)
 76
 77	// Copy env
 78	cmd.Env = slices.Concat(os.Environ(), config.ResolvedEnv())
 79
 80	stdin, err := cmd.StdinPipe()
 81	if err != nil {
 82		return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
 83	}
 84
 85	stdout, err := cmd.StdoutPipe()
 86	if err != nil {
 87		return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
 88	}
 89
 90	stderr, err := cmd.StderrPipe()
 91	if err != nil {
 92		return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
 93	}
 94
 95	client := &Client{
 96		Cmd:                   cmd,
 97		name:                  name,
 98		fileTypes:             config.FileTypes,
 99		stdin:                 stdin,
100		stdout:                bufio.NewReader(stdout),
101		stderr:                stderr,
102		handlers:              make(map[int32]chan *Message),
103		notificationHandlers:  make(map[string]NotificationHandler),
104		serverRequestHandlers: make(map[string]ServerRequestHandler),
105		diagnostics:           make(map[protocol.DocumentURI][]protocol.Diagnostic),
106		openFiles:             make(map[string]*OpenFileInfo),
107		shutdownChan:          make(chan struct{}),
108		stderrDone:            make(chan struct{}),
109		messageHandlerDone:    make(chan struct{}),
110	}
111
112	// Initialize server state
113	client.serverState.Store(StateStarting)
114
115	// Start the LSP server process
116	if err := cmd.Start(); err != nil {
117		return nil, fmt.Errorf("failed to start LSP server: %w", err)
118	}
119
120	// Handle stderr in a separate goroutine
121	go func() {
122		defer close(client.stderrDone)
123		scanner := bufio.NewScanner(stderr)
124		for scanner.Scan() {
125			select {
126			case <-client.shutdownChan:
127				return
128			default:
129				slog.Error("LSP Server", "err", scanner.Text())
130			}
131		}
132		if err := scanner.Err(); err != nil {
133			slog.Error("Error reading", "err", err)
134		}
135	}()
136
137	// Start message handling loop
138	go func() {
139		defer close(client.messageHandlerDone)
140		defer log.RecoverPanic("LSP-message-handler", func() {
141			slog.Error("LSP message handler crashed, LSP functionality may be impaired")
142		})
143		client.handleMessages()
144	}()
145
146	return client, nil
147}
148
149func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) {
150	c.notificationMu.Lock()
151	defer c.notificationMu.Unlock()
152	c.notificationHandlers[method] = handler
153}
154
155func (c *Client) RegisterServerRequestHandler(method string, handler ServerRequestHandler) {
156	c.serverHandlersMu.Lock()
157	defer c.serverHandlersMu.Unlock()
158	c.serverRequestHandlers[method] = handler
159}
160
161func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
162	initParams := &protocol.InitializeParams{
163		WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{
164			WorkspaceFolders: []protocol.WorkspaceFolder{
165				{
166					URI:  protocol.URI(protocol.URIFromPath(workspaceDir)),
167					Name: workspaceDir,
168				},
169			},
170		},
171
172		XInitializeParams: protocol.XInitializeParams{
173			ProcessID: int32(os.Getpid()),
174			ClientInfo: &protocol.ClientInfo{
175				Name:    "mcp-language-server",
176				Version: "0.1.0",
177			},
178			RootPath: workspaceDir,
179			RootURI:  protocol.URIFromPath(workspaceDir),
180			Capabilities: protocol.ClientCapabilities{
181				Workspace: protocol.WorkspaceClientCapabilities{
182					Configuration: true,
183					DidChangeConfiguration: protocol.DidChangeConfigurationClientCapabilities{
184						DynamicRegistration: true,
185					},
186					DidChangeWatchedFiles: protocol.DidChangeWatchedFilesClientCapabilities{
187						DynamicRegistration:    true,
188						RelativePatternSupport: true,
189					},
190				},
191				TextDocument: protocol.TextDocumentClientCapabilities{
192					Synchronization: &protocol.TextDocumentSyncClientCapabilities{
193						DynamicRegistration: true,
194						DidSave:             true,
195					},
196					Completion: protocol.CompletionClientCapabilities{
197						CompletionItem: protocol.ClientCompletionItemOptions{},
198					},
199					CodeLens: &protocol.CodeLensClientCapabilities{
200						DynamicRegistration: true,
201					},
202					DocumentSymbol: protocol.DocumentSymbolClientCapabilities{},
203					CodeAction: protocol.CodeActionClientCapabilities{
204						CodeActionLiteralSupport: protocol.ClientCodeActionLiteralOptions{
205							CodeActionKind: protocol.ClientCodeActionKindOptions{
206								ValueSet: []protocol.CodeActionKind{},
207							},
208						},
209					},
210					PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{
211						VersionSupport: true,
212					},
213					SemanticTokens: protocol.SemanticTokensClientCapabilities{
214						Requests: protocol.ClientSemanticTokensRequestOptions{
215							Range: &protocol.Or_ClientSemanticTokensRequestOptions_range{},
216							Full:  &protocol.Or_ClientSemanticTokensRequestOptions_full{},
217						},
218						TokenTypes:     []string{},
219						TokenModifiers: []string{},
220						Formats:        []protocol.TokenFormat{},
221					},
222				},
223				Window: protocol.WindowClientCapabilities{},
224			},
225			InitializationOptions: map[string]any{
226				"codelenses": map[string]bool{
227					"generate":           true,
228					"regenerate_cgo":     true,
229					"test":               true,
230					"tidy":               true,
231					"upgrade_dependency": true,
232					"vendor":             true,
233					"vulncheck":          false,
234				},
235			},
236		},
237	}
238
239	var result protocol.InitializeResult
240	if err := c.Call(ctx, "initialize", initParams, &result); err != nil {
241		return nil, fmt.Errorf("initialize failed: %w", err)
242	}
243
244	if err := c.Notify(ctx, "initialized", struct{}{}); err != nil {
245		return nil, fmt.Errorf("initialized notification failed: %w", err)
246	}
247
248	// Register handlers
249	c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
250	c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
251	c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
252	c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
253	c.RegisterNotificationHandler("textDocument/publishDiagnostics",
254		func(params json.RawMessage) { HandleDiagnostics(c, params) })
255
256	// Notify the LSP server
257	err := c.Initialized(ctx, protocol.InitializedParams{})
258	if err != nil {
259		return nil, fmt.Errorf("initialization failed: %w", err)
260	}
261
262	return &result, nil
263}
264
265func (c *Client) Close() error {
266	// Try to close all open files first
267	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
268	defer cancel()
269
270	// Attempt to close files but continue shutdown regardless
271	c.CloseAllFiles(ctx)
272
273	// Close stdin to signal the server
274	if err := c.stdin.Close(); err != nil {
275		return fmt.Errorf("failed to close stdin: %w", err)
276	}
277
278	// Use a channel to handle the Wait with timeout
279	done := make(chan error, 1)
280	go func() {
281		done <- c.Cmd.Wait()
282	}()
283
284	// Wait for process to exit with timeout
285	select {
286	case err := <-done:
287		return err
288	case <-time.After(2 * time.Second):
289		// If we timeout, try to kill the process
290		if err := c.Cmd.Process.Kill(); err != nil {
291			return fmt.Errorf("failed to kill process: %w", err)
292		}
293		return fmt.Errorf("process killed after timeout")
294	}
295}
296
297type ServerState int
298
299const (
300	StateStarting ServerState = iota
301	StateReady
302	StateError
303)
304
305// GetServerState returns the current state of the LSP server
306func (c *Client) GetServerState() ServerState {
307	if val := c.serverState.Load(); val != nil {
308		return val.(ServerState)
309	}
310	return StateStarting
311}
312
313// SetServerState sets the current state of the LSP server
314func (c *Client) SetServerState(state ServerState) {
315	c.serverState.Store(state)
316}
317
318// GetName returns the name of the LSP client
319func (c *Client) GetName() string {
320	return c.name
321}
322
323// SetDiagnosticsCallback sets the callback function for diagnostic changes
324func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
325	c.onDiagnosticsChanged = callback
326}
327
328// WaitForServerReady waits for the server to be ready by polling the server
329// with a simple request until it responds successfully or times out
330func (c *Client) WaitForServerReady(ctx context.Context) error {
331	cfg := config.Get()
332
333	// Set initial state
334	c.SetServerState(StateStarting)
335
336	// Create a context with timeout
337	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
338	defer cancel()
339
340	// Try to ping the server with a simple request
341	ticker := time.NewTicker(500 * time.Millisecond)
342	defer ticker.Stop()
343
344	if cfg.Options.DebugLSP {
345		slog.Debug("Waiting for LSP server to be ready...")
346	}
347
348	// Determine server type for specialized initialization
349	serverType := c.detectServerType()
350
351	// For TypeScript-like servers, we need to open some key files first
352	if serverType == ServerTypeTypeScript {
353		if cfg.Options.DebugLSP {
354			slog.Debug("TypeScript-like server detected, opening key configuration files")
355		}
356		c.openKeyConfigFiles(ctx)
357	}
358
359	for {
360		select {
361		case <-ctx.Done():
362			c.SetServerState(StateError)
363			return fmt.Errorf("timeout waiting for LSP server to be ready")
364		case <-ticker.C:
365			// Try a ping method appropriate for this server type
366			err := c.pingServerByType(ctx, serverType)
367			if err == nil {
368				// Server responded successfully
369				c.SetServerState(StateReady)
370				if cfg.Options.DebugLSP {
371					slog.Debug("LSP server is ready")
372				}
373				return nil
374			} else {
375				slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
376			}
377
378			if cfg.Options.DebugLSP {
379				slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
380			}
381		}
382	}
383}
384
385// ServerType represents the type of LSP server
386type ServerType int
387
388const (
389	ServerTypeUnknown ServerType = iota
390	ServerTypeGo
391	ServerTypeTypeScript
392	ServerTypeRust
393	ServerTypePython
394	ServerTypeGeneric
395)
396
397// detectServerType tries to determine what type of LSP server we're dealing with
398func (c *Client) detectServerType() ServerType {
399	if c.Cmd == nil {
400		return ServerTypeUnknown
401	}
402
403	cmdPath := strings.ToLower(c.Cmd.Path)
404
405	switch {
406	case strings.Contains(cmdPath, "gopls"):
407		return ServerTypeGo
408	case strings.Contains(cmdPath, "typescript") || strings.Contains(cmdPath, "vtsls") || strings.Contains(cmdPath, "tsserver"):
409		return ServerTypeTypeScript
410	case strings.Contains(cmdPath, "rust-analyzer"):
411		return ServerTypeRust
412	case strings.Contains(cmdPath, "pyright") || strings.Contains(cmdPath, "pylsp") || strings.Contains(cmdPath, "python"):
413		return ServerTypePython
414	default:
415		return ServerTypeGeneric
416	}
417}
418
419// openKeyConfigFiles opens important configuration files that help initialize the server
420func (c *Client) openKeyConfigFiles(ctx context.Context) {
421	workDir := config.Get().WorkingDir()
422	serverType := c.detectServerType()
423
424	var filesToOpen []string
425
426	switch serverType {
427	case ServerTypeTypeScript:
428		// TypeScript servers need these config files to properly initialize
429		filesToOpen = []string{
430			filepath.Join(workDir, "tsconfig.json"),
431			filepath.Join(workDir, "package.json"),
432			filepath.Join(workDir, "jsconfig.json"),
433		}
434
435		// Also find and open a few TypeScript files to help the server initialize
436		c.openTypeScriptFiles(ctx, workDir)
437	case ServerTypeGo:
438		filesToOpen = []string{
439			filepath.Join(workDir, "go.mod"),
440			filepath.Join(workDir, "go.sum"),
441		}
442	case ServerTypeRust:
443		filesToOpen = []string{
444			filepath.Join(workDir, "Cargo.toml"),
445			filepath.Join(workDir, "Cargo.lock"),
446		}
447	}
448
449	// Try to open each file, ignoring errors if they don't exist
450	for _, file := range filesToOpen {
451		if _, err := os.Stat(file); err == nil {
452			// File exists, try to open it
453			if err := c.OpenFile(ctx, file); err != nil {
454				slog.Debug("Failed to open key config file", "file", file, "error", err)
455			} else {
456				slog.Debug("Opened key config file for initialization", "file", file)
457			}
458		}
459	}
460}
461
462// pingServerByType sends a ping request appropriate for the server type
463func (c *Client) pingServerByType(ctx context.Context, serverType ServerType) error {
464	switch serverType {
465	case ServerTypeTypeScript:
466		// For TypeScript, try a document symbol request on an open file
467		return c.pingTypeScriptServer(ctx)
468	case ServerTypeGo:
469		// For Go, workspace/symbol works well
470		return c.pingWithWorkspaceSymbol(ctx)
471	case ServerTypeRust:
472		// For Rust, workspace/symbol works well
473		return c.pingWithWorkspaceSymbol(ctx)
474	default:
475		// Default ping method
476		return c.pingWithWorkspaceSymbol(ctx)
477	}
478}
479
480// pingTypeScriptServer tries to ping a TypeScript server with appropriate methods
481func (c *Client) pingTypeScriptServer(ctx context.Context) error {
482	// First try workspace/symbol which works for many servers
483	if err := c.pingWithWorkspaceSymbol(ctx); err == nil {
484		return nil
485	}
486
487	// If that fails, try to find an open file and request document symbols
488	c.openFilesMu.RLock()
489	defer c.openFilesMu.RUnlock()
490
491	// If we have any open files, try to get document symbols for one
492	for uri := range c.openFiles {
493		filePath, err := protocol.DocumentURI(uri).Path()
494		if err != nil {
495			slog.Error("Failed to convert URI to path for TypeScript symbol collection", "uri", uri, "error", err)
496			continue
497		}
498
499		if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
500			strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
501			var symbols []protocol.DocumentSymbol
502			err := c.Call(ctx, "textDocument/documentSymbol", protocol.DocumentSymbolParams{
503				TextDocument: protocol.TextDocumentIdentifier{
504					URI: protocol.DocumentURI(uri),
505				},
506			}, &symbols)
507			if err == nil {
508				return nil
509			}
510		}
511	}
512
513	// If we have no open TypeScript files, try to find and open one
514	workDir := config.Get().WorkingDir()
515	err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
516		if err != nil {
517			return err
518		}
519
520		// Skip directories and non-TypeScript files
521		if d.IsDir() {
522			return nil
523		}
524
525		ext := filepath.Ext(path)
526		if ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" {
527			// Found a TypeScript file, try to open it
528			if err := c.OpenFile(ctx, path); err == nil {
529				// Successfully opened, stop walking
530				return filepath.SkipAll
531			}
532		}
533
534		return nil
535	})
536	if err != nil {
537		slog.Debug("Error walking directory for TypeScript files", "error", err)
538	}
539
540	// Final fallback - just try a generic capability
541	return c.pingWithServerCapabilities(ctx)
542}
543
544// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
545func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
546	cfg := config.Get()
547	filesOpened := 0
548	maxFilesToOpen := 5 // Limit to a reasonable number of files
549
550	// Find and open TypeScript files
551	err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
552		if err != nil {
553			return err
554		}
555
556		// Skip directories and non-TypeScript files
557		if d.IsDir() {
558			// Skip common directories to avoid wasting time
559			if shouldSkipDir(path) {
560				return filepath.SkipDir
561			}
562			return nil
563		}
564
565		// Check if we've opened enough files
566		if filesOpened >= maxFilesToOpen {
567			return filepath.SkipAll
568		}
569
570		// Check file extension
571		ext := filepath.Ext(path)
572		if ext == ".ts" || ext == ".tsx" || ext == ".js" || ext == ".jsx" {
573			// Try to open the file
574			if err := c.OpenFile(ctx, path); err == nil {
575				filesOpened++
576				if cfg.Options.DebugLSP {
577					slog.Debug("Opened TypeScript file for initialization", "file", path)
578				}
579			}
580		}
581
582		return nil
583	})
584
585	if err != nil && cfg.Options.DebugLSP {
586		slog.Debug("Error walking directory for TypeScript files", "error", err)
587	}
588
589	if cfg.Options.DebugLSP {
590		slog.Debug("Opened TypeScript files for initialization", "count", filesOpened)
591	}
592}
593
594// shouldSkipDir returns true if the directory should be skipped during file search
595func shouldSkipDir(path string) bool {
596	dirName := filepath.Base(path)
597
598	// Skip hidden directories
599	if strings.HasPrefix(dirName, ".") {
600		return true
601	}
602
603	// Skip common directories that won't contain relevant source files
604	skipDirs := map[string]bool{
605		"node_modules": true,
606		"dist":         true,
607		"build":        true,
608		"coverage":     true,
609		"vendor":       true,
610		"target":       true,
611	}
612
613	return skipDirs[dirName]
614}
615
616// pingWithWorkspaceSymbol tries a workspace/symbol request
617func (c *Client) pingWithWorkspaceSymbol(ctx context.Context) error {
618	var result []protocol.SymbolInformation
619	return c.Call(ctx, "workspace/symbol", protocol.WorkspaceSymbolParams{
620		Query: "",
621	}, &result)
622}
623
624// pingWithServerCapabilities tries to get server capabilities
625func (c *Client) pingWithServerCapabilities(ctx context.Context) error {
626	// This is a very lightweight request that should work for most servers
627	return c.Notify(ctx, "$/cancelRequest", struct{ ID int }{ID: -1})
628}
629
630type OpenFileInfo struct {
631	Version int32
632	URI     protocol.DocumentURI
633}
634
635// HandlesFile checks if this LSP client handles the given file based on its
636// extension.
637func (c *Client) HandlesFile(path string) bool {
638	// If no file types are specified, handle all files (backward compatibility)
639	if len(c.fileTypes) == 0 {
640		return true
641	}
642
643	name := strings.ToLower(filepath.Base(path))
644	for _, filetpe := range c.fileTypes {
645		suffix := strings.ToLower(filetpe)
646		if !strings.HasPrefix(suffix, ".") {
647			suffix = "." + suffix
648		}
649		if strings.HasSuffix(name, suffix) {
650			slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetpe)
651			return true
652		}
653	}
654	slog.Debug("doesn't handle file", "name", c.name, "file", name)
655	return false
656}
657
658func (c *Client) OpenFile(ctx context.Context, filepath string) error {
659	if !c.HandlesFile(filepath) {
660		return nil
661	}
662
663	uri := string(protocol.URIFromPath(filepath))
664
665	c.openFilesMu.Lock()
666	if _, exists := c.openFiles[uri]; exists {
667		c.openFilesMu.Unlock()
668		return nil // Already open
669	}
670	c.openFilesMu.Unlock()
671
672	// Skip files that do not exist or cannot be read
673	content, err := os.ReadFile(filepath)
674	if err != nil {
675		return fmt.Errorf("error reading file: %w", err)
676	}
677
678	params := protocol.DidOpenTextDocumentParams{
679		TextDocument: protocol.TextDocumentItem{
680			URI:        protocol.DocumentURI(uri),
681			LanguageID: DetectLanguageID(uri),
682			Version:    1,
683			Text:       string(content),
684		},
685	}
686
687	if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil {
688		return err
689	}
690
691	c.openFilesMu.Lock()
692	c.openFiles[uri] = &OpenFileInfo{
693		Version: 1,
694		URI:     protocol.DocumentURI(uri),
695	}
696	c.openFilesMu.Unlock()
697
698	return nil
699}
700
701func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
702	uri := string(protocol.URIFromPath(filepath))
703
704	content, err := os.ReadFile(filepath)
705	if err != nil {
706		return fmt.Errorf("error reading file: %w", err)
707	}
708
709	c.openFilesMu.Lock()
710	fileInfo, isOpen := c.openFiles[uri]
711	if !isOpen {
712		c.openFilesMu.Unlock()
713		return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
714	}
715
716	// Increment version
717	fileInfo.Version++
718	version := fileInfo.Version
719	c.openFilesMu.Unlock()
720
721	params := protocol.DidChangeTextDocumentParams{
722		TextDocument: protocol.VersionedTextDocumentIdentifier{
723			TextDocumentIdentifier: protocol.TextDocumentIdentifier{
724				URI: protocol.DocumentURI(uri),
725			},
726			Version: version,
727		},
728		ContentChanges: []protocol.TextDocumentContentChangeEvent{
729			{
730				Value: protocol.TextDocumentContentChangeWholeDocument{
731					Text: string(content),
732				},
733			},
734		},
735	}
736
737	return c.Notify(ctx, "textDocument/didChange", params)
738}
739
740func (c *Client) CloseFile(ctx context.Context, filepath string) error {
741	cfg := config.Get()
742	uri := string(protocol.URIFromPath(filepath))
743
744	c.openFilesMu.Lock()
745	if _, exists := c.openFiles[uri]; !exists {
746		c.openFilesMu.Unlock()
747		return nil // Already closed
748	}
749	c.openFilesMu.Unlock()
750
751	params := protocol.DidCloseTextDocumentParams{
752		TextDocument: protocol.TextDocumentIdentifier{
753			URI: protocol.DocumentURI(uri),
754		},
755	}
756
757	if cfg.Options.DebugLSP {
758		slog.Debug("Closing file", "file", filepath)
759	}
760	if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
761		return err
762	}
763
764	c.openFilesMu.Lock()
765	delete(c.openFiles, uri)
766	c.openFilesMu.Unlock()
767
768	return nil
769}
770
771func (c *Client) IsFileOpen(filepath string) bool {
772	uri := string(protocol.URIFromPath(filepath))
773	c.openFilesMu.RLock()
774	defer c.openFilesMu.RUnlock()
775	_, exists := c.openFiles[uri]
776	return exists
777}
778
779// CloseAllFiles closes all currently open files
780func (c *Client) CloseAllFiles(ctx context.Context) {
781	cfg := config.Get()
782	c.openFilesMu.Lock()
783	filesToClose := make([]string, 0, len(c.openFiles))
784
785	// First collect all URIs that need to be closed
786	for uri := range c.openFiles {
787		// Convert URI back to file path using proper URI handling
788		filePath, err := protocol.DocumentURI(uri).Path()
789		if err != nil {
790			slog.Error("Failed to convert URI to path for file closing", "uri", uri, "error", err)
791			continue
792		}
793		filesToClose = append(filesToClose, filePath)
794	}
795	c.openFilesMu.Unlock()
796
797	// Then close them all
798	for _, filePath := range filesToClose {
799		err := c.CloseFile(ctx, filePath)
800		if err != nil && cfg.Options.DebugLSP {
801			slog.Warn("Error closing file", "file", filePath, "error", err)
802		}
803	}
804
805	if cfg.Options.DebugLSP {
806		slog.Debug("Closed all files", "files", filesToClose)
807	}
808}
809
810func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnostic {
811	c.diagnosticsMu.RLock()
812	defer c.diagnosticsMu.RUnlock()
813
814	return c.diagnostics[uri]
815}
816
817// GetDiagnostics returns all diagnostics for all files
818func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic {
819	c.diagnosticsMu.RLock()
820	defer c.diagnosticsMu.RUnlock()
821
822	return maps.Clone(c.diagnostics)
823}
824
825// OpenFileOnDemand opens a file only if it's not already open
826// This is used for lazy-loading files when they're actually needed
827func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
828	// Check if the file is already open
829	if c.IsFileOpen(filepath) {
830		return nil
831	}
832
833	// Open the file
834	return c.OpenFile(ctx, filepath)
835}
836
837// GetDiagnosticsForFile ensures a file is open and returns its diagnostics
838// This is useful for on-demand diagnostics when using lazy loading
839func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
840	documentURI := protocol.URIFromPath(filepath)
841
842	// Make sure the file is open
843	if !c.IsFileOpen(filepath) {
844		if err := c.OpenFile(ctx, filepath); err != nil {
845			return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
846		}
847
848		// Give the LSP server a moment to process the file
849		time.Sleep(100 * time.Millisecond)
850	}
851
852	// Get diagnostics
853	c.diagnosticsMu.RLock()
854	diagnostics := c.diagnostics[documentURI]
855	c.diagnosticsMu.RUnlock()
856
857	return diagnostics, nil
858}
859
860// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache
861func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
862	c.diagnosticsMu.Lock()
863	defer c.diagnosticsMu.Unlock()
864	delete(c.diagnostics, uri)
865}