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.NewEnvironmentVariableResolver(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
64func TestNilClient(t *testing.T) {
65 t.Parallel()
66
67 var c *Client
68
69 require.False(t, c.HandlesFile("/some/file.go"))
70 require.Equal(t, DiagnosticCounts{}, c.GetDiagnosticCounts())
71 require.Nil(t, c.GetDiagnostics())
72 require.Nil(t, c.OpenFileOnDemand(context.Background(), "/some/file.go"))
73 require.Nil(t, c.NotifyChange(context.Background(), "/some/file.go"))
74 c.WaitForDiagnostics(context.Background(), time.Second)
75}
76
77func newTestClient() *Client {
78 c := &Client{
79 name: "test",
80 diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
81 openFiles: csync.NewMap[string, *OpenFileInfo](),
82 }
83 c.serverState.Store(StateStopped)
84 return c
85}
86
87func TestWaitForDiagnostics_NoChange(t *testing.T) {
88 t.Parallel()
89
90 c := newTestClient()
91 start := time.Now()
92 c.WaitForDiagnostics(t.Context(), 5*time.Second)
93 elapsed := time.Since(start)
94
95 // Should return early via firstChangeDeadline (~1s), not the full timeout.
96 require.Less(t, elapsed, 2*time.Second, "should return early when no diagnostics change")
97}
98
99func TestWaitForDiagnostics_ImmediateChange(t *testing.T) {
100 t.Parallel()
101
102 c := newTestClient()
103
104 go func() {
105 time.Sleep(100 * time.Millisecond)
106 c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), nil)
107 }()
108
109 start := time.Now()
110 c.WaitForDiagnostics(t.Context(), 5*time.Second)
111 elapsed := time.Since(start)
112
113 // Should detect the change and then settle (~300ms settle + overhead).
114 require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
115 require.Greater(t, elapsed, 200*time.Millisecond, "should wait for settle duration")
116}
117
118func TestWaitForDiagnostics_RepeatedChanges(t *testing.T) {
119 t.Parallel()
120
121 c := newTestClient()
122
123 // Simulate an LSP server that publishes diagnostics in bursts.
124 go func() {
125 for i := range 5 {
126 time.Sleep(50 * time.Millisecond)
127 c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), []protocol.Diagnostic{
128 {Message: fmt.Sprintf("diag-%d", i)},
129 })
130 }
131 }()
132
133 start := time.Now()
134 c.WaitForDiagnostics(t.Context(), 5*time.Second)
135 elapsed := time.Since(start)
136
137 // Should wait for diagnostics to settle after the burst finishes.
138 // Burst lasts ~250ms, then 300ms settle window, so total ~550ms+.
139 require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
140 require.Greater(t, elapsed, 400*time.Millisecond, "should wait for all changes to settle")
141}
142
143func TestWaitForDiagnostics_ContextCancellation(t *testing.T) {
144 t.Parallel()
145
146 c := newTestClient()
147 ctx, cancel := context.WithCancel(t.Context())
148 defer cancel()
149
150 go func() {
151 time.Sleep(200 * time.Millisecond)
152 cancel()
153 }()
154
155 start := time.Now()
156 c.WaitForDiagnostics(ctx, 5*time.Second)
157 elapsed := time.Since(start)
158
159 require.Less(t, elapsed, 1*time.Second, "should return shortly after context cancellation")
160}
161
162func TestWaitForDiagnostics_NilClient(t *testing.T) {
163 t.Parallel()
164
165 var c *Client
166 // Should not panic.
167 c.WaitForDiagnostics(context.Background(), time.Second)
168}