internal/lsp: support configurable codeLens

Some code lenses may be undesirable for certain users or editors -- for
example a code lens that runs tests, when VSCode already supports this
functionality outside of the LSP. To handle such situations, support
configuring code lenses via a new 'codelens' gopls option.

Add support for code lens in regtests, and use this to test the new
configuration. To achieve this, thread through a new 'EditorConfig' type
that configures the fake editor's LSP session. It made sense to move the
test Env overlay onto this config object as well.

While looking at them, document some types in source.Options.

Change-Id: I961077422a273829c5cbd83c3b87fae29f77eeda
Reviewed-on: https://go-review.googlesource.com/c/tools/+/232680
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-05-06 22:54:50 -04:00 committed by Robert Findley
parent 480da3ebd7
commit cb8d9cd245
17 changed files with 258 additions and 67 deletions

View File

@ -83,9 +83,15 @@ Example Usage:
...
```
### **staticcheck** *boolean*
### **codelens** *map[string]bool*
If true, it enables the use of the staticcheck.io analyzers.
Overrides the enabled/disabled state of various code lenses. Currently, we
support two code lenses:
* `generate`: run `go generate` as specified by a `//go:generate` directive.
* `upgrade.dependency`: upgrade a dependency listed in a `go.mod` file.
By default, both of these code lenses are enabled.
### **completionDocumentation** *boolean*
@ -129,3 +135,7 @@ At the location of the `<>` in this program, deep completion would suggest the r
If true, this enables server side fuzzy matching of completion candidates.
Default: `true`.
### **staticcheck** *boolean*
If true, it enables the use of the staticcheck.io analyzers.

View File

@ -18,13 +18,13 @@ import (
func (s *Server) executeCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (interface{}, error) {
switch params.Command {
case "generate":
case source.CommandGenerate:
dir, recursive, err := getGenerateRequest(params.Arguments)
if err != nil {
return nil, err
}
go s.runGenerate(xcontext.Detach(ctx), dir, recursive)
case "tidy":
case source.CommandTidy:
if len(params.Arguments) == 0 || len(params.Arguments) > 1 {
return nil, errors.Errorf("expected one file URI for call to `go mod tidy`, got %v", params.Arguments)
}
@ -45,7 +45,7 @@ func (s *Server) executeCommand(ctx context.Context, params *protocol.ExecuteCom
if _, err := gocmdRunner.Run(ctx, inv); err != nil {
return nil, err
}
case "upgrade.dependency":
case source.CommandUpgradeDependency:
if len(params.Arguments) < 2 {
return nil, errors.Errorf("expected one file URI and one dependency for call to `go get`, got %v", params.Arguments)
}

View File

@ -20,8 +20,10 @@ import (
// 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 sandbox are concurrency safe and written only at
// construction, so do not require synchronization.
Config EditorConfig
// server, client, and sandbox are concurrency safe and written only
// at construction time, so do not require synchronization.
server protocol.Server
client *Client
sandbox *Sandbox
@ -49,11 +51,26 @@ func (b buffer) text() string {
return strings.Join(b.content, "\n")
}
// EditorConfig configures the editor's LSP session. This is similar to
// source.UserOptions, but we use a separate type here so that we expose only
// that configuration which we support.
//
// The zero value for EditorConfig should correspond to its defaults.
type EditorConfig struct {
Env []string
// CodeLens is a map defining whether codelens are enabled, keyed by the
// codeLens command. CodeLens which are not present in this map are left in
// their default state.
CodeLens map[string]bool
}
// NewEditor Creates a new Editor.
func NewEditor(ws *Sandbox) *Editor {
func NewEditor(ws *Sandbox, config EditorConfig) *Editor {
return &Editor{
buffers: make(map[string]buffer),
sandbox: ws,
Config: config,
}
}
@ -105,15 +122,24 @@ func (e *Editor) Client() *Client {
}
func (e *Editor) configuration() map[string]interface{} {
config := map[string]interface{}{
"verboseWorkDoneProgress": true,
}
envvars := e.sandbox.GoEnv()
envvars = append(envvars, e.Config.Env...)
env := map[string]interface{}{}
for _, value := range e.sandbox.GoEnv() {
for _, value := range envvars {
kv := strings.SplitN(value, "=", 2)
env[kv[0]] = kv[1]
}
return map[string]interface{}{
"env": env,
"verboseWorkDoneProgress": true,
config["env"] = env
if e.Config.CodeLens != nil {
config["codelens"] = e.Config.CodeLens
}
return config
}
func (e *Editor) initialize(ctx context.Context) error {
@ -235,9 +261,7 @@ func (e *Editor) CloseBuffer(ctx context.Context, path string) error {
if e.server != nil {
if err := e.server.DidClose(ctx, &protocol.DidCloseTextDocumentParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: e.sandbox.Workdir.URI(path),
},
TextDocument: e.textDocumentIdentifier(path),
}); err != nil {
return fmt.Errorf("DidClose: %w", err)
}
@ -245,6 +269,12 @@ func (e *Editor) CloseBuffer(ctx context.Context, path string) error {
return nil
}
func (e *Editor) textDocumentIdentifier(path string) protocol.TextDocumentIdentifier {
return protocol.TextDocumentIdentifier{
URI: e.sandbox.Workdir.URI(path),
}
}
// SaveBuffer writes the content of the buffer specified by the given path to
// the filesystem.
func (e *Editor) SaveBuffer(ctx context.Context, path string) error {
@ -269,9 +299,7 @@ func (e *Editor) SaveBuffer(ctx context.Context, path string) error {
}
e.mu.Unlock()
docID := protocol.TextDocumentIdentifier{
URI: e.sandbox.Workdir.URI(buf.path),
}
docID := e.textDocumentIdentifier(buf.path)
if e.server != nil {
if err := e.server.WillSave(ctx, &protocol.WillSaveTextDocumentParams{
TextDocument: docID,
@ -456,10 +484,8 @@ func (e *Editor) editBufferLocked(ctx context.Context, path string, edits []Edit
}
params := &protocol.DidChangeTextDocumentParams{
TextDocument: protocol.VersionedTextDocumentIdentifier{
Version: float64(buf.version),
TextDocumentIdentifier: protocol.TextDocumentIdentifier{
URI: e.sandbox.Workdir.URI(buf.path),
},
Version: float64(buf.version),
TextDocumentIdentifier: e.textDocumentIdentifier(buf.path),
},
ContentChanges: evts,
}
@ -614,3 +640,24 @@ func (e *Editor) RunGenerate(ctx context.Context, dir string) error {
// the caller.
return nil
}
// CodeLens execute a codelens request on the server.
func (e *Editor) CodeLens(ctx context.Context, path string) ([]protocol.CodeLens, error) {
if e.server == nil {
return nil, nil
}
e.mu.Lock()
_, ok := e.buffers[path]
e.mu.Unlock()
if !ok {
return nil, fmt.Errorf("buffer %q is not open", path)
}
params := &protocol.CodeLensParams{
TextDocument: e.textDocumentIdentifier(path),
}
lens, err := e.server.CodeLens(ctx, params)
if err != nil {
return nil, err
}
return lens, nil
}

View File

@ -54,7 +54,7 @@ func TestClientEditing(t *testing.T) {
}
defer ws.Close()
ctx := context.Background()
editor := NewEditor(ws)
editor := NewEditor(ws, EditorConfig{})
if err := editor.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}

View File

@ -22,7 +22,6 @@ type Sandbox struct {
name string
gopath string
basedir string
env []string
Proxy *Proxy
Workdir *Workdir
}
@ -31,10 +30,9 @@ type Sandbox struct {
// working directory populated by the txtar-encoded content in srctxt, and a
// file-based module proxy populated with the txtar-encoded content in
// proxytxt.
func NewSandbox(name, srctxt, proxytxt string, inGopath bool, env ...string) (_ *Sandbox, err error) {
func NewSandbox(name, srctxt, proxytxt string, inGopath bool) (_ *Sandbox, err error) {
sb := &Sandbox{
name: name,
env: env,
}
defer func() {
// Clean up if we fail at any point in this constructor.
@ -103,12 +101,12 @@ func (sb *Sandbox) GOPATH() string {
// GoEnv returns the default environment variables that can be used for
// invoking Go commands in the sandbox.
func (sb *Sandbox) GoEnv() []string {
return append([]string{
return []string{
"GOPATH=" + sb.GOPATH(),
"GOPROXY=" + sb.Proxy.GOPROXY(),
"GO111MODULE=",
"GOSUMDB=off",
}, sb.env...)
}
}
// RunGoCommand executes a go command in the sandbox.

View File

@ -218,13 +218,13 @@ func TestDebugInfoLifecycle(t *testing.T) {
tsForwarder := servertest.NewPipeServer(clientCtx, forwarder)
conn1 := tsForwarder.Connect(clientCtx)
ed1, err := fake.NewEditor(sb).Connect(clientCtx, conn1)
ed1, err := fake.NewEditor(sb, fake.EditorConfig{}).Connect(clientCtx, conn1)
if err != nil {
t.Fatal(err)
}
defer ed1.Shutdown(clientCtx)
conn2 := tsBackend.Connect(baseCtx)
ed2, err := fake.NewEditor(sb).Connect(baseCtx, conn2)
ed2, err := fake.NewEditor(sb, fake.EditorConfig{}).Connect(baseCtx, conn2)
if err != nil {
t.Fatal(err)
}

View File

@ -13,7 +13,11 @@ import (
"golang.org/x/tools/internal/span"
)
// CodeLens computes code lens for a go.mod file.
func CodeLens(ctx context.Context, snapshot source.Snapshot, uri span.URI) ([]protocol.CodeLens, error) {
if !snapshot.View().Options().EnabledCodeLens[source.CommandUpgradeDependency] {
return nil, nil
}
realURI, _ := snapshot.View().ModFiles()
if realURI == "" {
return nil, nil
@ -50,7 +54,7 @@ func CodeLens(ctx context.Context, snapshot source.Snapshot, uri span.URI) ([]pr
Range: rng,
Command: protocol.Command{
Title: fmt.Sprintf("Upgrade dependency to %s", latest),
Command: "upgrade.dependency",
Command: source.CommandUpgradeDependency,
Arguments: []interface{}{uri, dep},
},
})
@ -67,7 +71,7 @@ func CodeLens(ctx context.Context, snapshot source.Snapshot, uri span.URI) ([]pr
Range: rng,
Command: protocol.Command{
Title: "Upgrade all dependencies",
Command: "upgrade.dependency",
Command: source.CommandUpgradeDependency,
Arguments: []interface{}{uri, strings.Join(append([]string{"-u"}, allUpgrades...), " ")},
},
})

View File

@ -0,0 +1,57 @@
// 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 regtest
import (
"testing"
"golang.org/x/tools/internal/lsp/fake"
"golang.org/x/tools/internal/lsp/source"
)
func TestDisablingCodeLens(t *testing.T) {
const workspace = `
-- go.mod --
module codelens.test
-- lib.go --
package lib
type Number int
const (
Zero Number = iota
One
Two
)
//go:generate stringer -type=Number
`
tests := []struct {
label string
enabled map[string]bool
wantCodeLens bool
}{
{
label: "default",
wantCodeLens: true,
},
{
label: "generate disabled",
enabled: map[string]bool{source.CommandGenerate: false},
wantCodeLens: false,
},
}
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
runner.Run(t, workspace, func(t *testing.T, env *Env) {
env.OpenFile("lib.go")
lens := env.CodeLens("lib.go")
if gotCodeLens := len(lens) > 0; gotCodeLens != test.wantCodeLens {
t.Errorf("got codeLens: %t, want %t", gotCodeLens, test.wantCodeLens)
}
}, WithEditorConfig(fake.EditorConfig{CodeLens: test.enabled}))
})
}
}

View File

@ -382,7 +382,7 @@ func _() {
if err := env.Editor.OrganizeImports(env.Ctx, "main.go"); err == nil {
t.Fatalf("organize imports should fail with an empty GOPATH")
}
}, WithEnv("GOPATH="))
}, WithEditorConfig(fake.EditorConfig{Env: []string{"GOPATH="}}))
}
// Tests golang/go#38669.
@ -404,7 +404,7 @@ var X = 0
env.OpenFile("main.go")
env.OrganizeImports("main.go")
env.Await(EmptyDiagnostics("main.go"))
}, WithEnv("GOFLAGS=-tags=foo"))
}, WithEditorConfig(fake.EditorConfig{Env: []string{"GOFLAGS=-tags=foo"}}))
}
// Tests golang/go#38467.

View File

@ -102,10 +102,10 @@ type condition struct {
// NewEnv creates a new test environment using the given scratch environment
// and gopls server.
func NewEnv(ctx context.Context, t *testing.T, scratch *fake.Sandbox, ts servertest.Connector) *Env {
func NewEnv(ctx context.Context, t *testing.T, scratch *fake.Sandbox, ts servertest.Connector, editorConfig fake.EditorConfig) *Env {
t.Helper()
conn := ts.Connect(ctx)
editor, err := fake.NewEditor(scratch).Connect(ctx, conn)
editor, err := fake.NewEditor(scratch, editorConfig).Connect(ctx, conn)
if err != nil {
t.Fatal(err)
}

View File

@ -66,12 +66,12 @@ type Runner struct {
}
type runConfig struct {
modes Mode
proxyTxt string
timeout time.Duration
env []string
skipCleanup bool
gopath bool
editorConfig fake.EditorConfig
modes Mode
proxyTxt string
timeout time.Duration
skipCleanup bool
gopath bool
}
func (r *Runner) defaultConfig() *runConfig {
@ -113,11 +113,10 @@ func WithModes(modes Mode) RunOption {
})
}
// WithEnv overlays environment variables encoded by "<var>=<value" on top of
// the default regtest environment.
func WithEnv(env ...string) RunOption {
// WithEditorConfig configures the editors LSP session.
func WithEditorConfig(config fake.EditorConfig) RunOption {
return optionSetter(func(opts *runConfig) {
opts.env = env
opts.editorConfig = config
})
}
@ -168,7 +167,7 @@ func (r *Runner) Run(t *testing.T, filedata string, test func(t *testing.T, e *E
defer cancel()
ctx = debug.WithInstance(ctx, "", "")
sandbox, err := fake.NewSandbox("regtest", filedata, config.proxyTxt, config.gopath, config.env...)
sandbox, err := fake.NewSandbox("regtest", filedata, config.proxyTxt, config.gopath)
if err != nil {
t.Fatal(err)
}
@ -191,7 +190,7 @@ func (r *Runner) Run(t *testing.T, filedata string, test func(t *testing.T, e *E
defer func() {
ts.Close()
}()
env := NewEnv(ctx, t, sandbox, ts)
env := NewEnv(ctx, t, sandbox, ts, config.editorConfig)
defer func() {
if t.Failed() && r.PrintGoroutinesOnFailure {
pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)

View File

@ -28,7 +28,7 @@ func runShared(t *testing.T, program string, testFunc func(env1 *Env, env2 *Env)
runner.Run(t, sharedProgram, func(t *testing.T, env1 *Env) {
// Create a second test session connected to the same workspace and server
// as the first.
env2 := NewEnv(env1.Ctx, t, env1.Sandbox, env1.Server)
env2 := NewEnv(env1.Ctx, t, env1.Sandbox, env1.Server, env1.Editor.Config)
testFunc(env1, env2)
}, WithModes(modes))
}

View File

@ -9,6 +9,8 @@ package regtest
import (
"fmt"
"testing"
"golang.org/x/tools/internal/lsp/fake"
)
func TestBadGOPATH(t *testing.T) {
@ -28,5 +30,7 @@ func _() {
if err := env.Editor.OrganizeImports(env.Ctx, "main.go"); err != nil {
t.Fatal(err)
}
}, WithEnv(fmt.Sprintf("GOPATH=:/path/to/gopath")))
}, WithEditorConfig(fake.EditorConfig{
Env: []string{fmt.Sprintf("GOPATH=:/path/to/gopath")},
}))
}

View File

@ -166,3 +166,14 @@ func (e *Env) CheckForFileChanges() {
e.T.Fatal(err)
}
}
// CodeLens calls textDocument/codeLens for the given path, calling t.Fatal on
// any error.
func (e *Env) CodeLens(path string) []protocol.CodeLens {
e.T.Helper()
lens, err := e.Editor.CodeLens(e.Ctx, path)
if err != nil {
e.T.Fatal(err)
}
return lens
}

View File

@ -13,7 +13,11 @@ import (
"golang.org/x/tools/internal/lsp/protocol"
)
// CodeLens computes code lens for Go source code.
func CodeLens(ctx context.Context, snapshot Snapshot, fh FileHandle) ([]protocol.CodeLens, error) {
if !snapshot.View().Options().EnabledCodeLens[CommandGenerate] {
return nil, nil
}
f, _, m, _, err := snapshot.View().Session().Cache().ParseGoHandle(fh, ParseFull).Parse(ctx)
if err != nil {
return nil, err
@ -35,7 +39,7 @@ func CodeLens(ctx context.Context, snapshot Snapshot, fh FileHandle) ([]protocol
Range: rng,
Command: protocol.Command{
Title: "run go generate",
Command: "generate",
Command: CommandGenerate,
Arguments: []interface{}{dir, false},
},
},
@ -43,7 +47,7 @@ func CodeLens(ctx context.Context, snapshot Snapshot, fh FileHandle) ([]protocol
Range: rng,
Command: protocol.Command{
Title: "run go generate ./...",
Command: "generate",
Command: CommandGenerate,
Arguments: []interface{}{dir, true},
},
},

View File

@ -52,6 +52,18 @@ import (
errors "golang.org/x/xerrors"
)
const (
// CommandGenerate is a gopls command to run `go generate` for a directory.
CommandGenerate = "generate"
// CommandTidy is a gopls command to run `go mod tidy` for a module.
CommandTidy = "tidy"
// CommandUpgradeDependency is a gopls command to upgrade a dependency.
CommandUpgradeDependency = "upgrade.dependency"
)
// DefaultOptions is the options that are used for Gopls execution independent
// of any externally provided configuration (LSP initialization, command
// invokation, etc.).
func DefaultOptions() Options {
return Options{
ClientOptions: ClientOptions{
@ -76,9 +88,9 @@ func DefaultOptions() Options {
Sum: {},
},
SupportedCommands: []string{
"tidy", // for go.mod files
"upgrade.dependency", // for go.mod dependency upgrades
"generate", // for "go generate" commands
CommandTidy, // for go.mod files
CommandUpgradeDependency, // for go.mod dependency upgrades
CommandGenerate, // for "go generate" commands
},
},
UserOptions: UserOptions{
@ -89,6 +101,10 @@ func DefaultOptions() Options {
DeepCompletion: true,
UnimportedCompletion: true,
CompletionDocumentation: true,
EnabledCodeLens: map[string]bool{
CommandGenerate: true,
CommandUpgradeDependency: true,
},
},
DebuggingOptions: DebuggingOptions{
CompletionBudget: 100 * time.Millisecond,
@ -106,6 +122,8 @@ func DefaultOptions() Options {
}
}
// Options holds various configuration that affects Gopls execution, organized
// by the nature or origin of the settings.
type Options struct {
ClientOptions
ServerOptions
@ -115,6 +133,8 @@ type Options struct {
Hooks
}
// ClientOptions holds LSP-specific configuration that is provided by the
// client.
type ClientOptions struct {
InsertTextFormat protocol.InsertTextFormat
ConfigurationSupported bool
@ -125,11 +145,15 @@ type ClientOptions struct {
HierarchicalDocumentSymbolSupport bool
}
// ServerOptions holds LSP-specific configuration that is provided by the
// server.
type ServerOptions struct {
SupportedCodeActions map[FileKind]map[protocol.CodeActionKind]bool
SupportedCommands []string
}
// UserOptions holds custom Gopls configuration (not part of the LSP) that is
// modified by the client.
type UserOptions struct {
// Env is the current set of environment overrides on this view.
Env []string
@ -140,9 +164,10 @@ type UserOptions struct {
// HoverKind specifies the format of the content for hover requests.
HoverKind HoverKind
// UserEnabledAnalyses specify analyses that the user would like to enable or disable.
// A map of the names of analysis passes that should be enabled/disabled.
// A full list of analyzers that gopls uses can be found [here](analyzers.md)
// UserEnabledAnalyses specifies analyses that the user would like to enable
// or disable. A map of the names of analysis passes that should be
// enabled/disabled. A full list of analyzers that gopls uses can be found
// [here](analyzers.md).
//
// Example Usage:
// ...
@ -152,6 +177,10 @@ type UserOptions struct {
// }
UserEnabledAnalyses map[string]bool
// EnabledCodeLens specifies which codelens are enabled, keyed by the gopls
// command that they provide.
EnabledCodeLens map[string]bool
// StaticCheck enables additional analyses from staticcheck.io.
StaticCheck bool
@ -191,6 +220,8 @@ type completionOptions struct {
budget time.Duration
}
// Hooks contains configuration that is provided to the Gopls command by the
// main package.
type Hooks struct {
GoDiff bool
ComputeEdits diff.ComputeEdits
@ -215,6 +246,8 @@ type ExperimentalOptions struct {
VerboseWorkDoneProgress bool
}
// DebuggingOptions should not affect the logical execution of Gopls, but may
// be altered for debugging purposes.
type DebuggingOptions struct {
VerboseOutput bool
@ -395,15 +428,17 @@ func (o *Options) set(name string, value interface{}) OptionResult {
o.LinkTarget = linkTarget
case "analyses":
allAnalyses, ok := value.(map[string]interface{})
if !ok {
result.errorf("Invalid type %T for map[string]interface{} option %q", value, name)
break
}
o.UserEnabledAnalyses = make(map[string]bool)
for a, enabled := range allAnalyses {
if enabled, ok := enabled.(bool); ok {
o.UserEnabledAnalyses[a] = enabled
result.setBoolMap(&o.UserEnabledAnalyses)
case "codelens":
var lensOverrides map[string]bool
result.setBoolMap(&lensOverrides)
if result.Error == nil {
if o.EnabledCodeLens == nil {
o.EnabledCodeLens = make(map[string]bool)
}
for lens, enabled := range lensOverrides {
o.EnabledCodeLens[lens] = enabled
}
}
@ -488,6 +523,24 @@ func (r *OptionResult) asBool() (bool, bool) {
return b, true
}
func (r *OptionResult) setBoolMap(bm *map[string]bool) {
all, ok := r.Value.(map[string]interface{})
if !ok {
r.errorf("Invalid type %T for map[string]interface{} option %q", r.Value, r.Name)
return
}
m := make(map[string]bool)
for a, enabled := range all {
if enabled, ok := enabled.(bool); ok {
m[a] = enabled
} else {
r.errorf("Invalid type %d for map key %q in option %q", a, r.Name)
return
}
}
*bm = m
}
func (r *OptionResult) asString() (string, bool) {
b, ok := r.Value.(string)
if !ok {

View File

@ -360,9 +360,13 @@ func (fileID FileIdentity) String() string {
type FileKind int
const (
// Go is a normal go source file.
Go = FileKind(iota)
// Mod is a go.mod file.
Mod
// Sum is a go.sum file.
Sum
// UnknownKind is a file type we don't know about.
UnknownKind
)