package lsp import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "testing" "time" "rickub.com/turbo-editors/turbo-core/profile" ) func TestFramingRoundTrip(t *testing.T) { var out bytes.Buffer body := []byte(`{"jsonrpc":"2.0","method":"hello"}`) if err := WriteMessage(&out, body); err != nil { t.Fatalf("WriteMessage() error = %v", err) } wantHeader := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)) if !strings.HasPrefix(out.String(), wantHeader) { t.Errorf("the frame starts with %q, want %q", out.String()[:len(wantHeader)], wantHeader) } got, err := ReadMessage(bufio.NewReader(&out)) if err != nil { t.Fatalf("ReadMessage() error = %v", err) } if string(got) != string(body) { t.Errorf("ReadMessage() = %q, want %q", got, body) } } func TestReadMessageReadsSeveralFramesInARow(t *testing.T) { var out bytes.Buffer for _, body := range []string{`{"a":1}`, `{"b":2}`, `{"c":3}`} { if err := WriteMessage(&out, []byte(body)); err != nil { t.Fatalf("WriteMessage() error = %v", err) } } reader := bufio.NewReader(&out) for _, want := range []string{`{"a":1}`, `{"b":2}`, `{"c":3}`} { got, err := ReadMessage(reader) if err != nil { t.Fatalf("ReadMessage() error = %v", err) } if string(got) != want { t.Errorf("ReadMessage() = %q, want %q", got, want) } } if _, err := ReadMessage(reader); !errors.Is(err, io.EOF) { t.Errorf("ReadMessage() at the end = %v, want io.EOF", err) } } func TestReadMessageRejectsBadFrames(t *testing.T) { tests := []struct { name string give string }{ {"no Content-Length", "X-Other: 1\r\n\r\n{}"}, {"a length that is not a number", "Content-Length: many\r\n\r\n{}"}, {"a negative length", "Content-Length: -5\r\n\r\n{}"}, {"a body shorter than announced", "Content-Length: 100\r\n\r\n{}"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := ReadMessage(bufio.NewReader(strings.NewReader(tc.give))) if err == nil { t.Fatal("ReadMessage() error = nil, want a failure") } }) } } func TestReadMessageRefusesAnAbsurdLength(t *testing.T) { frame := "Content-Length: 999999999999\r\n\r\n" _, err := ReadMessage(bufio.NewReader(strings.NewReader(frame))) if !errors.Is(err, ErrMessageTooLarge) { t.Errorf("ReadMessage() error = %v, want ErrMessageTooLarge", err) } } func TestInitializeSendsTheHandshakeAndMarksTheClientReady(t *testing.T) { client, server := newFakeServer(t) if client.Ready() { t.Fatal("Ready() = true before the handshake") } if err := client.Initialize(t.Context()); err != nil { t.Fatalf("Initialize() error = %v", err) } if !client.Ready() { t.Error("Ready() = false after a successful handshake") } waitForMethod(t, server, "initialized") if got := server.methods(); got[0] != "initialize" || got[1] != "initialized" { t.Errorf("the server saw %v, want initialize then initialized", got) } } func TestRequestsBeforeInitializationAreRefused(t *testing.T) { client, _ := newFakeServer(t) if _, err := client.Complete(t.Context(), "main.go", 0, 0, ""); !errors.Is(err, ErrNotReady) { t.Errorf("Complete() error = %v, want ErrNotReady", err) } if _, err := client.Hover(t.Context(), "main.go", 0, 0, ""); !errors.Is(err, ErrNotReady) { t.Errorf("Hover() error = %v, want ErrNotReady", err) } if _, err := client.Definition(t.Context(), "main.go", 0, 0, ""); !errors.Is(err, ErrNotReady) { t.Errorf("Definition() error = %v, want ErrNotReady", err) } } func TestCompleteReadsAListObject(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(method string, _ json.RawMessage) (any, *ResponseError) { if method != "textDocument/completion" { return nil, nil } return CompletionList{Items: []CompletionItem{ {Label: "Println", Kind: KindFunction, Detail: "func(a ...any)"}, {Label: "Printf", Kind: KindFunction}, }}, nil }) mustInitialize(t, client) items, err := client.Complete(t.Context(), "main.go", 3, 4, "fmt.") if err != nil { t.Fatalf("Complete() error = %v", err) } if len(items) != 2 { t.Fatalf("Complete() returned %d items, want 2", len(items)) } if items[0].Label != "Println" || items[0].Kind != KindFunction { t.Errorf("the first item is %+v", items[0]) } } func TestCompleteReadsABareArrayToo(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(string, json.RawMessage) (any, *ResponseError) { return []CompletionItem{{Label: "len"}}, nil }) mustInitialize(t, client) items, err := client.Complete(t.Context(), "main.go", 0, 0, "") if err != nil { t.Fatalf("Complete() error = %v", err) } if len(items) != 1 || items[0].Label != "len" { t.Errorf("Complete() returned %+v, want one item labelled len", items) } } func TestCompleteSendsUTF16Columns(t *testing.T) { client, server := newFakeServer(t) var seen Position server.setHandler(func(method string, params json.RawMessage) (any, *ResponseError) { if method == "textDocument/completion" { var p TextDocumentPositionParams json.Unmarshal(params, &p) //nolint:errcheck // a bad decode shows up as a zero position seen = p.Position } return CompletionList{}, nil }) mustInitialize(t, client) // Four runes precede the cursor, but the clef needs a surrogate pair, so // the protocol column is five. if _, err := client.Complete(t.Context(), "main.go", 2, 4, "a𝄞bc"); err != nil { t.Fatalf("Complete() error = %v", err) } if seen.Line != 2 || seen.Character != 5 { t.Errorf("the server saw %+v, want {Line:2 Character:5}", seen) } } func TestAServerErrorReachesTheCaller(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(method string, _ json.RawMessage) (any, *ResponseError) { if method != "textDocument/completion" { return nil, nil // the handshake must still succeed } return nil, &ResponseError{Code: -32000, Message: "no package for this file"} }) mustInitialize(t, client) _, err := client.Complete(t.Context(), "main.go", 0, 0, "") var responseErr *ResponseError if !errors.As(err, &responseErr) { t.Fatalf("Complete() error = %v, want a *ResponseError", err) } if !strings.Contains(responseErr.Error(), "no package for this file") { t.Errorf("the error reads %q, want the server's message in it", responseErr.Error()) } } func TestARequestGivesUpWhenItsContextDoes(t *testing.T) { client, server := newFakeServer(t) block := make(chan struct{}) t.Cleanup(func() { close(block) }) server.setHandler(func(method string, _ json.RawMessage) (any, *ResponseError) { if method == "textDocument/completion" { <-block // never answer this one } return CompletionList{}, nil }) mustInitialize(t, client) ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) defer cancel() _, err := client.Complete(ctx, "main.go", 0, 0, "") if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("Complete() error = %v, want the deadline to have been reached", err) } } func TestHover(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(string, json.RawMessage) (any, *ResponseError) { return Hover{Contents: MarkupContent{Kind: "plaintext", Value: "func len(v Type) int"}}, nil }) mustInitialize(t, client) got, err := client.Hover(t.Context(), "main.go", 0, 0, "len") if err != nil { t.Fatalf("Hover() error = %v", err) } if got != "func len(v Type) int" { t.Errorf("Hover() = %q", got) } } func TestHoverWithNothingToSay(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(string, json.RawMessage) (any, *ResponseError) { return nil, nil }) mustInitialize(t, client) got, err := client.Hover(t.Context(), "main.go", 0, 0, "") if err != nil { t.Fatalf("Hover() error = %v", err) } if got != "" { t.Errorf("Hover() = %q, want empty", got) } } func TestDefinitionReadsBothShapes(t *testing.T) { location := Location{URI: PathToURI("/tmp/other.go"), Range: Range{Start: Position{Line: 4}}} tests := []struct { name string reply any want int }{ {"a single location", location, 1}, {"an array of locations", []Location{location, location}, 2}, {"nothing at all", nil, 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(string, json.RawMessage) (any, *ResponseError) { return tc.reply, nil }) mustInitialize(t, client) got, err := client.Definition(t.Context(), "main.go", 0, 0, "") if err != nil { t.Fatalf("Definition() error = %v", err) } if len(got) != tc.want { t.Errorf("Definition() returned %d locations, want %d", len(got), tc.want) } }) } } func TestDiagnosticsReachTheEditor(t *testing.T) { client, server := newFakeServer(t) received := make(chan []Diagnostic, 1) var receivedPath string client.OnDiagnostics = func(path string, diagnostics []Diagnostic) { receivedPath = path received <- diagnostics } mustInitialize(t, client) path := filepath.Join(t.TempDir(), "main.go") server.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{ URI: PathToURI(path), Diagnostics: []Diagnostic{ {Message: "undefined: foo", Severity: SeverityError, Range: Range{Start: Position{Line: 3}}}, }, }) select { case diagnostics := <-received: if len(diagnostics) != 1 || diagnostics[0].Message != "undefined: foo" { t.Errorf("the diagnostics are %+v", diagnostics) } if receivedPath != path { t.Errorf("the path is %q, want %q — the URI must be converted back", receivedPath, path) } case <-time.After(time.Second): t.Fatal("no diagnostics arrived") } } func TestServerMessagesAreLogged(t *testing.T) { client, server := newFakeServer(t) logged := make(chan string, 1) client.OnLog = func(message string) { logged <- message } mustInitialize(t, client) server.notify("window/showMessage", map[string]any{"type": 3, "message": "gopls is indexing"}) select { case message := <-logged: if message != "gopls is indexing" { t.Errorf("the logged message is %q", message) } case <-time.After(time.Second): t.Fatal("no message arrived") } } func TestTheClientAnswersAConfigurationRequest(t *testing.T) { client, server := newFakeServer(t) mustInitialize(t, client) result, responseErr := server.request("workspace/configuration", map[string]any{ "items": []map[string]string{{"section": "gopls"}, {"section": "gopls"}}, }) if responseErr != nil { t.Fatalf("the client answered with an error: %v", responseErr) } var settings []map[string]any if err := json.Unmarshal(result, &settings); err != nil { t.Fatalf("the answer is not a settings array: %v", err) } if len(settings) != 2 { t.Errorf("the client sent %d settings objects, want one per item asked about", len(settings)) } } func TestTheClientRefusesARequestItDoesNotKnow(t *testing.T) { client, server := newFakeServer(t) mustInitialize(t, client) _, responseErr := server.request("workspace/applyEdit", map[string]any{}) if responseErr == nil { t.Fatal("the client accepted a request it cannot serve") } if responseErr.Code != CodeMethodNotFound { t.Errorf("the error code is %d, want %d", responseErr.Code, CodeMethodNotFound) } } func TestDocumentSynchronisationSendsTheRightNotifications(t *testing.T) { client, server := newFakeServer(t) mustInitialize(t, client) if err := client.DidOpen("main.go", "package main"); err != nil { t.Fatalf("DidOpen() error = %v", err) } if err := client.DidChange("main.go", "package main\n"); err != nil { t.Fatalf("DidChange() error = %v", err) } if err := client.DidSave("main.go", "package main\n"); err != nil { t.Fatalf("DidSave() error = %v", err) } if err := client.DidClose("main.go"); err != nil { t.Fatalf("DidClose() error = %v", err) } waitForMethod(t, server, "textDocument/didClose") want := []string{ "textDocument/didOpen", "textDocument/didChange", "textDocument/didSave", "textDocument/didClose", } got := server.methods()[2:] // after initialize and initialized for i, method := range want { if got[i] != method { t.Errorf("notification %d was %q, want %q", i, got[i], method) } } } func TestVersionsGoUpWithEachChange(t *testing.T) { client, server := newFakeServer(t) versions := make(chan int, 4) server.setHandler(func(string, json.RawMessage) (any, *ResponseError) { return nil, nil }) mustInitialize(t, client) // The fake server records methods, not parameters, so the versions are // read back from the client's own bookkeeping instead. client.DidOpen("main.go", "a") //nolint:errcheck // checked below client.DidChange("main.go", "b") //nolint:errcheck client.DidChange("main.go", "c") //nolint:errcheck client.mu.Lock() versions <- client.versions["main.go"] client.mu.Unlock() if got := <-versions; got != 3 { t.Errorf("the document is at version %d, want 3", got) } } func TestRequestsAfterTheConnectionClosesFail(t *testing.T) { client, _ := newFakeServer(t) mustInitialize(t, client) if err := client.conn.Close(); err != nil { t.Fatalf("Close() error = %v", err) } if _, err := client.Complete(t.Context(), "main.go", 0, 0, ""); err == nil { t.Error("a request on a closed connection succeeded") } } func TestPathAndURIRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "sub dir", "main.go") uri := PathToURI(path) if !strings.HasPrefix(uri, "file://") { t.Errorf("PathToURI() = %q, want a file URI", uri) } if strings.Contains(uri, " ") { t.Errorf("PathToURI() = %q, want the space percent-encoded", uri) } if got := URIToPath(uri); got != path { t.Errorf("URIToPath(PathToURI(%q)) = %q", path, got) } } func TestURIToPathLeavesOtherSchemesAlone(t *testing.T) { if got := URIToPath("https://example.com/x"); got != "https://example.com/x" { t.Errorf("URIToPath() = %q, want it unchanged", got) } } func TestUTF16Conversion(t *testing.T) { tests := []struct { name string line string rune_ int utf16 int }{ {"ascii", "hello", 3, 3}, {"an accent is one unit", "héllo", 3, 3}, {"beyond the BMP needs two", "a𝄞bc", 2, 3}, {"the start of a line", "anything", 0, 0}, {"the end of a line", "a𝄞", 2, 3}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := RuneToUTF16(tc.line, tc.rune_); got != tc.utf16 { t.Errorf("RuneToUTF16(%q, %d) = %d, want %d", tc.line, tc.rune_, got, tc.utf16) } if got := UTF16ToRune(tc.line, tc.utf16); got != tc.rune_ { t.Errorf("UTF16ToRune(%q, %d) = %d, want %d", tc.line, tc.utf16, got, tc.rune_) } }) } } func TestUTF16ConversionClampsPastTheEnd(t *testing.T) { if got := RuneToUTF16("ab", 99); got != 2 { t.Errorf("RuneToUTF16() = %d, want the line's length", got) } if got := UTF16ToRune("ab", 99); got != 2 { t.Errorf("UTF16ToRune() = %d, want the line's length", got) } } func TestCompletionInsertion(t *testing.T) { tests := []struct { name string item CompletionItem want string }{ {"the label when there is no insert text", CompletionItem{Label: "Println"}, "Println"}, {"the insert text when there is one", CompletionItem{Label: "Println", InsertText: "Println()"}, "Println()"}, {"a snippet's placeholders removed", CompletionItem{InsertText: "Printf(${1:format}, ${2:a})"}, "Printf(format, a)"}, {"a bare tab stop", CompletionItem{InsertText: "if err != nil {$0}"}, "if err != nil {}"}, {"an empty placeholder", CompletionItem{InsertText: "f(${1})"}, "f()"}, {"an unterminated placeholder", CompletionItem{InsertText: "f(${1:x"}, "f("}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := tc.item.Insertion(); got != tc.want { t.Errorf("Insertion() = %q, want %q", got, tc.want) } }) } } func TestCompletionKindNames(t *testing.T) { if got := KindFunction.String(); got != "func" { t.Errorf("KindFunction.String() = %q", got) } if got := CompletionItemKind(999).String(); got != "?" { t.Errorf("an unknown kind prints %q, want %q", got, "?") } } func TestFindServerReportsWhenThereIsNone(t *testing.T) { t.Setenv("PATH", t.TempDir()) server := profile.Server{Command: "no-such-language-server", Dirs: []string{t.TempDir()}} _, err := FindServer(server) if !errors.Is(err, ErrServerNotFound) { t.Errorf("FindServer() error = %v, want ErrServerNotFound", err) } } func TestFindServerSaysWhereItLooked(t *testing.T) { // "not found" on its own tells nobody what to do about it; the message has // to name the executable and the directories that were searched. dir := t.TempDir() t.Setenv("PATH", t.TempDir()) _, err := FindServer(profile.Server{Command: "rust-analyzer", Dirs: []string{dir}}) if err == nil { t.Fatal("FindServer() error = nil, want a failure") } for _, want := range []string{"rust-analyzer", "PATH", dir} { if !strings.Contains(err.Error(), want) { t.Errorf("FindServer() error = %q, want it to mention %q", err, want) } } } func TestFindServerFindsTheServerInOneOfTheProfilesDirectories(t *testing.T) { // The whole point of Dirs: cargo and go install put a language server // somewhere that is very often not on PATH. dir := t.TempDir() path := filepath.Join(dir, "pretend-analyzer") if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("writing a stand-in server: %v", err) } t.Setenv("PATH", t.TempDir()) found, err := FindServer(profile.Server{Command: "pretend-analyzer", Dirs: []string{"", dir}}) if err != nil { t.Fatalf("FindServer() error = %v", err) } if found != path { t.Errorf("FindServer() = %q, want %q", found, path) } } // mustInitialize performs the handshake, failing the test if it does not work. func mustInitialize(t *testing.T, client *Client) { t.Helper() if err := client.Initialize(t.Context()); err != nil { t.Fatalf("Initialize() error = %v", err) } } // waitForMethod blocks until the fake server has seen a method, so a test // never races the notification it is about to assert on. func waitForMethod(t *testing.T, server *fakeServer, method string) { t.Helper() deadline := time.After(2 * time.Second) for { for _, seen := range server.methods() { if seen == method { return } } select { case <-deadline: t.Fatalf("the server never saw %q; it saw %v", method, server.methods()) case <-time.After(time.Millisecond): } } } // locationAnswerers are the four requests that answer with places in the code. // They share a decoder, so they share their tests. var locationAnswerers = []struct { method string ask func(*Client) ([]Location, error) }{ {"textDocument/definition", func(c *Client) ([]Location, error) { return c.Definition(context.Background(), "main.go", 3, 4, "x := y") }}, {"textDocument/typeDefinition", func(c *Client) ([]Location, error) { return c.TypeDefinition(context.Background(), "main.go", 3, 4, "x := y") }}, {"textDocument/implementation", func(c *Client) ([]Location, error) { return c.Implementation(context.Background(), "main.go", 3, 4, "x := y") }}, {"textDocument/references", func(c *Client) ([]Location, error) { return c.References(context.Background(), "main.go", 3, 4, "x := y", true) }}, } func TestEveryLocationRequestSendsItsOwnMethod(t *testing.T) { // One helper serves all four, so the one thing that can go wrong is a // request going out under the wrong name — which a server answers with an // error the caller reads as "nothing found". for _, request := range locationAnswerers { t.Run(request.method, func(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(method string, _ json.RawMessage) (any, *ResponseError) { if method != request.method { return nil, nil } return []Location{{URI: PathToURI("/tmp/other.go")}}, nil }) mustInitialize(t, client) locations, err := request.ask(client) if err != nil { t.Fatalf("error = %v", err) } if len(locations) != 1 { t.Errorf("got %d locations, want the one the server answered to %s", len(locations), request.method) } }) } } func TestEveryLocationRequestReadsBothShapes(t *testing.T) { // A server may answer one location as a bare object rather than an array // of one. gopls does it; the specification allows it. location := Location{URI: PathToURI("/tmp/other.go"), Range: Range{Start: Position{Line: 4}}} for _, request := range locationAnswerers { for _, shape := range []struct { name string reply any want int }{ {"an array", []Location{location, location}, 2}, {"a bare object", location, 1}, {"null", nil, 0}, } { t.Run(request.method+"/"+shape.name, func(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(method string, _ json.RawMessage) (any, *ResponseError) { if method != request.method { return nil, nil } return shape.reply, nil }) mustInitialize(t, client) locations, err := request.ask(client) if err != nil { t.Fatalf("error = %v", err) } if len(locations) != shape.want { t.Errorf("got %d locations, want %d", len(locations), shape.want) } }) } } } func TestEveryLocationRequestRefusesBeforeTheServerIsReady(t *testing.T) { for _, request := range locationAnswerers { t.Run(request.method, func(t *testing.T) { client, _ := newFakeServer(t) if _, err := request.ask(client); !errors.Is(err, ErrNotReady) { t.Errorf("error = %v, want ErrNotReady", err) } }) } } func TestReferencesSaysWhetherItWantsTheDeclaration(t *testing.T) { // The one parameter that is not shared. A server that never sees it // applies its own default, and the caller's choice is silently lost. for _, want := range []bool{true, false} { t.Run(fmt.Sprint(want), func(t *testing.T) { client, server := newFakeServer(t) var sent json.RawMessage server.setHandler(func(method string, params json.RawMessage) (any, *ResponseError) { if method == "textDocument/references" { sent = params } return []Location{}, nil }) mustInitialize(t, client) if _, err := client.References(t.Context(), "main.go", 1, 0, "", want); err != nil { t.Fatalf("References() error = %v", err) } var got struct { Context struct { IncludeDeclaration bool `json:"includeDeclaration"` } `json:"context"` } if err := json.Unmarshal(sent, &got); err != nil { t.Fatalf("the parameters do not parse: %v\n%s", err, sent) } if got.Context.IncludeDeclaration != want { t.Errorf("includeDeclaration = %v, want %v — sent %s", got.Context.IncludeDeclaration, want, sent) } }) } } func TestTheClientAsksForTheCapabilitiesItUses(t *testing.T) { // Claiming nothing is as wrong as claiming too much: a server may decline // to answer a request the client never said it could use. capabilities := clientCapabilities() document, ok := capabilities["textDocument"].(map[string]any) if !ok { t.Fatal("no textDocument capabilities at all") } for _, want := range []string{"completion", "hover", "references", "implementation", "typeDefinition", "publishDiagnostics"} { if _, declared := document[want]; !declared { t.Errorf("the client never declares %q, but sends it", want) } } }