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