client.go

  1package lsp
  2
  3import (
  4	"bufio"
  5	"context"
  6	"fmt"
  7	"io"
  8	"log/slog"
  9	"os"
 10	"os/exec"
 11	"path/filepath"
 12	"strings"
 13	"sync"
 14	"sync/atomic"
 15	"time"
 16
 17	"github.com/charmbracelet/crush/internal/log"
 18	"github.com/charmbracelet/crush/internal/lsp/protocol"
 19)
 20
 21type Client struct {
 22	Cmd     *exec.Cmd
 23	stdin   io.WriteCloser
 24	stdout  *bufio.Reader
 25	stderr  io.ReadCloser
 26	workDir string
 27	debug   bool
 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, workDir string, 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		workDir:               workDir,
 82		handlers:              make(map[int32]chan *Message),
 83		notificationHandlers:  make(map[string]NotificationHandler),
 84		serverRequestHandlers: make(map[string]ServerRequestHandler),
 85		diagnostics:           make(map[protocol.DocumentURI][]protocol.Diagnostic),
 86		openFiles:             make(map[string]*OpenFileInfo),
 87	}
 88
 89	// Initialize server state
 90	client.serverState.Store(StateStarting)
 91
 92	// Start the LSP server process
 93	if err := cmd.Start(); err != nil {
 94		return nil, fmt.Errorf("failed to start LSP server: %w", err)
 95	}
 96
 97	// Handle stderr in a separate goroutine
 98	go func() {
 99		scanner := bufio.NewScanner(stderr)
100		for scanner.Scan() {
101			slog.Error("LSP Server", "err", scanner.Text())
102		}
103		if err := scanner.Err(); err != nil {
104			slog.Error("Error reading", "err", err)
105		}
106	}()
107
108	// Start message handling loop
109	go func() {
110		defer log.RecoverPanic("LSP-message-handler", func() {
111			slog.Error("LSP message handler crashed, LSP functionality may be impaired")
112		})
113		client.handleMessages()
114	}()
115
116	return client, nil
117}
118
119func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) {
120	c.notificationMu.Lock()
121	defer c.notificationMu.Unlock()
122	c.notificationHandlers[method] = handler
123}
124
125func (c *Client) RegisterServerRequestHandler(method string, handler ServerRequestHandler) {
126	c.serverHandlersMu.Lock()
127	defer c.serverHandlersMu.Unlock()
128	c.serverRequestHandlers[method] = handler
129}
130
131func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
132	initParams := &protocol.InitializeParams{
133		WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{
134			WorkspaceFolders: []protocol.WorkspaceFolder{
135				{
136					URI:  protocol.URI(protocol.URIFromPath(workspaceDir)),
137					Name: workspaceDir,
138				},
139			},
140		},
141
142		XInitializeParams: protocol.XInitializeParams{
143			ProcessID: int32(os.Getpid()),
144			ClientInfo: &protocol.ClientInfo{
145				Name:    "mcp-language-server",
146				Version: "0.1.0",
147			},
148			RootPath: workspaceDir,
149			RootURI:  protocol.URIFromPath(workspaceDir),
150			Capabilities: protocol.ClientCapabilities{
151				Workspace: protocol.WorkspaceClientCapabilities{
152					Configuration: true,
153					DidChangeConfiguration: protocol.DidChangeConfigurationClientCapabilities{
154						DynamicRegistration: true,
155					},
156					DidChangeWatchedFiles: protocol.DidChangeWatchedFilesClientCapabilities{
157						DynamicRegistration:    true,
158						RelativePatternSupport: true,
159					},
160				},
161				TextDocument: protocol.TextDocumentClientCapabilities{
162					Synchronization: &protocol.TextDocumentSyncClientCapabilities{
163						DynamicRegistration: true,
164						DidSave:             true,
165					},
166					Completion: protocol.CompletionClientCapabilities{
167						CompletionItem: protocol.ClientCompletionItemOptions{},
168					},
169					CodeLens: &protocol.CodeLensClientCapabilities{
170						DynamicRegistration: true,
171					},
172					DocumentSymbol: protocol.DocumentSymbolClientCapabilities{},
173					CodeAction: protocol.CodeActionClientCapabilities{
174						CodeActionLiteralSupport: protocol.ClientCodeActionLiteralOptions{
175							CodeActionKind: protocol.ClientCodeActionKindOptions{
176								ValueSet: []protocol.CodeActionKind{},
177							},
178						},
179					},
180					PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{
181						VersionSupport: true,
182					},
183					SemanticTokens: protocol.SemanticTokensClientCapabilities{
184						Requests: protocol.ClientSemanticTokensRequestOptions{
185							Range: &protocol.Or_ClientSemanticTokensRequestOptions_range{},
186							Full:  &protocol.Or_ClientSemanticTokensRequestOptions_full{},
187						},
188						TokenTypes:     []string{},
189						TokenModifiers: []string{},
190						Formats:        []protocol.TokenFormat{},
191					},
192				},
193				Window: protocol.WindowClientCapabilities{},
194			},
195			InitializationOptions: map[string]any{
196				"codelenses": map[string]bool{
197					"generate":           true,
198					"regenerate_cgo":     true,
199					"test":               true,
200					"tidy":               true,
201					"upgrade_dependency": true,
202					"vendor":             true,
203					"vulncheck":          false,
204				},
205			},
206		},
207	}
208
209	var result protocol.InitializeResult
210	if err := c.Call(ctx, "initialize", initParams, &result); err != nil {
211		return nil, fmt.Errorf("initialize failed: %w", err)
212	}
213
214	if err := c.Notify(ctx, "initialized", struct{}{}); err != nil {
215		return nil, fmt.Errorf("initialized notification failed: %w", err)
216	}
217
218	// Register handlers
219	c.RegisterServerRequestHandler("workspace/applyEdit", c.HandleApplyEdit)
220	c.RegisterServerRequestHandler("workspace/configuration", c.HandleWorkspaceConfiguration)
221	c.RegisterServerRequestHandler("client/registerCapability", c.HandleRegisterCapability)
222	c.RegisterNotificationHandler("window/showMessage", c.HandleServerMessage)
223	c.RegisterNotificationHandler("textDocument/publishDiagnostics", c.HandleDiagnostics)
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	// Set initial state
291	c.SetServerState(StateStarting)
292
293	// Create a context with timeout
294	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
295	defer cancel()
296
297	// Try to ping the server with a simple request
298	ticker := time.NewTicker(500 * time.Millisecond)
299	defer ticker.Stop()
300
301	if c.debug {
302		slog.Debug("Waiting for LSP server to be ready...")
303	}
304
305	// Determine server type for specialized initialization
306	serverType := c.detectServerType()
307
308	// For TypeScript-like servers, we need to open some key files first
309	if serverType == ServerTypeTypeScript {
310		if c.debug {
311			slog.Debug("TypeScript-like server detected, opening key configuration files")
312		}
313		c.openKeyConfigFiles(ctx)
314	}
315
316	for {
317		select {
318		case <-ctx.Done():
319			c.SetServerState(StateError)
320			return fmt.Errorf("timeout waiting for LSP server to be ready")
321		case <-ticker.C:
322			// Try a ping method appropriate for this server type
323			err := c.pingServerByType(ctx, serverType)
324			if err == nil {
325				// Server responded successfully
326				c.SetServerState(StateReady)
327				if c.debug {
328					slog.Debug("LSP server is ready")
329				}
330				return nil
331			} else {
332				slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
333			}
334
335			if c.debug {
336				slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
337			}
338		}
339	}
340}
341
342// ServerType represents the type of LSP server
343type ServerType int
344
345const (
346	ServerTypeUnknown ServerType = iota
347	ServerTypeGo
348	ServerTypeTypeScript
349	ServerTypeRust
350	ServerTypePython
351	ServerTypeGeneric
352)
353
354// detectServerType tries to determine what type of LSP server we're dealing with
355func (c *Client) detectServerType() ServerType {
356	if c.Cmd == nil {
357		return ServerTypeUnknown
358	}
359
360	cmdPath := strings.ToLower(c.Cmd.Path)
361
362	switch {
363	case strings.Contains(cmdPath, "gopls"):
364		return ServerTypeGo
365	case strings.Contains(cmdPath, "typescript") || strings.Contains(cmdPath, "vtsls") || strings.Contains(cmdPath, "tsserver"):
366		return ServerTypeTypeScript
367	case strings.Contains(cmdPath, "rust-analyzer"):
368		return ServerTypeRust
369	case strings.Contains(cmdPath, "pyright") || strings.Contains(cmdPath, "pylsp") || strings.Contains(cmdPath, "python"):
370		return ServerTypePython
371	default:
372		return ServerTypeGeneric
373	}
374}
375
376// openKeyConfigFiles opens important configuration files that help initialize the server
377func (c *Client) openKeyConfigFiles(ctx context.Context) {
378	serverType := c.detectServerType()
379
380	var filesToOpen []string
381
382	switch serverType {
383	case ServerTypeTypeScript:
384		// TypeScript servers need these config files to properly initialize
385		filesToOpen = []string{
386			filepath.Join(c.workDir, "tsconfig.json"),
387			filepath.Join(c.workDir, "package.json"),
388			filepath.Join(c.workDir, "jsconfig.json"),
389		}
390
391		// Also find and open a few TypeScript files to help the server initialize
392		c.openTypeScriptFiles(ctx, c.workDir)
393	case ServerTypeGo:
394		filesToOpen = []string{
395			filepath.Join(c.workDir, "go.mod"),
396			filepath.Join(c.workDir, "go.sum"),
397		}
398	case ServerTypeRust:
399		filesToOpen = []string{
400			filepath.Join(c.workDir, "Cargo.toml"),
401			filepath.Join(c.workDir, "Cargo.lock"),
402		}
403	}
404
405	// Try to open each file, ignoring errors if they don't exist
406	for _, file := range filesToOpen {
407		if _, err := os.Stat(file); err == nil {
408			// File exists, try to open it
409			if err := c.OpenFile(ctx, file); err != nil {
410				slog.Debug("Failed to open key config file", "file", file, "error", err)
411			} else {
412				slog.Debug("Opened key config file for initialization", "file", file)
413			}
414		}
415	}
416}
417
418// pingServerByType sends a ping request appropriate for the server type
419func (c *Client) pingServerByType(ctx context.Context, serverType ServerType) error {
420	switch serverType {
421	case ServerTypeTypeScript:
422		// For TypeScript, try a document symbol request on an open file
423		return c.pingTypeScriptServer(ctx)
424	case ServerTypeGo:
425		// For Go, workspace/symbol works well
426		return c.pingWithWorkspaceSymbol(ctx)
427	case ServerTypeRust:
428		// For Rust, workspace/symbol works well
429		return c.pingWithWorkspaceSymbol(ctx)
430	default:
431		// Default ping method
432		return c.pingWithWorkspaceSymbol(ctx)
433	}
434}
435
436// pingTypeScriptServer tries to ping a TypeScript server with appropriate methods
437func (c *Client) pingTypeScriptServer(ctx context.Context) error {
438	// First try workspace/symbol which works for many servers
439	if err := c.pingWithWorkspaceSymbol(ctx); err == nil {
440		return nil
441	}
442
443	// If that fails, try to find an open file and request document symbols
444	c.openFilesMu.RLock()
445	defer c.openFilesMu.RUnlock()
446
447	// If we have any open files, try to get document symbols for one
448	for uri := range c.openFiles {
449		filePath, err := protocol.DocumentURI(uri).Path()
450		if err != nil {
451			slog.Error("Failed to convert URI to path for TypeScript symbol collection", "uri", uri, "error", err)
452			continue
453		}
454
455		if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
456			strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
457			var symbols []protocol.DocumentSymbol
458			err := c.Call(ctx, "textDocument/documentSymbol", protocol.DocumentSymbolParams{
459				TextDocument: protocol.TextDocumentIdentifier{
460					URI: protocol.DocumentURI(uri),
461				},
462			}, &symbols)
463			if err == nil {
464				return nil
465			}
466		}
467	}
468
469	// If we have no open TypeScript files, try to find and open one
470	err := filepath.WalkDir(c.workDir, func(path string, d os.DirEntry, err error) error {
471		if err != nil {
472			return err
473		}
474
475		// Skip directories and non-TypeScript files
476		if d.IsDir() {
477			return nil
478		}
479
480		ext := filepath.Ext(path)
481		if ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" {
482			// Found a TypeScript file, try to open it
483			if err := c.OpenFile(ctx, path); err == nil {
484				// Successfully opened, stop walking
485				return filepath.SkipAll
486			}
487		}
488
489		return nil
490	})
491	if err != nil {
492		slog.Debug("Error walking directory for TypeScript files", "error", err)
493	}
494
495	// Final fallback - just try a generic capability
496	return c.pingWithServerCapabilities(ctx)
497}
498
499// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
500func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
501	filesOpened := 0
502	maxFilesToOpen := 5 // Limit to a reasonable number of files
503
504	// Find and open TypeScript files
505	err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
506		if err != nil {
507			return err
508		}
509
510		// Skip directories and non-TypeScript files
511		if d.IsDir() {
512			// Skip common directories to avoid wasting time
513			if shouldSkipDir(path) {
514				return filepath.SkipDir
515			}
516			return nil
517		}
518
519		// Check if we've opened enough files
520		if filesOpened >= maxFilesToOpen {
521			return filepath.SkipAll
522		}
523
524		// Check file extension
525		ext := filepath.Ext(path)
526		if ext == ".ts" || ext == ".tsx" || ext == ".js" || ext == ".jsx" {
527			// Try to open the file
528			if err := c.OpenFile(ctx, path); err == nil {
529				filesOpened++
530				if c.debug {
531					slog.Debug("Opened TypeScript file for initialization", "file", path)
532				}
533			}
534		}
535
536		return nil
537	})
538
539	if err != nil && c.debug {
540		slog.Debug("Error walking directory for TypeScript files", "error", err)
541	}
542
543	if c.debug {
544		slog.Debug("Opened TypeScript files for initialization", "count", filesOpened)
545	}
546}
547
548// shouldSkipDir returns true if the directory should be skipped during file search
549func shouldSkipDir(path string) bool {
550	dirName := filepath.Base(path)
551
552	// Skip hidden directories
553	if strings.HasPrefix(dirName, ".") {
554		return true
555	}
556
557	// Skip common directories that won't contain relevant source files
558	skipDirs := map[string]bool{
559		"node_modules": true,
560		"dist":         true,
561		"build":        true,
562		"coverage":     true,
563		"vendor":       true,
564		"target":       true,
565	}
566
567	return skipDirs[dirName]
568}
569
570// pingWithWorkspaceSymbol tries a workspace/symbol request
571func (c *Client) pingWithWorkspaceSymbol(ctx context.Context) error {
572	var result []protocol.SymbolInformation
573	return c.Call(ctx, "workspace/symbol", protocol.WorkspaceSymbolParams{
574		Query: "",
575	}, &result)
576}
577
578// pingWithServerCapabilities tries to get server capabilities
579func (c *Client) pingWithServerCapabilities(ctx context.Context) error {
580	// This is a very lightweight request that should work for most servers
581	return c.Notify(ctx, "$/cancelRequest", struct{ ID int }{ID: -1})
582}
583
584type OpenFileInfo struct {
585	Version int32
586	URI     protocol.DocumentURI
587}
588
589func (c *Client) OpenFile(ctx context.Context, filepath string) error {
590	uri := string(protocol.URIFromPath(filepath))
591
592	c.openFilesMu.Lock()
593	if _, exists := c.openFiles[uri]; exists {
594		c.openFilesMu.Unlock()
595		return nil // Already open
596	}
597	c.openFilesMu.Unlock()
598
599	// Skip files that do not exist or cannot be read
600	content, err := os.ReadFile(filepath)
601	if err != nil {
602		return fmt.Errorf("error reading file: %w", err)
603	}
604
605	params := protocol.DidOpenTextDocumentParams{
606		TextDocument: protocol.TextDocumentItem{
607			URI:        protocol.DocumentURI(uri),
608			LanguageID: DetectLanguageID(uri),
609			Version:    1,
610			Text:       string(content),
611		},
612	}
613
614	if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil {
615		return err
616	}
617
618	c.openFilesMu.Lock()
619	c.openFiles[uri] = &OpenFileInfo{
620		Version: 1,
621		URI:     protocol.DocumentURI(uri),
622	}
623	c.openFilesMu.Unlock()
624
625	return nil
626}
627
628func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
629	uri := string(protocol.URIFromPath(filepath))
630
631	content, err := os.ReadFile(filepath)
632	if err != nil {
633		return fmt.Errorf("error reading file: %w", err)
634	}
635
636	c.openFilesMu.Lock()
637	fileInfo, isOpen := c.openFiles[uri]
638	if !isOpen {
639		c.openFilesMu.Unlock()
640		return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
641	}
642
643	// Increment version
644	fileInfo.Version++
645	version := fileInfo.Version
646	c.openFilesMu.Unlock()
647
648	params := protocol.DidChangeTextDocumentParams{
649		TextDocument: protocol.VersionedTextDocumentIdentifier{
650			TextDocumentIdentifier: protocol.TextDocumentIdentifier{
651				URI: protocol.DocumentURI(uri),
652			},
653			Version: version,
654		},
655		ContentChanges: []protocol.TextDocumentContentChangeEvent{
656			{
657				Value: protocol.TextDocumentContentChangeWholeDocument{
658					Text: string(content),
659				},
660			},
661		},
662	}
663
664	return c.Notify(ctx, "textDocument/didChange", params)
665}
666
667func (c *Client) CloseFile(ctx context.Context, filepath string) error {
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 c.debug {
684		slog.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	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 && c.debug {
726			slog.Warn("Error closing file", "file", filePath, "error", err)
727		}
728	}
729
730	if c.debug {
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	return c.diagnostics
745}
746
747// OpenFileOnDemand opens a file only if it's not already open
748// This is used for lazy-loading files when they're actually needed
749func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
750	// Check if the file is already open
751	if c.IsFileOpen(filepath) {
752		return nil
753	}
754
755	// Open the file
756	return c.OpenFile(ctx, filepath)
757}
758
759// GetDiagnosticsForFile ensures a file is open and returns its diagnostics
760// This is useful for on-demand diagnostics when using lazy loading
761func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
762	documentURI := protocol.URIFromPath(filepath)
763
764	// Make sure the file is open
765	if !c.IsFileOpen(filepath) {
766		if err := c.OpenFile(ctx, filepath); err != nil {
767			return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
768		}
769
770		// Give the LSP server a moment to process the file
771		time.Sleep(100 * time.Millisecond)
772	}
773
774	// Get diagnostics
775	c.diagnosticsMu.RLock()
776	diagnostics := c.diagnostics[documentURI]
777	c.diagnosticsMu.RUnlock()
778
779	return diagnostics, nil
780}
781
782// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache
783func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
784	c.diagnosticsMu.Lock()
785	defer c.diagnosticsMu.Unlock()
786	delete(c.diagnostics, uri)
787}
788
789func (c *Client) DebugMode() {
790	c.debug = true
791}