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	var err error
183	client := s.clients.GetOrSet(name, func() *Client {
184		var cli *Client
185		cli, err = New(
186			ctx,
187			name,
188			cfg,
189			s.cfg.Resolver(),
190			s.cfg.WorkingDir(),
191			s.cfg.Options.DebugLSP,
192		)
193		return cli
194	})
195	if err != nil {
196		slog.Error("Failed to create LSP client", "name", name, "error", err)
197		return
198	}
199	defer func() {
200		s.callback(name, client)
201	}()
202
203	switch client.GetServerState() {
204	case StateReady, StateStarting, StateDisabled:
205		// already done, return
206		return
207	}
208
209	client.serverState.Store(StateStarting)
210
211	initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
212	defer cancel()
213
214	if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
215		slog.Error("LSP client initialization failed", "name", name, "error", err)
216		client.Close(ctx)
217		return
218	}
219
220	if err := client.WaitForServerReady(initCtx); err != nil {
221		slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
222		client.SetServerState(StateError)
223	} else {
224		client.SetServerState(StateReady)
225	}
226
227	slog.Debug("LSP client started", "name", name)
228}
229
230func (s *Manager) isUserConfigured(name string) bool {
231	cfg, ok := s.cfg.LSP[name]
232	return ok && !cfg.Disabled
233}
234
235func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
236	cfg := config.LSPConfig{
237		Command:     server.Command,
238		Args:        server.Args,
239		Env:         server.Environment,
240		FileTypes:   server.FileTypes,
241		RootMarkers: server.RootMarkers,
242		InitOptions: server.InitOptions,
243		Options:     server.Settings,
244	}
245	if userCfg, ok := s.cfg.LSP[name]; ok {
246		cfg.Timeout = userCfg.Timeout
247	}
248	return cfg
249}
250
251func resolveServerName(manager *powernapconfig.Manager, name string) string {
252	if _, ok := manager.GetServer(name); ok {
253		return name
254	}
255	for sname, server := range manager.GetServers() {
256		if server.Command == name {
257			return sname
258		}
259	}
260	return name
261}
262
263func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
264	if len(fileTypes) == 0 {
265		return true
266	}
267
268	kind := powernap.DetectLanguage(filePath)
269	name := strings.ToLower(filepath.Base(filePath))
270	for _, filetype := range fileTypes {
271		suffix := strings.ToLower(filetype)
272		if !strings.HasPrefix(suffix, ".") {
273			suffix = "." + suffix
274		}
275		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
276			slog.Debug("Handles file", "name", sname, "file", name, "filetype", filetype, "kind", kind)
277			return true
278		}
279	}
280
281	slog.Debug("Doesn't handle file", "name", sname, "file", name)
282	return false
283}
284
285func hasRootMarkers(dir string, markers []string) bool {
286	if len(markers) == 0 {
287		return true
288	}
289	for _, pattern := range markers {
290		// Use fsext.GlobWithDoubleStar to find matches
291		matches, _, err := fsext.Glob(pattern, dir, 1)
292		if err == nil && len(matches) > 0 {
293			return true
294		}
295	}
296	return false
297}
298
299func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
300	return handlesFiletype(server.Command, server.FileTypes, filePath) &&
301		hasRootMarkers(workDir, server.RootMarkers)
302}
303
304// KillAll force-kills all the LSP clients.
305//
306// This is generally faster than [Manager.StopAll] because it doesn't wait for
307// the server to exit gracefully, but it can lead to data loss if the server is
308// in the middle of writing something.
309// Generally it doesn't matter when shutting down Crush, though.
310func (s *Manager) KillAll(context.Context) {
311	var wg sync.WaitGroup
312	for name, client := range s.clients.Seq2() {
313		wg.Go(func() {
314			defer func() { s.callback(name, client) }()
315			client.client.Kill()
316			client.SetServerState(StateStopped)
317			s.clients.Del(name)
318			slog.Debug("Killed LSP client", "name", name)
319		})
320	}
321	wg.Wait()
322}
323
324// StopAll stops all running LSP clients and clears the client map.
325func (s *Manager) StopAll(ctx context.Context) {
326	var wg sync.WaitGroup
327	for name, client := range s.clients.Seq2() {
328		wg.Go(func() {
329			defer func() { s.callback(name, client) }()
330			if err := client.Close(ctx); err != nil &&
331				!errors.Is(err, io.EOF) &&
332				!errors.Is(err, context.Canceled) &&
333				!errors.Is(err, jsonrpc2.ErrClosed) &&
334				err.Error() != "signal: killed" {
335				slog.Warn("Failed to stop LSP client", "name", name, "error", err)
336			}
337			client.SetServerState(StateStopped)
338			s.clients.Del(name)
339			slog.Debug("Stopped LSP client", "name", name)
340		})
341	}
342	wg.Wait()
343}