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