Vectrum
Sign In
583 lines · 16.6 KB · Go
package diag

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"
)

// fakeServer is a language server that lives in the test process and speaks
// the wire protocol over pipes, so every ugly path — silence, a half-written
// header, an early exit — can be produced on demand without a real server
// installed. serve is called for each message the client sends.
type fakeServer struct {
	in    *bufio.Reader
	out   io.WriteCloser
	outMu sync.Mutex

	mu       sync.Mutex
	received []incoming
	replies  []incoming // responses the client sent to our requests
	closed   bool
}

type serveFunc func(f *fakeServer, msg incoming)

// startFake wires a session to a fakeServer. idle is the session's idle
// timeout, 0 for none.
func startFake(t *testing.T, serve serveFunc, idle time.Duration, onExit func(*session)) (*session, *fakeServer) {
	t.Helper()
	toServer, clientStdin := io.Pipe()
	clientStdout, fromServer := io.Pipe()
	f := &fakeServer{in: bufio.NewReader(toServer), out: fromServer}
	go f.loop(serve)
	kill := func() {
		f.close()
		_ = toServer.Close()
	}
	spec := serverSpec{Bin: "fakels", Languages: map[string]string{".go": "go"}}
	s := newSession(spec, t.TempDir(), clientStdin, clientStdout, kill, nil, idle, onExit)
	t.Cleanup(func() {
		done := make(chan struct{})
		go func() { s.Close(); close(done) }()
		select {
		case <-done:
		case <-time.After(3 * time.Second):
			t.Error("session.Close hung")
		}
	})
	return s, f
}

func (f *fakeServer) loop(serve serveFunc) {
	for {
		body, err := readMessage(f.in)
		if err != nil {
			return
		}
		var msg incoming
		if err := json.Unmarshal(body, &msg); err != nil {
			return
		}
		f.mu.Lock()
		if msg.Method == "" {
			f.replies = append(f.replies, msg)
		} else {
			f.received = append(f.received, msg)
		}
		f.mu.Unlock()
		if msg.Method != "" && serve != nil {
			serve(f, msg)
		}
	}
}

func (f *fakeServer) send(v any) {
	body, _ := json.Marshal(v)
	f.outMu.Lock()
	defer f.outMu.Unlock()
	if f.closed {
		return
	}
	_ = writeMessage(f.out, body)
}

// raw writes bytes with no framing, for producing broken streams.
func (f *fakeServer) raw(s string) {
	f.outMu.Lock()
	defer f.outMu.Unlock()
	if !f.closed {
		_, _ = io.WriteString(f.out, s)
	}
}

func (f *fakeServer) close() {
	f.outMu.Lock()
	defer f.outMu.Unlock()
	if !f.closed {
		f.closed = true
		_ = f.out.Close()
	}
}

func (f *fakeServer) respond(id json.RawMessage, result any) {
	f.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
}

func (f *fakeServer) notify(method string, params any) {
	f.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
}

func (f *fakeServer) request(id int, method string, params any) {
	f.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
}

func (f *fakeServer) methods() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	out := make([]string, len(f.received))
	for i, m := range f.received {
		out[i] = m.Method
	}
	return out
}

func (f *fakeServer) publish(uri string, diags []map[string]any) {
	if diags == nil {
		diags = []map[string]any{}
	}
	f.notify("textDocument/publishDiagnostics", map[string]any{"uri": uri, "diagnostics": diags})
}

// lspDiag builds one LSP diagnostic at 0-based line and character.
func lspDiag(line, char, severity int, msg string) map[string]any {
	return map[string]any{
		"range": map[string]any{
			"start": map[string]any{"line": line, "character": char},
			"end":   map[string]any{"line": line, "character": char + 1},
		},
		"severity": severity,
		"message":  msg,
	}
}

// docText pulls the file content out of a didOpen or didChange.
func docText(msg incoming) (uri, text string) {
	var open struct {
		TextDocument struct {
			URI  string `json:"uri"`
			Text string `json:"text"`
		} `json:"textDocument"`
		ContentChanges []struct {
			Text string `json:"text"`
		} `json:"contentChanges"`
	}
	_ = json.Unmarshal(msg.Params, &open)
	text = open.TextDocument.Text
	if len(open.ContentChanges) > 0 {
		text = open.ContentChanges[0].Text
	}
	return open.TextDocument.URI, text
}

// wellBehaved answers like a real server: initialize gets capabilities, an
// opened or changed document gets one error per line containing BUG, one
// warning per WARN and one hint per HINT, shutdown gets null and exit closes
// the connection.
func wellBehaved(f *fakeServer, msg incoming) {
	switch msg.Method {
	case "initialize":
		f.respond(msg.ID, map[string]any{"capabilities": map[string]any{}})
	case "textDocument/didOpen", "textDocument/didChange":
		uri, text := docText(msg)
		var diags []map[string]any
		for i, line := range strings.Split(text, "\n") {
			switch {
			case strings.Contains(line, "BUG"):
				diags = append(diags, lspDiag(i, strings.Index(line, "BUG"), 1, "this is a bug"))
			case strings.Contains(line, "WARN"):
				diags = append(diags, lspDiag(i, 0, 2, "this is a warning"))
			case strings.Contains(line, "HINT"):
				diags = append(diags, lspDiag(i, 0, 4, "this is a hint"))
			}
		}
		f.publish(uri, diags)
	case "shutdown":
		f.respond(msg.ID, nil)
	case "exit":
		f.close()
	}
}

func writeTemp(t *testing.T, name, content string) string {
	t.Helper()
	p := filepath.Join(t.TempDir(), name)
	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

func shortCtx(t *testing.T, d time.Duration) context.Context {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), d)
	t.Cleanup(cancel)
	return ctx
}

func TestSessionReportsDiagnosticsForTheOpenedFile(t *testing.T) {
	s, f := startFake(t, wellBehaved, 0, nil)
	path := writeTemp(t, "main.go", "package main\n\nfunc main() {\n\tx := BUG\n\tWARN\n\tHINT\n}\n")

	ds, err := s.Check(shortCtx(t, 2*time.Second), path)
	if err != nil {
		t.Fatalf("Check: %v", err)
	}
	if len(ds) != 3 {
		t.Fatalf("got %d diagnostics, want 3: %+v", len(ds), ds)
	}
	sortForReport(ds, path)
	bug := ds[0]
	if bug.File != path || bug.Line != 4 || bug.Col != 7 || bug.Severity != SeverityError || bug.Message != "this is a bug" || bug.Source != "fakels" {
		t.Errorf("bug diagnostic wrong: %+v", bug)
	}
	if ds[1].Severity != SeverityWarning || ds[1].Line != 5 {
		t.Errorf("warning wrong: %+v", ds[1])
	}
	if ds[2].Severity != SeverityHint {
		t.Errorf("hint wrong: %+v", ds[2])
	}

	got := f.methods()
	want := []string{"initialize", "initialized", "textDocument/didOpen", "textDocument/didSave"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Errorf("server saw %v, want %v", got, want)
	}
}

func TestSessionSecondCheckIsADidChange(t *testing.T) {
	s, f := startFake(t, wellBehaved, 0, nil)
	path := writeTemp(t, "main.go", "BUG\n")
	ctx := shortCtx(t, 4*time.Second)

	if ds, err := s.Check(ctx, path); err != nil || len(ds) != 1 {
		t.Fatalf("first check: %v, %d diagnostics", err, len(ds))
	}
	if err := os.WriteFile(path, []byte("clean now\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	ds, err := s.Check(ctx, path)
	if err != nil {
		t.Fatalf("second check: %v", err)
	}
	if len(ds) != 0 {
		t.Errorf("second check returned stale diagnostics: %+v", ds)
	}
	methods := strings.Join(f.methods(), ",")
	if strings.Count(methods, "textDocument/didOpen") != 1 || !strings.Contains(methods, "textDocument/didChange") {
		t.Errorf("expected one didOpen then a didChange, got %s", methods)
	}
}

func TestSessionCleanFileReturnsPromptly(t *testing.T) {
	s, _ := startFake(t, wellBehaved, 0, nil)
	path := writeTemp(t, "main.go", "package main\n")
	start := time.Now()
	ds, err := s.Check(shortCtx(t, 2*time.Second), path)
	if err != nil {
		t.Fatal(err)
	}
	if len(ds) != 0 {
		t.Errorf("clean file got %+v", ds)
	}
	// An empty batch is an answer. The check should cost the settle window,
	// not the deadline.
	if took := time.Since(start); took > settleWindow+time.Second {
		t.Errorf("clean check took %s", took)
	}
}

func TestSessionCollectsNeighboursAndKeepsTheLatestBatch(t *testing.T) {
	var neighbour string
	serve := func(f *fakeServer, msg incoming) {
		switch msg.Method {
		case "textDocument/didOpen":
			uri, _ := docText(msg)
			// A clearing batch, then the real one, then a neighbour — the
			// order gopls produces.
			f.publish(uri, nil)
			f.publish(uri, []map[string]any{lspDiag(0, 0, 1, "real")})
			f.publish(neighbour, []map[string]any{lspDiag(2, 0, 1, "neighbour broke")})
		default:
			wellBehaved(f, msg)
		}
	}
	s, _ := startFake(t, serve, 0, nil)
	path := writeTemp(t, "main.go", "x\n")
	neighbour = fileURI(filepath.Join(filepath.Dir(path), "other.go"))

	ds, err := s.Check(shortCtx(t, 2*time.Second), path)
	if err != nil {
		t.Fatal(err)
	}
	if len(ds) != 2 {
		t.Fatalf("got %+v", ds)
	}
	sortForReport(ds, path)
	if ds[0].Message != "real" || ds[1].Message != "neighbour broke" || filepath.Base(ds[1].File) != "other.go" {
		t.Errorf("got %+v", ds)
	}
}

func TestSessionServerThatNeverAnswersDoesNotHang(t *testing.T) {
	silent := func(*fakeServer, incoming) {}
	s, _ := startFake(t, silent, 0, nil)
	path := writeTemp(t, "main.go", "x\n")

	start := time.Now()
	_, err := s.Check(shortCtx(t, 150*time.Millisecond), path)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Errorf("err = %v, want deadline exceeded", err)
	}
	if took := time.Since(start); took > time.Second {
		t.Errorf("check took %s against a silent server", took)
	}
	// Close must also not wait on it.
	start = time.Now()
	s.Close()
	if took := time.Since(start); took > 3*shutdownGrace {
		t.Errorf("Close took %s against a silent server", took)
	}
	if !s.Dead() {
		t.Error("session not dead after Close")
	}
}

func TestSessionServerThatInitializesButNeverPublishes(t *testing.T) {
	serve := func(f *fakeServer, msg incoming) {
		if msg.Method == "initialize" {
			f.respond(msg.ID, map[string]any{"capabilities": map[string]any{}})
		}
	}
	s, _ := startFake(t, serve, 0, nil)
	path := writeTemp(t, "main.go", "x\n")
	_, err := s.Check(shortCtx(t, 150*time.Millisecond), path)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Errorf("err = %v, want deadline exceeded", err)
	}
	// The server is fine, just slow; it must not have been declared dead.
	if s.Dead() {
		t.Error("a slow server was killed")
	}
}

func TestSessionServerThatExitsImmediately(t *testing.T) {
	exited := make(chan *session, 1)
	serve := func(f *fakeServer, _ incoming) { f.close() }
	s, _ := startFake(t, serve, 0, func(s *session) { exited <- s })
	path := writeTemp(t, "main.go", "x\n")

	_, err := s.Check(shortCtx(t, time.Second), path)
	if err == nil {
		t.Fatal("check against a dead server succeeded")
	}
	if errors.Is(err, context.DeadlineExceeded) {
		t.Errorf("waited for the deadline rather than noticing the exit: %v", err)
	}
	select {
	case got := <-exited:
		if got != s {
			t.Error("onExit called with a different session")
		}
	case <-time.After(time.Second):
		t.Fatal("onExit never called")
	}
	if !s.failedToStart() {
		t.Error("a server that exited before initialize should count as failed to start")
	}
}

func TestSessionServerThatDiesMidCheck(t *testing.T) {
	serve := func(f *fakeServer, msg incoming) {
		switch msg.Method {
		case "initialize":
			f.respond(msg.ID, map[string]any{"capabilities": map[string]any{}})
		case "textDocument/didOpen":
			f.close()
		}
	}
	s, _ := startFake(t, serve, 0, nil)
	path := writeTemp(t, "main.go", "x\n")
	_, err := s.Check(shortCtx(t, time.Second), path)
	if !errors.Is(err, errServerDead) {
		t.Errorf("err = %v, want errServerDead", err)
	}
	if s.failedToStart() {
		t.Error("a server that died after initializing is a crash, not a start failure")
	}
}

func TestSessionGarbageOnTheWireEndsTheSession(t *testing.T) {
	serve := func(f *fakeServer, msg incoming) {
		if msg.Method == "initialize" {
			f.raw("this is not a language server\n")
		}
	}
	s, _ := startFake(t, serve, 0, nil)
	_, err := s.Check(shortCtx(t, time.Second), writeTemp(t, "main.go", "x\n"))
	if err == nil || errors.Is(err, context.DeadlineExceeded) {
		t.Errorf("err = %v, want a prompt failure", err)
	}
	if !s.Dead() {
		t.Error("session survived garbage")
	}
}

func TestSessionFloodIsCapped(t *testing.T) {
	serve := func(f *fakeServer, msg incoming) {
		switch msg.Method {
		case "initialize":
			f.respond(msg.ID, map[string]any{"capabilities": map[string]any{}})
		case "textDocument/didOpen":
			uri, _ := docText(msg)
			dir := filepath.Dir(uriPath(uri))
			// Far more files than anyone can use, and far more problems
			// per file.
			for i := 0; i < 3*maxDiagnosticFiles; i++ {
				f.publish(fileURI(filepath.Join(dir, fmt.Sprintf("n%d.go", i))), []map[string]any{lspDiag(0, 0, 1, "x")})
			}
			many := make([]map[string]any, 5*maxDiagnosticsPerURI)
			for i := range many {
				many[i] = lspDiag(i, 0, 1, "again")
			}
			f.publish(uri, many)
		}
	}
	s, _ := startFake(t, serve, 0, nil)
	path := writeTemp(t, "main.go", "x\n")
	ds, err := s.Check(shortCtx(t, 3*time.Second), path)
	if err != nil {
		t.Fatal(err)
	}
	files := map[string]int{}
	for _, d := range ds {
		files[d.File]++
	}
	// The cap is on neighbours; the checked file is kept on top of it.
	if len(files) > maxDiagnosticFiles+1 {
		t.Errorf("%d files collected, cap is %d", len(files), maxDiagnosticFiles)
	}
	if files[path] != maxDiagnosticsPerURI {
		t.Errorf("%d diagnostics for the target, cap is %d", files[path], maxDiagnosticsPerURI)
	}
	if files[path] == 0 {
		t.Error("the target file was pushed out by the flood")
	}
}

func TestSessionAnswersServerRequests(t *testing.T) {
	serve := func(f *fakeServer, msg incoming) {
		switch msg.Method {
		case "initialize":
			f.respond(msg.ID, map[string]any{"capabilities": map[string]any{}})
		case "initialized":
			f.request(7, "workspace/configuration", map[string]any{"items": []any{map[string]any{}, map[string]any{}}})
			f.request(8, "client/registerCapability", map[string]any{"registrations": []any{}})
			f.request(9, "something/unknown", nil)
		case "textDocument/didOpen":
			uri, _ := docText(msg)
			f.publish(uri, nil)
		}
	}
	s, f := startFake(t, serve, 0, nil)
	if _, err := s.Check(shortCtx(t, 2*time.Second), writeTemp(t, "main.go", "x\n")); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(time.Second)
	for {
		f.mu.Lock()
		n := len(f.replies)
		f.mu.Unlock()
		if n >= 3 || time.Now().After(deadline) {
			break
		}
		time.Sleep(5 * time.Millisecond)
	}
	f.mu.Lock()
	defer f.mu.Unlock()
	byID := map[string]incoming{}
	for _, r := range f.replies {
		byID[string(r.ID)] = r
	}
	if r, ok := byID["7"]; !ok || string(r.Result) != "[null,null]" {
		t.Errorf("workspace/configuration reply = %+v", r)
	}
	if r, ok := byID["8"]; !ok || r.Error != nil {
		t.Errorf("client/registerCapability reply = %+v", r)
	}
	if r, ok := byID["9"]; !ok || r.Error == nil || r.Error.Code != -32601 {
		t.Errorf("unknown method reply = %+v", r)
	}
}

func TestSessionIdleTimeoutShutsDownPolitely(t *testing.T) {
	exited := make(chan *session, 1)
	s, f := startFake(t, wellBehaved, 100*time.Millisecond, func(s *session) { exited <- s })
	if _, err := s.Check(shortCtx(t, 2*time.Second), writeTemp(t, "main.go", "x\n")); err != nil {
		t.Fatal(err)
	}
	select {
	case <-exited:
	case <-time.After(3 * time.Second):
		t.Fatal("idle server was never shut down")
	}
	methods := strings.Join(f.methods(), ",")
	if !strings.Contains(methods, "shutdown") || !strings.HasSuffix(methods, "exit") {
		t.Errorf("idle shutdown was not the polite sequence: %s", methods)
	}
	if _, err := s.Check(shortCtx(t, time.Second), writeTemp(t, "main.go", "x\n")); !errors.Is(err, errServerDead) {
		t.Errorf("check after idle shutdown: %v, want errServerDead", err)
	}
}

func TestSessionConcurrentChecksDoNotInterleave(t *testing.T) {
	s, _ := startFake(t, wellBehaved, 0, nil)
	paths := []string{
		writeTemp(t, "a.go", "BUG\n"),
		writeTemp(t, "b.go", "ok\nBUG\nBUG\n"),
		writeTemp(t, "c.go", "ok\n"),
	}
	want := []int{1, 2, 0}
	var wg sync.WaitGroup
	errs := make([]error, len(paths))
	counts := make([]int, len(paths))
	for i, p := range paths {
		wg.Add(1)
		go func(i int, p string) {
			defer wg.Done()
			ds, err := s.Check(shortCtx(t, 5*time.Second), p)
			errs[i] = err
			for _, d := range ds {
				if d.File == p {
					counts[i]++
				}
			}
		}(i, p)
	}
	wg.Wait()
	for i := range paths {
		if errs[i] != nil {
			t.Errorf("%s: %v", paths[i], errs[i])
		}
		if counts[i] != want[i] {
			t.Errorf("%s: %d diagnostics, want %d", filepath.Base(paths[i]), counts[i], want[i])
		}
	}
}

func TestFileURIRoundTrip(t *testing.T) {
	for _, p := range []string{"/tmp/a.go", "/tmp/with space/b.go", "/tmp/ünïcode/c.go", "/tmp/q?x#y.go"} {
		uri := fileURI(p)
		if !strings.HasPrefix(uri, "file:///") {
			t.Errorf("fileURI(%q) = %q", p, uri)
		}
		if got := uriPath(uri); got != p {
			t.Errorf("uriPath(fileURI(%q)) = %q", p, got)
		}
	}
	if uriPath("untitled:Untitled-1") != "" {
		t.Error("non-file URI should map to no path")
	}
	if !sameURI("file:///tmp/with%20space/b.go", fileURI("/tmp/with space/b.go")) {
		t.Error("sameURI should compare by path")
	}
}