package diag import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "time" ) // queryCaps is what a server that can answer every query advertises. var queryCaps = map[string]any{ "definitionProvider": true, "referencesProvider": true, "hoverProvider": map[string]any{}, "documentSymbolProvider": true, "workspaceSymbolProvider": true, } // positionOf pulls the 0-based position out of a request's params. func positionOf(msg incoming) (line, char int) { var p struct { Position struct { Line int `json:"line"` Character int `json:"character"` } `json:"position"` } _ = json.Unmarshal(msg.Params, &p) return p.Position.Line, p.Position.Character } func uriOf(msg incoming) string { var p struct { TextDocument struct { URI string `json:"uri"` } `json:"textDocument"` } _ = json.Unmarshal(msg.Params, &p) return p.TextDocument.URI } func lspLocation(uri string, line, char int) map[string]any { return map[string]any{ "uri": uri, "range": map[string]any{ "start": map[string]any{"line": line, "character": char}, "end": map[string]any{"line": line, "character": char + 1}, }, } } // answersQueries is wellBehaved plus every query, each answered with // something that carries the request's own position back, so a test can see // exactly what went over the wire in each direction. func answersQueries(f *fakeServer, msg incoming) { switch msg.Method { case "initialize": f.respond(msg.ID, map[string]any{"capabilities": queryCaps}) case "textDocument/definition": line, char := positionOf(msg) f.respond(msg.ID, []any{lspLocation(uriOf(msg), line+10, char+10)}) case "textDocument/references": line, char := positionOf(msg) uri := uriOf(msg) f.respond(msg.ID, []any{ lspLocation(uri, line, char), lspLocation(uri, line+1, 0), lspLocation(fileURI(filepath.Join(filepath.Dir(uriPath(uri)), "other.go")), 0, 0), }) case "textDocument/hover": f.respond(msg.ID, map[string]any{ "contents": map[string]any{"kind": "markdown", "value": "```go\nfunc Foo() error\n```\n\nFoo does a thing."}, }) case "textDocument/documentSymbol": f.respond(msg.ID, []any{ map[string]any{ "name": "Runner", "kind": 23, "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}}, "selectionRange": map[string]any{"start": map[string]any{"line": 1, "character": 5}}, "children": []any{ map[string]any{ "name": "Close", "kind": 6, "range": map[string]any{"start": map[string]any{"line": 4, "character": 0}}, "selectionRange": map[string]any{"start": map[string]any{"line": 5, "character": 17}}, }, }, }, map[string]any{ "name": "NewRunner", "kind": 12, "selectionRange": map[string]any{"start": map[string]any{"line": 9, "character": 5}}, }, }) case "workspace/symbol": var p struct { Query string `json:"query"` } _ = json.Unmarshal(msg.Params, &p) f.respond(msg.ID, []any{ map[string]any{ "name": p.Query + "One", "kind": 12, "containerName": "pkg", "location": lspLocation("file:///ws/a.go", 2, 5), }, map[string]any{ "name": p.Query + "Two", "kind": 5, "location": map[string]any{"uri": "file:///ws/b.go"}, }, }) default: wellBehaved(f, msg) } } // runnerWithFake returns a Runner whose gopls is an in-process fake, so the // exported query methods can be driven end to end without a child process. // The file lives in a module directory so the workspace key matches. func runnerWithFake(t *testing.T, serve serveFunc) (*Runner, *fakeServer, string) { t.Helper() r := runnerWith(t, map[string]string{"gopls": "/fake/gopls"}) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module m\n"), 0o644); err != nil { t.Fatal(err) } path := filepath.Join(dir, "main.go") if err := os.WriteFile(path, []byte("package m\n\nfunc Foo() error {\n\treturn nil\n}\n"), 0o644); err != nil { t.Fatal(err) } s, f := startFake(t, serve, 0, nil) r.mu.Lock() r.servers[dir+"\x00gopls"] = s r.mu.Unlock() return r, f, path } func TestDefinitionConvertsPositionsBothWays(t *testing.T) { r, f, path := runnerWithFake(t, answersQueries) locs, err := r.Definition(shortCtx(t, 2*time.Second), path, 4, 7) if err != nil { t.Fatalf("Definition: %v", err) } f.mu.Lock() var req incoming for _, m := range f.received { if m.Method == "textDocument/definition" { req = m } } f.mu.Unlock() if line, char := positionOf(req); line != 3 || char != 6 { t.Errorf("server received position %d:%d, want 3:6 (0-based) for 4:7 (1-based)", line, char) } // The fake answers with the request's position plus ten in each // direction, so the round trip 4:7 → 3:6 → 13:16 → 14:17 pins both // conversions and would show a double or missing +1 at once. if len(locs) != 1 || locs[0].File != path || locs[0].Line != 14 || locs[0].Col != 17 { t.Errorf("got %+v, want one location at %s:14:17", locs, path) } methods := strings.Join(f.methods(), ",") if !strings.Contains(methods, "textDocument/didOpen,textDocument/didSave,textDocument/definition") { t.Errorf("the file should be opened before it is asked about: %s", methods) } } func TestReferencesAskForTheDeclarationAndKeepEveryFile(t *testing.T) { r, f, path := runnerWithFake(t, answersQueries) locs, err := r.References(shortCtx(t, 2*time.Second), path, 3, 6) if err != nil { t.Fatalf("References: %v", err) } if len(locs) != 3 { t.Fatalf("got %d locations, want 3: %+v", len(locs), locs) } if locs[0].Line != 3 || locs[0].Col != 6 || locs[1].Line != 4 || locs[1].Col != 1 { t.Errorf("positions wrong: %+v", locs[:2]) } if filepath.Base(locs[2].File) != "other.go" || locs[2].Line != 1 || locs[2].Col != 1 { t.Errorf("neighbour wrong: %+v", locs[2]) } f.mu.Lock() defer f.mu.Unlock() for _, m := range f.received { if m.Method == "textDocument/references" { if !strings.Contains(string(m.Params), `"includeDeclaration":true`) { t.Errorf("references params %s should ask for the declaration", m.Params) } } } } func TestHoverReturnsTheServersText(t *testing.T) { r, _, path := runnerWithFake(t, answersQueries) got, err := r.Hover(shortCtx(t, 2*time.Second), path, 3, 6) if err != nil { t.Fatal(err) } if !strings.HasPrefix(got, "```go\nfunc Foo() error") || !strings.HasSuffix(got, "Foo does a thing.") { t.Errorf("hover = %q", got) } } func TestDocumentSymbolsFlattenTheHierarchy(t *testing.T) { r, _, path := runnerWithFake(t, answersQueries) syms, err := r.DocumentSymbols(shortCtx(t, 2*time.Second), path) if err != nil { t.Fatal(err) } want := []Symbol{ {Name: "Runner", Kind: "struct", Location: Location{File: path, Line: 2, Col: 6}}, {Name: "Close", Kind: "method", Container: "Runner", Location: Location{File: path, Line: 6, Col: 18}}, {Name: "NewRunner", Kind: "func", Location: Location{File: path, Line: 10, Col: 6}}, } if len(syms) != len(want) { t.Fatalf("got %+v", syms) } for i := range want { if syms[i] != want[i] { t.Errorf("symbol %d = %+v, want %+v", i, syms[i], want[i]) } } } func TestWorkspaceSymbolsDoNotOpenTheFile(t *testing.T) { r, f, path := runnerWithFake(t, answersQueries) syms, err := r.WorkspaceSymbols(shortCtx(t, 2*time.Second), path, "Run") if err != nil { t.Fatal(err) } if len(syms) != 2 { t.Fatalf("got %+v", syms) } if syms[0].Name != "RunOne" || syms[0].Container != "pkg" || syms[0].File != "/ws/a.go" || syms[0].Line != 3 || syms[0].Col != 6 { t.Errorf("first = %+v", syms[0]) } // A WorkspaceSymbol may name only the file; that is still worth showing. if syms[1].Name != "RunTwo" || syms[1].Kind != "class" || syms[1].File != "/ws/b.go" || syms[1].Line != 0 { t.Errorf("second = %+v", syms[1]) } for _, m := range f.methods() { if m == "textDocument/didOpen" { t.Error("a workspace search opened the file") } } } func TestQueryRefusedWhenTheServerLacksTheCapability(t *testing.T) { // wellBehaved advertises nothing, which is what a diagnostics-only server // looks like. r, f, path := runnerWithFake(t, wellBehaved) _, err := r.Definition(shortCtx(t, 2*time.Second), path, 1, 1) if !errors.Is(err, ErrUnsupported) { t.Fatalf("err = %v, want ErrUnsupported", err) } if !strings.Contains(err.Error(), "fakels") || !strings.Contains(err.Error(), "textDocument/definition") { t.Errorf("error should name the server and the request: %v", err) } for _, m := range f.methods() { if m == "textDocument/definition" { t.Error("an unsupported request was still sent") } } } func TestQueryMethodNotFoundIsUnsupported(t *testing.T) { serve := func(f *fakeServer, msg incoming) { if msg.Method == "textDocument/hover" { f.send(map[string]any{"jsonrpc": "2.0", "id": msg.ID, "error": map[string]any{"code": -32601, "message": "method not found"}}) return } answersQueries(f, msg) } r, _, path := runnerWithFake(t, serve) _, err := r.Hover(shortCtx(t, 2*time.Second), path, 1, 1) if !errors.Is(err, ErrUnsupported) { t.Errorf("err = %v, want ErrUnsupported", err) } } func TestQueryOtherServerErrorsAreReportedAsTheyAre(t *testing.T) { serve := func(f *fakeServer, msg incoming) { if msg.Method == "textDocument/hover" { f.send(map[string]any{"jsonrpc": "2.0", "id": msg.ID, "error": map[string]any{"code": -32603, "message": "no package for file"}}) return } answersQueries(f, msg) } r, _, path := runnerWithFake(t, serve) _, err := r.Hover(shortCtx(t, 2*time.Second), path, 1, 1) if err == nil || errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), "no package for file") { t.Errorf("err = %v", err) } } func TestQueryEmptyResultsAreNotErrors(t *testing.T) { serve := func(f *fakeServer, msg incoming) { switch msg.Method { case "textDocument/definition", "textDocument/references", "textDocument/hover", "textDocument/documentSymbol", "workspace/symbol": f.respond(msg.ID, nil) default: answersQueries(f, msg) } } r, _, path := runnerWithFake(t, serve) ctx := shortCtx(t, 4*time.Second) if locs, err := r.Definition(ctx, path, 1, 1); err != nil || len(locs) != 0 { t.Errorf("Definition = %v, %v", locs, err) } if locs, err := r.References(ctx, path, 1, 1); err != nil || len(locs) != 0 { t.Errorf("References = %v, %v", locs, err) } if h, err := r.Hover(ctx, path, 1, 1); err != nil || h != "" { t.Errorf("Hover = %q, %v", h, err) } if syms, err := r.DocumentSymbols(ctx, path); err != nil || len(syms) != 0 { t.Errorf("DocumentSymbols = %v, %v", syms, err) } if syms, err := r.WorkspaceSymbols(ctx, path, "x"); err != nil || len(syms) != 0 { t.Errorf("WorkspaceSymbols = %v, %v", syms, err) } } func TestQueryAgainstAServerThatDiesNamesIt(t *testing.T) { serve := func(f *fakeServer, msg incoming) { if msg.Method == "textDocument/didOpen" { f.close() return } answersQueries(f, msg) } r, _, path := runnerWithFake(t, serve) _, err := r.Definition(shortCtx(t, 2*time.Second), path, 1, 1) if !errors.Is(err, errServerDead) { t.Fatalf("err = %v, want errServerDead", err) } if !strings.HasPrefix(err.Error(), "fakels: ") { t.Errorf("error should name the server: %v", err) } } func TestQueryThatTimesOutNamesTheServer(t *testing.T) { serve := func(f *fakeServer, msg incoming) { if msg.Method == "textDocument/definition" { return } answersQueries(f, msg) } r, _, path := runnerWithFake(t, serve) _, err := r.Definition(shortCtx(t, 100*time.Millisecond), path, 1, 1) if !errors.Is(err, context.DeadlineExceeded) || !strings.HasPrefix(err.Error(), "fakels: ") { t.Errorf("err = %v", err) } } func TestQueryRejectsZeroBasedPositions(t *testing.T) { r, f, path := runnerWithFake(t, answersQueries) if _, err := r.Definition(shortCtx(t, time.Second), path, 0, 1); err == nil || !strings.Contains(err.Error(), "1-based") { t.Errorf("line 0: %v", err) } if _, err := r.Hover(shortCtx(t, time.Second), path, 1, 0); err == nil || !strings.Contains(err.Error(), "1-based") { t.Errorf("character 0: %v", err) } if n := len(f.methods()); n != 0 { t.Errorf("a rejected position still reached the server: %v", f.methods()) } } func TestLookupExplainsWhyThereIsNoServer(t *testing.T) { ctx := shortCtx(t, time.Second) var nilRunner *Runner if _, err := nilRunner.Definition(ctx, "/r/a.go", 1, 1); !errors.Is(err, ErrDisabled) { t.Errorf("nil Runner: %v", err) } r := runnerWith(t, nil) _, err := r.Definition(ctx, writeTemp(t, "notes.md", "# hi\n"), 1, 1) if !errors.Is(err, ErrNoServer) || !strings.Contains(err.Error(), ".md") { t.Errorf("markdown: %v", err) } _, err = r.DocumentSymbols(ctx, writeTemp(t, "Makefile", "all:\n")) if !errors.Is(err, ErrNoServer) || !strings.Contains(err.Error(), "Makefile") { t.Errorf("no extension: %v", err) } _, err = r.References(ctx, writeTemp(t, "main.go", "package main\n"), 1, 1) if !errors.Is(err, ErrNotInstalled) || !strings.Contains(err.Error(), "gopls") { t.Errorf("not installed should name the binary: %v", err) } // A server that failed recently is not retried, and the error says so // rather than looking like a fresh failure. r = runnerWith(t, map[string]string{"pyright-langserver": "/fake/pyright"}) p := writeTemp(t, "x.py", "x = 1\n") r.mu.Lock() r.cooldown[filepath.Dir(p)+"\x00pyright-langserver"] = time.Now().Add(time.Minute) r.mu.Unlock() _, err = r.Hover(ctx, p, 1, 1) if !errors.Is(err, ErrStartFailed) || !strings.Contains(err.Error(), "pyright-langserver") { t.Errorf("cooldown: %v", err) } r.Close() if _, err := r.Hover(ctx, p, 1, 1); !errors.Is(err, errServerDead) { t.Errorf("closed runner: %v", err) } } func TestQueryAgainstAServerThatWillNotStart(t *testing.T) { r := runnerWith(t, map[string]string{"gopls": fakeServerScript(t, "exit")}) p := goModule(t, "package main\n") _, err := r.Definition(shortCtx(t, 5*time.Second), p, 1, 1) if err == nil { t.Fatal("a server that exits at once answered a query") } if errors.Is(err, context.DeadlineExceeded) { t.Errorf("waited for the deadline rather than noticing the exit: %v", err) } if !strings.Contains(err.Error(), "gopls") { t.Errorf("error should name the server: %v", err) } } func TestParseLocationsAcceptsEveryShape(t *testing.T) { cases := map[string]string{ "single": `{"uri":"file:///r/a.go","range":{"start":{"line":2,"character":4},"end":{"line":2,"character":5}}}`, "list": `[{"uri":"file:///r/a.go","range":{"start":{"line":2,"character":4},"end":{"line":2,"character":5}}}]`, "links": `[{"targetUri":"file:///r/a.go","targetRange":{"start":{"line":0,"character":0}},"targetSelectionRange":{"start":{"line":2,"character":4}}}]`, "trailer": ` [{"uri":"file:///r/a.go","range":{"start":{"line":2,"character":4}}},{"uri":"untitled:x","range":{"start":{"line":0,"character":0}}}] `, } for name, raw := range cases { locs, err := parseLocations(json.RawMessage(raw)) if err != nil { t.Errorf("%s: %v", name, err) continue } if len(locs) != 1 || locs[0] != (Location{File: "/r/a.go", Line: 3, Col: 5}) { t.Errorf("%s: %+v", name, locs) } } if locs, err := parseLocations(json.RawMessage("null")); err != nil || locs != nil { t.Errorf("null: %v, %v", locs, err) } if _, err := parseLocations(json.RawMessage(`"what"`)); err == nil { t.Error("a string is not a location") } } func TestParseHoverAcceptsEveryShape(t *testing.T) { cases := map[string]struct{ raw, want string }{ "markup": {`{"contents":{"kind":"markdown","value":"**x**"}}`, "**x**"}, "string": {`{"contents":"plain"}`, "plain"}, "marked": {`{"contents":{"language":"go","value":"func F()"}}`, "```go\nfunc F()\n```"}, "array": {`{"contents":["a",{"language":"go","value":"b"}]}`, "a\n\n```go\nb\n```"}, "null": {`null`, ""}, "nullcontents": {`{"contents":null}`, ""}, "emptyarray": {`{"contents":[]}`, ""}, } for name, c := range cases { got, err := parseHover(json.RawMessage(c.raw)) if err != nil || got != c.want { t.Errorf("%s: got %q, %v; want %q", name, got, err, c.want) } } } func TestParseSymbolsFlatShape(t *testing.T) { raw := `[{"name":"Foo","kind":12,"containerName":"pkg","location":{"uri":"file:///r/a.go","range":{"start":{"line":9,"character":5}}}}]` syms, err := parseSymbols(json.RawMessage(raw), "/r/a.go") if err != nil || len(syms) != 1 { t.Fatalf("%v, %v", syms, err) } want := Symbol{Name: "Foo", Kind: "func", Container: "pkg", Location: Location{File: "/r/a.go", Line: 10, Col: 6}} if syms[0] != want { t.Errorf("got %+v, want %+v", syms[0], want) } if symbolKind(99) != "symbol" || symbolKind(0) != "symbol" || symbolKind(26) != "type param" { t.Error("symbolKind out of range") } }