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	"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}