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:
parent
61798d64f0
commit
f9587291b6
111
internal/lsp/fake/client.go
Normal file
111
internal/lsp/fake/client.go
Normal 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
19
internal/lsp/fake/doc.go
Normal 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
89
internal/lsp/fake/edit.go
Normal 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
|
||||
}
|
97
internal/lsp/fake/edit_test.go
Normal file
97
internal/lsp/fake/edit_test.go
Normal 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
327
internal/lsp/fake/editor.go
Normal 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.
|
60
internal/lsp/fake/editor_test.go
Normal file
60
internal/lsp/fake/editor_test.go
Normal 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)
|
||||
}
|
||||
}
|
178
internal/lsp/fake/workspace.go
Normal file
178
internal/lsp/fake/workspace.go
Normal 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()
|
||||
}
|
92
internal/lsp/fake/workspace_test.go
Normal file
92
internal/lsp/fake/workspace_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
196
internal/lsp/lsprpc/diagnostics_test.go
Normal file
196
internal/lsp/lsprpc/diagnostics_test.go
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user