x/tools/gopls: run go generate through CodeLens
This change adds support for recognizing a //go:generate directive
and offering a CodeLens that will then send a "generate" command to
the server to run "go generate" or "go generate ./...". Because
"go generate" can only be executed per package, there is no need to show
the CodeLens on top of every //go:generate comment. Therefore, only the
top directive will be considered.
The stdout/stderr of the go generate command will be piped to the logger
while stderr will also be sent to the editor as a window/showMessage
The user will only know when the process starts and when it ends so that they wouldn't
get bogged with a large number of message windows popping up. However, they can
check the logs for all the details.
If a user wants to cancel the "go generate" command, they will be able
to do so with a "Cancel" ActionItem that the server will offer to the client
Fixes golang/go#37680
Change-Id: I89a9617521eab20859cb2215db133f34fda856c7
Reviewed-on: https://go-review.googlesource.com/c/tools/+/222247
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
diff --git a/internal/gocommand/invoke.go b/internal/gocommand/invoke.go
index 75d73e7..468db40 100644
--- a/internal/gocommand/invoke.go
+++ b/internal/gocommand/invoke.go
@@ -5,6 +5,7 @@
"bytes"
"context"
"fmt"
+ "io"
"os"
"os/exec"
"strings"
@@ -28,9 +29,27 @@
return stdout, friendly
}
-// RunRaw is like Run, but also returns the raw stderr and error for callers
+// RunRaw is like RunPiped, but also returns the raw stderr and error for callers
// that want to do low-level error handling/recovery.
func (i *Invocation) RunRaw(ctx context.Context) (stdout *bytes.Buffer, stderr *bytes.Buffer, friendlyError error, rawError error) {
+ stdout = &bytes.Buffer{}
+ stderr = &bytes.Buffer{}
+ rawError = i.RunPiped(ctx, stdout, stderr)
+ if rawError != nil {
+ // Check for 'go' executable not being found.
+ if ee, ok := rawError.(*exec.Error); ok && ee.Err == exec.ErrNotFound {
+ friendlyError = fmt.Errorf("go command required, not found: %v", ee)
+ }
+ if ctx.Err() != nil {
+ friendlyError = ctx.Err()
+ }
+ friendlyError = fmt.Errorf("err: %v: stderr: %s", rawError, stderr)
+ }
+ return
+}
+
+// RunPiped is like Run, but relies on the given stdout/stderr
+func (i *Invocation) RunPiped(ctx context.Context, stdout, stderr io.Writer) error {
log := i.Logf
if log == nil {
log = func(string, ...interface{}) {}
@@ -51,8 +70,6 @@
goArgs = append(goArgs, i.Args...)
}
cmd := exec.Command("go", goArgs...)
- stdout = &bytes.Buffer{}
- stderr = &bytes.Buffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr
// On darwin the cwd gets resolved to the real path, which breaks anything that
@@ -66,19 +83,7 @@
defer func(start time.Time) { log("%s for %v", time.Since(start), cmdDebugStr(cmd)) }(time.Now())
- rawError = runCmdContext(ctx, cmd)
- friendlyError = rawError
- if rawError != nil {
- // Check for 'go' executable not being found.
- if ee, ok := rawError.(*exec.Error); ok && ee.Err == exec.ErrNotFound {
- friendlyError = fmt.Errorf("go command required, not found: %v", ee)
- }
- if ctx.Err() != nil {
- friendlyError = ctx.Err()
- }
- friendlyError = fmt.Errorf("err: %v: stderr: %s", rawError, stderr)
- }
- return
+ return runCmdContext(ctx, cmd)
}
// runCmdContext is like exec.CommandContext except it sends os.Interrupt
diff --git a/internal/lsp/command.go b/internal/lsp/command.go
index 2c00b7e..6c5f51f 100644
--- a/internal/lsp/command.go
+++ b/internal/lsp/command.go
@@ -1,3 +1,7 @@
+// 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 lsp
import (
@@ -7,11 +11,18 @@
"golang.org/x/tools/internal/gocommand"
"golang.org/x/tools/internal/lsp/protocol"
"golang.org/x/tools/internal/lsp/source"
+ "golang.org/x/tools/internal/xcontext"
errors "golang.org/x/xerrors"
)
func (s *Server) executeCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (interface{}, error) {
switch params.Command {
+ case "generate":
+ dir, recursive, err := getGenerateRequest(params.Arguments)
+ if err != nil {
+ return nil, err
+ }
+ go s.runGenerate(xcontext.Detach(ctx), dir, recursive)
case "tidy":
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)
@@ -54,3 +65,18 @@
}
return nil, nil
}
+
+func getGenerateRequest(args []interface{}) (string, bool, error) {
+ if len(args) != 2 {
+ return "", false, errors.Errorf("expected exactly 2 arguments but got %d", len(args))
+ }
+ dir, ok := args[0].(string)
+ if !ok {
+ return "", false, errors.Errorf("expected dir to be a string value but got %T", args[0])
+ }
+ recursive, ok := args[1].(bool)
+ if !ok {
+ return "", false, errors.Errorf("expected recursive to be a boolean but got %T", args[1])
+ }
+ return dir, recursive, nil
+}
diff --git a/internal/lsp/generate.go b/internal/lsp/generate.go
new file mode 100644
index 0000000..bddf9c2
--- /dev/null
+++ b/internal/lsp/generate.go
@@ -0,0 +1,125 @@
+// 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 lsp
+
+import (
+ "context"
+ "io"
+ "log"
+ "math/rand"
+ "strconv"
+
+ "golang.org/x/tools/internal/gocommand"
+ "golang.org/x/tools/internal/lsp/protocol"
+ "golang.org/x/tools/internal/telemetry/event"
+ errors "golang.org/x/xerrors"
+)
+
+func (s *Server) runGenerate(ctx context.Context, dir string, recursive bool) {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ token := strconv.FormatInt(rand.Int63(), 10)
+ s.inProgressMu.Lock()
+ s.inProgress[token] = cancel
+ s.inProgressMu.Unlock()
+ defer s.clearInProgress(token)
+
+ er := &eventWriter{ctx: ctx}
+ wc := s.newProgressWriter(ctx, cancel)
+ defer wc.Close()
+ args := []string{"-x"}
+ if recursive {
+ args = append(args, "./...")
+ }
+ inv := &gocommand.Invocation{
+ Verb: "generate",
+ Args: args,
+ Env: s.session.Options().Env,
+ WorkingDir: dir,
+ }
+ stderr := io.MultiWriter(er, wc)
+ err := inv.RunPiped(ctx, er, stderr)
+ if err != nil && !errors.Is(ctx.Err(), context.Canceled) {
+ log.Printf("generate: command error: %v", err)
+ s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
+ Type: protocol.Error,
+ Message: "go generate exited with an error, check gopls logs",
+ })
+ }
+}
+
+// eventWriter writes every incoming []byte to
+// event.Print with the operation=generate tag
+// to distinguish its logs from others.
+type eventWriter struct {
+ ctx context.Context
+}
+
+func (ew *eventWriter) Write(p []byte) (n int, err error) {
+ event.Print(ew.ctx, string(p), event.Tag{
+ Key: &event.Key{
+ Name: "operation",
+ },
+ Value: "generate",
+ })
+ return len(p), nil
+}
+
+// newProgressWriter returns an io.WriterCloser that can be used
+// to report progress on the "go generate" command based on the
+// client capabilities.
+func (s *Server) newProgressWriter(ctx context.Context, cancel func()) io.WriteCloser {
+ var wc interface {
+ io.WriteCloser
+ start()
+ }
+ // TODO(marwan-at-work): add $/progress notifications
+ wc = &messageWriter{cancel, ctx, s.client}
+ wc.start()
+ return wc
+}
+
+// messageWriter implements progressWriter
+// and only tells the user that "go generate"
+// has started through window/showMessage but does not
+// report anything afterwards. This is because each
+// log shows up as a separate window and therefore
+// would be obnoxious to show every incoming line.
+type messageWriter struct {
+ cancel func()
+ ctx context.Context
+ client protocol.Client
+}
+
+func (lw *messageWriter) Write(p []byte) (n int, err error) {
+ return len(p), nil
+}
+
+func (lw *messageWriter) start() {
+ go func() {
+ msg, err := lw.client.ShowMessageRequest(lw.ctx, &protocol.ShowMessageRequestParams{
+ Type: protocol.Log,
+ Message: "go generate has started, check logs for progress",
+ Actions: []protocol.MessageActionItem{{
+ Title: "Cancel",
+ }},
+ })
+ if err != nil {
+ event.Error(lw.ctx, "error sending initial generate msg", err)
+ return
+ }
+ if msg != nil && msg.Title == "Cancel" {
+ lw.cancel()
+ }
+ }()
+}
+
+func (lw *messageWriter) Close() error {
+ return lw.client.ShowMessage(lw.ctx, &protocol.ShowMessageParams{
+ Type: protocol.Info,
+ Message: "go generate has finished",
+ })
+}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index b8e997b..27b431a 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -101,11 +101,18 @@
}
func (s *Server) codeLens(ctx context.Context, params *protocol.CodeLensParams) ([]protocol.CodeLens, error) {
- snapshot, fh, ok, err := s.beginFileRequest(params.TextDocument.URI, source.Mod)
+ snapshot, fh, ok, err := s.beginFileRequest(params.TextDocument.URI, source.UnknownKind)
if !ok {
return nil, err
}
- return mod.CodeLens(ctx, snapshot, fh.Identity().URI)
+ switch fh.Identity().Kind {
+ case source.Mod:
+ return mod.CodeLens(ctx, snapshot, fh.Identity().URI)
+ case source.Go:
+ return source.CodeLens(ctx, snapshot, fh)
+ }
+ // Unsupported file kind for a code action.
+ return nil, nil
}
func (s *Server) nonstandardRequest(ctx context.Context, method string, params interface{}) (interface{}, error) {
@@ -154,6 +161,12 @@
return nil
}
+func (s *Server) clearInProgress(token string) {
+ s.inProgressMu.Lock()
+ delete(s.inProgress, token)
+ s.inProgressMu.Unlock()
+}
+
func notImplemented(method string) *jsonrpc2.Error {
return jsonrpc2.NewErrorf(jsonrpc2.CodeMethodNotFound, "method %q not yet implemented", method)
}
diff --git a/internal/lsp/source/code_lens.go b/internal/lsp/source/code_lens.go
new file mode 100644
index 0000000..6bdda9e
--- /dev/null
+++ b/internal/lsp/source/code_lens.go
@@ -0,0 +1,55 @@
+// 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 source
+
+import (
+ "context"
+ "go/token"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/tools/internal/lsp/protocol"
+)
+
+func CodeLens(ctx context.Context, snapshot Snapshot, fh FileHandle) ([]protocol.CodeLens, error) {
+ f, _, m, _, err := snapshot.View().Session().Cache().ParseGoHandle(fh, ParseFull).Parse(ctx)
+ if err != nil {
+ return nil, err
+ }
+ const ggDirective = "//go:generate"
+ for _, c := range f.Comments {
+ for _, l := range c.List {
+ if !strings.HasPrefix(l.Text, ggDirective) {
+ continue
+ }
+ fset := snapshot.View().Session().Cache().FileSet()
+ rng, err := newMappedRange(fset, m, l.Pos(), l.Pos()+token.Pos(len(ggDirective))).Range()
+ if err != nil {
+ return nil, err
+ }
+ dir := filepath.Dir(fh.Identity().URI.Filename())
+ return []protocol.CodeLens{
+ {
+ Range: rng,
+ Command: protocol.Command{
+ Title: "run go generate",
+ Command: "generate",
+ Arguments: []interface{}{dir, false},
+ },
+ },
+ {
+ Range: rng,
+ Command: protocol.Command{
+ Title: "run go generate ./...",
+ Command: "generate",
+ Arguments: []interface{}{dir, true},
+ },
+ },
+ }, nil
+
+ }
+ }
+ return nil, nil
+}
diff --git a/internal/lsp/source/options.go b/internal/lsp/source/options.go
index b0c5b0a..5c1eb17 100644
--- a/internal/lsp/source/options.go
+++ b/internal/lsp/source/options.go
@@ -69,6 +69,7 @@
SupportedCommands: []string{
"tidy", // for go.mod files
"upgrade.dependency", // for go.mod dependency upgrades
+ "generate", // for "go generate" commands
},
},
UserOptions: UserOptions{
diff --git a/internal/lsp/source/source_test.go b/internal/lsp/source/source_test.go
index 46b3371..e7358ce 100644
--- a/internal/lsp/source/source_test.go
+++ b/internal/lsp/source/source_test.go
@@ -895,7 +895,17 @@
}
func (r *runner) CodeLens(t *testing.T, uri span.URI, want []protocol.CodeLens) {
- // This is a pure LSP feature, no source level functionality to be tested.
+ fh, err := r.view.Snapshot().GetFile(uri)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := source.CodeLens(r.ctx, r.view.Snapshot(), fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := tests.DiffCodeLens(uri, want, got); diff != "" {
+ t.Error(diff)
+ }
}
func spanToRange(data *tests.Data, spn span.Span) (*protocol.ColumnMapper, protocol.Range, error) {
diff --git a/internal/lsp/testdata/lsp/primarymod/generate/generate.go b/internal/lsp/testdata/lsp/primarymod/generate/generate.go
new file mode 100644
index 0000000..ae5e90d
--- /dev/null
+++ b/internal/lsp/testdata/lsp/primarymod/generate/generate.go
@@ -0,0 +1,4 @@
+package generate
+
+//go:generate echo Hi //@ codelens("//go:generate", "run go generate", "generate"), codelens("//go:generate", "run go generate ./...", "generate")
+//go:generate echo I shall have no CodeLens
diff --git a/internal/lsp/testdata/lsp/summary.txt.golden b/internal/lsp/testdata/lsp/summary.txt.golden
index c44224a..9dc1e2a 100644
--- a/internal/lsp/testdata/lsp/summary.txt.golden
+++ b/internal/lsp/testdata/lsp/summary.txt.golden
@@ -1,5 +1,5 @@
-- summary --
-CodeLensCount = 0
+CodeLensCount = 2
CompletionsCount = 237
CompletionSnippetCount = 74
UnimportedCompletionsCount = 11