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