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