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// IsFileOpen checks if a file is currently open.
354func (c *Client) IsFileOpen(filepath string) bool {
355 uri := string(protocol.URIFromPath(filepath))
356 _, exists := c.openFiles.Get(uri)
357 return exists
358}
359
360// CloseAllFiles closes all currently open files.
361func (c *Client) CloseAllFiles(ctx context.Context) {
362 debugLSP := c.cfg != nil && c.cfg.Options.DebugLSP
363 for uri := range c.openFiles.Seq2() {
364 if debugLSP {
365 slog.Debug("Closing file", "file", uri)
366 }
367 if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil {
368 slog.Warn("Error closing rile", "uri", uri, "error", err)
369 continue
370 }
371 c.openFiles.Del(uri)
372 }
373}
374
375// GetFileDiagnostics returns diagnostics for a specific file.
376func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnostic {
377 diags, _ := c.diagnostics.Get(uri)
378 return diags
379}
380
381// GetDiagnostics returns all diagnostics for all files.
382func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic {
383 return maps.Collect(c.diagnostics.Seq2())
384}
385
386// OpenFileOnDemand opens a file only if it's not already open.
387func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
388 // Check if the file is already open
389 if c.IsFileOpen(filepath) {
390 return nil
391 }
392
393 // Open the file
394 return c.OpenFile(ctx, filepath)
395}
396
397// GetDiagnosticsForFile ensures a file is open and returns its diagnostics.
398func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
399 documentURI := protocol.URIFromPath(filepath)
400
401 // Make sure the file is open
402 if !c.IsFileOpen(filepath) {
403 if err := c.OpenFile(ctx, filepath); err != nil {
404 return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
405 }
406
407 // Give the LSP server a moment to process the file
408 time.Sleep(100 * time.Millisecond)
409 }
410
411 // Get diagnostics
412 diagnostics, _ := c.diagnostics.Get(documentURI)
413
414 return diagnostics, nil
415}
416
417// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache.
418func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
419 c.diagnostics.Del(uri)
420}
421
422// RegisterNotificationHandler registers a notification handler.
423func (c *Client) RegisterNotificationHandler(method string, handler transport.NotificationHandler) {
424 c.client.RegisterNotificationHandler(method, handler)
425}
426
427// RegisterServerRequestHandler handles server requests.
428func (c *Client) RegisterServerRequestHandler(method string, handler transport.Handler) {
429 c.client.RegisterHandler(method, handler)
430}
431
432// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the server.
433func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
434 return c.client.NotifyDidChangeWatchedFiles(ctx, params.Changes)
435}
436
437// openKeyConfigFiles opens important configuration files that help initialize the server.
438func (c *Client) openKeyConfigFiles(ctx context.Context) {
439 wd, err := os.Getwd()
440 if err != nil {
441 return
442 }
443
444 // Try to open each file, ignoring errors if they don't exist
445 for _, file := range c.config.RootMarkers {
446 file = filepath.Join(wd, file)
447 if _, err := os.Stat(file); err == nil {
448 // File exists, try to open it
449 if err := c.OpenFile(ctx, file); err != nil {
450 slog.Debug("Failed to open key config file", "file", file, "error", err)
451 } else {
452 slog.Debug("Opened key config file for initialization", "file", file)
453 }
454 }
455 }
456}
457
458// WaitForDiagnostics waits until diagnostics change or the timeout is reached.
459func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
460 ticker := time.NewTicker(200 * time.Millisecond)
461 defer ticker.Stop()
462 timeout := time.After(d)
463 pv := c.diagnostics.Version()
464 for {
465 select {
466 case <-ctx.Done():
467 return
468 case <-timeout:
469 return
470 case <-ticker.C:
471 if pv != c.diagnostics.Version() {
472 return
473 }
474 }
475 }
476}
477
478// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
479// Uses glob patterns to match files, allowing for more flexible matching.
480func HasRootMarkers(dir string, rootMarkers []string) bool {
481 if len(rootMarkers) == 0 {
482 return true
483 }
484 for _, pattern := range rootMarkers {
485 // Use fsext.GlobWithDoubleStar to find matches
486 matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
487 if err == nil && len(matches) > 0 {
488 return true
489 }
490 }
491 return false
492}