client.go

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