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 (m *Manager) Clients() *csync.Map[string, *Client] {
69 return m.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, filePath string) {
83 s.mu.Lock()
84 defer s.mu.Unlock()
85
86 var wg sync.WaitGroup
87 for name, server := range s.manager.GetServers() {
88 if !handles(server, filePath, s.cfg.WorkingDir()) {
89 continue
90 }
91 wg.Go(func() {
92 s.startServer(ctx, name, server)
93 })
94 }
95 wg.Wait()
96}
97
98// skipAutoStartCommands contains commands that are too generic or ambiguous to
99// auto-start without explicit user configuration.
100var skipAutoStartCommands = map[string]bool{
101 "buck2": true,
102 "buf": true,
103 "cue": true,
104 "dart": true,
105 "deno": true,
106 "dotnet": true,
107 "dprint": true,
108 "gleam": true,
109 "java": true,
110 "julia": true,
111 "koka": true,
112 "node": true,
113 "npx": true,
114 "perl": true,
115 "plz": true,
116 "python": true,
117 "python3": true,
118 "R": true,
119 "racket": true,
120 "rome": true,
121 "rubocop": true,
122 "ruff": true,
123 "scarb": true,
124 "solc": true,
125 "stylua": true,
126 "swipl": true,
127 "tflint": true,
128}
129
130func (s *Manager) startServer(ctx context.Context, name string, server *powernapconfig.ServerConfig) {
131 userConfigured := s.isUserConfigured(name)
132
133 if !userConfigured {
134 if _, err := exec.LookPath(server.Command); err != nil {
135 slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
136 return
137 }
138 if skipAutoStartCommands[server.Command] {
139 slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
140 return
141 }
142 }
143
144 cfg := s.buildConfig(name, server)
145 if client, ok := s.clients.Get(name); ok {
146 switch client.GetServerState() {
147 case StateReady, StateStarting:
148 s.callback(name, client)
149 // already done, return
150 return
151 }
152 }
153 client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP)
154 if err != nil {
155 slog.Error("Failed to create LSP client", "name", name, "error", err)
156 return
157 }
158 s.callback(name, client)
159
160 defer func() {
161 s.clients.Set(name, client)
162 s.callback(name, client)
163 }()
164
165 initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
166 defer cancel()
167
168 if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
169 slog.Error("LSP client initialization failed", "name", name, "error", err)
170 client.Close(ctx)
171 return
172 }
173
174 if err := client.WaitForServerReady(initCtx); err != nil {
175 slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
176 client.SetServerState(StateError)
177 } else {
178 client.SetServerState(StateReady)
179 }
180
181 slog.Debug("LSP client started", "name", name)
182}
183
184func (s *Manager) isUserConfigured(name string) bool {
185 cfg, ok := s.cfg.LSP[name]
186 return ok && !cfg.Disabled
187}
188
189func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
190 cfg := config.LSPConfig{
191 Command: server.Command,
192 Args: server.Args,
193 Env: server.Environment,
194 FileTypes: server.FileTypes,
195 RootMarkers: server.RootMarkers,
196 InitOptions: server.InitOptions,
197 Options: server.Settings,
198 }
199 if userCfg, ok := s.cfg.LSP[name]; ok {
200 cfg.Timeout = userCfg.Timeout
201 }
202 return cfg
203}
204
205func resolveServerName(manager *powernapconfig.Manager, name string) string {
206 if _, ok := manager.GetServer(name); ok {
207 return name
208 }
209 for sname, server := range manager.GetServers() {
210 if server.Command == name {
211 return sname
212 }
213 }
214 return name
215}
216
217func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
218 if len(fileTypes) == 0 {
219 return true
220 }
221
222 kind := powernap.DetectLanguage(filePath)
223 name := strings.ToLower(filepath.Base(filePath))
224 for _, filetype := range fileTypes {
225 suffix := strings.ToLower(filetype)
226 if !strings.HasPrefix(suffix, ".") {
227 suffix = "." + suffix
228 }
229 if strings.HasSuffix(name, suffix) || filetype == string(kind) {
230 slog.Debug("Handles file", "name", sname, "file", name, "filetype", filetype, "kind", kind)
231 return true
232 }
233 }
234
235 slog.Debug("Doesn't handle file", "name", sname, "file", name)
236 return false
237}
238
239func hasRootMarkers(dir string, markers []string) bool {
240 if len(markers) == 0 {
241 return true
242 }
243 for _, pattern := range markers {
244 // Use fsext.GlobWithDoubleStar to find matches
245 matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
246 if err == nil && len(matches) > 0 {
247 return true
248 }
249 }
250 return false
251}
252
253func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
254 return handlesFiletype(server.Command, server.FileTypes, filePath) &&
255 hasRootMarkers(workDir, server.RootMarkers)
256}
257
258// KillAll force-kills all the LSP clients.
259//
260// This is generally faster than [Manager.StopAll] because it doesn't wait for
261// the server to exit gracefully, but it can lead to data loss if the server is
262// in the middle of writing something.
263// Generally it doesn't matter when shutting down Crush, though.
264func (s *Manager) KillAll(context.Context) {
265 s.mu.Lock()
266 defer s.mu.Unlock()
267
268 var wg sync.WaitGroup
269 for name, client := range s.clients.Seq2() {
270 wg.Go(func() {
271 defer func() { s.callback(name, client) }()
272 client.client.Kill()
273 client.SetServerState(StateStopped)
274 slog.Debug("Killed LSP client", "name", name)
275 })
276 }
277 wg.Wait()
278}
279
280// StopAll stops all running LSP clients and clears the client map.
281func (s *Manager) StopAll(ctx context.Context) {
282 s.mu.Lock()
283 defer s.mu.Unlock()
284
285 var wg sync.WaitGroup
286 for name, client := range s.clients.Seq2() {
287 wg.Go(func() {
288 defer func() { s.callback(name, client) }()
289 if err := client.Close(ctx); err != nil &&
290 !errors.Is(err, io.EOF) &&
291 !errors.Is(err, context.Canceled) &&
292 !errors.Is(err, jsonrpc2.ErrClosed) &&
293 err.Error() != "signal: killed" {
294 slog.Warn("Failed to stop LSP client", "name", name, "error", err)
295 }
296 client.SetServerState(StateStopped)
297 slog.Debug("Stopped LSP client", "name", name)
298 })
299 }
300 wg.Wait()
301}