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 cfg := s.buildConfig(name, server)
145 if cfg.Disabled {
146 return
147 }
148
149 if _, exists := unavailable.Get(name); exists {
150 return
151 }
152
153 if client, ok := s.clients.Get(name); ok {
154 switch client.GetServerState() {
155 case StateReady, StateStarting, StateDisabled:
156 s.callback(name, client)
157 // already done, return
158 return
159 }
160 }
161
162 userConfigured := s.isUserConfigured(name)
163
164 if !userConfigured {
165 if _, err := exec.LookPath(server.Command); err != nil {
166 slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
167 unavailable.Set(name, struct{}{})
168 return
169 }
170 if skipAutoStartCommands[server.Command] {
171 slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
172 return
173 }
174 }
175
176 // this is the slowest bit, so we do it last.
177 if !handles(server, filepath, s.cfg.WorkingDir()) {
178 // nothing to do
179 return
180 }
181
182 // Check again in case another goroutine started it in the meantime.
183 if client, ok := s.clients.Get(name); ok {
184 switch client.GetServerState() {
185 case StateReady, StateStarting, StateDisabled:
186 s.callback(name, client)
187 return
188 }
189 }
190
191 client, err := New(
192 ctx,
193 name,
194 cfg,
195 s.cfg.Resolver(),
196 s.cfg.WorkingDir(),
197 s.cfg.Config().Options.DebugLSP,
198 )
199 if err != nil {
200 slog.Error("Failed to create LSP client", "name", name, "error", err)
201 return
202 }
203 // Only store non-nil clients. If another goroutine raced us,
204 // prefer the already-stored client.
205 if existing, ok := s.clients.Get(name); ok {
206 switch existing.GetServerState() {
207 case StateReady, StateStarting, StateDisabled:
208 _ = client.Close(ctx)
209 s.callback(name, existing)
210 return
211 }
212 }
213 s.clients.Set(name, client)
214 defer func() {
215 s.callback(name, client)
216 }()
217
218 switch client.GetServerState() {
219 case StateReady, StateStarting, StateDisabled:
220 // already done, return
221 return
222 }
223
224 client.serverState.Store(StateStarting)
225
226 initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
227 defer cancel()
228
229 if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
230 slog.Error("LSP client initialization failed", "name", name, "error", err)
231 _ = client.Close(ctx)
232 s.clients.Del(name)
233 return
234 }
235
236 if err := client.WaitForServerReady(initCtx); err != nil {
237 slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
238 client.SetServerState(StateError)
239 } else {
240 client.SetServerState(StateReady)
241 }
242
243 slog.Debug("LSP client started", "name", name)
244}
245
246func (s *Manager) isUserConfigured(name string) bool {
247 cfg, ok := s.cfg.Config().LSP[name]
248 return ok && !cfg.Disabled
249}
250
251func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
252 cfg := config.LSPConfig{
253 Command: server.Command,
254 Args: server.Args,
255 Env: server.Environment,
256 FileTypes: server.FileTypes,
257 RootMarkers: server.RootMarkers,
258 InitOptions: server.InitOptions,
259 Options: server.Settings,
260 }
261 if userCfg, ok := s.cfg.Config().LSP[name]; ok {
262 cfg.Timeout = userCfg.Timeout
263 }
264 return cfg
265}
266
267func resolveServerName(manager *powernapconfig.Manager, name string) string {
268 if _, ok := manager.GetServer(name); ok {
269 return name
270 }
271 for sname, server := range manager.GetServers() {
272 if server.Command == name {
273 return sname
274 }
275 }
276 return name
277}
278
279func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
280 if len(fileTypes) == 0 {
281 return true
282 }
283
284 kind := powernap.DetectLanguage(filePath)
285 name := strings.ToLower(filepath.Base(filePath))
286 for _, filetype := range fileTypes {
287 suffix := strings.ToLower(filetype)
288 if !strings.HasPrefix(suffix, ".") {
289 suffix = "." + suffix
290 }
291 if strings.HasSuffix(name, suffix) || filetype == string(kind) {
292 slog.Debug("Handles file", "name", sname, "file", name, "filetype", filetype, "kind", kind)
293 return true
294 }
295 }
296
297 slog.Debug("Doesn't handle file", "name", sname, "file", name)
298 return false
299}
300
301func hasRootMarkers(dir string, markers []string) bool {
302 if len(markers) == 0 {
303 return true
304 }
305 for _, pattern := range markers {
306 // Use filepath.Glob for a non-recursive check in the root
307 // directory. This avoids walking the entire tree (which is
308 // catastrophic in large monorepos with node_modules, etc.).
309 matches, err := filepath.Glob(filepath.Join(dir, pattern))
310 if err == nil && len(matches) > 0 {
311 return true
312 }
313 }
314 return false
315}
316
317func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
318 return handlesFiletype(server.Command, server.FileTypes, filePath) &&
319 hasRootMarkers(workDir, server.RootMarkers)
320}
321
322// KillAll force-kills all the LSP clients.
323//
324// This is generally faster than [Manager.StopAll] because it doesn't wait for
325// the server to exit gracefully, but it can lead to data loss if the server is
326// in the middle of writing something.
327// Generally it doesn't matter when shutting down Crush, though.
328func (s *Manager) KillAll(context.Context) {
329 var wg sync.WaitGroup
330 for name, client := range s.clients.Seq2() {
331 wg.Go(func() {
332 defer func() { s.callback(name, client) }()
333 client.client.Kill()
334 client.SetServerState(StateStopped)
335 s.clients.Del(name)
336 slog.Debug("Killed LSP client", "name", name)
337 })
338 }
339 wg.Wait()
340}
341
342// StopAll stops all running LSP clients and clears the client map.
343func (s *Manager) StopAll(ctx context.Context) {
344 var wg sync.WaitGroup
345 for name, client := range s.clients.Seq2() {
346 wg.Go(func() {
347 defer func() { s.callback(name, client) }()
348 if err := client.Close(ctx); err != nil &&
349 !errors.Is(err, io.EOF) &&
350 !errors.Is(err, context.Canceled) &&
351 !errors.Is(err, jsonrpc2.ErrClosed) &&
352 err.Error() != "signal: killed" {
353 slog.Warn("Failed to stop LSP client", "name", name, "error", err)
354 }
355 client.SetServerState(StateStopped)
356 s.clients.Del(name)
357 slog.Debug("Stopped LSP client", "name", name)
358 })
359 }
360 wg.Wait()
361}