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