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