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