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 "github.com/charmbracelet/x/powernap/pkg/lsp"
21 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
22 "github.com/sourcegraph/jsonrpc2"
23)
24
25// Manager handles lazy initialization of LSP clients based on file types.
26type Manager struct {
27 clients *csync.Map[string, *Client]
28 cfg *config.Config
29 manager *powernapconfig.Manager
30 callback func(name string, client *Client)
31 mu sync.Mutex
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 (m *Manager) Clients() *csync.Map[string, *Client] {
70 return m.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.mu.Lock()
77 defer s.mu.Unlock()
78 s.callback = cb
79}
80
81// Start starts an LSP server that can handle the given file path.
82// If an appropriate LSP is already running, this is a no-op.
83func (s *Manager) Start(ctx context.Context, filePath string) {
84 s.mu.Lock()
85 defer s.mu.Unlock()
86
87 var wg sync.WaitGroup
88 for name, server := range s.manager.GetServers() {
89 if !handles(server, filePath, s.cfg.WorkingDir()) {
90 continue
91 }
92 wg.Go(func() {
93 s.startServer(ctx, name, server)
94 })
95 }
96 wg.Wait()
97}
98
99// skipAutoStartCommands contains commands that are too generic or ambiguous to
100// auto-start without explicit user configuration.
101var skipAutoStartCommands = map[string]bool{
102 "buck2": true,
103 "buf": true,
104 "cue": true,
105 "dart": true,
106 "deno": true,
107 "dotnet": true,
108 "dprint": true,
109 "gleam": true,
110 "java": true,
111 "julia": true,
112 "koka": true,
113 "node": true,
114 "npx": true,
115 "perl": true,
116 "plz": true,
117 "python": true,
118 "python3": true,
119 "R": true,
120 "racket": true,
121 "rome": true,
122 "rubocop": true,
123 "ruff": true,
124 "scarb": true,
125 "solc": true,
126 "stylua": true,
127 "swipl": true,
128 "tflint": true,
129}
130
131func (s *Manager) startServer(ctx context.Context, name string, server *powernapconfig.ServerConfig) {
132 userConfigured := s.isUserConfigured(name)
133
134 if !userConfigured {
135 if _, err := exec.LookPath(server.Command); err != nil {
136 slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
137 return
138 }
139 if skipAutoStartCommands[server.Command] {
140 slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
141 return
142 }
143 }
144
145 cfg := s.buildConfig(name, server)
146 if client, ok := s.clients.Get(name); ok {
147 switch client.GetServerState() {
148 case StateReady, StateStarting:
149 s.callback(name, client)
150 // already done, return
151 return
152 }
153 }
154 client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP)
155 if err != nil {
156 slog.Error("Failed to create LSP client", "name", name, "error", err)
157 return
158 }
159 s.callback(name, client)
160
161 defer func() {
162 s.clients.Set(name, client)
163 s.callback(name, client)
164 }()
165
166 initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
167 defer cancel()
168
169 if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
170 slog.Error("LSP client initialization failed", "name", name, "error", err)
171 client.Close(ctx)
172 return
173 }
174
175 if err := client.WaitForServerReady(initCtx); err != nil {
176 slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
177 client.SetServerState(StateError)
178 } else {
179 client.SetServerState(StateReady)
180 }
181
182 slog.Debug("LSP client started", "name", name)
183}
184
185func (s *Manager) isUserConfigured(name string) bool {
186 cfg, ok := s.cfg.LSP[name]
187 return ok && !cfg.Disabled
188}
189
190func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
191 cfg := config.LSPConfig{
192 Command: server.Command,
193 Args: server.Args,
194 Env: server.Environment,
195 FileTypes: server.FileTypes,
196 RootMarkers: server.RootMarkers,
197 InitOptions: server.InitOptions,
198 Options: server.Settings,
199 }
200 if userCfg, ok := s.cfg.LSP[name]; ok {
201 cfg.Timeout = userCfg.Timeout
202 }
203 return cfg
204}
205
206func resolveServerName(manager *powernapconfig.Manager, name string) string {
207 if _, ok := manager.GetServer(name); ok {
208 return name
209 }
210 for sname, server := range manager.GetServers() {
211 if server.Command == name {
212 return sname
213 }
214 }
215 return name
216}
217
218func handlesFiletype(server *powernapconfig.ServerConfig, ext string, language protocol.LanguageKind) bool {
219 for _, ft := range server.FileTypes {
220 if protocol.LanguageKind(ft) == language ||
221 ft == strings.TrimPrefix(ext, ".") ||
222 "."+ft == ext {
223 return true
224 }
225 }
226 return false
227}
228
229func hasRootMarkers(dir string, markers []string) bool {
230 if len(markers) == 0 {
231 return true
232 }
233 for _, pattern := range markers {
234 // Use fsext.GlobWithDoubleStar to find matches
235 matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
236 if err == nil && len(matches) > 0 {
237 return true
238 }
239 }
240 return false
241}
242
243func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
244 language := lsp.DetectLanguage(filePath)
245 ext := filepath.Ext(filePath)
246 return handlesFiletype(server, ext, language) &&
247 hasRootMarkers(workDir, server.RootMarkers)
248}
249
250// StopAll stops all running LSP clients and clears the client map.
251func (s *Manager) StopAll(ctx context.Context) {
252 s.mu.Lock()
253 defer s.mu.Unlock()
254
255 var wg sync.WaitGroup
256 for name, client := range s.clients.Seq2() {
257 wg.Go(func() {
258 defer func() { s.callback(name, client) }()
259 if err := client.Close(ctx); err != nil &&
260 !errors.Is(err, io.EOF) &&
261 !errors.Is(err, context.Canceled) &&
262 !errors.Is(err, jsonrpc2.ErrClosed) &&
263 err.Error() != "signal: killed" {
264 slog.Warn("Failed to stop LSP client", "name", name, "error", err)
265 }
266 client.SetServerState(StateStopped)
267 slog.Debug("Stopped LSP client", "name", name)
268 })
269 }
270 wg.Wait()
271}