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