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