client.go

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