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