manager.go

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