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")
}
}