Vectrum
Sign In
479 lines · 16.4 KB · Go
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")
	}
}