fix: address `nil` pointer dereference panics on lsp client methods (#2256)

Copilot and andreynering created

Co-authored-by: andreynering <7011819+andreynering@users.noreply.github.com>

Change summary

internal/lsp/client.go      | 18 ++++++++++++++++++
internal/lsp/client_test.go | 15 +++++++++++++++
2 files changed, 33 insertions(+)

Detailed changes

internal/lsp/client.go 🔗

@@ -324,6 +324,9 @@ 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 {
+	if c == nil {
+		return false
+	}
 	if !fsext.HasPrefix(path, c.cwd) {
 		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.cwd)
 		return false
@@ -364,6 +367,9 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
 
 // NotifyChange notifies the server about a file change.
 func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
+	if c == nil {
+		return nil
+	}
 	uri := string(protocol.URIFromPath(filepath))
 
 	content, err := os.ReadFile(filepath)
@@ -420,12 +426,18 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnos
 
 // GetDiagnostics returns all diagnostics for all files.
 func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic {
+	if c == nil {
+		return nil
+	}
 	return c.diagnostics.Copy()
 }
 
 // GetDiagnosticCounts returns cached diagnostic counts by severity.
 // Uses the VersionedMap version to avoid recomputing on every call.
 func (c *Client) GetDiagnosticCounts() DiagnosticCounts {
+	if c == nil {
+		return DiagnosticCounts{}
+	}
 	currentVersion := c.diagnostics.Version()
 
 	c.diagCountsMu.Lock()
@@ -459,6 +471,9 @@ func (c *Client) GetDiagnosticCounts() DiagnosticCounts {
 
 // OpenFileOnDemand opens a file only if it's not already open.
 func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
+	if c == nil {
+		return nil
+	}
 	// Check if the file is already open
 	if c.IsFileOpen(filepath) {
 		return nil
@@ -501,6 +516,9 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) {
 
 // WaitForDiagnostics waits until diagnostics change or the timeout is reached.
 func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
+	if c == nil {
+		return
+	}
 	ticker := time.NewTicker(200 * time.Millisecond)
 	defer ticker.Stop()
 	timeout := time.After(d)

internal/lsp/client_test.go 🔗

@@ -3,9 +3,11 @@ package lsp
 import (
 	"context"
 	"testing"
+	"time"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/env"
+	"github.com/stretchr/testify/require"
 )
 
 func TestClient(t *testing.T) {
@@ -55,3 +57,16 @@ func TestClient(t *testing.T) {
 		t.Logf("Close failed as expected with dummy command: %v", err)
 	}
 }
+
+func TestNilClient(t *testing.T) {
+	t.Parallel()
+
+	var c *Client
+
+	require.False(t, c.HandlesFile("/some/file.go"))
+	require.Equal(t, DiagnosticCounts{}, c.GetDiagnosticCounts())
+	require.Nil(t, c.GetDiagnostics())
+	require.Nil(t, c.OpenFileOnDemand(context.Background(), "/some/file.go"))
+	require.Nil(t, c.NotifyChange(context.Background(), "/some/file.go"))
+	c.WaitForDiagnostics(context.Background(), time.Second)
+}