1package lsp
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "iter"
8 "log/slog"
9 "maps"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "time"
16
17 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/csync"
19 "github.com/charmbracelet/crush/internal/fsext"
20 "github.com/charmbracelet/crush/internal/home"
21 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
22 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
23 "github.com/charmbracelet/x/powernap/pkg/transport"
24)
25
26// DiagnosticCounts holds the count of diagnostics by severity.
27type DiagnosticCounts struct {
28 Error int
29 Warning int
30 Information int
31 Hint int
32}
33
34type Client struct {
35 client *powernap.Client
36 name string
37
38 // File types this LSP server handles (e.g., .go, .rs, .py)
39 fileTypes []string
40
41 // Configuration for this LSP client
42 config config.LSPConfig
43
44 // Diagnostic change callback
45 onDiagnosticsChanged func(name string, count int)
46
47 // Diagnostic cache
48 diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
49
50 // Cached diagnostic counts to avoid map copy on every UI render.
51 diagCountsCache DiagnosticCounts
52 diagCountsVersion uint64
53 diagCountsMu sync.Mutex
54
55 // Files are currently opened by the LSP
56 openFiles *csync.Map[string, *OpenFileInfo]
57
58 // Server state
59 serverState atomic.Value
60}
61
62// New creates a new LSP client using the powernap implementation.
63func New(ctx context.Context, name string, config config.LSPConfig, resolver config.VariableResolver) (*Client, error) {
64 // Convert working directory to file URI
65 workDir, err := os.Getwd()
66 if err != nil {
67 return nil, fmt.Errorf("failed to get working directory: %w", err)
68 }
69
70 rootURI := string(protocol.URIFromPath(workDir))
71
72 command, err := resolver.ResolveValue(config.Command)
73 if err != nil {
74 return nil, fmt.Errorf("invalid lsp command: %w", err)
75 }
76
77 // Create powernap client config
78 clientConfig := powernap.ClientConfig{
79 Command: home.Long(command),
80 Args: config.Args,
81 RootURI: rootURI,
82 Environment: func() map[string]string {
83 env := make(map[string]string)
84 maps.Copy(env, config.Env)
85 return env
86 }(),
87 Settings: config.Options,
88 InitOptions: config.InitOptions,
89 WorkspaceFolders: []protocol.WorkspaceFolder{
90 {
91 URI: rootURI,
92 Name: filepath.Base(workDir),
93 },
94 },
95 }
96
97 // Create the powernap client
98 powernapClient, err := powernap.NewClient(clientConfig)
99 if err != nil {
100 return nil, fmt.Errorf("failed to create lsp client: %w", err)
101 }
102
103 client := &Client{
104 client: powernapClient,
105 name: name,
106 fileTypes: config.FileTypes,
107 diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
108 openFiles: csync.NewMap[string, *OpenFileInfo](),
109 config: config,
110 }
111
112 // Initialize server state
113 client.serverState.Store(StateStarting)
114
115 return client, nil
116}
117
118// Initialize initializes the LSP client and returns the server capabilities.
119func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
120 if err := c.client.Initialize(ctx, false); err != nil {
121 return nil, fmt.Errorf("failed to initialize the lsp client: %w", err)
122 }
123
124 // Convert powernap capabilities to protocol capabilities
125 caps := c.client.GetCapabilities()
126 protocolCaps := protocol.ServerCapabilities{
127 TextDocumentSync: caps.TextDocumentSync,
128 CompletionProvider: func() *protocol.CompletionOptions {
129 if caps.CompletionProvider != nil {
130 return &protocol.CompletionOptions{
131 TriggerCharacters: caps.CompletionProvider.TriggerCharacters,
132 AllCommitCharacters: caps.CompletionProvider.AllCommitCharacters,
133 ResolveProvider: caps.CompletionProvider.ResolveProvider,
134 }
135 }
136 return nil
137 }(),
138 }
139
140 result := &protocol.InitializeResult{
141 Capabilities: protocolCaps,
142 }
143
144 c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
145 c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
146 c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
147 c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
148 c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) {
149 HandleDiagnostics(c, params)
150 })
151
152 return result, nil
153}
154
155// Close closes the LSP client.
156func (c *Client) Close(ctx context.Context) error {
157 c.CloseAllFiles(ctx)
158
159 // Shutdown and exit the client
160 if err := c.client.Shutdown(ctx); err != nil {
161 slog.Warn("Failed to shutdown LSP client", "error", err)
162 }
163
164 return c.client.Exit()
165}
166
167// ServerState represents the state of an LSP server
168type ServerState int
169
170const (
171 StateStarting ServerState = iota
172 StateReady
173 StateError
174 StateDisabled
175)
176
177// GetServerState returns the current state of the LSP server
178func (c *Client) GetServerState() ServerState {
179 if val := c.serverState.Load(); val != nil {
180 return val.(ServerState)
181 }
182 return StateStarting
183}
184
185// SetServerState sets the current state of the LSP server
186func (c *Client) SetServerState(state ServerState) {
187 c.serverState.Store(state)
188}
189
190// GetName returns the name of the LSP client
191func (c *Client) GetName() string {
192 return c.name
193}
194
195// SetDiagnosticsCallback sets the callback function for diagnostic changes
196func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
197 c.onDiagnosticsChanged = callback
198}
199
200// WaitForServerReady waits for the server to be ready
201func (c *Client) WaitForServerReady(ctx context.Context) error {
202 cfg := config.Get()
203
204 // Set initial state
205 c.SetServerState(StateStarting)
206
207 // Create a context with timeout
208 ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
209 defer cancel()
210
211 // Try to ping the server with a simple request
212 ticker := time.NewTicker(500 * time.Millisecond)
213 defer ticker.Stop()
214
215 if cfg != nil && cfg.Options.DebugLSP {
216 slog.Debug("Waiting for LSP server to be ready...")
217 }
218
219 c.openKeyConfigFiles(ctx)
220
221 for {
222 select {
223 case <-ctx.Done():
224 c.SetServerState(StateError)
225 return fmt.Errorf("timeout waiting for LSP server to be ready")
226 case <-ticker.C:
227 // Check if client is running
228 if !c.client.IsRunning() {
229 if cfg != nil && cfg.Options.DebugLSP {
230 slog.Debug("LSP server not ready yet", "server", c.name)
231 }
232 continue
233 }
234
235 // Server is ready
236 c.SetServerState(StateReady)
237 if cfg != nil && cfg.Options.DebugLSP {
238 slog.Debug("LSP server is ready")
239 }
240 return nil
241 }
242 }
243}
244
245// OpenFileInfo contains information about an open file
246type OpenFileInfo struct {
247 Version int32
248 URI protocol.DocumentURI
249}
250
251// HandlesFile checks if this LSP client handles the given file based on its extension.
252func (c *Client) HandlesFile(path string) bool {
253 // If no file types are specified, handle all files (backward compatibility)
254 if len(c.fileTypes) == 0 {
255 return true
256 }
257
258 name := strings.ToLower(filepath.Base(path))
259 for _, filetype := range c.fileTypes {
260 suffix := strings.ToLower(filetype)
261 if !strings.HasPrefix(suffix, ".") {
262 suffix = "." + suffix
263 }
264 if strings.HasSuffix(name, suffix) {
265 slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype)
266 return true
267 }
268 }
269 slog.Debug("doesn't handle file", "name", c.name, "file", name)
270 return false
271}
272
273// OpenFile opens a file in the LSP server.
274func (c *Client) OpenFile(ctx context.Context, filepath string) error {
275 if !c.HandlesFile(filepath) {
276 return nil
277 }
278
279 uri := string(protocol.URIFromPath(filepath))
280
281 if _, exists := c.openFiles.Get(uri); exists {
282 return nil // Already open
283 }
284
285 // Skip files that do not exist or cannot be read
286 content, err := os.ReadFile(filepath)
287 if err != nil {
288 return fmt.Errorf("error reading file: %w", err)
289 }
290
291 // Notify the server about the opened document
292 if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil {
293 return err
294 }
295
296 c.openFiles.Set(uri, &OpenFileInfo{
297 Version: 1,
298 URI: protocol.DocumentURI(uri),
299 })
300
301 return nil
302}
303
304// NotifyChange notifies the server about a file change.
305func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
306 uri := string(protocol.URIFromPath(filepath))
307
308 content, err := os.ReadFile(filepath)
309 if err != nil {
310 return fmt.Errorf("error reading file: %w", err)
311 }
312
313 fileInfo, isOpen := c.openFiles.Get(uri)
314 if !isOpen {
315 return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
316 }
317
318 // Increment version
319 fileInfo.Version++
320
321 // Create change event
322 changes := []protocol.TextDocumentContentChangeEvent{
323 {
324 Value: protocol.TextDocumentContentChangeWholeDocument{
325 Text: string(content),
326 },
327 },
328 }
329
330 return c.client.NotifyDidChangeTextDocument(ctx, uri, int(fileInfo.Version), changes)
331}
332
333// IsFileOpen checks if a file is currently open.
334func (c *Client) IsFileOpen(filepath string) bool {
335 uri := string(protocol.URIFromPath(filepath))
336 _, exists := c.openFiles.Get(uri)
337 return exists
338}
339
340// CloseFile closes a single file in the LSP server and clears its diagnostics.
341func (c *Client) CloseFile(ctx context.Context, filepath string) error {
342 uri := string(protocol.URIFromPath(filepath))
343
344 if _, exists := c.openFiles.Get(uri); !exists {
345 return nil // Not open, nothing to do.
346 }
347
348 if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil {
349 return fmt.Errorf("error closing file: %w", err)
350 }
351
352 c.openFiles.Del(uri)
353 c.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
354
355 return nil
356}
357
358// DeleteFile deletes a file from the LSP context.
359func (c *Client) DeleteFile(ctx context.Context, filepath string) error {
360 if err := c.CloseFile(ctx, filepath); err != nil {
361 return err
362 }
363 return c.DidChangeWatchedFiles(ctx, protocol.DidChangeWatchedFilesParams{
364 Changes: []protocol.FileEvent{
365 {
366 URI: protocol.URIFromPath(filepath),
367 Type: protocol.Deleted,
368 },
369 },
370 })
371}
372
373// OpenFiles returns an iterator over all currently open file URIs.
374func (c *Client) OpenFiles() iter.Seq[string] {
375 return func(yield func(string) bool) {
376 for uri := range c.openFiles.Seq2() {
377 if !yield(uri) {
378 return
379 }
380 }
381 }
382}
383
384// CloseAllFiles closes all currently open files.
385func (c *Client) CloseAllFiles(ctx context.Context) {
386 cfg := config.Get()
387 debugLSP := cfg != nil && cfg.Options.DebugLSP
388 for uri := range c.openFiles.Seq2() {
389 if debugLSP {
390 slog.Debug("Closing file", "file", uri)
391 }
392 if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil {
393 slog.Warn("Error closing rile", "uri", uri, "error", err)
394 continue
395 }
396 c.openFiles.Del(uri)
397 }
398}
399
400// GetFileDiagnostics returns diagnostics for a specific file.
401func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnostic {
402 diags, _ := c.diagnostics.Get(uri)
403 return diags
404}
405
406// GetDiagnostics returns all diagnostics for all files.
407func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic {
408 return c.diagnostics.Copy()
409}
410
411// GetDiagnosticCounts returns cached diagnostic counts by severity.
412// Uses the VersionedMap version to avoid recomputing on every call.
413func (c *Client) GetDiagnosticCounts() DiagnosticCounts {
414 currentVersion := c.diagnostics.Version()
415
416 c.diagCountsMu.Lock()
417 defer c.diagCountsMu.Unlock()
418
419 if currentVersion == c.diagCountsVersion {
420 return c.diagCountsCache
421 }
422
423 // Recompute counts.
424 counts := DiagnosticCounts{}
425 for _, diags := range c.diagnostics.Seq2() {
426 for _, diag := range diags {
427 switch diag.Severity {
428 case protocol.SeverityError:
429 counts.Error++
430 case protocol.SeverityWarning:
431 counts.Warning++
432 case protocol.SeverityInformation:
433 counts.Information++
434 case protocol.SeverityHint:
435 counts.Hint++
436 }
437 }
438 }
439
440 c.diagCountsCache = counts
441 c.diagCountsVersion = currentVersion
442 return counts
443}
444
445// OpenFileOnDemand opens a file only if it's not already open.
446func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
447 // Check if the file is already open
448 if c.IsFileOpen(filepath) {
449 return nil
450 }
451
452 // Open the file
453 return c.OpenFile(ctx, filepath)
454}
455
456// GetDiagnosticsForFile ensures a file is open and returns its diagnostics.
457func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
458 documentURI := protocol.URIFromPath(filepath)
459
460 // Make sure the file is open
461 if !c.IsFileOpen(filepath) {
462 if err := c.OpenFile(ctx, filepath); err != nil {
463 return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
464 }
465
466 // Give the LSP server a moment to process the file
467 time.Sleep(100 * time.Millisecond)
468 }
469
470 // Get diagnostics
471 diagnostics, _ := c.diagnostics.Get(documentURI)
472
473 return diagnostics, nil
474}
475
476// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache.
477func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
478 c.diagnostics.Del(uri)
479}
480
481// RegisterNotificationHandler registers a notification handler.
482func (c *Client) RegisterNotificationHandler(method string, handler transport.NotificationHandler) {
483 c.client.RegisterNotificationHandler(method, handler)
484}
485
486// RegisterServerRequestHandler handles server requests.
487func (c *Client) RegisterServerRequestHandler(method string, handler transport.Handler) {
488 c.client.RegisterHandler(method, handler)
489}
490
491// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the server.
492func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
493 return c.client.NotifyDidChangeWatchedFiles(ctx, params.Changes)
494}
495
496// openKeyConfigFiles opens important configuration files that help initialize the server.
497func (c *Client) openKeyConfigFiles(ctx context.Context) {
498 wd, err := os.Getwd()
499 if err != nil {
500 return
501 }
502
503 // Try to open each file, ignoring errors if they don't exist
504 for _, file := range c.config.RootMarkers {
505 file = filepath.Join(wd, file)
506 if _, err := os.Stat(file); err == nil {
507 // File exists, try to open it
508 if err := c.OpenFile(ctx, file); err != nil {
509 slog.Error("Failed to open key config file", "file", file, "error", err)
510 } else {
511 slog.Debug("Opened key config file for initialization", "file", file)
512 }
513 }
514 }
515}
516
517// WaitForDiagnostics waits until diagnostics change or the timeout is reached.
518func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
519 ticker := time.NewTicker(200 * time.Millisecond)
520 defer ticker.Stop()
521 timeout := time.After(d)
522 pv := c.diagnostics.Version()
523 for {
524 select {
525 case <-ctx.Done():
526 return
527 case <-timeout:
528 return
529 case <-ticker.C:
530 if pv != c.diagnostics.Version() {
531 return
532 }
533 }
534 }
535}
536
537// FindReferences finds all references to the symbol at the given position.
538func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) {
539 if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
540 return nil, err
541 }
542 // NOTE: line and character should be 0-based.
543 // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
544 return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
545}
546
547// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
548// Uses glob patterns to match files, allowing for more flexible matching.
549func HasRootMarkers(dir string, rootMarkers []string) bool {
550 if len(rootMarkers) == 0 {
551 return true
552 }
553 for _, pattern := range rootMarkers {
554 // Use fsext.GlobWithDoubleStar to find matches
555 matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
556 if err == nil && len(matches) > 0 {
557 return true
558 }
559 }
560 return false
561}