返回 DeepSeek-Reasonix
lsp_test.go
根目录 / internal / lsp / lsp_test.go
1 package lsp
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15 )
16
17 func TestDefaultSpecsInvariants(t *testing.T) {
18 seen := map[string]string{}
19 for lang, s := range DefaultSpecs() {
20 if s.Command == "" || s.LanguageID == "" || len(s.Extensions) == 0 {
21 t.Errorf("lang %q: incomplete spec %+v", lang, s)
22 }
23 for _, ext := range s.Extensions {
24 if prev, dup := seen[ext]; dup {
25 t.Errorf("extension %q claimed by both %q and %q", ext, prev, lang)
26 }
27 seen[ext] = lang
28 }
29 for _, fb := range s.Fallbacks {
30 if fb == "" || fb == s.Command {
31 t.Errorf("lang %q: bad fallback %q", lang, fb)
32 }
33 }
34 }
35 if seen[".go"] != "go" || seen[".rs"] != "rust" || seen[".cpp"] != "cpp" || seen[".cs"] != "csharp" {
36 t.Errorf("unexpected routing: %v", seen)
37 }
38 }
39
40 func TestExtensionRouting(t *testing.T) {
41 m := NewManager(t.TempDir(), map[string]ServerSpec{
42 "elixir": {Command: "no-such-elixir-ls-xyz", LanguageID: "elixir", Extensions: []string{".ex", ".exs"}, InstallHint: "mix archive.install"},
43 })
44 defer m.Close()
45
46 if _, err := m.resolve("a.ex"); !errors.As(err, new(*notInstalledError)) {
47 t.Fatalf("configured-but-missing language should yield notInstalledError, got %v", err)
48 }
49 _, err := m.resolve("a.go")
50 if err == nil || !strings.Contains(err.Error(), "no language server") {
51 t.Fatalf("unconfigured extension should report no server, got %v", err)
52 }
53 }
54
55 func TestKotlinDefaultSpec(t *testing.T) {
56 spec, ok := DefaultSpecs()["kotlin"]
57 if !ok {
58 t.Fatal("kotlin default spec missing")
59 }
60 if spec.Command != "kotlin-lsp" {
61 t.Errorf("kotlin Command = %q, want the official PATH name kotlin-lsp", spec.Command)
62 }
63 if len(spec.Args) != 1 || spec.Args[0] != "--stdio" {
64 t.Errorf("kotlin Args = %v, want [--stdio] (client speaks stdio, server defaults to socket)", spec.Args)
65 }
66 hasFallback := false
67 for _, fb := range spec.Fallbacks {
68 if fb == "intellij-server" {
69 hasFallback = true
70 }
71 }
72 if !hasFallback {
73 t.Errorf("kotlin Fallbacks = %v, want intellij-server fallback for the Windows zip layout", spec.Fallbacks)
74 }
75 for _, want := range []string{
76 "macOS",
77 "brew install JetBrains/utils/kotlin-lsp",
78 "Linux",
79 "kotlin-lsp.sh",
80 "Windows",
81 "intellij-server.exe",
82 } {
83 if !strings.Contains(spec.InstallHint, want) {
84 t.Errorf("kotlin InstallHint = %q, want platform guidance containing %q", spec.InstallHint, want)
85 }
86 }
87 }
88
89 func TestResolveCommandFallback(t *testing.T) {
90 binDir := t.TempDir()
91 fake := func(name string) string {
92 if runtime.GOOS == "windows" {
93 name += ".exe"
94 }
95 path := filepath.Join(binDir, name)
96 if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
97 t.Fatal(err)
98 }
99 return path
100 }
101 spec := ServerSpec{Command: "kotlin-lsp", Fallbacks: []string{"intellij-server"}, InstallHint: "hint"}
102 t.Setenv("PATH", binDir) // hermetic: the real PATH may already have kotlin-lsp
103
104 // Only the fallback on PATH → it is used.
105 fallback := fake("intellij-server")
106 bin, err := resolveCommand(spec)
107 if err != nil {
108 t.Fatalf("resolveCommand: %v", err)
109 }
110 if bin != fallback {
111 t.Errorf("resolved %q, want fallback %q", bin, fallback)
112 }
113
114 // Both on PATH → the primary command wins.
115 primary := fake("kotlin-lsp")
116 bin, err = resolveCommand(spec)
117 if err != nil {
118 t.Fatalf("resolveCommand with both: %v", err)
119 }
120 if bin != primary {
121 t.Errorf("resolved %q, want primary %q", bin, primary)
122 }
123
124 // Neither name on PATH surfaces the primary command in the install error.
125 t.Setenv("PATH", t.TempDir())
126 if _, err := resolveCommand(spec); !errors.As(err, new(*notInstalledError)) {
127 t.Fatalf("expected notInstalledError, got %v", err)
128 }
129 }
130
131 func TestConnBidirectional(t *testing.T) {
132 caR, caW := io.Pipe()
133 acR, acW := io.Pipe()
134 // Close the writers at the end so both readLoop goroutines see EOF and exit
135 // (in production the subprocess pipe EOFs on kill; here nothing else closes it).
136 defer caW.Close()
137 defer acW.Close()
138
139 notif := make(chan string, 4)
140 var client *conn
141 client = newConn(caW, acR,
142 func(method string, _ json.RawMessage) { notif <- method },
143 func(id int64, _ string, _ json.RawMessage) { _ = client.reply(id, map[string]any{"ok": true}) })
144
145 var server *conn
146 server = newConn(acW, caR,
147 func(string, json.RawMessage) {},
148 func(id int64, method string, _ json.RawMessage) { _ = server.reply(id, map[string]any{"echo": method}) })
149
150 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
151 defer cancel()
152
153 res, err := client.call(ctx, "ping", map[string]any{"x": 1})
154 if err != nil {
155 t.Fatalf("client call: %v", err)
156 }
157 if !strings.Contains(string(res), `"echo":"ping"`) {
158 t.Fatalf("unexpected response: %s", res)
159 }
160
161 if err := server.notify("textDocument/publishDiagnostics", map[string]any{}); err != nil {
162 t.Fatalf("server notify: %v", err)
163 }
164 select {
165 case m := <-notif:
166 if m != "textDocument/publishDiagnostics" {
167 t.Fatalf("notify method = %q", m)
168 }
169 case <-ctx.Done():
170 t.Fatal("notification not delivered")
171 }
172
173 sres, err := server.call(ctx, "workspace/configuration", nil)
174 if err != nil {
175 t.Fatalf("server→client call: %v", err)
176 }
177 if !strings.Contains(string(sres), `"ok":true`) {
178 t.Fatalf("server→client reply: %s", sres)
179 }
180 }
181
182 func TestReadFrame(t *testing.T) {
183 in := "Content-Length: 17\r\nContent-Type: x\r\n\r\n" + `{"jsonrpc":"2.0"}` + "Content-Length: 2\r\n\r\n{}"
184 r := bufio.NewReader(strings.NewReader(in))
185 first, err := readFrame(r)
186 if err != nil || string(first) != `{"jsonrpc":"2.0"}` {
187 t.Fatalf("first frame = %q, err %v", first, err)
188 }
189 second, err := readFrame(r)
190 if err != nil || string(second) != `{}` {
191 t.Fatalf("second frame = %q, err %v", second, err)
192 }
193 if _, err := readFrame(r); err == nil {
194 t.Fatal("expected EOF on third read")
195 }
196 }
197
198 func TestURIRoundtrip(t *testing.T) {
199 paths := []string{"/home/u/a b.go", "/x/y.rs"}
200 if runtime.GOOS == "windows" {
201 paths = []string{`C:\Users\u\a b.go`, `D:\x\y.rs`}
202 }
203 for _, p := range paths {
204 uri := pathToURI(p)
205 if !strings.HasPrefix(uri, "file://") {
206 t.Errorf("%q → %q is not a file URI", p, uri)
207 }
208 if got, err := uriToPath(uri); err != nil || got != p {
209 t.Errorf("roundtrip %q → %q, %v", p, got, err)
210 }
211 }
212 }
213
214 func TestWindowsFileURIConversions(t *testing.T) {
215 tests := []struct {
216 path string
217 uri string
218 }{
219 {`C:\Users\Test User\中文%20.go`, `file:///C:/Users/Test%20User/%E4%B8%AD%E6%96%87%2520.go`},
220 {`\\server\share\Test User\中文%20.go`, `file://server/share/Test%20User/%E4%B8%AD%E6%96%87%2520.go`},
221 }
222 for _, tt := range tests {
223 if got := pathToURIForOS(tt.path, "windows"); got != tt.uri {
224 t.Errorf("pathToURIForOS(%q) = %q, want %q", tt.path, got, tt.uri)
225 }
226 if got, err := uriToPathForOS(tt.uri, "windows"); err != nil || got != tt.path {
227 t.Errorf("uriToPathForOS(%q) = %q, %v; want %q", tt.uri, got, err, tt.path)
228 }
229 }
230 }
231
232 func TestURIToPathAuthorityAndValidation(t *testing.T) {
233 if got, err := uriToPathForOS("file://localhost/tmp/a%20b%2520.go", "linux"); err != nil || got != "/tmp/a b%20.go" {
234 t.Fatalf("localhost URI = %q, %v", got, err)
235 }
236 for _, uri := range []string{
237 "https://server/share/a.go",
238 "file://server/share/a.go",
239 "file://server:123/share/a.go",
240 "file:///tmp/a.go?mode=ro",
241 "file:///tmp/a.go#fragment",
242 "file:///tmp/%00.go",
243 "%",
244 } {
245 if _, err := uriToPathForOS(uri, "linux"); err == nil {
246 t.Errorf("uriToPathForOS(%q) unexpectedly succeeded", uri)
247 }
248 }
249 }
250
251 func TestFormatLocationsKeepsInvalidURIAndSkipsSnippet(t *testing.T) {
252 root := t.TempDir()
253 path := filepath.Join(root, "valid.go")
254 if err := os.WriteFile(path, []byte("package valid\n"), 0o600); err != nil {
255 t.Fatal(err)
256 }
257 m := &Manager{wsRoot: root}
258 remote := "https://server/share/secret.go"
259 got := m.formatLocations("definition", []Location{
260 {URI: pathToURI(path), Range: Range{Start: Position{Line: 0}}},
261 {URI: remote, Range: Range{Start: Position{Line: 6}}},
262 })
263 if !strings.Contains(got, "valid.go:1 package valid") {
264 t.Fatalf("valid location lost snippet:\n%s", got)
265 }
266 if !strings.Contains(got, remote+":7") || strings.Contains(got, remote+":7 ") {
267 t.Fatalf("invalid URI was treated as a local path:\n%s", got)
268 }
269 }
270
271 func TestLocateEncoding(t *testing.T) {
272 content := "package x\nαβ foo()\n" // line 2 has two 2-byte runes then a space
273 u16, err := locate(content, 2, "foo", encodingUTF16)
274 if err != nil {
275 t.Fatal(err)
276 }
277 if u16.Line != 1 || u16.Character != 3 {
278 t.Errorf("utf16 pos = %+v, want line 1 char 3", u16)
279 }
280 u8, err := locate(content, 2, "foo", encodingUTF8)
281 if err != nil {
282 t.Fatal(err)
283 }
284 if u8.Character != 5 {
285 t.Errorf("utf8 char = %d, want 5", u8.Character)
286 }
287 if _, err := locate(content, 2, "missing", encodingUTF16); err == nil {
288 t.Error("expected not-found error")
289 }
290 }
291
292 func TestParseLocations(t *testing.T) {
293 single := `{"uri":"file:///a","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":2}}}`
294 if got := parseLocations(json.RawMessage(single)); len(got) != 1 || got[0].URI != "file:///a" {
295 t.Errorf("single: %+v", got)
296 }
297 arr := `[{"uri":"file:///a","range":{}},{"uri":"file:///b","range":{}}]`
298 if got := parseLocations(json.RawMessage(arr)); len(got) != 2 {
299 t.Errorf("array: %+v", got)
300 }
301 link := `[{"targetUri":"file:///c","targetRange":{"start":{"line":2,"character":0},"end":{"line":2,"character":1}}}]`
302 got := parseLocations(json.RawMessage(link))
303 if len(got) != 1 || got[0].URI != "file:///c" || got[0].Range.Start.Line != 2 {
304 t.Errorf("locationlink: %+v", got)
305 }
306 if parseLocations(json.RawMessage("null")) != nil {
307 t.Error("null should yield nil")
308 }
309 }
310
311 func TestParseHover(t *testing.T) {
312 markup := `{"contents":{"kind":"markdown","value":"func F()"}}`
313 if got := parseHover(json.RawMessage(markup)); got != "func F()" {
314 t.Errorf("markup hover = %q", got)
315 }
316 marked := `{"contents":[{"language":"go","value":"func F()"},"docs"]}`
317 if got := parseHover(json.RawMessage(marked)); got != "func F()\ndocs" {
318 t.Errorf("marked array hover = %q", got)
319 }
320 if got := parseHover(json.RawMessage(`{"contents":""}`)); got != "" {
321 t.Errorf("empty hover = %q", got)
322 }
323 }
324
324 lines GO