1package lsp
2
3import (
4 "context"
5 "fmt"
6 "testing"
7 "time"
8
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/csync"
11 "github.com/charmbracelet/crush/internal/env"
12 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
13 "github.com/stretchr/testify/require"
14)
15
16func TestClient(t *testing.T) {
17 ctx := context.Background()
18
19 // Create a simple config for testing
20 cfg := config.LSPConfig{
21 Command: "$THE_CMD", // Use echo as a dummy command that won't fail
22 Args: []string{"hello"},
23 FileTypes: []string{"go"},
24 Env: map[string]string{},
25 }
26
27 // Test creating a powernap client - this will likely fail with echo
28 // but we can still test the basic structure
29 client, err := New(ctx, "test", cfg, config.NewShellVariableResolver(env.NewFromMap(map[string]string{
30 "THE_CMD": "echo",
31 })), ".", false)
32 if err != nil {
33 // Expected to fail with echo command, skip the rest
34 t.Skipf("Powernap client creation failed as expected with dummy command: %v", err)
35 return
36 }
37
38 // If we get here, test basic interface methods
39 if client.GetName() != "test" {
40 t.Errorf("Expected name 'test', got '%s'", client.GetName())
41 }
42
43 if !client.HandlesFile("test.go") {
44 t.Error("Expected client to handle .go files")
45 }
46
47 if client.HandlesFile("test.py") {
48 t.Error("Expected client to not handle .py files")
49 }
50
51 // Test server state
52 client.SetServerState(StateReady)
53 if client.GetServerState() != StateReady {
54 t.Error("Expected server state to be StateReady")
55 }
56
57 // Clean up - expect this to fail with echo command
58 if err := client.Close(t.Context()); err != nil {
59 // Expected to fail with echo command
60 t.Logf("Close failed as expected with dummy command: %v", err)
61 }
62}
63
64// TestNew_ExpansionFailure_Args pins that a failing $(cmd) in LSP
65// args surfaces as a load error prefixed "invalid lsp args:" and that
66// no client is returned. Mirrors the MCP contract where expansion
67// failure hard-stops transport creation rather than silently running
68// with an empty or literal value.
69func TestNew_ExpansionFailure_Args(t *testing.T) {
70 t.Parallel()
71
72 cfg := config.LSPConfig{
73 Command: "echo",
74 Args: []string{"--root", "$(false)"},
75 }
76 resolver := config.NewShellVariableResolver(env.NewFromMap(map[string]string{}))
77
78 client, err := New(t.Context(), "test-args-fail", cfg, resolver, ".", false)
79 require.Error(t, err)
80 require.Nil(t, client, "client must not start when args expansion fails")
81 require.Contains(t, err.Error(), "invalid lsp args")
82}
83
84// TestNew_ExpansionFailure_Env pins the same contract for env values.
85func TestNew_ExpansionFailure_Env(t *testing.T) {
86 t.Parallel()
87
88 cfg := config.LSPConfig{
89 Command: "echo",
90 Env: map[string]string{"BAD": "$(false)"},
91 }
92 resolver := config.NewShellVariableResolver(env.NewFromMap(map[string]string{}))
93
94 client, err := New(t.Context(), "test-env-fail", cfg, resolver, ".", false)
95 require.Error(t, err)
96 require.Nil(t, client, "client must not start when env expansion fails")
97 require.Contains(t, err.Error(), "invalid lsp env")
98}
99
100func TestNilClient(t *testing.T) {
101 t.Parallel()
102
103 var c *Client
104
105 require.False(t, c.HandlesFile("/some/file.go"))
106 require.Equal(t, DiagnosticCounts{}, c.GetDiagnosticCounts())
107 require.Nil(t, c.GetDiagnostics())
108 require.Nil(t, c.OpenFileOnDemand(context.Background(), "/some/file.go"))
109 require.Nil(t, c.NotifyChange(context.Background(), "/some/file.go"))
110 c.WaitForDiagnostics(context.Background(), time.Second)
111}
112
113func newTestClient() *Client {
114 c := &Client{
115 name: "test",
116 diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
117 openFiles: csync.NewMap[string, *OpenFileInfo](),
118 }
119 c.serverState.Store(StateStopped)
120 return c
121}
122
123func TestWaitForDiagnostics_NoChange(t *testing.T) {
124 t.Parallel()
125
126 c := newTestClient()
127 start := time.Now()
128 c.WaitForDiagnostics(t.Context(), 5*time.Second)
129 elapsed := time.Since(start)
130
131 // Should return early via firstChangeDeadline (~1s), not the full timeout.
132 require.Less(t, elapsed, 2*time.Second, "should return early when no diagnostics change")
133}
134
135func TestWaitForDiagnostics_ImmediateChange(t *testing.T) {
136 t.Parallel()
137
138 c := newTestClient()
139
140 go func() {
141 time.Sleep(100 * time.Millisecond)
142 c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), nil)
143 }()
144
145 start := time.Now()
146 c.WaitForDiagnostics(t.Context(), 5*time.Second)
147 elapsed := time.Since(start)
148
149 // Should detect the change and then settle (~300ms settle + overhead).
150 require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
151 require.Greater(t, elapsed, 200*time.Millisecond, "should wait for settle duration")
152}
153
154func TestWaitForDiagnostics_RepeatedChanges(t *testing.T) {
155 t.Parallel()
156
157 c := newTestClient()
158
159 // Simulate an LSP server that publishes diagnostics in bursts.
160 go func() {
161 for i := range 5 {
162 time.Sleep(50 * time.Millisecond)
163 c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), []protocol.Diagnostic{
164 {Message: fmt.Sprintf("diag-%d", i)},
165 })
166 }
167 }()
168
169 start := time.Now()
170 c.WaitForDiagnostics(t.Context(), 5*time.Second)
171 elapsed := time.Since(start)
172
173 // Should wait for diagnostics to settle after the burst finishes.
174 // Burst lasts ~250ms, then 300ms settle window, so total ~550ms+.
175 require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
176 require.Greater(t, elapsed, 400*time.Millisecond, "should wait for all changes to settle")
177}
178
179func TestWaitForDiagnostics_ContextCancellation(t *testing.T) {
180 t.Parallel()
181
182 c := newTestClient()
183 ctx, cancel := context.WithCancel(t.Context())
184 defer cancel()
185
186 go func() {
187 time.Sleep(200 * time.Millisecond)
188 cancel()
189 }()
190
191 start := time.Now()
192 c.WaitForDiagnostics(ctx, 5*time.Second)
193 elapsed := time.Since(start)
194
195 require.Less(t, elapsed, 1*time.Second, "should return shortly after context cancellation")
196}
197
198func TestWaitForDiagnostics_NilClient(t *testing.T) {
199 t.Parallel()
200
201 var c *Client
202 // Should not panic.
203 c.WaitForDiagnostics(context.Background(), time.Second)
204}