327 lines
10 KiB
Go
327 lines
10 KiB
Go
package lsptestutil
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/microsoft/typescript-go/internal/core"
|
|
"github.com/microsoft/typescript-go/internal/json"
|
|
"github.com/microsoft/typescript-go/internal/jsonrpc"
|
|
"github.com/microsoft/typescript-go/internal/lsp"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
"golang.org/x/sync/errgroup"
|
|
"gotest.tools/v3/assert"
|
|
)
|
|
|
|
// clientTransport wires a test client to a server using real LSP
|
|
// "Content-Length"-framed JSON streamed over byte pipes, exactly like
|
|
// communication with a real editor. This exercises the full marshal/unmarshal
|
|
// round-trip of every protocol data structure. The two directions are:
|
|
//
|
|
// client --(clientOut)--> serverIn --> server
|
|
// server --(serverOut)--> clientIn --> client
|
|
type clientTransport struct {
|
|
serverIn lsp.Reader // server reads client->server messages
|
|
serverOut lsp.Writer // server writes server->client messages
|
|
clientIn lsp.Reader // client reads server->client messages
|
|
clientOut lsp.Writer // client writes client->server messages
|
|
closeClientOut func() // closes the client->server direction
|
|
closeServerOut func() // closes the server->client direction
|
|
}
|
|
|
|
func newClientTransport() clientTransport {
|
|
clientToServerReader, clientToServerWriter := io.Pipe()
|
|
serverToClientReader, serverToClientWriter := io.Pipe()
|
|
return clientTransport{
|
|
serverIn: lsp.ToReader(clientToServerReader),
|
|
serverOut: lsp.ToWriter(serverToClientWriter),
|
|
clientIn: lsp.ToReader(serverToClientReader),
|
|
clientOut: lsp.ToWriter(clientToServerWriter),
|
|
closeClientOut: func() { _ = clientToServerWriter.Close() },
|
|
closeServerOut: func() { _ = serverToClientWriter.Close() },
|
|
}
|
|
}
|
|
|
|
// ServerRequestHandler handles server-initiated requests and returns the response to send back.
|
|
type ServerRequestHandler func(ctx context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage
|
|
|
|
// ServerNotificationHandler handles server-initiated notifications (e.g., $/progress).
|
|
type ServerNotificationHandler func(ctx context.Context, req *lsproto.RequestMessage)
|
|
|
|
// LSPClient provides infrastructure for communicating with an LSP server in tests.
|
|
type LSPClient struct {
|
|
Server *lsp.Server
|
|
inputWriter lsp.Writer
|
|
outputReader lsp.Reader
|
|
id int32
|
|
ctx context.Context
|
|
|
|
// inputWriterMu serializes writes to the server. The test goroutine (sending
|
|
// requests/notifications) and the MessageRouter goroutine (sending responses
|
|
// to server-initiated requests) both write to the same stream; a single
|
|
// message is written as multiple underlying writes (header, body, flush), so
|
|
// concurrent writers must not interleave.
|
|
inputWriterMu sync.Mutex
|
|
|
|
// OnServerRequest handles server-initiated requests (e.g., workspace/configuration).
|
|
// If nil, all server requests receive a MethodNotFound error.
|
|
onServerRequest ServerRequestHandler
|
|
|
|
// OnServerNotification handles server-initiated notifications (e.g., $/progress).
|
|
// If nil, notifications are ignored.
|
|
OnServerNotification ServerNotificationHandler
|
|
|
|
// Async message handling
|
|
pendingRequests map[jsonrpc.ID]chan *lsproto.ResponseMessage
|
|
pendingRequestsMu sync.Mutex
|
|
}
|
|
|
|
// writeToServer writes a message to the server, serializing concurrent writers.
|
|
func (c *LSPClient) writeToServer(msg *lsproto.Message) error {
|
|
c.inputWriterMu.Lock()
|
|
defer c.inputWriterMu.Unlock()
|
|
return c.inputWriter.Write(msg)
|
|
}
|
|
|
|
// NewLSPClient creates an LSPClient wrapping the given server and pipes.
|
|
func NewLSPClient(t *testing.T, serverOpts lsp.ServerOptions, onServerRequest ServerRequestHandler) (*LSPClient, func() error) {
|
|
transport := newClientTransport()
|
|
serverOpts.In = transport.serverIn
|
|
serverOpts.Out = transport.serverOut
|
|
|
|
server := lsp.NewServer(&serverOpts)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
g, ctx := errgroup.WithContext(ctx)
|
|
client := &LSPClient{
|
|
Server: server,
|
|
inputWriter: transport.clientOut,
|
|
outputReader: transport.clientIn,
|
|
pendingRequests: make(map[jsonrpc.ID]chan *lsproto.ResponseMessage),
|
|
onServerRequest: onServerRequest,
|
|
ctx: ctx,
|
|
}
|
|
|
|
// Start server goroutine
|
|
g.Go(func() error {
|
|
defer transport.closeServerOut()
|
|
return server.Run(ctx)
|
|
})
|
|
|
|
// Start async message router
|
|
g.Go(func() error {
|
|
return client.MessageRouter(ctx)
|
|
})
|
|
|
|
return client, func() error {
|
|
cancel()
|
|
transport.closeClientOut()
|
|
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// NextID returns the next request ID.
|
|
func (c *LSPClient) NextID() int32 {
|
|
id := c.id
|
|
c.id++
|
|
return id
|
|
}
|
|
|
|
// MessageRouter runs in a goroutine and routes incoming messages from the server.
|
|
// It handles responses to client requests and server-initiated requests.
|
|
// It continues draining the output channel until it is closed (EOF), even after
|
|
// context cancellation, to prevent the server's writeLoop from blocking on a send.
|
|
func (c *LSPClient) MessageRouter(ctx context.Context) error {
|
|
for {
|
|
msg, err := c.outputReader.Read()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to read message: %w", err)
|
|
}
|
|
|
|
// After context cancellation, keep draining but don't process messages.
|
|
if ctx.Err() != nil {
|
|
continue
|
|
}
|
|
|
|
// Validate message can be marshaled
|
|
if err := json.MarshalWrite(io.Discard, msg); err != nil {
|
|
if ctx.Err() != nil {
|
|
continue
|
|
}
|
|
|
|
return fmt.Errorf("failed to encode message as JSON: %w", err)
|
|
}
|
|
|
|
switch msg.Kind {
|
|
case jsonrpc.MessageKindResponse:
|
|
c.handleResponse(ctx, msg.AsResponse())
|
|
case jsonrpc.MessageKindRequest:
|
|
if err := c.handleServerRequest(ctx, msg.AsRequest()); err != nil {
|
|
return err
|
|
}
|
|
case jsonrpc.MessageKindNotification:
|
|
if c.OnServerNotification != nil {
|
|
c.OnServerNotification(ctx, msg.AsRequest())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleResponse routes a response message to the waiting request goroutine.
|
|
func (c *LSPClient) handleResponse(ctx context.Context, resp *lsproto.ResponseMessage) {
|
|
if resp.ID == nil {
|
|
return
|
|
}
|
|
|
|
c.pendingRequestsMu.Lock()
|
|
respChan, ok := c.pendingRequests[*resp.ID]
|
|
if ok {
|
|
delete(c.pendingRequests, *resp.ID)
|
|
}
|
|
c.pendingRequestsMu.Unlock()
|
|
|
|
if ok {
|
|
select {
|
|
case respChan <- resp:
|
|
// sent response
|
|
case <-ctx.Done():
|
|
// context cancelled
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleServerRequest handles requests initiated by the server (e.g., workspace/configuration).
|
|
func (c *LSPClient) handleServerRequest(ctx context.Context, req *lsproto.RequestMessage) error {
|
|
var response *lsproto.ResponseMessage
|
|
|
|
if c.onServerRequest != nil {
|
|
response = c.onServerRequest(ctx, req)
|
|
}
|
|
|
|
if response == nil {
|
|
// Default: unknown server request
|
|
response = &lsproto.ResponseMessage{
|
|
ID: req.ID,
|
|
JSONRPC: req.JSONRPC,
|
|
Error: &jsonrpc.ResponseError{
|
|
Code: int32(lsproto.ErrorCodeMethodNotFound),
|
|
Message: fmt.Sprintf("Unknown method: %s", req.Method),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Send response back to server
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
|
|
if err := c.writeToServer(response.Message()); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to write server request response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteMsg validates and sends a message to the server.
|
|
// This is an untyped low-level method; prefer SendRequest and SendNotification for typed interactions.
|
|
func (c *LSPClient) WriteMsg(t *testing.T, msg *lsproto.Message) {
|
|
assert.NilError(t, json.MarshalWrite(io.Discard, msg), "failed to encode message as JSON")
|
|
if err := c.writeToServer(msg); err != nil {
|
|
t.Fatalf("failed to write message: %v", err)
|
|
}
|
|
}
|
|
|
|
// SendRequest sends a typed request and waits for the response.
|
|
func SendRequest[Params, Resp any](t *testing.T, c *LSPClient, info lsproto.RequestInfo[Params, Resp], params Params) (*lsproto.Message, Resp, bool) {
|
|
id := c.NextID()
|
|
reqID := lsproto.NewID(lsproto.IntegerOrString{Integer: &id})
|
|
req := info.NewRequestMessage(reqID, params)
|
|
|
|
resp, ok := c.SendRequestWorker(t, req, reqID)
|
|
if !ok {
|
|
return nil, *new(Resp), false
|
|
}
|
|
// The result arrives as a raw json.Value; decode it into Resp.
|
|
result, err := info.UnmarshalResult(resp.Result)
|
|
return resp.Message(), result, err == nil
|
|
}
|
|
|
|
// SendRequestAsync sends a typed request and returns a waiter for its response.
|
|
func SendRequestAsync[Params, Resp any](t *testing.T, c *LSPClient, info lsproto.RequestInfo[Params, Resp], params Params) func() (*lsproto.Message, Resp, bool) {
|
|
id := c.NextID()
|
|
reqID := lsproto.NewID(lsproto.IntegerOrString{Integer: &id})
|
|
req := info.NewRequestMessage(reqID, params)
|
|
|
|
responseChan := c.startRequestWorker(t, req, reqID)
|
|
return func() (*lsproto.Message, Resp, bool) {
|
|
resp, ok := c.waitForResponse(t, reqID, responseChan)
|
|
if !ok {
|
|
return nil, *new(Resp), false
|
|
}
|
|
result, err := info.UnmarshalResult(resp.Result)
|
|
return resp.Message(), result, err == nil
|
|
}
|
|
}
|
|
|
|
// This is an untyped version of SendRequest. Prefer to use SendRequest when possible.
|
|
func (c *LSPClient) SendRequestWorker(t *testing.T, req *lsproto.RequestMessage, reqID *jsonrpc.ID) (*lsproto.ResponseMessage, bool) {
|
|
responseChan := c.startRequestWorker(t, req, reqID)
|
|
return c.waitForResponse(t, reqID, responseChan)
|
|
}
|
|
|
|
func (c *LSPClient) startRequestWorker(t *testing.T, req *lsproto.RequestMessage, reqID *jsonrpc.ID) chan *lsproto.ResponseMessage {
|
|
responseChan := make(chan *lsproto.ResponseMessage, 1)
|
|
c.pendingRequestsMu.Lock()
|
|
c.pendingRequests[*reqID] = responseChan
|
|
c.pendingRequestsMu.Unlock()
|
|
|
|
c.WriteMsg(t, req.Message())
|
|
return responseChan
|
|
}
|
|
|
|
func (c *LSPClient) waitForResponse(t *testing.T, reqID *jsonrpc.ID, responseChan <-chan *lsproto.ResponseMessage) (*lsproto.ResponseMessage, bool) {
|
|
ctx := t.Context()
|
|
var resp *lsproto.ResponseMessage
|
|
select {
|
|
case <-ctx.Done():
|
|
c.pendingRequestsMu.Lock()
|
|
delete(c.pendingRequests, *reqID)
|
|
c.pendingRequestsMu.Unlock()
|
|
t.Fatalf("Request cancelled: %v", ctx.Err())
|
|
return nil, false
|
|
case resp = <-responseChan:
|
|
if resp == nil {
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
return resp, true
|
|
}
|
|
|
|
// SendNotification sends a typed notification.
|
|
func SendNotification[Params any](t *testing.T, c *LSPClient, info lsproto.NotificationInfo[Params], params Params) {
|
|
notification := info.NewNotificationMessage(
|
|
params,
|
|
)
|
|
c.WriteMsg(t, notification.Message())
|
|
}
|
|
|
|
func (c *LSPClient) SetCompilerOptionsForInferredProjects(options *core.CompilerOptions) {
|
|
c.Server.SetCompilerOptionsForInferredProjects(c.ctx, options)
|
|
}
|