Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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, &notif)
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)
}