1// Package lsp provides a manager for Language Server Protocol (LSP) clients.
2package lsp
3
4import (
5 "cmp"
6 "context"
7 "errors"
8 "io"
9 "log/slog"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "sync"
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 powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
20 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
21 "github.com/sourcegraph/jsonrpc2"
22)
23
24const unavailableRetryDelay = 30 * time.Second
25
26// Manager handles lazy initialization of LSP clients based on file types.
27type Manager struct {
28 clients *csync.Map[string, *Client]
29 unavailable *csync.Map[string, time.Time]
30 cfg *config.ConfigStore
31 manager *powernapconfig.Manager
32 callback func(name string, client *Client)
33 now func() time.Time
34}
35
36// NewManager creates a new LSP manager service.
37func NewManager(cfg *config.ConfigStore) *Manager {
38 manager := powernapconfig.NewManager()
39 manager.LoadDefaults()
40
41 // Merge user-configured LSPs into the manager.
42 for name, clientConfig := range cfg.Config().LSP {
43 if clientConfig.Disabled {
44 slog.Debug("LSP disabled by user config", "name", name)
45 manager.RemoveServer(name)
46 continue
47 }
48
49 // HACK: the user might have the command name in their config instead
50 // of the actual name. Find and use the correct name.
51 actualName := resolveServerName(manager, name)
52 manager.AddServer(actualName, &powernapconfig.ServerConfig{
53 Command: clientConfig.Command,
54 Args: clientConfig.Args,
55 Environment: clientConfig.Env,
56 FileTypes: clientConfig.FileTypes,
57 RootMarkers: clientConfig.RootMarkers,
58 InitOptions: clientConfig.InitOptions,
59 Settings: clientConfig.Options,
60 })
61 }
62
63 return &Manager{
64 clients: csync.NewMap[string, *Client](),
65 unavailable: csync.NewMap[string, time.Time](),
66 cfg: cfg,
67 manager: manager,
68 callback: func(string, *Client) {}, // default no-op callback
69 now: time.Now,
70 }
71}
72
73// Clients returns the map of LSP clients.
74func (s *Manager) Clients() *csync.Map[string, *Client] {
75 return s.clients
76}
77
78// SetCallback sets a callback that is invoked when a new LSP
79// client is successfully started. This allows the coordinator to add LSP tools.
80func (s *Manager) SetCallback(cb func(name string, client *Client)) {
81 s.callback = cb
82}
83
84// TrackConfigured will callback the user-configured LSPs, but will not create
85// any clients.
86func (s *Manager) TrackConfigured() {
87 var wg sync.WaitGroup
88 for name := range s.manager.GetServers() {
89 if !s.isUserConfigured(name) {
90 continue
91 }
92 wg.Go(func() {
93 s.callback(name, nil)
94 })
95 }
96 wg.Wait()
97}
98
99// Start starts an LSP server that can handle the given file path.
100// If an appropriate LSP is already running, this is a no-op.
101func (s *Manager) Start(ctx context.Context, path string) {
102 if !fsext.HasPrefix(path, s.cfg.WorkingDir()) {
103 return
104 }
105
106 var wg sync.WaitGroup
107 for name, server := range s.manager.GetServers() {
108 wg.Go(func() {
109 s.startServer(ctx, name, path, server)
110 })
111 }
112 wg.Wait()
113}
114
115// skipAutoStartCommands contains commands that are too generic or ambiguous to
116// auto-start without explicit user configuration.
117var skipAutoStartCommands = map[string]bool{
118 "buck2": true,
119 "buf": true,
120 "cue": true,
121 "dart": true,
122 "deno": true,
123 "dotnet": true,
124 "dprint": true,
125 "gleam": true,
126 "java": true,
127 "julia": true,
128 "koka": true,
129 "node": true,
130 "npx": true,
131 "perl": true,
132 "plz": true,
133 "python": true,
134 "python3": true,
135 "R": true,
136 "racket": true,
137 "rome": true,
138 "rubocop": true,
139 "ruff": true,
140 "scarb": true,
141 "solc": true,
142 "stylua": true,
143 "swipl": true,
144 "tflint": true,
145}
146
147func (s *Manager) startServer(ctx context.Context, name, filepath string, server *powernapconfig.ServerConfig) {
148 var (
149 isUserConfigured = s.isUserConfigured(name)
150 autoLSP = s.cfg.Config().Options.AutoLSP
151 )
152 if !isUserConfigured && autoLSP != nil && !*autoLSP {
153 slog.Debug("Auto-start LSP disabled", "name", name)
154 return
155 }
156
157 cfg := s.buildConfig(name, server)
158 if cfg.Disabled {
159 return
160 }
161
162 if client, ok := s.clients.Get(name); ok {
163 switch client.GetServerState() {
164 case StateReady, StateStarting, StateDisabled:
165 s.callback(name, client)
166 // already done, return
167 return
168 }
169 }
170
171 if !isUserConfigured {
172 if s.recentlyUnavailable(name) {
173 return
174 }
175 if _, err := exec.LookPath(server.Command); err != nil {
176 slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
177 s.markUnavailable(name)
178 return
179 }
180 s.clearUnavailable(name)
181 if skipAutoStartCommands[server.Command] {
182 slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
183 return
184 }
185 }
186
187 // this is the slowest bit, so we do it last.
188 if !handles(server, filepath, s.cfg.WorkingDir()) {
189 // nothing to do
190 return
191 }
192
193 // Check again in case another goroutine started it in the meantime.
194 if client, ok := s.clients.Get(name); ok {
195 switch client.GetServerState() {
196 case StateReady, StateStarting, StateDisabled:
197 s.callback(name, client)
198 return
199 }
200 }
201
202 client, err := New(
203 ctx,
204 name,
205 cfg,
206 s.cfg.Resolver(),
207 s.cfg.WorkingDir(),
208 s.cfg.Config().Options.DebugLSP,
209 )
210 if err != nil {
211 slog.Error("Failed to create LSP client", "name", name, "error", err)
212 return
213 }
214 // Only store non-nil clients. If another goroutine raced us,
215 // prefer the already-stored client.
216 if existing, ok := s.clients.Get(name); ok {
217 switch existing.GetServerState() {
218 case StateReady, StateStarting, StateDisabled:
219 _ = client.Close(ctx)
220 s.callback(name, existing)
221 return
222 }
223 }
224 s.clients.Set(name, client)
225 defer func() {
226 s.callback(name, client)
227 }()
228
229 switch client.GetServerState() {
230 case StateReady, StateStarting, StateDisabled:
231 // already done, return
232 return
233 }
234
235 client.serverState.Store(StateStarting)
236
237 initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
238 defer cancel()
239
240 if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
241 slog.Error("LSP client initialization failed", "name", name, "error", err)
242 _ = client.Close(ctx)
243 s.clients.Del(name)
244 return
245 }
246
247 if err := client.WaitForServerReady(initCtx); err != nil {
248 slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
249 client.SetServerState(StateError)
250 } else {
251 client.SetServerState(StateReady)
252 }
253
254 slog.Debug("LSP client started", "name", name)
255}
256
257func (s *Manager) isUserConfigured(name string) bool {
258 cfg, ok := s.cfg.Config().LSP[name]
259 return ok && !cfg.Disabled
260}
261
262func (s *Manager) recentlyUnavailable(name string) bool {
263 lastUnavailableAt, exists := s.unavailable.Get(name)
264 if !exists {
265 return false
266 }
267 if s.now().Sub(lastUnavailableAt) < unavailableRetryDelay {
268 return true
269 }
270 s.unavailable.Del(name)
271 return false
272}
273
274func (s *Manager) markUnavailable(name string) {
275 s.unavailable.Set(name, s.now())
276}
277
278func (s *Manager) clearUnavailable(name string) {
279 s.unavailable.Del(name)
280}
281
282func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
283 cfg := config.LSPConfig{
284 Command: server.Command,
285 Args: server.Args,
286 Env: server.Environment,
287 FileTypes: server.FileTypes,
288 RootMarkers: server.RootMarkers,
289 InitOptions: server.InitOptions,
290 Options: server.Settings,
291 }
292 if userCfg, ok := s.cfg.Config().LSP[name]; ok {
293 cfg.Timeout = userCfg.Timeout
294 }
295 return cfg
296}
297
298func resolveServerName(manager *powernapconfig.Manager, name string) string {
299 if _, ok := manager.GetServer(name); ok {
300 return name
301 }
302 for sname, server := range manager.GetServers() {
303 if server.Command == name {
304 return sname
305 }
306 }
307 return name
308}
309
310func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
311 if len(fileTypes) == 0 {
312 return true
313 }
314
315 kind := powernap.DetectLanguage(filePath)
316 name := strings.ToLower(filepath.Base(filePath))
317 for _, filetype := range fileTypes {
318 suffix := strings.ToLower(filetype)
319 if !strings.HasPrefix(suffix, ".") {
320 suffix = "." + suffix
321 }
322 if strings.HasSuffix(name, suffix) || filetype == string(kind) {
323 slog.Debug("Handles file", "name", sname, "file", name, "filetype", filetype, "kind", kind)
324 return true
325 }
326 }
327
328 slog.Debug("Doesn't handle file", "name", sname, "file", name)
329 return false
330}
331
332func hasRootMarkers(dir string, markers []string) bool {
333 if len(markers) == 0 {
334 return true
335 }
336 for _, pattern := range markers {
337 // Use filepath.Glob for a non-recursive check in the root
338 // directory. This avoids walking the entire tree (which is
339 // catastrophic in large monorepos with node_modules, etc.).
340 matches, err := filepath.Glob(filepath.Join(dir, pattern))
341 if err == nil && len(matches) > 0 {
342 return true
343 }
344 }
345 return false
346}
347
348func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
349 return handlesFiletype(server.Command, server.FileTypes, filePath) &&
350 hasRootMarkers(workDir, server.RootMarkers)
351}
352
353// KillAll force-kills all the LSP clients.
354//
355// This is generally faster than [Manager.StopAll] because it doesn't wait for
356// the server to exit gracefully, but it can lead to data loss if the server is
357// in the middle of writing something.
358// Generally it doesn't matter when shutting down Crush, though.
359func (s *Manager) KillAll(context.Context) {
360 var wg sync.WaitGroup
361 for name, client := range s.clients.Seq2() {
362 wg.Go(func() {
363 defer func() { s.callback(name, client) }()
364 client.client.Kill()
365 client.SetServerState(StateStopped)
366 s.clients.Del(name)
367 slog.Debug("Killed LSP client", "name", name)
368 })
369 }
370 wg.Wait()
371}
372
373// StopAll stops all running LSP clients and clears the client map.
374func (s *Manager) StopAll(ctx context.Context) {
375 var wg sync.WaitGroup
376 for name, client := range s.clients.Seq2() {
377 wg.Go(func() {
378 defer func() { s.callback(name, client) }()
379 if err := client.Close(ctx); err != nil &&
380 !errors.Is(err, io.EOF) &&
381 !errors.Is(err, context.Canceled) &&
382 !errors.Is(err, jsonrpc2.ErrClosed) &&
383 err.Error() != "signal: killed" {
384 slog.Warn("Failed to stop LSP client", "name", name, "error", err)
385 }
386 client.SetServerState(StateStopped)
387 s.clients.Del(name)
388 slog.Debug("Stopped LSP client", "name", name)
389 })
390 }
391 wg.Wait()
392}