Skip to content

Commit

Permalink
internal/lsp/fake: add fakes for testing editor interaction
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
findleyr committed Feb 10, 2020
1 parent 61798d6 commit f958729
Show file tree
Hide file tree
Showing 9 changed files with 1,169 additions and 0 deletions.
111 changes: 111 additions & 0 deletions internal/lsp/fake/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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"

"golang.org/x/tools/internal/lsp/protocol"
)

// Client is an adapter that converts a *Client into an LSP Client.
type Client struct {
*Editor

// Hooks for testing. Add additional hooks here as needed for testing.
onLogMessage func(context.Context, *protocol.LogMessageParams) error
onDiagnostics func(context.Context, *protocol.PublishDiagnosticsParams) error
}

// OnLogMessage sets the hook to run when the editor receives a log message.
func (c *Client) OnLogMessage(hook func(context.Context, *protocol.LogMessageParams) error) {
c.mu.Lock()
c.onLogMessage = hook
c.mu.Unlock()
}

// OnDiagnostics sets the hook to run when the editor receives diagnostics
// published from the language server.
func (c *Client) OnDiagnostics(hook func(context.Context, *protocol.PublishDiagnosticsParams) error) {
c.mu.Lock()
c.onDiagnostics = hook
c.mu.Unlock()
}

func (c *Client) ShowMessage(ctx context.Context, params *protocol.ShowMessageParams) error {
c.mu.Lock()
c.lastMessage = params
c.mu.Unlock()
return nil
}

func (c *Client) ShowMessageRequest(ctx context.Context, params *protocol.ShowMessageRequestParams) (*protocol.MessageActionItem, error) {
return nil, nil
}

func (c *Client) LogMessage(ctx context.Context, params *protocol.LogMessageParams) error {
c.mu.Lock()
c.logs = append(c.logs, params)
onLogMessage := c.onLogMessage
c.mu.Unlock()
if onLogMessage != nil {
return onLogMessage(ctx, params)
}
return nil
}

func (c *Client) Event(ctx context.Context, event *interface{}) error {
c.mu.Lock()
c.events = append(c.events, event)
c.mu.Unlock()
return nil
}

func (c *Client) PublishDiagnostics(ctx context.Context, params *protocol.PublishDiagnosticsParams) error {
c.mu.Lock()
c.diagnostics = params
onPublishDiagnostics := c.onDiagnostics
c.mu.Unlock()
if onPublishDiagnostics != nil {
return onPublishDiagnostics(ctx, params)
}
return nil
}

func (c *Client) WorkspaceFolders(context.Context) ([]protocol.WorkspaceFolder, error) {
return []protocol.WorkspaceFolder{}, nil
}

func (c *Client) Configuration(context.Context, *protocol.ParamConfiguration) ([]interface{}, error) {
return []interface{}{c.configuration()}, nil
}

func (c *Client) RegisterCapability(context.Context, *protocol.RegistrationParams) error {
return nil
}

func (c *Client) UnregisterCapability(context.Context, *protocol.UnregistrationParams) error {
return nil
}

// ApplyEdit applies edits sent from the server. Note that as of writing gopls
// doesn't use this feature, so it is untested.
func (c *Client) ApplyEdit(ctx context.Context, params *protocol.ApplyWorkspaceEditParams) (*protocol.ApplyWorkspaceEditResponse, error) {
if len(params.Edit.Changes) != 0 {
return &protocol.ApplyWorkspaceEditResponse{FailureReason: "Edit.Changes is unsupported"}, nil
}
for _, change := range params.Edit.DocumentChanges {
path, err := c.ws.URIToPath(change.TextDocument.URI)
if err != nil {
return nil, err
}
var edits []Edit
for _, lspEdit := range change.Edits {
edits = append(edits, fromProtocolTextEdit(lspEdit))
}
c.EditBuffer(ctx, path, edits)
}
return &protocol.ApplyWorkspaceEditResponse{Applied: true}, nil
}
19 changes: 19 additions & 0 deletions internal/lsp/fake/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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 provides fake implementations of a text editor, LSP client
// plugin, and workspace for use in tests.
//
// The Editor type provides a high level API for text editor operations
// (open/modify/save/close a buffer, jump to definition, etc.), and the Client
// type exposes an LSP client for the editor that can be connected to a
// language server. By default, the Editor and Client should be compliant with
// the LSP spec: their intended use is to verify server compliance with the
// spec in a variety of environment. Possible future enhancements of these
// types may allow them to misbehave in configurable ways, but that is not
// their primary use.
//
// The Workspace type provides a facility for executing tests in a clean
// workspace and GOPATH.
package fake
89 changes: 89 additions & 0 deletions internal/lsp/fake/edit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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 (
"fmt"
"strings"

"golang.org/x/tools/internal/lsp/protocol"
)

// Pos represents a 0-indexed position in a text buffer.
type Pos struct {
Line, Column int
}

func (p Pos) toProtocolPosition() protocol.Position {
return protocol.Position{
Line: float64(p.Line),
Character: float64(p.Column),
}
}

func fromProtocolPosition(pos protocol.Position) Pos {
return Pos{
Line: int(pos.Line),
Column: int(pos.Character),
}
}

// Edit represents a single (contiguous) buffer edit.
type Edit struct {
Start, End Pos
Text string
}

func (e Edit) toProtocolChangeEvent() protocol.TextDocumentContentChangeEvent {
return protocol.TextDocumentContentChangeEvent{
Range: &protocol.Range{
Start: e.Start.toProtocolPosition(),
End: e.End.toProtocolPosition(),
},
Text: e.Text,
}
}

func fromProtocolTextEdit(textEdit protocol.TextEdit) Edit {
return Edit{
Start: fromProtocolPosition(textEdit.Range.Start),
End: fromProtocolPosition(textEdit.Range.End),
Text: textEdit.NewText,
}
}

// editContent implements a simplistic, inefficient algorithm for applying text
// edits to our buffer representation. It returns an error if the edit is
// invalid for the current content.
func editContent(content []string, edit Edit) ([]string, error) {
if edit.End.Line < edit.Start.Line || (edit.End.Line == edit.Start.Line && edit.End.Column < edit.Start.Column) {
return nil, fmt.Errorf("invalid edit: end %v before start %v", edit.End, edit.Start)
}
// inText reports whether a position is within the bounds of the current
// text.
inText := func(p Pos) bool {
if p.Line < 0 || p.Line >= len(content) {
return false
}
// Note the strict right bound: the column indexes character _separators_,
// not characters.
if p.Column < 0 || p.Column > len(content[p.Line]) {
return false
}
return true
}
if !inText(edit.Start) {
return nil, fmt.Errorf("start position %v is out of bounds", edit.Start)
}
if !inText(edit.End) {
return nil, fmt.Errorf("end position %v is out of bounds", edit.End)
}
// Splice the edit text in between the first and last lines of the edit.
prefix := string([]rune(content[edit.Start.Line])[:edit.Start.Column])
suffix := string([]rune(content[edit.End.Line])[edit.End.Column:])
newLines := strings.Split(prefix+edit.Text+suffix, "\n")
newContent := append(content[:edit.Start.Line], newLines...)
return append(newContent, content[edit.End.Line+1:]...), nil
}
97 changes: 97 additions & 0 deletions internal/lsp/fake/edit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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 (
"strings"
"testing"
)

func TestApplyEdit(t *testing.T) {
tests := []struct {
label string
content string
edit Edit
want string
wantErr bool
}{
{
label: "empty content",
},
{
label: "empty edit",
content: "hello",
edit: Edit{},
want: "hello",
},
{
label: "unicode edit",
content: "hello, 日本語",
edit: Edit{
Start: Pos{Line: 0, Column: 7},
End: Pos{Line: 0, Column: 10},
Text: "world",
},
want: "hello, world",
},
{
label: "range edit",
content: "ABC\nDEF\nGHI\nJKL",
edit: Edit{
Start: Pos{Line: 1, Column: 1},
End: Pos{Line: 2, Column: 3},
Text: "12\n345",
},
want: "ABC\nD12\n345\nJKL",
},
{
label: "end before start",
content: "ABC\nDEF\nGHI\nJKL",
edit: Edit{
End: Pos{Line: 1, Column: 1},
Start: Pos{Line: 2, Column: 3},
Text: "12\n345",
},
wantErr: true,
},
{
label: "out of bounds line",
content: "ABC\nDEF\nGHI\nJKL",
edit: Edit{
Start: Pos{Line: 1, Column: 1},
End: Pos{Line: 4, Column: 3},
Text: "12\n345",
},
wantErr: true,
},
{
label: "out of bounds column",
content: "ABC\nDEF\nGHI\nJKL",
edit: Edit{
Start: Pos{Line: 1, Column: 4},
End: Pos{Line: 2, Column: 3},
Text: "12\n345",
},
wantErr: true,
},
}

for _, test := range tests {
test := test
t.Run(test.label, func(t *testing.T) {
lines := strings.Split(test.content, "\n")
newLines, err := editContent(lines, test.edit)
if (err != nil) != test.wantErr {
t.Errorf("got err %v, want error: %t", err, test.wantErr)
}
if err != nil {
return
}
if got := strings.Join(newLines, "\n"); got != test.want {
t.Errorf("got %q, want %q", got, test.want)
}
})
}
}
Loading

0 comments on commit f958729

Please sign in to comment.