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