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