1
0
mirror of https://github.com/golang/go synced 2024-11-19 11:24:51 -07:00
go/internal/lsp/fake/editor.go

370 lines
9.4 KiB
Go
Raw Normal View History

internal/lsp/fake: add fakes for testing editor interaction A lot of bug reports originating from LSP clients are related to either the timing or sequence of editor interactions with gopls (or at least they're originally reported this way). For example: "when I open a package and then create a new file, I lose diagnostics for existing files". These conditions are often hard to reproduce, and to isolate as either a gopls bug or a bug in the editor. Right now we're relying on govim integration tests to catch these regressions, but it's important to also have a testing framework that can exercise this functionality in-process. As a starting point this CL adds test fakes that implement a high level API for scripting editor interactions. A fake workspace can be used to sandbox file operations; a fake editor provides an interface for text editing operations; a fake LSP client can be used to connect the fake editor to a gopls instance. Some tests are added to the lsprpc package to demonstrate the API. The primary goal of these fakes should be to simulate an client that complies to the LSP spec. Put another way: if we have a bug report that we can't reproduce with our regression tests, it should either be a bug in our test fakes or a bug in the LSP client originating the report. I did my best to comply with the spec in this implementation, but it will certainly develop as we write more tests. We will also need to add to the editor API in the future for testing more language features. Updates golang/go#36879 Updates golang/go#34111 Change-Id: Ib81188683a7066184b8a254275ed5525191a2d68 Reviewed-on: https://go-review.googlesource.com/c/tools/+/217598 Run-TryBot: Robert Findley <rfindley@google.com> TryBot-Result: Gobot Gobot <gobot@golang.org> Reviewed-by: Rebecca Stambler <rstambler@golang.org>
2020-02-02 10:53:30 -07:00
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package fake
import (
"context"
"fmt"
"strings"
"sync"
"golang.org/x/tools/internal/jsonrpc2"
"golang.org/x/tools/internal/lsp/protocol"
)
// Editor is a fake editor client. It keeps track of client state and can be
// used for writing LSP tests.
type Editor struct {
// server, client, and workspace are concurrency safe and written only at
// construction, so do not require synchronization.
server protocol.Server
client *Client
ws *Workspace
// Since this editor is intended just for testing, we use very coarse
// locking.
mu sync.Mutex
// Editor state.
buffers map[string]buffer
lastMessage *protocol.ShowMessageParams
logs []*protocol.LogMessageParams
diagnostics *protocol.PublishDiagnosticsParams
events []interface{}
// Capabilities / Options
serverCapabilities protocol.ServerCapabilities
}
type buffer struct {
version int
path string
content []string
}
func (b buffer) text() string {
return strings.Join(b.content, "\n")
}
// NewConnectedEditor creates a new editor that dispatches the LSP across the
// provided jsonrpc2 connection.
//
// The returned editor is initialized and ready to use.
func NewConnectedEditor(ctx context.Context, ws *Workspace, conn *jsonrpc2.Conn) (*Editor, error) {
e := NewEditor(ws)
e.server = protocol.ServerDispatcher(conn)
e.client = &Client{Editor: e}
conn.AddHandler(protocol.ClientHandler(e.client))
if err := e.initialize(ctx); err != nil {
return nil, err
}
e.ws.AddWatcher(e.onFileChanges)
return e, nil
}
// NewEditor Creates a new Editor.
func NewEditor(ws *Workspace) *Editor {
return &Editor{
buffers: make(map[string]buffer),
ws: ws,
}
}
// Shutdown issues the 'shutdown' LSP notification.
func (e *Editor) Shutdown(ctx context.Context) error {
internal/lsp/fake: add fakes for testing editor interaction A lot of bug reports originating from LSP clients are related to either the timing or sequence of editor interactions with gopls (or at least they're originally reported this way). For example: "when I open a package and then create a new file, I lose diagnostics for existing files". These conditions are often hard to reproduce, and to isolate as either a gopls bug or a bug in the editor. Right now we're relying on govim integration tests to catch these regressions, but it's important to also have a testing framework that can exercise this functionality in-process. As a starting point this CL adds test fakes that implement a high level API for scripting editor interactions. A fake workspace can be used to sandbox file operations; a fake editor provides an interface for text editing operations; a fake LSP client can be used to connect the fake editor to a gopls instance. Some tests are added to the lsprpc package to demonstrate the API. The primary goal of these fakes should be to simulate an client that complies to the LSP spec. Put another way: if we have a bug report that we can't reproduce with our regression tests, it should either be a bug in our test fakes or a bug in the LSP client originating the report. I did my best to comply with the spec in this implementation, but it will certainly develop as we write more tests. We will also need to add to the editor API in the future for testing more language features. Updates golang/go#36879 Updates golang/go#34111 Change-Id: Ib81188683a7066184b8a254275ed5525191a2d68 Reviewed-on: https://go-review.googlesource.com/c/tools/+/217598 Run-TryBot: Robert Findley <rfindley@google.com> TryBot-Result: Gobot Gobot <gobot@golang.org> Reviewed-by: Rebecca Stambler <rstambler@golang.org>
2020-02-02 10:53:30 -07:00
if e.server != nil {
if err := e.server.Shutdown(ctx); err != nil {
return fmt.Errorf("Shutdown: %v", err)
}
}
return nil
}
// Exit issues the 'exit' LSP notification.
func (e *Editor) Exit(ctx context.Context) error {
if e.server != nil {
internal/lsp/fake: add fakes for testing editor interaction A lot of bug reports originating from LSP clients are related to either the timing or sequence of editor interactions with gopls (or at least they're originally reported this way). For example: "when I open a package and then create a new file, I lose diagnostics for existing files". These conditions are often hard to reproduce, and to isolate as either a gopls bug or a bug in the editor. Right now we're relying on govim integration tests to catch these regressions, but it's important to also have a testing framework that can exercise this functionality in-process. As a starting point this CL adds test fakes that implement a high level API for scripting editor interactions. A fake workspace can be used to sandbox file operations; a fake editor provides an interface for text editing operations; a fake LSP client can be used to connect the fake editor to a gopls instance. Some tests are added to the lsprpc package to demonstrate the API. The primary goal of these fakes should be to simulate an client that complies to the LSP spec. Put another way: if we have a bug report that we can't reproduce with our regression tests, it should either be a bug in our test fakes or a bug in the LSP client originating the report. I did my best to comply with the spec in this implementation, but it will certainly develop as we write more tests. We will also need to add to the editor API in the future for testing more language features. Updates golang/go#36879 Updates golang/go#34111 Change-Id: Ib81188683a7066184b8a254275ed5525191a2d68 Reviewed-on: https://go-review.googlesource.com/c/tools/+/217598 Run-TryBot: Robert Findley <rfindley@google.com> TryBot-Result: Gobot Gobot <gobot@golang.org> Reviewed-by: Rebecca Stambler <rstambler@golang.org>
2020-02-02 10:53:30 -07:00
// Not all LSP clients issue the exit RPC, but we do so here to ensure that
// we gracefully handle it on multi-session servers.
if err := e.server.Exit(ctx); err != nil {
return fmt.Errorf("Exit: %v", err)
}
}
return nil
}
// Client returns the LSP client for this editor.
func (e *Editor) Client() *Client {
return e.client
}
func (e *Editor) configuration() map[string]interface{} {
return map[string]interface{}{
"env": map[string]interface{}{
"GOPATH": e.ws.GOPATH(),
"GO111MODULE": "on",
},
}
}
func (e *Editor) initialize(ctx context.Context) error {
params := &protocol.ParamInitialize{}
params.ClientInfo.Name = "fakeclient"
params.ClientInfo.Version = "v1.0.0"
params.RootURI = e.ws.RootURI()
// TODO: set client capabilities.
params.Trace = "messages"
// TODO: support workspace folders.
if e.server != nil {
resp, err := e.server.Initialize(ctx, params)
if err != nil {
return fmt.Errorf("Initialize: %v", err)
}
e.mu.Lock()
e.serverCapabilities = resp.Capabilities
e.mu.Unlock()
if err := e.server.Initialized(ctx, &protocol.InitializedParams{}); err != nil {
return fmt.Errorf("Initialized: %v", err)
}
}
return nil
}
func (e *Editor) onFileChanges(ctx context.Context, evts []FileEvent) {
if e.server == nil {
return
}
var lspevts []protocol.FileEvent
for _, evt := range evts {
lspevts = append(lspevts, evt.ProtocolEvent)
}
e.server.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{
Changes: lspevts,
})
}
// OpenFile creates a buffer for the given workspace-relative file.
func (e *Editor) OpenFile(ctx context.Context, path string) error {
content, err := e.ws.ReadFile(path)
if err != nil {
return err
}
buf := newBuffer(path, content)
e.mu.Lock()
e.buffers[path] = buf
item := textDocumentItem(e.ws, buf)
e.mu.Unlock()
if e.server != nil {
if err := e.server.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{
TextDocument: item,
}); err != nil {
return fmt.Errorf("DidOpen: %v", err)
}
}
return nil
}
func newBuffer(path, content string) buffer {
return buffer{
version: 1,
path: path,
content: strings.Split(content, "\n"),
}
}
func textDocumentItem(ws *Workspace, buf buffer) protocol.TextDocumentItem {
uri := ws.URI(buf.path)
languageID := ""
if strings.HasSuffix(buf.path, ".go") {
// TODO: what about go.mod files? What is their language ID?
languageID = "go"
}
return protocol.TextDocumentItem{
URI: uri,
LanguageID: languageID,
Version: float64(buf.version),
Text: buf.text(),
}
}
// CreateBuffer creates a new unsaved buffer corresponding to the workspace
// path, containing the given textual content.
func (e *Editor) CreateBuffer(ctx context.Context, path, content string) error {
buf := newBuffer(path, content)
e.mu.Lock()
e.buffers[path] = buf
item := textDocumentItem(e.ws, buf)
e.mu.Unlock()
if e.server != nil {
if err := e.server.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{
TextDocument: item,
}); err != nil {
return fmt.Errorf("DidOpen: %v", err)
}
}
return nil
}
// CloseBuffer removes the current buffer (regardless of whether it is saved).
func (e *Editor) CloseBuffer(ctx context.Context, path string) error {
e.mu.Lock()
_, ok := e.buffers[path]
if !ok {
e.mu.Unlock()
return fmt.Errorf("unknown path %q", path)
}
delete(e.buffers, path)
e.mu.Unlock()
if e.server != nil {
if err := e.server.DidClose(ctx, &protocol.DidCloseTextDocumentParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: e.ws.URI(path),
},
}); err != nil {
return fmt.Errorf("DidClose: %v", err)
}
}
return nil
}
// WriteBuffer writes the content of the buffer specified by the given path to
// the filesystem.
func (e *Editor) WriteBuffer(ctx context.Context, path string) error {
e.mu.Lock()
buf, ok := e.buffers[path]
if !ok {
e.mu.Unlock()
return fmt.Errorf(fmt.Sprintf("unknown buffer: %q", path))
}
content := buf.text()
includeText := false
syncOptions, ok := e.serverCapabilities.TextDocumentSync.(protocol.TextDocumentSyncOptions)
if ok {
includeText = syncOptions.Save.IncludeText
}
e.mu.Unlock()
docID := protocol.TextDocumentIdentifier{
URI: e.ws.URI(buf.path),
}
if e.server != nil {
if err := e.server.WillSave(ctx, &protocol.WillSaveTextDocumentParams{
TextDocument: docID,
Reason: protocol.Manual,
}); err != nil {
return fmt.Errorf("WillSave: %v", err)
}
}
if err := e.ws.WriteFile(ctx, path, content); err != nil {
return fmt.Errorf("writing %q: %v", path, err)
}
if e.server != nil {
params := &protocol.DidSaveTextDocumentParams{
TextDocument: protocol.VersionedTextDocumentIdentifier{
Version: float64(buf.version),
TextDocumentIdentifier: docID,
},
}
if includeText {
params.Text = &content
}
if err := e.server.DidSave(ctx, params); err != nil {
return fmt.Errorf("DidSave: %v", err)
}
}
return nil
}
// EditBuffer applies the given test edits to the buffer identified by path.
func (e *Editor) EditBuffer(ctx context.Context, path string, edits []Edit) error {
params, err := e.doEdits(ctx, path, edits)
if err != nil {
return err
}
if e.server != nil {
if err := e.server.DidChange(ctx, params); err != nil {
return fmt.Errorf("DidChange: %v", err)
}
}
return nil
}
func (e *Editor) doEdits(ctx context.Context, path string, edits []Edit) (*protocol.DidChangeTextDocumentParams, error) {
e.mu.Lock()
defer e.mu.Unlock()
buf, ok := e.buffers[path]
if !ok {
return nil, fmt.Errorf("unknown buffer %q", path)
}
var (
content = make([]string, len(buf.content))
err error
evts []protocol.TextDocumentContentChangeEvent
)
copy(content, buf.content)
for _, edit := range edits {
content, err = editContent(content, edit)
if err != nil {
return nil, err
}
evts = append(evts, edit.toProtocolChangeEvent())
}
buf.content = content
buf.version++
e.buffers[path] = buf
params := &protocol.DidChangeTextDocumentParams{
TextDocument: protocol.VersionedTextDocumentIdentifier{
Version: float64(buf.version),
TextDocumentIdentifier: protocol.TextDocumentIdentifier{
URI: e.ws.URI(buf.path),
},
},
ContentChanges: evts,
}
return params, nil
}
// GoToDefinition jumps to the definition of the symbol at the given position
// in an open buffer.
func (e *Editor) GoToDefinition(ctx context.Context, path string, pos Pos) (string, Pos, error) {
if err := e.checkBufferPosition(path, pos); err != nil {
return "", Pos{}, err
}
params := &protocol.DefinitionParams{}
params.TextDocument.URI = e.ws.URI(path)
params.Position = pos.toProtocolPosition()
resp, err := e.server.Definition(ctx, params)
if err != nil {
return "", Pos{}, fmt.Errorf("Definition: %v", err)
}
if len(resp) == 0 {
return "", Pos{}, nil
}
newPath := e.ws.URIToPath(resp[0].URI)
newPos := fromProtocolPosition(resp[0].Range.Start)
e.OpenFile(ctx, newPath)
return newPath, newPos, nil
}
func (e *Editor) checkBufferPosition(path string, pos Pos) error {
e.mu.Lock()
defer e.mu.Unlock()
buf, ok := e.buffers[path]
if !ok {
return fmt.Errorf("buffer %q is not open", path)
}
if !inText(pos, buf.content) {
return fmt.Errorf("position %v is invalid in buffer %q", pos, path)
}
return nil
}
// TODO: expose more client functionality, for example Hover, CodeAction,
// Rename, Completion, etc. setting the content of an entire buffer, etc.