a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
338 lines
9.4 KiB
Go
338 lines
9.4 KiB
Go
package lsp
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func TestLSP_PathToURI(t *testing.T) {
|
|
uri := pathToURI("/repo/main.go")
|
|
assert.Equal(t, "file:///repo/main.go", uri)
|
|
}
|
|
|
|
func TestLSP_URIToPath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
uri string
|
|
root string
|
|
want string
|
|
}{
|
|
{"inside root", "file:///repo/pkg/a/a.go", "/repo", "pkg/a/a.go"},
|
|
{"outside root returns empty", "file:///elsewhere/x.go", "/repo", ""},
|
|
{"malformed uri returns empty", "://broken", "/repo", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, uriToPath(tt.uri, tt.root))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLSP_ExtractTypeFromHover(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hover string
|
|
want string
|
|
}{
|
|
{"go func with code fence", "```go\nfunc Hello() string\n```", "func Hello() string"},
|
|
{"plain func", "func Hello() string", "func Hello() string"},
|
|
{"type", "type Greeter interface { Greet() string }", "type Greeter interface { Greet() string }"},
|
|
{"var", "var Counter int", "var Counter int"},
|
|
{"const", "const Pi = 3.14", "const Pi = 3.14"},
|
|
{"short type without space", "*Foo", "*Foo"},
|
|
{"long prose returns empty", "This is a long English sentence describing something.", ""},
|
|
{"empty hover", "", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, extractTypeFromHover(tt.hover))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLSP_JSONRPCError_Error(t *testing.T) {
|
|
err := &jsonRPCError{Code: -32601, Message: "Method not found"}
|
|
assert.Equal(t, "LSP error -32601: Method not found", err.Error())
|
|
}
|
|
|
|
func TestLSP_Provider_Available_FalseWhenMissing(t *testing.T) {
|
|
p := NewProvider("definitely-not-a-real-lsp-binary-xyz", nil, []string{"go"}, false, 0, zap.NewNop())
|
|
assert.False(t, p.Available())
|
|
assert.Equal(t, "lsp-definitely-not-a-real-lsp-binary-xyz", p.Name())
|
|
}
|
|
|
|
func TestLSP_NewClient_FailsForBadCommand(t *testing.T) {
|
|
_, err := NewClient("/nonexistent/path/to/lsp", nil, nil, t.TempDir(), zap.NewNop())
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestBuildWorkspaceFolders(t *testing.T) {
|
|
// Always include the primary root. Pyright relies on this to build
|
|
// workspace context for textDocument/definition even when rootUri is set.
|
|
require.Equal(t, []WorkspaceFolder{{URI: "file:///repo", Name: "repo"}}, buildWorkspaceFolders("/repo", nil))
|
|
require.Equal(t, []WorkspaceFolder{{URI: "file:///repo", Name: "repo"}}, buildWorkspaceFolders("/repo", []string{}))
|
|
|
|
folders := buildWorkspaceFolders("/repo/app", []string{"/repo/shared", "/repo/types"})
|
|
require.Len(t, folders, 3) // primary + 2 additional
|
|
require.Equal(t, "app", folders[0].Name)
|
|
require.Equal(t, "shared", folders[1].Name)
|
|
require.Equal(t, "types", folders[2].Name)
|
|
for _, f := range folders {
|
|
require.Contains(t, f.URI, "file://")
|
|
}
|
|
}
|
|
|
|
func TestRouter_WithAdditionalWorkspaceFolders(t *testing.T) {
|
|
r := NewRouter(t.TempDir(), zap.NewNop()).
|
|
WithAdditionalWorkspaceFolders([]string{"/extra/pkg"})
|
|
require.Equal(t, []string{"/extra/pkg"}, r.additionalWorkspaceFolders)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client framing / request-response round-trip via io.Pipe pairs.
|
|
// These tests exercise the JSON-RPC plumbing without requiring a real LSP
|
|
// server subprocess.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// readFramed reads one Content-Length-framed JSON-RPC message off r. Returns
|
|
// (nil, false) on EOF so server-side goroutines can exit cleanly when the
|
|
// client closes the pipe at test teardown.
|
|
func readFramed(r *bufio.Reader) ([]byte, bool) {
|
|
contentLength := -1
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
break
|
|
}
|
|
if strings.HasPrefix(line, "Content-Length:") {
|
|
contentLength, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Content-Length:")))
|
|
}
|
|
}
|
|
if contentLength < 0 {
|
|
return nil, false
|
|
}
|
|
body := make([]byte, contentLength)
|
|
if _, err := io.ReadFull(r, body); err != nil {
|
|
return nil, false
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
// writeFramed writes a JSON-RPC message with Content-Length framing to w.
|
|
func writeFramed(t *testing.T, w io.Writer, payload any) {
|
|
t.Helper()
|
|
data, err := json.Marshal(payload)
|
|
require.NoError(t, err)
|
|
_, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data))
|
|
require.NoError(t, err)
|
|
_, err = w.Write(data)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// newPipedClient builds a Client whose stdin/stdout are connected to in-memory
|
|
// pipes. The returned write-end-of-server-input and read-end-of-server-output
|
|
// let the test impersonate an LSP server.
|
|
func newPipedClient(t *testing.T) (client *Client, serverIn *bufio.Reader, serverOut io.Writer, cleanup func()) {
|
|
t.Helper()
|
|
clientStdinR, clientStdinW := io.Pipe() // client writes → server reads
|
|
clientStdoutR, clientStdoutW := io.Pipe()
|
|
|
|
c := &Client{
|
|
stdin: clientStdinW,
|
|
stdout: bufio.NewReader(clientStdoutR),
|
|
logger: zap.NewNop(),
|
|
done: make(chan struct{}),
|
|
notifHandlers: make(map[string]NotificationHandler),
|
|
reqHandlers: make(map[string]RequestHandler),
|
|
}
|
|
go c.readResponses()
|
|
|
|
cleanup = func() {
|
|
_ = clientStdinW.Close()
|
|
_ = clientStdoutW.Close()
|
|
_ = clientStdinR.Close()
|
|
_ = clientStdoutR.Close()
|
|
// readResponses' defer closes done on EOF; the test may also
|
|
// have closed it directly to simulate server exit. Close
|
|
// idempotently using a select probe.
|
|
select {
|
|
case <-c.done:
|
|
// Already closed.
|
|
default:
|
|
c.mu.Lock()
|
|
if !c.closed {
|
|
c.closed = true
|
|
close(c.done)
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
return c, bufio.NewReader(clientStdinR), clientStdoutW, cleanup
|
|
}
|
|
|
|
func TestLSP_Client_CallRoundTrip(t *testing.T) {
|
|
c, serverIn, serverOut, cleanup := newPipedClient(t)
|
|
defer cleanup()
|
|
|
|
// Fake server: read one request, echo "pong" back as the result.
|
|
serverDone := make(chan error, 1)
|
|
go func() {
|
|
body, ok := readFramed(serverIn)
|
|
if !ok {
|
|
serverDone <- fmt.Errorf("server: unexpected EOF")
|
|
return
|
|
}
|
|
var req jsonRPCRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
serverDone <- err
|
|
return
|
|
}
|
|
writeFramed(t, serverOut, jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: json.RawMessage(`"pong"`),
|
|
})
|
|
serverDone <- nil
|
|
}()
|
|
|
|
var result string
|
|
err := c.Call("test/echo", map[string]string{"msg": "ping"}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "pong", result)
|
|
|
|
select {
|
|
case err := <-serverDone:
|
|
require.NoError(t, err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("fake server did not finish in time")
|
|
}
|
|
}
|
|
|
|
func TestLSP_Client_CallReturnsError(t *testing.T) {
|
|
c, serverIn, serverOut, cleanup := newPipedClient(t)
|
|
defer cleanup()
|
|
|
|
go func() {
|
|
body, ok := readFramed(serverIn)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req jsonRPCRequest
|
|
_ = json.Unmarshal(body, &req)
|
|
writeFramed(t, serverOut, jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &jsonRPCError{Code: -32601, Message: "method not found"},
|
|
})
|
|
}()
|
|
|
|
err := c.Call("unknown/method", nil, nil)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "method not found")
|
|
assert.Contains(t, err.Error(), "-32601")
|
|
}
|
|
|
|
func TestLSP_Client_CallUnblocksOnServerExit(t *testing.T) {
|
|
c, serverIn, _, cleanup := newPipedClient(t)
|
|
defer cleanup()
|
|
|
|
// Drain whatever the client writes so the io.Pipe send doesn't block.
|
|
go func() {
|
|
for {
|
|
if _, ok := readFramed(serverIn); !ok {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Close c.done to simulate the server having exited.
|
|
close(c.done)
|
|
|
|
err := c.Call("test/method", nil, nil)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "exited")
|
|
}
|
|
|
|
func TestLSP_Client_NotifyDoesNotWaitForResponse(t *testing.T) {
|
|
c, serverIn, _, cleanup := newPipedClient(t)
|
|
defer cleanup()
|
|
|
|
gotMethod := make(chan string, 1)
|
|
go func() {
|
|
body, ok := readFramed(serverIn)
|
|
if !ok {
|
|
return
|
|
}
|
|
var notif jsonRPCNotification
|
|
_ = json.Unmarshal(body, ¬if)
|
|
gotMethod <- notif.Method
|
|
}()
|
|
|
|
err := c.Notify("textDocument/didOpen", map[string]string{"uri": "file:///x.go"})
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case method := <-gotMethod:
|
|
assert.Equal(t, "textDocument/didOpen", method)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("fake server did not receive the notification")
|
|
}
|
|
}
|
|
|
|
func TestLSP_Client_CallAssignsUniqueIDs(t *testing.T) {
|
|
c, serverIn, serverOut, cleanup := newPipedClient(t)
|
|
defer cleanup()
|
|
|
|
// Server: echo each request's ID back as the result.
|
|
go func() {
|
|
for {
|
|
body, ok := readFramed(serverIn)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req jsonRPCRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return
|
|
}
|
|
writeFramed(t, serverOut, jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: json.RawMessage(strconv.FormatInt(req.ID, 10)),
|
|
})
|
|
}
|
|
}()
|
|
|
|
var first, second int64
|
|
require.NoError(t, c.Call("a", nil, &first))
|
|
require.NoError(t, c.Call("b", nil, &second))
|
|
|
|
assert.NotZero(t, first)
|
|
assert.Equal(t, first+1, second, "request IDs should monotonically increase")
|
|
}
|
|
|
|
// pathToURI on a relative path delegates to filepath.Abs and prefixes "file://".
|
|
// Verify that joining a known absolute path keeps the URI parseable round-trip.
|
|
func TestLSP_PathToURI_RoundTrip(t *testing.T) {
|
|
abs, err := filepath.Abs(".")
|
|
require.NoError(t, err)
|
|
uri := pathToURI(abs)
|
|
assert.True(t, strings.HasPrefix(uri, "file://"))
|
|
got := uriToPath(uri, abs)
|
|
assert.Equal(t, ".", got)
|
|
}
|