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