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 to close all open files first
280 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
281 defer cancel()
282
283 // Attempt to close files but continue shutdown regardless
284 c.CloseAllFiles(ctx)
285
286 // Close stdin to signal the server
287 if err := c.stdin.Close(); err != nil {
288 return fmt.Errorf("failed to close stdin: %w", err)
289 }
290
291 // Use a channel to handle the Wait with timeout
292 done := make(chan error, 1)
293 go func() {
294 done <- c.Cmd.Wait()
295 }()
296
297 // Wait for process to exit with timeout
298 select {
299 case err := <-done:
300 return err
301 case <-time.After(2 * time.Second):
302 // If we timeout, try to kill the process
303 if err := c.Cmd.Process.Kill(); err != nil {
304 return fmt.Errorf("failed to kill process: %w", err)
305 }
306 return fmt.Errorf("process killed after timeout")
307 }
308}
309
310type ServerState int
311
312const (
313 StateStarting ServerState = iota
314 StateReady
315 StateError
316)
317
318// GetServerState returns the current state of the LSP server
319func (c *Client) GetServerState() ServerState {
320 if val := c.serverState.Load(); val != nil {
321 return val.(ServerState)
322 }
323 return StateStarting
324}
325
326// SetServerState sets the current state of the LSP server
327func (c *Client) SetServerState(state ServerState) {
328 c.serverState.Store(state)
329}
330
331// GetName returns the name of the LSP client
332func (c *Client) GetName() string {
333 return c.name
334}
335
336// SetDiagnosticsCallback sets the callback function for diagnostic changes
337func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
338 c.onDiagnosticsChanged = callback
339}
340
341// WaitForServerReady waits for the server to be ready by polling the server
342// with a simple request until it responds successfully or times out
343func (c *Client) WaitForServerReady(ctx context.Context) error {
344 cfg := config.Get()
345
346 // Set initial state
347 c.SetServerState(StateStarting)
348
349 // Create a context with timeout
350 ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
351 defer cancel()
352
353 // Try to ping the server with a simple request
354 ticker := time.NewTicker(500 * time.Millisecond)
355 defer ticker.Stop()
356
357 if cfg.Options.DebugLSP {
358 slog.Debug("Waiting for LSP server to be ready...")
359 }
360
361 // Determine server type for specialized initialization
362 serverType := c.detectServerType()
363
364 // For TypeScript-like servers, we need to open some key files first
365 if serverType == ServerTypeTypeScript {
366 if cfg.Options.DebugLSP {
367 slog.Debug("TypeScript-like server detected, opening key configuration files")
368 }
369 c.openKeyConfigFiles(ctx)
370 }
371
372 for {
373 select {
374 case <-ctx.Done():
375 c.SetServerState(StateError)
376 return fmt.Errorf("timeout waiting for LSP server to be ready")
377 case <-ticker.C:
378 // Try a ping method appropriate for this server type
379 err := c.pingServerByType(ctx, serverType)
380 if err == nil {
381 // Server responded successfully
382 c.SetServerState(StateReady)
383 if cfg.Options.DebugLSP {
384 slog.Debug("LSP server is ready")
385 }
386 return nil
387 } else {
388 slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
389 }
390
391 if cfg.Options.DebugLSP {
392 slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
393 }
394 }
395 }
396}
397
398// ServerType represents the type of LSP server
399type ServerType int
400
401const (
402 ServerTypeUnknown ServerType = iota
403 ServerTypeGo
404 ServerTypeTypeScript
405 ServerTypeRust
406 ServerTypePython
407 ServerTypeGeneric
408)
409
410// detectServerType tries to determine what type of LSP server we're dealing with
411func (c *Client) detectServerType() ServerType {
412 if c.Cmd == nil {
413 return ServerTypeUnknown
414 }
415
416 cmdPath := strings.ToLower(c.Cmd.Path)
417
418 switch {
419 case strings.Contains(cmdPath, "gopls"):
420 return ServerTypeGo
421 case strings.Contains(cmdPath, "typescript") || strings.Contains(cmdPath, "vtsls") || strings.Contains(cmdPath, "tsserver"):
422 return ServerTypeTypeScript
423 case strings.Contains(cmdPath, "rust-analyzer"):
424 return ServerTypeRust
425 case strings.Contains(cmdPath, "pyright") || strings.Contains(cmdPath, "pylsp") || strings.Contains(cmdPath, "python"):
426 return ServerTypePython
427 default:
428 return ServerTypeGeneric
429 }
430}
431
432// openKeyConfigFiles opens important configuration files that help initialize the server
433func (c *Client) openKeyConfigFiles(ctx context.Context) {
434 workDir := config.Get().WorkingDir()
435 serverType := c.detectServerType()
436
437 var filesToOpen []string
438
439 switch serverType {
440 case ServerTypeTypeScript:
441 // TypeScript servers need these config files to properly initialize
442 filesToOpen = []string{
443 filepath.Join(workDir, "tsconfig.json"),
444 filepath.Join(workDir, "package.json"),
445 filepath.Join(workDir, "jsconfig.json"),
446 }
447
448 // Also find and open a few TypeScript files to help the server initialize
449 c.openTypeScriptFiles(ctx, workDir)
450 case ServerTypeGo:
451 filesToOpen = []string{
452 filepath.Join(workDir, "go.mod"),
453 filepath.Join(workDir, "go.sum"),
454 }
455 case ServerTypeRust:
456 filesToOpen = []string{
457 filepath.Join(workDir, "Cargo.toml"),
458 filepath.Join(workDir, "Cargo.lock"),
459 }
460 }
461
462 // Try to open each file, ignoring errors if they don't exist
463 for _, file := range filesToOpen {
464 if _, err := os.Stat(file); err == nil {
465 // File exists, try to open it
466 if err := c.OpenFile(ctx, file); err != nil {
467 slog.Debug("Failed to open key config file", "file", file, "error", err)
468 } else {
469 slog.Debug("Opened key config file for initialization", "file", file)
470 }
471 }
472 }
473}
474
475// pingServerByType sends a ping request appropriate for the server type
476func (c *Client) pingServerByType(ctx context.Context, serverType ServerType) error {
477 switch serverType {
478 case ServerTypeTypeScript:
479 // For TypeScript, try a document symbol request on an open file
480 return c.pingTypeScriptServer(ctx)
481 case ServerTypeGo:
482 // For Go, workspace/symbol works well
483 return c.pingWithWorkspaceSymbol(ctx)
484 case ServerTypeRust:
485 // For Rust, workspace/symbol works well
486 return c.pingWithWorkspaceSymbol(ctx)
487 default:
488 // Default ping method
489 return c.pingWithWorkspaceSymbol(ctx)
490 }
491}
492
493// pingTypeScriptServer tries to ping a TypeScript server with appropriate methods
494func (c *Client) pingTypeScriptServer(ctx context.Context) error {
495 // First try workspace/symbol which works for many servers
496 if err := c.pingWithWorkspaceSymbol(ctx); err == nil {
497 return nil
498 }
499
500 // If that fails, try to find an open file and request document symbols
501 c.openFilesMu.RLock()
502 defer c.openFilesMu.RUnlock()
503
504 // If we have any open files, try to get document symbols for one
505 for uri := range c.openFiles {
506 filePath, err := protocol.DocumentURI(uri).Path()
507 if err != nil {
508 slog.Error("Failed to convert URI to path for TypeScript symbol collection", "uri", uri, "error", err)
509 continue
510 }
511
512 if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
513 strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
514 var symbols []protocol.DocumentSymbol
515 err := c.Call(ctx, "textDocument/documentSymbol", protocol.DocumentSymbolParams{
516 TextDocument: protocol.TextDocumentIdentifier{
517 URI: protocol.DocumentURI(uri),
518 },
519 }, &symbols)
520 if err == nil {
521 return nil
522 }
523 }
524 }
525
526 // If we have no open TypeScript files, try to find and open one
527 workDir := config.Get().WorkingDir()
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 return nil
536 }
537
538 ext := filepath.Ext(path)
539 if ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" {
540 // Found a TypeScript file, try to open it
541 if err := c.OpenFile(ctx, path); err == nil {
542 // Successfully opened, stop walking
543 return filepath.SkipAll
544 }
545 }
546
547 return nil
548 })
549 if err != nil {
550 slog.Debug("Error walking directory for TypeScript files", "error", err)
551 }
552
553 // Final fallback - just try a generic capability
554 return c.pingWithServerCapabilities(ctx)
555}
556
557// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
558func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
559 cfg := config.Get()
560 filesOpened := 0
561 maxFilesToOpen := 5 // Limit to a reasonable number of files
562
563 // Find and open TypeScript files
564 err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
565 if err != nil {
566 return err
567 }
568
569 // Skip directories and non-TypeScript files
570 if d.IsDir() {
571 // Skip common directories to avoid wasting time
572 if shouldSkipDir(path) {
573 return filepath.SkipDir
574 }
575 return nil
576 }
577
578 // Check if we've opened enough files
579 if filesOpened >= maxFilesToOpen {
580 return filepath.SkipAll
581 }
582
583 // Check file extension
584 ext := filepath.Ext(path)
585 if ext == ".ts" || ext == ".tsx" || ext == ".js" || ext == ".jsx" {
586 // Try to open the file
587 if err := c.OpenFile(ctx, path); err == nil {
588 filesOpened++
589 if cfg.Options.DebugLSP {
590 slog.Debug("Opened TypeScript file for initialization", "file", path)
591 }
592 }
593 }
594
595 return nil
596 })
597
598 if err != nil && cfg.Options.DebugLSP {
599 slog.Debug("Error walking directory for TypeScript files", "error", err)
600 }
601
602 if cfg.Options.DebugLSP {
603 slog.Debug("Opened TypeScript files for initialization", "count", filesOpened)
604 }
605}
606
607// shouldSkipDir returns true if the directory should be skipped during file search
608func shouldSkipDir(path string) bool {
609 dirName := filepath.Base(path)
610
611 // Skip hidden directories
612 if strings.HasPrefix(dirName, ".") {
613 return true
614 }
615
616 // Skip common directories that won't contain relevant source files
617 skipDirs := map[string]bool{
618 "node_modules": true,
619 "dist": true,
620 "build": true,
621 "coverage": true,
622 "vendor": true,
623 "target": true,
624 }
625
626 return skipDirs[dirName]
627}
628
629// pingWithWorkspaceSymbol tries a workspace/symbol request
630func (c *Client) pingWithWorkspaceSymbol(ctx context.Context) error {
631 var result []protocol.SymbolInformation
632 return c.Call(ctx, "workspace/symbol", protocol.WorkspaceSymbolParams{
633 Query: "",
634 }, &result)
635}
636
637// pingWithServerCapabilities tries to get server capabilities
638func (c *Client) pingWithServerCapabilities(ctx context.Context) error {
639 // This is a very lightweight request that should work for most servers
640 return c.Notify(ctx, "$/cancelRequest", struct{ ID int }{ID: -1})
641}
642
643type OpenFileInfo struct {
644 Version int32
645 URI protocol.DocumentURI
646}
647
648// HandlesFile checks if this LSP client handles the given file based on its
649// extension.
650func (c *Client) HandlesFile(path string) bool {
651 // If no file types are specified, handle all files (backward compatibility)
652 if len(c.fileTypes) == 0 {
653 return true
654 }
655
656 name := strings.ToLower(filepath.Base(path))
657 for _, filetpe := range c.fileTypes {
658 suffix := strings.ToLower(filetpe)
659 if !strings.HasPrefix(suffix, ".") {
660 suffix = "." + suffix
661 }
662 if strings.HasSuffix(name, suffix) {
663 slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetpe)
664 return true
665 }
666 }
667 slog.Debug("doesn't handle file", "name", c.name, "file", name)
668 return false
669}
670
671func (c *Client) OpenFile(ctx context.Context, filepath string) error {
672 if !c.HandlesFile(filepath) {
673 return nil
674 }
675
676 uri := string(protocol.URIFromPath(filepath))
677
678 c.openFilesMu.Lock()
679 if _, exists := c.openFiles[uri]; exists {
680 c.openFilesMu.Unlock()
681 return nil // Already open
682 }
683 c.openFilesMu.Unlock()
684
685 // Skip files that do not exist or cannot be read
686 content, err := os.ReadFile(filepath)
687 if err != nil {
688 return fmt.Errorf("error reading file: %w", err)
689 }
690
691 params := protocol.DidOpenTextDocumentParams{
692 TextDocument: protocol.TextDocumentItem{
693 URI: protocol.DocumentURI(uri),
694 LanguageID: DetectLanguageID(uri),
695 Version: 1,
696 Text: string(content),
697 },
698 }
699
700 if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil {
701 return err
702 }
703
704 c.openFilesMu.Lock()
705 c.openFiles[uri] = &OpenFileInfo{
706 Version: 1,
707 URI: protocol.DocumentURI(uri),
708 }
709 c.openFilesMu.Unlock()
710
711 return nil
712}
713
714func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
715 uri := string(protocol.URIFromPath(filepath))
716
717 content, err := os.ReadFile(filepath)
718 if err != nil {
719 return fmt.Errorf("error reading file: %w", err)
720 }
721
722 c.openFilesMu.Lock()
723 fileInfo, isOpen := c.openFiles[uri]
724 if !isOpen {
725 c.openFilesMu.Unlock()
726 return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
727 }
728
729 // Increment version
730 fileInfo.Version++
731 version := fileInfo.Version
732 c.openFilesMu.Unlock()
733
734 params := protocol.DidChangeTextDocumentParams{
735 TextDocument: protocol.VersionedTextDocumentIdentifier{
736 TextDocumentIdentifier: protocol.TextDocumentIdentifier{
737 URI: protocol.DocumentURI(uri),
738 },
739 Version: version,
740 },
741 ContentChanges: []protocol.TextDocumentContentChangeEvent{
742 {
743 Value: protocol.TextDocumentContentChangeWholeDocument{
744 Text: string(content),
745 },
746 },
747 },
748 }
749
750 return c.Notify(ctx, "textDocument/didChange", params)
751}
752
753func (c *Client) CloseFile(ctx context.Context, filepath string) error {
754 cfg := config.Get()
755 uri := string(protocol.URIFromPath(filepath))
756
757 c.openFilesMu.Lock()
758 if _, exists := c.openFiles[uri]; !exists {
759 c.openFilesMu.Unlock()
760 return nil // Already closed
761 }
762 c.openFilesMu.Unlock()
763
764 params := protocol.DidCloseTextDocumentParams{
765 TextDocument: protocol.TextDocumentIdentifier{
766 URI: protocol.DocumentURI(uri),
767 },
768 }
769
770 if cfg.Options.DebugLSP {
771 slog.Debug("Closing file", "file", filepath)
772 }
773 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
774 return err
775 }
776
777 c.openFilesMu.Lock()
778 delete(c.openFiles, uri)
779 c.openFilesMu.Unlock()
780
781 return nil
782}
783
784func (c *Client) IsFileOpen(filepath string) bool {
785 uri := string(protocol.URIFromPath(filepath))
786 c.openFilesMu.RLock()
787 defer c.openFilesMu.RUnlock()
788 _, exists := c.openFiles[uri]
789 return exists
790}
791
792// CloseAllFiles closes all currently open files
793func (c *Client) CloseAllFiles(ctx context.Context) {
794 cfg := config.Get()
795 c.openFilesMu.Lock()
796 filesToClose := make([]string, 0, len(c.openFiles))
797
798 // First collect all URIs that need to be closed
799 for uri := range c.openFiles {
800 // Convert URI back to file path using proper URI handling
801 filePath, err := protocol.DocumentURI(uri).Path()
802 if err != nil {
803 slog.Error("Failed to convert URI to path for file closing", "uri", uri, "error", err)
804 continue
805 }
806 filesToClose = append(filesToClose, filePath)
807 }
808 c.openFilesMu.Unlock()
809
810 // Then close them all
811 for _, filePath := range filesToClose {
812 err := c.CloseFile(ctx, filePath)
813 if err != nil && cfg.Options.DebugLSP {
814 slog.Warn("Error closing file", "file", filePath, "error", err)
815 }
816 }
817
818 if cfg.Options.DebugLSP {
819 slog.Debug("Closed all files", "files", filesToClose)
820 }
821}
822
823func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnostic {
824 c.diagnosticsMu.RLock()
825 defer c.diagnosticsMu.RUnlock()
826
827 return c.diagnostics[uri]
828}
829
830// GetDiagnostics returns all diagnostics for all files
831func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic {
832 c.diagnosticsMu.RLock()
833 defer c.diagnosticsMu.RUnlock()
834
835 return maps.Clone(c.diagnostics)
836}
837
838// OpenFileOnDemand opens a file only if it's not already open
839// This is used for lazy-loading files when they're actually needed
840func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
841 // Check if the file is already open
842 if c.IsFileOpen(filepath) {
843 return nil
844 }
845
846 // Open the file
847 return c.OpenFile(ctx, filepath)
848}
849
850// GetDiagnosticsForFile ensures a file is open and returns its diagnostics
851// This is useful for on-demand diagnostics when using lazy loading
852func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
853 documentURI := protocol.URIFromPath(filepath)
854
855 // Make sure the file is open
856 if !c.IsFileOpen(filepath) {
857 if err := c.OpenFile(ctx, filepath); err != nil {
858 return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
859 }
860
861 // Give the LSP server a moment to process the file
862 time.Sleep(100 * time.Millisecond)
863 }
864
865 // Get diagnostics
866 c.diagnosticsMu.RLock()
867 diagnostics := c.diagnostics[documentURI]
868 c.diagnosticsMu.RUnlock()
869
870 return diagnostics, nil
871}
872
873// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache
874func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
875 c.diagnosticsMu.Lock()
876 defer c.diagnosticsMu.Unlock()
877 delete(c.diagnostics, uri)
878}