client.go

  1package lsp
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"maps"
  9	"os"
 10	"path/filepath"
 11	"strings"
 12	"sync"
 13	"sync/atomic"
 14	"time"
 15
 16	"github.com/bmatcuk/doublestar/v4"
 17	"github.com/charmbracelet/crush/internal/config"
 18	"github.com/charmbracelet/crush/internal/csync"
 19	"github.com/charmbracelet/crush/internal/fsext"
 20	"github.com/charmbracelet/crush/internal/home"
 21	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
 22	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 23	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 24	"github.com/charmbracelet/x/powernap/pkg/transport"
 25)
 26
 27// DiagnosticCounts holds the count of diagnostics by severity.
 28type DiagnosticCounts struct {
 29	Error       int
 30	Warning     int
 31	Information int
 32	Hint        int
 33}
 34
 35type Client struct {
 36	client *powernap.Client
 37	name   string
 38
 39	// Working directory this LSP is scoped to.
 40	workDir string
 41
 42	// File types this LSP server handles (e.g., .go, .rs, .py)
 43	fileTypes []string
 44
 45	// Configuration for this LSP client
 46	config config.LSPConfig
 47
 48	// Original context and resolver for recreating the client
 49	ctx      context.Context
 50	resolver config.VariableResolver
 51
 52	// Diagnostic change callback
 53	onDiagnosticsChanged func(name string, count int)
 54
 55	// Diagnostic cache
 56	diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
 57
 58	// Cached diagnostic counts to avoid map copy on every UI render.
 59	diagCountsCache   DiagnosticCounts
 60	diagCountsVersion uint64
 61	diagCountsMu      sync.Mutex
 62
 63	// Files are currently opened by the LSP
 64	openFiles *csync.Map[string, *OpenFileInfo]
 65
 66	// Server state
 67	serverState atomic.Value
 68}
 69
 70// New creates a new LSP client using the powernap implementation.
 71func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) {
 72	client := &Client{
 73		name:        name,
 74		fileTypes:   cfg.FileTypes,
 75		diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
 76		openFiles:   csync.NewMap[string, *OpenFileInfo](),
 77		config:      cfg,
 78		ctx:         ctx,
 79		resolver:    resolver,
 80	}
 81	client.serverState.Store(StateStarting)
 82
 83	if err := client.createPowernapClient(); err != nil {
 84		return nil, err
 85	}
 86
 87	return client, nil
 88}
 89
 90// Initialize initializes the LSP client and returns the server capabilities.
 91func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
 92	if err := c.client.Initialize(ctx, false); err != nil {
 93		return nil, fmt.Errorf("failed to initialize the lsp client: %w", err)
 94	}
 95
 96	// Convert powernap capabilities to protocol capabilities
 97	caps := c.client.GetCapabilities()
 98	protocolCaps := protocol.ServerCapabilities{
 99		TextDocumentSync: caps.TextDocumentSync,
100		CompletionProvider: func() *protocol.CompletionOptions {
101			if caps.CompletionProvider != nil {
102				return &protocol.CompletionOptions{
103					TriggerCharacters:   caps.CompletionProvider.TriggerCharacters,
104					AllCommitCharacters: caps.CompletionProvider.AllCommitCharacters,
105					ResolveProvider:     caps.CompletionProvider.ResolveProvider,
106				}
107			}
108			return nil
109		}(),
110	}
111
112	result := &protocol.InitializeResult{
113		Capabilities: protocolCaps,
114	}
115
116	c.registerHandlers()
117
118	return result, nil
119}
120
121// Close closes the LSP client.
122func (c *Client) Close(ctx context.Context) error {
123	c.CloseAllFiles(ctx)
124
125	// Shutdown and exit the client
126	if err := c.client.Shutdown(ctx); err != nil {
127		slog.Warn("Failed to shutdown LSP client", "error", err)
128	}
129
130	return c.client.Exit()
131}
132
133// createPowernapClient creates a new powernap client with the current configuration.
134func (c *Client) createPowernapClient() error {
135	workDir, err := os.Getwd()
136	if err != nil {
137		return fmt.Errorf("failed to get working directory: %w", err)
138	}
139
140	rootURI := string(protocol.URIFromPath(workDir))
141	c.workDir = workDir
142
143	command, err := c.resolver.ResolveValue(c.config.Command)
144	if err != nil {
145		return fmt.Errorf("invalid lsp command: %w", err)
146	}
147
148	clientConfig := powernap.ClientConfig{
149		Command:     home.Long(command),
150		Args:        c.config.Args,
151		RootURI:     rootURI,
152		Environment: maps.Clone(c.config.Env),
153		Settings:    c.config.Options,
154		InitOptions: c.config.InitOptions,
155		WorkspaceFolders: []protocol.WorkspaceFolder{
156			{
157				URI:  rootURI,
158				Name: filepath.Base(workDir),
159			},
160		},
161	}
162
163	powernapClient, err := powernap.NewClient(clientConfig)
164	if err != nil {
165		return fmt.Errorf("failed to create lsp client: %w", err)
166	}
167
168	c.client = powernapClient
169	return nil
170}
171
172// registerHandlers registers the standard LSP notification and request handlers.
173func (c *Client) registerHandlers() {
174	c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
175	c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
176	c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
177	c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
178	c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) {
179		HandleDiagnostics(c, params)
180	})
181}
182
183// Restart closes the current LSP client and creates a new one with the same configuration.
184func (c *Client) Restart() error {
185	var openFiles []string
186	for uri := range c.openFiles.Seq2() {
187		openFiles = append(openFiles, string(uri))
188	}
189
190	closeCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
191	defer cancel()
192
193	if err := c.Close(closeCtx); err != nil {
194		slog.Warn("Error closing client during restart", "name", c.name, "error", err)
195	}
196
197	c.diagCountsCache = DiagnosticCounts{}
198	c.diagCountsVersion = 0
199
200	if err := c.createPowernapClient(); err != nil {
201		return err
202	}
203
204	initCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
205	defer cancel()
206
207	c.SetServerState(StateStarting)
208
209	if err := c.client.Initialize(initCtx, false); err != nil {
210		c.SetServerState(StateError)
211		return fmt.Errorf("failed to initialize lsp client: %w", err)
212	}
213
214	c.registerHandlers()
215
216	if err := c.WaitForServerReady(initCtx); err != nil {
217		slog.Error("Server failed to become ready after restart", "name", c.name, "error", err)
218		c.SetServerState(StateError)
219		return err
220	}
221
222	for _, uri := range openFiles {
223		if err := c.OpenFile(initCtx, uri); err != nil {
224			slog.Warn("Failed to reopen file after restart", "file", uri, "error", err)
225		}
226	}
227	return nil
228}
229
230// ServerState represents the state of an LSP server
231type ServerState int
232
233const (
234	StateStarting ServerState = iota
235	StateReady
236	StateError
237	StateDisabled
238)
239
240// GetServerState returns the current state of the LSP server
241func (c *Client) GetServerState() ServerState {
242	if val := c.serverState.Load(); val != nil {
243		return val.(ServerState)
244	}
245	return StateStarting
246}
247
248// SetServerState sets the current state of the LSP server
249func (c *Client) SetServerState(state ServerState) {
250	c.serverState.Store(state)
251}
252
253// GetName returns the name of the LSP client
254func (c *Client) GetName() string {
255	return c.name
256}
257
258// SetDiagnosticsCallback sets the callback function for diagnostic changes
259func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
260	c.onDiagnosticsChanged = callback
261}
262
263// WaitForServerReady waits for the server to be ready
264func (c *Client) WaitForServerReady(ctx context.Context) error {
265	cfg := config.Get()
266
267	// Set initial state
268	c.SetServerState(StateStarting)
269
270	// Create a context with timeout
271	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
272	defer cancel()
273
274	// Try to ping the server with a simple request
275	ticker := time.NewTicker(500 * time.Millisecond)
276	defer ticker.Stop()
277
278	if cfg != nil && cfg.Options.DebugLSP {
279		slog.Debug("Waiting for LSP server to be ready...")
280	}
281
282	c.openKeyConfigFiles(ctx)
283
284	for {
285		select {
286		case <-ctx.Done():
287			c.SetServerState(StateError)
288			return fmt.Errorf("timeout waiting for LSP server to be ready")
289		case <-ticker.C:
290			// Check if client is running
291			if !c.client.IsRunning() {
292				if cfg != nil && cfg.Options.DebugLSP {
293					slog.Debug("LSP server not ready yet", "server", c.name)
294				}
295				continue
296			}
297
298			// Server is ready
299			c.SetServerState(StateReady)
300			if cfg != nil && cfg.Options.DebugLSP {
301				slog.Debug("LSP server is ready")
302			}
303			return nil
304		}
305	}
306}
307
308// OpenFileInfo contains information about an open file
309type OpenFileInfo struct {
310	Version int32
311	URI     protocol.DocumentURI
312}
313
314// HandlesFile checks if this LSP client handles the given file based on its
315// extension and whether it's within the working directory.
316func (c *Client) HandlesFile(path string) bool {
317	// Check if file is within working directory.
318	absPath, err := filepath.Abs(path)
319	if err != nil {
320		slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
321		return false
322	}
323	relPath, err := filepath.Rel(c.workDir, absPath)
324	if err != nil || strings.HasPrefix(relPath, "..") {
325		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
326		return false
327	}
328
329	// If no file types are specified, handle all files (backward compatibility).
330	if len(c.fileTypes) == 0 {
331		return true
332	}
333
334	kind := powernap.DetectLanguage(path)
335	name := strings.ToLower(filepath.Base(path))
336	for _, filetype := range c.fileTypes {
337		suffix := strings.ToLower(filetype)
338		if !strings.HasPrefix(suffix, ".") {
339			suffix = "." + suffix
340		}
341		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
342			slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
343			return true
344		}
345	}
346	slog.Debug("Doesn't handle file", "name", c.name, "file", name)
347	return false
348}
349
350// OpenFile opens a file in the LSP server.
351func (c *Client) OpenFile(ctx context.Context, filepath string) error {
352	if !c.HandlesFile(filepath) {
353		return nil
354	}
355
356	uri := string(protocol.URIFromPath(filepath))
357
358	if _, exists := c.openFiles.Get(uri); exists {
359		return nil // Already open
360	}
361
362	// Skip files that do not exist or cannot be read
363	content, err := os.ReadFile(filepath)
364	if err != nil {
365		return fmt.Errorf("error reading file: %w", err)
366	}
367
368	// Notify the server about the opened document
369	if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil {
370		return err
371	}
372
373	c.openFiles.Set(uri, &OpenFileInfo{
374		Version: 1,
375		URI:     protocol.DocumentURI(uri),
376	})
377
378	return nil
379}
380
381// NotifyChange notifies the server about a file change.
382func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
383	uri := string(protocol.URIFromPath(filepath))
384
385	content, err := os.ReadFile(filepath)
386	if err != nil {
387		return fmt.Errorf("error reading file: %w", err)
388	}
389
390	fileInfo, isOpen := c.openFiles.Get(uri)
391	if !isOpen {
392		return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
393	}
394
395	// Increment version
396	fileInfo.Version++
397
398	// Create change event
399	changes := []protocol.TextDocumentContentChangeEvent{
400		{
401			Value: protocol.TextDocumentContentChangeWholeDocument{
402				Text: string(content),
403			},
404		},
405	}
406
407	return c.client.NotifyDidChangeTextDocument(ctx, uri, int(fileInfo.Version), changes)
408}
409
410// IsFileOpen checks if a file is currently open.
411func (c *Client) IsFileOpen(filepath string) bool {
412	uri := string(protocol.URIFromPath(filepath))
413	_, exists := c.openFiles.Get(uri)
414	return exists
415}
416
417// CloseAllFiles closes all currently open files.
418func (c *Client) CloseAllFiles(ctx context.Context) {
419	cfg := config.Get()
420	debugLSP := cfg != nil && cfg.Options.DebugLSP
421	for uri := range c.openFiles.Seq2() {
422		if debugLSP {
423			slog.Debug("Closing file", "file", uri)
424		}
425		if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil {
426			slog.Warn("Error closing file", "uri", uri, "error", err)
427			continue
428		}
429		c.openFiles.Del(uri)
430	}
431}
432
433// GetFileDiagnostics returns diagnostics for a specific file.
434func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnostic {
435	diags, _ := c.diagnostics.Get(uri)
436	return diags
437}
438
439// GetDiagnostics returns all diagnostics for all files.
440func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic {
441	return c.diagnostics.Copy()
442}
443
444// GetDiagnosticCounts returns cached diagnostic counts by severity.
445// Uses the VersionedMap version to avoid recomputing on every call.
446func (c *Client) GetDiagnosticCounts() DiagnosticCounts {
447	currentVersion := c.diagnostics.Version()
448
449	c.diagCountsMu.Lock()
450	defer c.diagCountsMu.Unlock()
451
452	if currentVersion == c.diagCountsVersion {
453		return c.diagCountsCache
454	}
455
456	// Recompute counts.
457	counts := DiagnosticCounts{}
458	for _, diags := range c.diagnostics.Seq2() {
459		for _, diag := range diags {
460			switch diag.Severity {
461			case protocol.SeverityError:
462				counts.Error++
463			case protocol.SeverityWarning:
464				counts.Warning++
465			case protocol.SeverityInformation:
466				counts.Information++
467			case protocol.SeverityHint:
468				counts.Hint++
469			}
470		}
471	}
472
473	c.diagCountsCache = counts
474	c.diagCountsVersion = currentVersion
475	return counts
476}
477
478// OpenFileOnDemand opens a file only if it's not already open.
479func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
480	// Check if the file is already open
481	if c.IsFileOpen(filepath) {
482		return nil
483	}
484
485	// Open the file
486	return c.OpenFile(ctx, filepath)
487}
488
489// GetDiagnosticsForFile ensures a file is open and returns its diagnostics.
490func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
491	documentURI := protocol.URIFromPath(filepath)
492
493	// Make sure the file is open
494	if !c.IsFileOpen(filepath) {
495		if err := c.OpenFile(ctx, filepath); err != nil {
496			return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
497		}
498
499		// Give the LSP server a moment to process the file
500		time.Sleep(100 * time.Millisecond)
501	}
502
503	// Get diagnostics
504	diagnostics, _ := c.diagnostics.Get(documentURI)
505
506	return diagnostics, nil
507}
508
509// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache.
510func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
511	c.diagnostics.Del(uri)
512}
513
514// RegisterNotificationHandler registers a notification handler.
515func (c *Client) RegisterNotificationHandler(method string, handler transport.NotificationHandler) {
516	c.client.RegisterNotificationHandler(method, handler)
517}
518
519// RegisterServerRequestHandler handles server requests.
520func (c *Client) RegisterServerRequestHandler(method string, handler transport.Handler) {
521	c.client.RegisterHandler(method, handler)
522}
523
524// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the server.
525func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
526	return c.client.NotifyDidChangeWatchedFiles(ctx, params.Changes)
527}
528
529// openKeyConfigFiles opens important configuration files that help initialize the server.
530func (c *Client) openKeyConfigFiles(ctx context.Context) {
531	wd, err := os.Getwd()
532	if err != nil {
533		return
534	}
535
536	// Try to open each file, ignoring errors if they don't exist
537	for _, file := range c.config.RootMarkers {
538		file = filepath.Join(wd, file)
539		if _, err := os.Stat(file); err == nil {
540			// File exists, try to open it
541			if err := c.OpenFile(ctx, file); err != nil {
542				slog.Error("Failed to open key config file", "file", file, "error", err)
543			} else {
544				slog.Debug("Opened key config file for initialization", "file", file)
545			}
546		}
547	}
548}
549
550// WaitForDiagnostics waits until diagnostics change or the timeout is reached.
551func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
552	ticker := time.NewTicker(200 * time.Millisecond)
553	defer ticker.Stop()
554	timeout := time.After(d)
555	pv := c.diagnostics.Version()
556	for {
557		select {
558		case <-ctx.Done():
559			return
560		case <-timeout:
561			return
562		case <-ticker.C:
563			if pv != c.diagnostics.Version() {
564				return
565			}
566		}
567	}
568}
569
570// FindReferences finds all references to the symbol at the given position.
571func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) {
572	if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
573		return nil, err
574	}
575	// NOTE: line and character should be 0-based.
576	// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
577	return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
578}
579
580// FilterMatching gets a list of configs and only returns the ones with
581// matching root markers.
582func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
583	result := map[string]*powernapconfig.ServerConfig{}
584	if len(servers) == 0 {
585		return result
586	}
587
588	type serverPatterns struct {
589		server   *powernapconfig.ServerConfig
590		patterns []string
591	}
592	normalized := make(map[string]serverPatterns, len(servers))
593	for name, server := range servers {
594		var patterns []string
595		for _, p := range server.RootMarkers {
596			if p == ".git" {
597				// ignore .git for discovery
598				continue
599			}
600			patterns = append(patterns, filepath.ToSlash(p))
601		}
602		if len(patterns) == 0 {
603			slog.Debug("ignoring lsp with no root markers", "name", name)
604			continue
605		}
606		normalized[name] = serverPatterns{server: server, patterns: patterns}
607	}
608
609	walker := fsext.NewFastGlobWalker(dir)
610	_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
611		if err != nil {
612			return nil
613		}
614
615		if walker.ShouldSkip(path) {
616			if d.IsDir() {
617				return filepath.SkipDir
618			}
619			return nil
620		}
621
622		relPath, err := filepath.Rel(dir, path)
623		if err != nil {
624			return nil
625		}
626		relPath = filepath.ToSlash(relPath)
627
628		for name, sp := range normalized {
629			for _, pattern := range sp.patterns {
630				matched, err := doublestar.Match(pattern, relPath)
631				if err != nil || !matched {
632					continue
633				}
634				result[name] = sp.server
635				delete(normalized, name)
636				break
637			}
638		}
639
640		if len(normalized) == 0 {
641			return filepath.SkipAll
642		}
643		return nil
644	})
645
646	return result
647}