@@ -0,0 +1,271 @@
1+// Package lsp provides a manager for Language Server Protocol (LSP) clients.
2+package lsp
3+
4+import (
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.
26+type 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.
35+func 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.
69+func (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.
75+func (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.
83+func (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.
101+var 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+
131+func (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+
185+func (s *Manager) isUserConfigured(name string) bool {
186+ cfg, ok := s.cfg.LSP[name]
187+ return ok && !cfg.Disabled
188+}
189+
190+func (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+
206+func 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+
218+func 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+
229+func 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+
243+func 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.
251+func (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+}