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