1
0
mirror of https://github.com/golang/go synced 2024-11-18 16:54:43 -07:00

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>
This commit is contained in:
Rob Findley 2020-02-02 12:53:30 -05:00 committed by Robert Findley
parent 61798d64f0
commit f9587291b6
9 changed files with 1169 additions and 0 deletions

111
internal/lsp/fake/client.go Normal file
View File

@ -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
internal/lsp/fake/doc.go Normal file
View File

@ -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
internal/lsp/fake/edit.go Normal file
View File

@ -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
}

View File

@ -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)
}
})
}
}

327
internal/lsp/fake/editor.go Normal file
View File

@ -0,0 +1,327 @@
// 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,
}
}
// ShutdownAndExit shuts down the client and issues the editor exit.
func (e *Editor) ShutdownAndExit(ctx context.Context) error {
if e.server != nil {
if err := e.server.Shutdown(ctx); err != nil {
return fmt.Errorf("Shutdown: %v", err)
}
// 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
}
// TODO: expose more client functionality, for example GoToDefinition, Hover,
// CodeAction, Rename, Completion, etc. setting the content of an entire
// buffer, etc.

View File

@ -0,0 +1,60 @@
// 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"
"testing"
)
const exampleProgram = `
-- go.mod --
go 1.12
-- main.go --
package main
import "fmt"
func main() {
fmt.Println("Hello World.")
}
`
func TestClientEditing(t *testing.T) {
ws, err := NewWorkspace("test", []byte(exampleProgram))
if err != nil {
t.Fatal(err)
}
defer ws.Close()
ctx := context.Background()
client := NewEditor(ws)
if err != nil {
t.Fatal(err)
}
if err := client.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
if err := client.EditBuffer(ctx, "main.go", []Edit{
{
Start: Pos{5, 14},
End: Pos{5, 26},
Text: "Hola, mundo.",
},
}); err != nil {
t.Fatal(err)
}
got := client.buffers["main.go"].text()
want := `package main
import "fmt"
func main() {
fmt.Println("Hola, mundo.")
}
`
if got != want {
t.Errorf("got text %q, want %q", got, want)
}
}

View File

@ -0,0 +1,178 @@
// 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"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/tools/internal/lsp/protocol"
"golang.org/x/tools/internal/span"
"golang.org/x/tools/txtar"
)
// FileEvent wraps the protocol.FileEvent so that it can be associated with a
// workspace-relative path.
type FileEvent struct {
Path string
ProtocolEvent protocol.FileEvent
}
// The Workspace type represents a temporary workspace to use for editing Go
// files in tests.
type Workspace struct {
name string
gopath string
workdir string
watcherMu sync.Mutex
watchers []func(context.Context, []FileEvent)
}
// NewWorkspace creates a named workspace populated by the txtar-encoded
// content given by txt. It creates temporary directories for the workspace
// content and for GOPATH.
func NewWorkspace(name string, txt []byte) (_ *Workspace, err error) {
w := &Workspace{name: name}
defer func() {
// Clean up if we fail at any point in this constructor.
if err != nil {
w.removeAll()
}
}()
dir, err := ioutil.TempDir("", fmt.Sprintf("goplstest-ws-%s-", name))
if err != nil {
return nil, fmt.Errorf("creating temporary workdir: %v", err)
}
w.workdir = dir
gopath, err := ioutil.TempDir("", fmt.Sprintf("goplstest-gopath-%s-", name))
if err != nil {
return nil, fmt.Errorf("creating temporary gopath: %v", err)
}
w.gopath = gopath
archive := txtar.Parse(txt)
for _, f := range archive.Files {
if err := w.writeFileData(f.Name, f.Data); err != nil {
return nil, err
}
}
return w, nil
}
// RootURI returns the root URI for this workspace.
func (w *Workspace) RootURI() protocol.DocumentURI {
return toURI(w.workdir)
}
// GOPATH returns the value that GOPATH should be set to for this workspace.
func (w *Workspace) GOPATH() string {
return w.gopath
}
// AddWatcher registers the given func to be called on any file change.
func (w *Workspace) AddWatcher(watcher func(context.Context, []FileEvent)) {
w.watcherMu.Lock()
w.watchers = append(w.watchers, watcher)
w.watcherMu.Unlock()
}
// filePath returns the absolute filesystem path to a the workspace-relative
// path.
func (w *Workspace) filePath(path string) string {
return filepath.Join(w.workdir, filepath.FromSlash(path))
}
// URI returns the URI to a the workspace-relative path.
func (w *Workspace) URI(path string) protocol.DocumentURI {
return toURI(w.filePath(path))
}
// URIToPath converts a uri to a workspace-relative path.
func (w *Workspace) URIToPath(uri protocol.DocumentURI) (string, error) {
prefix := w.RootURI() + "/"
if !strings.HasPrefix(uri, prefix) {
return "", fmt.Errorf("uri %q outside of workspace", uri)
}
return strings.TrimPrefix(uri, prefix), nil
}
func toURI(fp string) protocol.DocumentURI {
return protocol.DocumentURI(span.FileURI(fp))
}
// ReadFile reads a text file specified by a workspace-relative path.
func (w *Workspace) ReadFile(path string) (string, error) {
b, err := ioutil.ReadFile(w.filePath(path))
if err != nil {
return "", err
}
return string(b), nil
}
// WriteFile writes text file content to a workspace-relative path.
func (w *Workspace) WriteFile(ctx context.Context, path, content string) error {
fp := w.filePath(path)
_, err := os.Stat(fp)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("checking if %q exists: %v", path, err)
}
var changeType protocol.FileChangeType
if os.IsNotExist(err) {
changeType = protocol.Created
} else {
changeType = protocol.Changed
}
werr := w.writeFileData(path, []byte(content))
w.watcherMu.Lock()
watchers := make([]func(context.Context, []FileEvent), len(w.watchers))
copy(watchers, w.watchers)
w.watcherMu.Unlock()
evts := []FileEvent{{
Path: path,
ProtocolEvent: protocol.FileEvent{
URI: w.URI(path),
Type: changeType,
},
}}
for _, w := range watchers {
go w(ctx, evts)
}
return werr
}
func (w *Workspace) writeFileData(path string, data []byte) error {
fp := w.filePath(path)
if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
return fmt.Errorf("creating nested directory: %v", err)
}
if err := ioutil.WriteFile(fp, data, 0644); err != nil {
return fmt.Errorf("writing %q: %v", path, err)
}
return nil
}
func (w *Workspace) removeAll() error {
var werr, perr error
if w.workdir != "" {
werr = os.RemoveAll(w.workdir)
}
if w.gopath != "" {
perr = os.RemoveAll(w.gopath)
}
if werr != nil || perr != nil {
return fmt.Errorf("error(s) cleaning workspace: removing workdir: %v; removing gopath: %v", werr, perr)
}
return nil
}
// Close removes all state associated with the workspace.
func (w *Workspace) Close() error {
return w.removeAll()
}

View File

@ -0,0 +1,92 @@
// 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"
"testing"
"golang.org/x/tools/internal/lsp/protocol"
)
const data = `
-- go.mod --
go 1.12
-- nested/README.md --
Hello World!
`
func newWorkspace(t *testing.T) (*Workspace, <-chan []FileEvent, func()) {
t.Helper()
ws, err := NewWorkspace("default", []byte(data))
if err != nil {
t.Fatal(err)
}
cleanup := func() {
if err := ws.Close(); err != nil {
t.Fatal(err)
}
}
fileEvents := make(chan []FileEvent)
watch := func(_ context.Context, events []FileEvent) {
fileEvents <- events
}
ws.AddWatcher(watch)
return ws, fileEvents, cleanup
}
func TestWorkspace_ReadFile(t *testing.T) {
ws, _, cleanup := newWorkspace(t)
defer cleanup()
got, err := ws.ReadFile("nested/README.md")
if err != nil {
t.Fatal(err)
}
want := "Hello World!\n"
if got != want {
t.Errorf("reading workspace file, got %q, want %q", got, want)
}
}
func TestWorkspace_WriteFile(t *testing.T) {
ws, events, cleanup := newWorkspace(t)
defer cleanup()
ctx := context.Background()
tests := []struct {
path string
wantType protocol.FileChangeType
}{
{"data.txt", protocol.Created},
{"nested/README.md", protocol.Changed},
}
for _, test := range tests {
if err := ws.WriteFile(ctx, test.path, "42"); err != nil {
t.Fatal(err)
}
es := <-events
if got := len(es); got != 1 {
t.Fatalf("len(events) = %d, want 1", got)
}
if es[0].Path != test.path {
t.Errorf("event.Path = %q, want %q", es[0].Path, test.path)
}
if es[0].ProtocolEvent.Type != test.wantType {
t.Errorf("event type = %v, want %v", es[0].ProtocolEvent.Type, test.wantType)
}
got, err := ws.ReadFile(test.path)
if err != nil {
t.Fatal(err)
}
want := "42"
if got != want {
t.Errorf("ws.ReadFile(%q) = %q, want %q", test.path, got, want)
}
}
}

View File

@ -0,0 +1,196 @@
// 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 lsprpc
import (
"context"
"errors"
"fmt"
"testing"
"time"
"golang.org/x/tools/internal/jsonrpc2/servertest"
"golang.org/x/tools/internal/lsp/cache"
"golang.org/x/tools/internal/lsp/fake"
"golang.org/x/tools/internal/lsp/protocol"
)
const exampleProgram = `
-- go.mod --
module mod
go 1.12
-- main.go --
package main
import "fmt"
func main() {
fmt.Println("Hello World.")
}`
type testEnvironment struct {
editor *fake.Editor
ws *fake.Workspace
ts *servertest.Server
diagnostics <-chan *protocol.PublishDiagnosticsParams
}
func setupEnv(t *testing.T) (context.Context, testEnvironment, func()) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ws, err := fake.NewWorkspace("get-diagnostics", []byte(exampleProgram))
if err != nil {
t.Fatal(err)
}
ss := NewStreamServer(cache.New(nil), false)
ts := servertest.NewServer(ctx, ss)
cc := ts.Connect(ctx)
editor, err := fake.NewConnectedEditor(ctx, ws, cc)
if err != nil {
t.Fatal(err)
}
diags := make(chan *protocol.PublishDiagnosticsParams, 10)
editor.Client().OnDiagnostics(func(_ context.Context, params *protocol.PublishDiagnosticsParams) error {
diags <- params
return nil
})
cleanup := func() {
cancel()
ts.Close()
ws.Close()
}
return ctx, testEnvironment{
editor: editor,
ws: ws,
ts: ts,
diagnostics: diags,
}, cleanup
}
func checkDiagnosticLocation(params *protocol.PublishDiagnosticsParams, filename string, line, col int) error {
if got, want := params.URI, filename; got != want {
return fmt.Errorf("got diagnostics for URI %q, want %q", got, want)
}
if len(params.Diagnostics) == 0 {
return errors.New("empty diagnostics")
}
diag := params.Diagnostics[0]
if diag.Range.Start.Line != float64(line) || diag.Range.Start.Character != float64(col) {
return fmt.Errorf("Diagnostics[0].Range.Start = %v, want (5,5)", diag.Range.Start)
}
return nil
}
func TestDiagnosticErrorInEditedFile(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t)
defer cleanup()
// Deleting the 'n' at the end of Println should generate a single error
// diagnostic.
edits := []fake.Edit{
{
Start: fake.Pos{Line: 5, Column: 11},
End: fake.Pos{Line: 5, Column: 12},
Text: "",
},
}
if err := env.editor.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
if err := env.editor.EditBuffer(ctx, "main.go", edits); err != nil {
t.Fatal(err)
}
params := awaitDiagnostics(ctx, t, env.diagnostics)
if err := checkDiagnosticLocation(params, env.ws.URI("main.go"), 5, 5); err != nil {
t.Fatal(err)
}
}
func TestSimultaneousEdits(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t)
defer cleanup()
// Set up a second editor session connected to the same server, using the
// same workspace.
conn2 := env.ts.Connect(ctx)
editor2, err := fake.NewConnectedEditor(ctx, env.ws, conn2)
if err != nil {
t.Fatal(err)
}
diags2 := make(chan *protocol.PublishDiagnosticsParams, 10)
editor2.Client().OnDiagnostics(func(_ context.Context, params *protocol.PublishDiagnosticsParams) error {
diags2 <- params
return nil
})
// In editor #1, break fmt.Println as before.
edits1 := []fake.Edit{{
Start: fake.Pos{Line: 5, Column: 11},
End: fake.Pos{Line: 5, Column: 12},
Text: "",
}}
if err := env.editor.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
if err := env.editor.EditBuffer(ctx, "main.go", edits1); err != nil {
t.Fatal(err)
}
// In editor #2 remove the closing brace.
edits2 := []fake.Edit{{
Start: fake.Pos{Line: 6, Column: 0},
End: fake.Pos{Line: 6, Column: 1},
Text: "",
}}
if err := editor2.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
if err := editor2.EditBuffer(ctx, "main.go", edits2); err != nil {
t.Fatal(err)
}
params1 := awaitDiagnostics(ctx, t, env.diagnostics)
params2 := awaitDiagnostics(ctx, t, diags2)
if err := checkDiagnosticLocation(params1, env.ws.URI("main.go"), 5, 5); err != nil {
t.Fatal(err)
}
if err := checkDiagnosticLocation(params2, env.ws.URI("main.go"), 7, 0); err != nil {
t.Fatal(err)
}
}
func awaitDiagnostics(ctx context.Context, t *testing.T, diags <-chan *protocol.PublishDiagnosticsParams) *protocol.PublishDiagnosticsParams {
t.Helper()
select {
case <-ctx.Done():
panic(ctx.Err())
case d := <-diags:
return d
}
}
const brokenFile = `package main
const Foo = "abc
`
func TestDiagnosticErrorInNewFile(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t)
defer cleanup()
if err := env.editor.CreateBuffer(ctx, "broken.go", brokenFile); err != nil {
t.Fatal(err)
}
params := awaitDiagnostics(ctx, t, env.diagnostics)
if got, want := params.URI, env.ws.URI("broken.go"); got != want {
t.Fatalf("got diagnostics for URI %q, want %q", got, want)
}
}