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