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