1package lsp
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "io"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16)
17
18func TestFakeLSPProcess(t *testing.T) {
19 if os.Getenv("ZUT_FAKE_LSP") != "1" {
20 return
21 }
22 reader := bufio.NewReader(os.Stdin)
23 for {
24 payload, err := ReadMessage(reader)
25 if err != nil {
26 return
27 }
28 var message rpcEnvelope
29 if json.Unmarshal(payload, &message) != nil {
30 continue
31 }
32 switch message.Method {
33 case "initialize":
34 response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"capabilities":{"definitionProvider":true}}}`)
35 _ = WriteMessage(os.Stdout, response)
36 case "test/echo":
37 response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"echo":true}}`)
38 _ = WriteMessage(os.Stdout, response)
39 case "shutdown":
40 response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":null}`)
41 _ = WriteMessage(os.Stdout, response)
42 case "textDocument/didOpen":
43 var params struct {
44 TextDocument struct {
45 URI string `json:"uri"`
46 } `json:"textDocument"`
47 }
48 _ = json.Unmarshal(message.Params, ¶ms)
49 response := map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": map[string]any{"uri": params.TextDocument.URI, "diagnostics": []any{map[string]any{"range": map[string]any{"start": map[string]int{"line": 0, "character": 0}, "end": map[string]int{"line": 0, "character": 1}}, "severity": 1, "code": "FAKE", "message": "fake problem"}}}}
50 data, _ := json.Marshal(response)
51 _ = WriteMessage(os.Stdout, data)
52 }
53 }
54}
55
56func TestClientFakeStdioRPC(t *testing.T) {
57 root := t.TempDir()
58 script := os.Args[0]
59 spec := ServerConfig{ID: "fake", Kind: "lsp", Command: script, Args: []string{"-test.run=TestFakeLSPProcess"}, Env: map[string]string{"ZUT_FAKE_LSP": "1"}}
60 diagnostics := make(chan []Diagnostic, 1)
61 client, err := NewClientWithContext(context.Background(), spec, root, ClientOptions{OnDiagnostics: func(value []Diagnostic) { diagnostics <- value }})
62 if err != nil {
63 t.Fatal(err)
64 }
65 t.Cleanup(func() { _ = client.Close() })
66 result, err := client.Request(context.Background(), "test/echo", map[string]any{})
67 if err != nil || !bytes.Contains(result, []byte(`"echo":true`)) {
68 t.Fatalf("echo result = %s, err = %v", result, err)
69 }
70 path := filepath.Join(root, "main.go")
71 if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
72 t.Fatal(err)
73 }
74 if err := client.DidOpen(path, "go", "package main\n"); err != nil {
75 t.Fatal(err)
76 }
77 select {
78 case values := <-diagnostics:
79 if len(values) != 1 || values[0].Code != "FAKE" || values[0].Path != path {
80 t.Fatalf("diagnostics = %#v", values)
81 }
82 case <-time.After(2 * time.Second):
83 t.Fatal("timed out waiting for fake diagnostics")
84 }
85}
86
87func TestFileURIPathRoundTrip(t *testing.T) {
88 path := filepath.Join(t.TempDir(), "space name", "main.go")
89 uri, err := pathToURI(path)
90 if err != nil {
91 t.Fatal(err)
92 }
93 if !strings.HasPrefix(uri, "file:///") {
94 t.Fatalf("URI = %q, want an absolute file URI", uri)
95 }
96 got, err := uriToPath(uri)
97 if err != nil {
98 t.Fatal(err)
99 }
100 abs, err := filepath.Abs(path)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if filepath.Clean(got) != filepath.Clean(abs) {
105 t.Fatalf("round-trip path = %q, want %q", got, abs)
106 }
107 if runtime.GOOS != "windows" {
108 posixPath, err := uriToPath("file:///x:tmp/main.go")
109 if err != nil || posixPath != "/x:tmp/main.go" {
110 t.Fatalf("POSIX drive-like URI = %q, err = %v", posixPath, err)
111 }
112 }
113 if runtime.GOOS == "windows" {
114 unc := `\\server\share\main.go`
115 uncURI, err := pathToURI(unc)
116 if err != nil {
117 t.Fatal(err)
118 }
119 if !strings.HasPrefix(uncURI, "file://server/") {
120 t.Fatalf("UNC URI = %q", uncURI)
121 }
122 uncPath, err := uriToPath(uncURI)
123 if err != nil || filepath.Clean(uncPath) != filepath.Clean(unc) {
124 t.Fatalf("UNC round trip = %q, err = %v", uncPath, err)
125 }
126 }
127}
128
129func TestFramingRejectsMalformedAndOversizedMessages(t *testing.T) {
130 cases := []string{
131 "\r\n{}",
132 "Content-Length: -1\r\n\r\n",
133 "Content-Length: nope\r\n\r\n",
134 "Content-Length: 1\r\n" + strings.Repeat("X", maxHeaderBytes) + "\r\n",
135 "Content-Length: " + strconv.Itoa(maxMessageBytes+1) + "\r\n\r\n",
136 }
137 for _, wire := range cases {
138 if _, err := ReadMessage(bufio.NewReader(strings.NewReader(wire))); err == nil {
139 t.Fatalf("accepted malformed frame %q", wire[:min(len(wire), 80)])
140 }
141 }
142 var out bytes.Buffer
143 if err := WriteMessage(&out, make([]byte, maxMessageBytes+1)); err == nil {
144 t.Fatal("accepted oversized outbound frame")
145 }
146}
147
148func TestFramingRoundTripWithAdditionalHeader(t *testing.T) {
149 payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`)
150 wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...)
151 got, err := ReadMessage(bufio.NewReader(bytes.NewReader(wire)))
152 if err != nil {
153 t.Fatal(err)
154 }
155 if !bytes.Equal(got, payload) {
156 t.Fatalf("payload = %q, want %q", got, payload)
157 }
158}
159
160func TestZutHomeEnvironmentPrecedence(t *testing.T) {
161 configuredHome := filepath.Join(t.TempDir(), "zut")
162 xdgHome := filepath.Join(t.TempDir(), "state")
163 t.Setenv("ZUT_HOME", configuredHome)
164 t.Setenv("XDG_STATE_HOME", xdgHome)
165 if got := zutHome(); got != configuredHome {
166 t.Fatalf("ZUT_HOME path = %q, want %q", got, configuredHome)
167 }
168 t.Setenv("ZUT_HOME", "")
169 if got := zutHome(); got != filepath.Join(xdgHome, "zut") {
170 t.Fatalf("XDG_STATE_HOME path = %q, want %q", got, filepath.Join(xdgHome, "zut"))
171 }
172}
173
174func TestLoadConfigMergesGlobalProjectAndBuiltins(t *testing.T) {
175 root := t.TempDir()
176 zutHome := t.TempDir()
177 t.Setenv("ZUT_HOME", zutHome)
178 if err := os.WriteFile(filepath.Join(zutHome, "lsp.json"), []byte(`{"servers":{"gopls":{"settings":{"global":true},"args":["--global"]}}}`), 0o644); err != nil {
179 t.Fatal(err)
180 }
181 if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
182 t.Fatal(err)
183 }
184 if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"gopls":{"command":"local-gopls","args":["--local"],"settings":{"local":true}},"sarif-lint":{"command":"reviewdog","kind":"cli","parser":"sarif","fileTypes":[".go"]}}`), 0o644); err != nil {
185 t.Fatal(err)
186 }
187 config, err := LoadConfig(root)
188 if err != nil {
189 t.Fatal(err)
190 }
191 gopls := config.Servers["gopls"]
192 if gopls.Command != "local-gopls" || len(gopls.Args) != 1 || gopls.Args[0] != "--local" {
193 t.Fatalf("gopls override = %#v", gopls)
194 }
195 if len(gopls.FileTypes) == 0 || len(gopls.RootMarkers) == 0 {
196 t.Fatalf("builtin selectors were not retained: %#v", gopls)
197 }
198 if gopls.Settings["global"] != true || gopls.Settings["local"] != true {
199 t.Fatalf("settings did not merge: %#v", gopls.Settings)
200 }
201 if config.Servers["sarif-lint"].Kind != "cli" || config.Servers["sarif-lint"].Parser != "sarif" {
202 t.Fatalf("custom linter = %#v", config.Servers["sarif-lint"])
203 }
204 selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
205 var foundGopls, foundSarif bool
206 for _, server := range selected {
207 foundGopls = foundGopls || server.ID == "gopls"
208 foundSarif = foundSarif || server.ID == "sarif-lint"
209 }
210 if !foundGopls || !foundSarif {
211 t.Fatalf("selected servers = %#v", selected)
212 }
213}
214
215func TestLoadConfigAcceptsProviderArrays(t *testing.T) {
216 root := t.TempDir()
217 zutHome := t.TempDir()
218 t.Setenv("ZUT_HOME", zutHome)
219 if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
220 t.Fatal(err)
221 }
222 configFile := `{
223 "autoDetect": true,
224 "providers": [
225 "gopls",
226 {"id":"gopls","args":["serve"]},
227 {"id":"custom-lint","kind":"cli","command":"custom-lint","fileTypes":[".go"],"rootMarkers":["go.mod"],"cli":{"parser":"generic","mode":"files"}}
228 ]
229 }`
230 if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(configFile), 0o644); err != nil {
231 t.Fatal(err)
232 }
233 config, err := LoadConfig(root)
234 if err != nil {
235 t.Fatal(err)
236 }
237 if got := config.Servers["gopls"].Args; len(got) != 1 || got[0] != "serve" {
238 t.Fatalf("gopls provider override = %#v", got)
239 }
240 lint := config.Servers["custom-lint"]
241 if lint.Kind != "cli" || lint.Parser != "generic" || lint.Mode != "files" {
242 t.Fatalf("custom provider = %#v", lint)
243 }
244}
245
246func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) {
247 root := t.TempDir()
248 t.Setenv("ZUT_HOME", t.TempDir())
249 if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
250 t.Fatal(err)
251 }
252 if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil {
253 t.Fatal(err)
254 }
255 config, err := LoadConfig(root)
256 if err != nil {
257 t.Fatal(err)
258 }
259 selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
260 if len(selected) != 1 || selected[0].ID != "gopls" {
261 t.Fatalf("explicit providers with autoDetect=false = %#v", selected)
262 }
263}
264
265func TestResolveCommandPrefersWorkspaceTools(t *testing.T) {
266 if runtime.GOOS == "windows" {
267 t.Skip("executable bits are not represented on Windows")
268 }
269 root := t.TempDir()
270 bin := filepath.Join(root, "node_modules", ".bin")
271 if err := os.MkdirAll(bin, 0o755); err != nil {
272 t.Fatal(err)
273 }
274 command := filepath.Join(bin, "fake-lsp")
275 if err := os.WriteFile(command, []byte("#!/bin/sh\n"), 0o755); err != nil {
276 t.Fatal(err)
277 }
278 got, err := ResolveCommand(root, "fake-lsp")
279 if err != nil {
280 t.Fatal(err)
281 }
282 if got != command {
283 t.Fatalf("resolved %q, want %q", got, command)
284 }
285}
286
287func TestParsers(t *testing.T) {
288 root := t.TempDir()
289 eslint := `[ {"filePath":"src/a.js","messages":[{"ruleId":"no-x","severity":2,"message":"bad","line":2,"column":3}] } ]`
290 got := ParseDiagnostics("eslint-json", "eslint", root, eslint)
291 if len(got) != 1 || got[0].Severity != "error" || got[0].Range.Start.Line != 1 {
292 t.Fatalf("eslint = %#v", got)
293 }
294 golangci := `{"Issues":[{"FromLinter":"govet","Text":"bad","Severity":"warning","Pos":{"Filename":"a.go","Line":3,"Column":4}}]}`
295 if got = ParseDiagnostics("golangci-lint-json", "golangci", root, golangci); len(got) != 1 || got[0].Severity != "warning" {
296 t.Fatalf("golangci = %#v", got)
297 }
298 ruff := `[{"code":"F401","message":"unused","filename":"a.py","location":{"row":1,"column":2}}]`
299 if got = ParseDiagnostics("ruff-json", "ruff", root, ruff); len(got) != 1 || got[0].Code != "F401" {
300 t.Fatalf("ruff = %#v", got)
301 }
302 sarif := `{"runs":[{"results":[{"ruleId":"R1","level":"error","message":{"text":"bad"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"a.c"},"region":{"startLine":4,"startColumn":2}}}]}]}]}`
303 if got = ParseDiagnostics("sarif", "scan", root, sarif); len(got) != 1 || got[0].Range.Start.Line != 3 {
304 t.Fatalf("sarif = %#v", got)
305 }
306 if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" {
307 t.Fatalf("generic = %#v", got)
308 }
309 if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: cannot find name"); len(got) != 1 || got[0].Message != "cannot find name" {
310 t.Fatalf("generic message = %#v", got)
311 }
312}
313
314func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) {
315 path := filepath.Join(t.TempDir(), "main.go")
316 base := Diagnostic{Path: path, Severity: "error", Code: "E1", Message: "same", Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 1, Character: 3}}}
317 endChanged := base
318 endChanged.Range.End.Character = 20
319 otherCode := base
320 otherCode.Code = "E2"
321 otherStart := base
322 otherStart.Range.Start.Line++
323 if got := DeduplicateDiagnostics([]Diagnostic{base, endChanged, otherCode, otherStart}); len(got) != 3 {
324 t.Fatalf("deduplicated diagnostics = %#v, want three reports", got)
325 }
326}
327
328func TestManagerClearsPublishedDiagnostics(t *testing.T) {
329 manager := NewManager()
330 root := t.TempDir()
331 path := filepath.Join(root, "main.go")
332 if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
333 t.Fatal(err)
334 }
335 ws, err := manager.workspace(root)
336 if err != nil {
337 t.Fatal(err)
338 }
339 manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Message: "problem"}})
340 if got := manager.snapshot(ws, path); len(got) != 1 {
341 t.Fatalf("stored diagnostics = %#v", got)
342 }
343 manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Clear: true}})
344 if got := manager.snapshot(ws, path); len(got) != 0 {
345 t.Fatalf("cleared diagnostics = %#v", got)
346 }
347}
348
349func TestDiagnosticsDedupAndGrouping(t *testing.T) {
350 root := t.TempDir()
351 makeDiag := func(path, message string, line int, server string) Diagnostic {
352 return Diagnostic{Path: filepath.Join(root, path), Severity: "error", Code: "E", Message: message, Server: server, Range: Range{Start: Position{Line: line}}}
353 }
354 input := []Diagnostic{makeDiag("a.go", "same", 0, "gopls"), makeDiag("a.go", "same", 0, "eslint"), makeDiag("a.go", `Cannot find module "missing"`, 1, "gopls"), makeDiag("a.go", "cascade one", 2, "gopls"), makeDiag("a.go", "cascade two", 3, "gopls"), makeDiag("b.go", "repeat", 1, "a"), makeDiag("c.go", "repeat", 2, "b"), makeDiag("d.go", "repeat", 3, "c")}
355 if got := DeduplicateDiagnostics(input); len(got) != len(input)-1 {
356 t.Fatalf("dedup count = %d", len(got))
357 }
358 text := SummarizeDiagnostics(input, root, 50)
359 if !strings.Contains(text, "repeated") || !strings.Contains(text, "SECONDARY") {
360 t.Fatalf("summary lacks grouping:\n%s", text)
361 }
362}
363
364func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) {
365 root := t.TempDir()
366 path := filepath.Join(root, "a.txt")
367 if err := os.WriteFile(path, []byte("a😀b\n"), 0o644); err != nil {
368 t.Fatal(err)
369 }
370 uri := URIForPath(path)
371 ed := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: Range{Start: Position{Line: 0, Character: 1}, End: Position{Line: 0, Character: 3}}, NewText: "X"}}}}
372 if err := ApplyWorkspaceEdit(root, ed); err != nil {
373 t.Fatal(err)
374 }
375 data, _ := os.ReadFile(path)
376 if string(data) != "aXb\n" {
377 t.Fatalf("edited data = %q", data)
378 }
379 outside := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(filepath.Join(filepath.Dir(root), "outside.txt")): {{Range: Range{}, NewText: "x"}}}}
380 if err := ApplyWorkspaceEdit(root, outside); err == nil {
381 t.Fatal("outside edit was accepted")
382 }
383}
384
385func TestWorkspaceEditPreflightsAllTargets(t *testing.T) {
386 root := t.TempDir()
387 inside := filepath.Join(root, "inside.txt")
388 outside := filepath.Join(t.TempDir(), "outside.txt")
389 if err := os.WriteFile(inside, []byte("old\n"), 0o644); err != nil {
390 t.Fatal(err)
391 }
392 if err := os.WriteFile(outside, []byte("old\n"), 0o644); err != nil {
393 t.Fatal(err)
394 }
395 rootReal, err := filepath.EvalSymlinks(root)
396 if err != nil {
397 t.Fatal(err)
398 }
399 _, err = prepareEditTargets(rootReal, map[string][]TextEdit{
400 URIForPath(inside): {{Range: Range{}, NewText: "inside"}},
401 URIForPath(outside): {{Range: Range{}, NewText: "outside"}},
402 }, nil)
403 if err == nil {
404 t.Fatal("workspace edit accepted an unsafe target after a safe target")
405 }
406}
407
408func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) {
409 if runtime.GOOS == "windows" {
410 t.Skip("symlink permissions vary on Windows")
411 }
412 root := t.TempDir()
413 target := filepath.Join(root, "target.txt")
414 link := filepath.Join(root, "link.txt")
415 if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
416 t.Fatal(err)
417 }
418 if err := os.Symlink(target, link); err != nil {
419 t.Fatal(err)
420 }
421 edit := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(link): {{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 3}}, NewText: "new"}}}}
422 if err := ApplyWorkspaceEdit(root, edit); err != nil {
423 t.Fatal(err)
424 }
425 data, err := os.ReadFile(target)
426 if err != nil {
427 t.Fatal(err)
428 }
429 if string(data) != "new\n" {
430 t.Fatalf("target data = %q", data)
431 }
432 info, err := os.Lstat(link)
433 if err != nil {
434 t.Fatal(err)
435 }
436 if info.Mode()&os.ModeSymlink == 0 {
437 t.Fatal("workspace edit replaced the symlink")
438 }
439}
440
441func TestLimitedBufferCapsReaderCopy(t *testing.T) {
442 var buffer limitedBuffer
443 buffer.limit = 4
444 copied, err := io.Copy(&buffer, strings.NewReader("123456"))
445 if err != nil {
446 t.Fatal(err)
447 }
448 if copied != 6 || buffer.String() != "1234" {
449 t.Fatalf("copied %d bytes into %q", copied, buffer.String())
450 }
451}
452
453func TestDiagnosticJSONCodeNumber(t *testing.T) {
454 var diagnostic Diagnostic
455 if err := json.Unmarshal([]byte(`{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"code":123,"message":"warning"}`), &diagnostic); err != nil {
456 t.Fatal(err)
457 }
458 if diagnostic.Code != "123" || diagnostic.Severity != "warning" {
459 t.Fatalf("diagnostic = %#v", diagnostic)
460 }
461}
462
463func TestManagerClosePreventsNewWorkspaces(t *testing.T) {
464 manager := NewManager()
465 root := t.TempDir()
466 if err := manager.Close(); err != nil {
467 t.Fatal(err)
468 }
469 if err := manager.Close(); err != nil {
470 t.Fatal(err)
471 }
472 if _, err := manager.Config(root); err == nil {
473 t.Fatal("closed manager accepted a new workspace")
474 }
475 if err := manager.Reload(root); err == nil {
476 t.Fatal("closed manager accepted a reload")
477 }
478}
479
480func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) {
481 manager := NewManager()
482 root := t.TempDir()
483 path := filepath.Join(root, "main.go")
484 first := Diagnostic{Path: path, Severity: "error", Message: "type mismatch", Range: Range{Start: Position{Line: 1, Character: 2}}}
485 if got := manager.ReduceDiagnostics(root, path, []Diagnostic{first}); len(got) != 1 {
486 t.Fatalf("first reduction returned %d diagnostics, want 1", len(got))
487 }
488 shifted := first
489 shifted.Range.Start.Line = 20
490 if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 0 {
491 t.Fatalf("shifted diagnostic was replayed: %#v", got)
492 }
493 if got := manager.ReduceDiagnostics(root, path, nil); len(got) != 0 {
494 t.Fatalf("clear returned %#v", got)
495 }
496 if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 1 {
497 t.Fatalf("diagnostic did not reappear after clear: %#v", got)
498 }
499}