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