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