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