fix(lsp): files outside cwd (#2180)

Carlos Alexandro Becker created

closes #1401

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/lsp/client.go      | 65 +++++++++------------------------------
internal/lsp/client_test.go |  2 
internal/lsp/manager.go     | 21 +++++++++---
3 files changed, 32 insertions(+), 56 deletions(-)

Detailed changes

internal/lsp/client.go 🔗

@@ -8,13 +8,13 @@ import (
 	"maps"
 	"os"
 	"path/filepath"
-	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/home"
 	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
@@ -35,7 +35,7 @@ type Client struct {
 	debug  bool
 
 	// Working directory this LSP is scoped to.
-	workDir string
+	cwd string
 
 	// File types this LSP server handles (e.g., .go, .rs, .py)
 	fileTypes []string
@@ -66,7 +66,14 @@ type Client struct {
 }
 
 // New creates a new LSP client using the powernap implementation.
-func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver, debug bool) (*Client, error) {
+func New(
+	ctx context.Context,
+	name string,
+	cfg config.LSPConfig,
+	resolver config.VariableResolver,
+	cwd string,
+	debug bool,
+) (*Client, error) {
 	client := &Client{
 		name:        name,
 		fileTypes:   cfg.FileTypes,
@@ -76,6 +83,7 @@ func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config
 		ctx:         ctx,
 		debug:       debug,
 		resolver:    resolver,
+		cwd:         cwd,
 	}
 	client.serverState.Store(StateStarting)
 
@@ -134,13 +142,7 @@ func (c *Client) Close(ctx context.Context) error {
 
 // createPowernapClient creates a new powernap client with the current configuration.
 func (c *Client) createPowernapClient() error {
-	workDir, err := os.Getwd()
-	if err != nil {
-		return fmt.Errorf("failed to get working directory: %w", err)
-	}
-
-	rootURI := string(protocol.URIFromPath(workDir))
-	c.workDir = workDir
+	rootURI := string(protocol.URIFromPath(c.cwd))
 
 	command, err := c.resolver.ResolveValue(c.config.Command)
 	if err != nil {
@@ -157,7 +159,7 @@ func (c *Client) createPowernapClient() error {
 		WorkspaceFolders: []protocol.WorkspaceFolder{
 			{
 				URI:  rootURI,
-				Name: filepath.Base(workDir),
+				Name: filepath.Base(c.cwd),
 			},
 		},
 	}
@@ -321,15 +323,8 @@ type OpenFileInfo struct {
 // HandlesFile checks if this LSP client handles the given file based on its
 // extension and whether it's within the working directory.
 func (c *Client) HandlesFile(path string) bool {
-	// Check if file is within working directory.
-	absPath, err := filepath.Abs(path)
-	if err != nil {
-		slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
-		return false
-	}
-	relPath, err := filepath.Rel(c.workDir, absPath)
-	if err != nil || strings.HasPrefix(relPath, "..") {
-		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+	if !fsext.HasPrefix(path, c.cwd) {
+		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.cwd)
 		return false
 	}
 	return handlesFiletype(c.name, c.fileTypes, path)
@@ -472,31 +467,6 @@ func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
 	return c.OpenFile(ctx, filepath)
 }
 
-// GetDiagnosticsForFile ensures a file is open and returns its diagnostics.
-func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
-	documentURI := protocol.URIFromPath(filepath)
-
-	// Make sure the file is open
-	if !c.IsFileOpen(filepath) {
-		if err := c.OpenFile(ctx, filepath); err != nil {
-			return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
-		}
-
-		// Give the LSP server a moment to process the file
-		time.Sleep(100 * time.Millisecond)
-	}
-
-	// Get diagnostics
-	diagnostics, _ := c.diagnostics.Get(documentURI)
-
-	return diagnostics, nil
-}
-
-// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache.
-func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
-	c.diagnostics.Del(uri)
-}
-
 // RegisterNotificationHandler registers a notification handler.
 func (c *Client) RegisterNotificationHandler(method string, handler transport.NotificationHandler) {
 	c.client.RegisterNotificationHandler(method, handler)
@@ -507,11 +477,6 @@ func (c *Client) RegisterServerRequestHandler(method string, handler transport.H
 	c.client.RegisterHandler(method, handler)
 }
 
-// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the server.
-func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
-	return c.client.NotifyDidChangeWatchedFiles(ctx, params.Changes)
-}
-
 // openKeyConfigFiles opens important configuration files that help initialize the server.
 func (c *Client) openKeyConfigFiles(ctx context.Context) {
 	wd, err := os.Getwd()

internal/lsp/client_test.go 🔗

@@ -23,7 +23,7 @@ func TestClient(t *testing.T) {
 	// but we can still test the basic structure
 	client, err := New(ctx, "test", cfg, config.NewEnvironmentVariableResolver(env.NewFromMap(map[string]string{
 		"THE_CMD": "echo",
-	})), false)
+	})), ".", false)
 	if err != nil {
 		// Expected to fail with echo command, skip the rest
 		t.Skipf("Powernap client creation failed as expected with dummy command: %v", err)

internal/lsp/manager.go 🔗

@@ -65,8 +65,8 @@ func NewManager(cfg *config.Config) *Manager {
 }
 
 // Clients returns the map of LSP clients.
-func (m *Manager) Clients() *csync.Map[string, *Client] {
-	return m.clients
+func (s *Manager) Clients() *csync.Map[string, *Client] {
+	return s.clients
 }
 
 // SetCallback sets a callback that is invoked when a new LSP
@@ -79,13 +79,17 @@ func (s *Manager) SetCallback(cb func(name string, client *Client)) {
 
 // Start starts an LSP server that can handle the given file path.
 // If an appropriate LSP is already running, this is a no-op.
-func (s *Manager) Start(ctx context.Context, filePath string) {
+func (s *Manager) Start(ctx context.Context, path string) {
+	if !fsext.HasPrefix(path, s.cfg.WorkingDir()) {
+		return
+	}
+
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
 	var wg sync.WaitGroup
 	for name, server := range s.manager.GetServers() {
-		if !handles(server, filePath, s.cfg.WorkingDir()) {
+		if !handles(server, path, s.cfg.WorkingDir()) {
 			continue
 		}
 		wg.Go(func() {
@@ -150,7 +154,14 @@ func (s *Manager) startServer(ctx context.Context, name string, server *powernap
 			return
 		}
 	}
-	client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP)
+	client, err := New(
+		ctx,
+		name,
+		cfg,
+		s.cfg.Resolver(),
+		s.cfg.WorkingDir(),
+		s.cfg.Options.DebugLSP,
+	)
 	if err != nil {
 		slog.Error("Failed to create LSP client", "name", name, "error", err)
 		return