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 var err error
183 client := s.clients.GetOrSet(name, func() *Client {
184 var cli *Client
185 cli, err = New(
186 ctx,
187 name,
188 cfg,
189 s.cfg.Resolver(),
190 s.cfg.WorkingDir(),
191 s.cfg.Options.DebugLSP,
192 )
193 return cli
194 })
195 if err != nil {
196 slog.Error("Failed to create LSP client", "name", name, "error", err)
197 return
198 }
199 defer func() {
200 s.callback(name, client)
201 }()
202
203 switch client.GetServerState() {
204 case StateReady, StateStarting, StateDisabled:
205 // already done, return
206 return
207 }
208
209 client.serverState.Store(StateStarting)
210
211 initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
212 defer cancel()
213
214 if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
215 slog.Error("LSP client initialization failed", "name", name, "error", err)
216 client.Close(ctx)
217 return
218 }
219
220 if err := client.WaitForServerReady(initCtx); err != nil {
221 slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
222 client.SetServerState(StateError)
223 } else {
224 client.SetServerState(StateReady)
225 }
226
227 slog.Debug("LSP client started", "name", name)
228}
229
230func (s *Manager) isUserConfigured(name string) bool {
231 cfg, ok := s.cfg.LSP[name]
232 return ok && !cfg.Disabled
233}
234
235func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
236 cfg := config.LSPConfig{
237 Command: server.Command,
238 Args: server.Args,
239 Env: server.Environment,
240 FileTypes: server.FileTypes,
241 RootMarkers: server.RootMarkers,
242 InitOptions: server.InitOptions,
243 Options: server.Settings,
244 }
245 if userCfg, ok := s.cfg.LSP[name]; ok {
246 cfg.Timeout = userCfg.Timeout
247 }
248 return cfg
249}
250
251func resolveServerName(manager *powernapconfig.Manager, name string) string {
252 if _, ok := manager.GetServer(name); ok {
253 return name
254 }
255 for sname, server := range manager.GetServers() {
256 if server.Command == name {
257 return sname
258 }
259 }
260 return name
261}
262
263func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
264 if len(fileTypes) == 0 {
265 return true
266 }
267
268 kind := powernap.DetectLanguage(filePath)
269 name := strings.ToLower(filepath.Base(filePath))
270 for _, filetype := range fileTypes {
271 suffix := strings.ToLower(filetype)
272 if !strings.HasPrefix(suffix, ".") {
273 suffix = "." + suffix
274 }
275 if strings.HasSuffix(name, suffix) || filetype == string(kind) {
276 slog.Debug("Handles file", "name", sname, "file", name, "filetype", filetype, "kind", kind)
277 return true
278 }
279 }
280
281 slog.Debug("Doesn't handle file", "name", sname, "file", name)
282 return false
283}
284
285func hasRootMarkers(dir string, markers []string) bool {
286 if len(markers) == 0 {
287 return true
288 }
289 for _, pattern := range markers {
290 // Use fsext.GlobWithDoubleStar to find matches
291 matches, _, err := fsext.Glob(pattern, dir, 1)
292 if err == nil && len(matches) > 0 {
293 return true
294 }
295 }
296 return false
297}
298
299func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
300 return handlesFiletype(server.Command, server.FileTypes, filePath) &&
301 hasRootMarkers(workDir, server.RootMarkers)
302}
303
304// KillAll force-kills all the LSP clients.
305//
306// This is generally faster than [Manager.StopAll] because it doesn't wait for
307// the server to exit gracefully, but it can lead to data loss if the server is
308// in the middle of writing something.
309// Generally it doesn't matter when shutting down Crush, though.
310func (s *Manager) KillAll(context.Context) {
311 var wg sync.WaitGroup
312 for name, client := range s.clients.Seq2() {
313 wg.Go(func() {
314 defer func() { s.callback(name, client) }()
315 client.client.Kill()
316 client.SetServerState(StateStopped)
317 s.clients.Del(name)
318 slog.Debug("Killed LSP client", "name", name)
319 })
320 }
321 wg.Wait()
322}
323
324// StopAll stops all running LSP clients and clears the client map.
325func (s *Manager) StopAll(ctx context.Context) {
326 var wg sync.WaitGroup
327 for name, client := range s.clients.Seq2() {
328 wg.Go(func() {
329 defer func() { s.callback(name, client) }()
330 if err := client.Close(ctx); err != nil &&
331 !errors.Is(err, io.EOF) &&
332 !errors.Is(err, context.Canceled) &&
333 !errors.Is(err, jsonrpc2.ErrClosed) &&
334 err.Error() != "signal: killed" {
335 slog.Warn("Failed to stop LSP client", "name", name, "error", err)
336 }
337 client.SetServerState(StateStopped)
338 s.clients.Del(name)
339 slog.Debug("Stopped LSP client", "name", name)
340 })
341 }
342 wg.Wait()
343}