From 2bc93b1c0c88b2406b967fcd19a623d1ff9ea0cd Mon Sep 17 00:00:00 2001 From: Martin Asquino Date: Tue, 5 May 2020 19:43:51 +0100 Subject: [PATCH] internal/lsp: add `run test` code lens Change-Id: I2c47fa038c81851b2c1e689adc3812b23af55461 Reviewed-on: https://go-review.googlesource.com/c/tools/+/231959 Reviewed-by: Robert Findley Run-TryBot: Robert Findley TryBot-Result: Gobot Gobot --- internal/lsp/command.go | 76 ++++++++++++ internal/lsp/generate.go | 16 +-- internal/lsp/source/code_lens.go | 108 +++++++++++++++++- internal/lsp/source/options.go | 3 + .../lsp/primarymod/codelens/codelens_test.go | 16 +++ internal/lsp/testdata/lsp/summary.txt.golden | 2 +- internal/lsp/tests/tests.go | 1 + 7 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 internal/lsp/testdata/lsp/primarymod/codelens/codelens_test.go diff --git a/internal/lsp/command.go b/internal/lsp/command.go index cd80d6df17..632779723b 100644 --- a/internal/lsp/command.go +++ b/internal/lsp/command.go @@ -6,18 +6,43 @@ package lsp import ( "context" + "io" + "path/filepath" "strings" + "golang.org/x/tools/internal/event" "golang.org/x/tools/internal/gocommand" + "golang.org/x/tools/internal/lsp/debug/tag" "golang.org/x/tools/internal/lsp/protocol" "golang.org/x/tools/internal/lsp/source" "golang.org/x/tools/internal/packagesinternal" "golang.org/x/tools/internal/xcontext" + "golang.org/x/xerrors" errors "golang.org/x/xerrors" ) func (s *Server) executeCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (interface{}, error) { switch params.Command { + case source.CommandTest: + if len(s.session.UnsavedFiles()) != 0 { + return nil, s.client.ShowMessage(ctx, &protocol.ShowMessageParams{ + Type: protocol.Error, + Message: "could not run tests, there are unsaved files in the view", + }) + } + + funcName, uri, err := getRunTestArguments(params.Arguments) + if err != nil { + return nil, err + } + + snapshot, fh, ok, err := s.beginFileRequest(protocol.DocumentURI(uri), source.Go) + if !ok { + return nil, err + } + + dir := filepath.Dir(fh.Identity().URI.Filename()) + go s.runTest(ctx, funcName, dir, snapshot) case source.CommandGenerate: dir, recursive, err := getGenerateRequest(params.Arguments) if err != nil { @@ -71,6 +96,57 @@ func (s *Server) executeCommand(ctx context.Context, params *protocol.ExecuteCom return nil, nil } +func (s *Server) runTest(ctx context.Context, funcName string, dir string, snapshot source.Snapshot) { + args := []string{"-run", funcName, dir} + inv := gocommand.Invocation{ + Verb: "test", + Args: args, + Env: snapshot.Config(ctx).Env, + WorkingDir: dir, + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + er := &eventWriter{ctx: ctx, operation: "test"} + wc := s.newProgressWriter(ctx, "test", "running "+funcName, cancel) + defer wc.Close() + + messageType := protocol.Info + message := "test passed" + stderr := io.MultiWriter(er, wc) + if err := inv.RunPiped(ctx, er, stderr); err != nil { + event.Error(ctx, "test: command error", err, tag.Directory.Of(dir)) + if !xerrors.Is(err, context.Canceled) { + messageType = protocol.Error + message = "test failed" + } + } + + s.client.ShowMessage(ctx, &protocol.ShowMessageParams{ + Type: messageType, + Message: message, + }) +} + +func getRunTestArguments(args []interface{}) (string, string, error) { + if len(args) != 2 { + return "", "", errors.Errorf("expected one test func name and one file path, got %v", args) + } + + funcName, ok := args[0].(string) + if !ok { + return "", "", errors.Errorf("expected func name to be a string, got %T", args[0]) + } + + file, ok := args[1].(string) + if !ok { + return "", "", errors.Errorf("expected file to be a string, got %T", args[1]) + } + + return funcName, file, 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)) diff --git a/internal/lsp/generate.go b/internal/lsp/generate.go index f434bc3857..b75f4a14ec 100644 --- a/internal/lsp/generate.go +++ b/internal/lsp/generate.go @@ -23,8 +23,8 @@ func (s *Server) runGenerate(ctx context.Context, dir string, recursive bool) { ctx, cancel := context.WithCancel(ctx) defer cancel() - er := &eventWriter{ctx: ctx} - wc := s.newProgressWriter(ctx, cancel) + er := &eventWriter{ctx: ctx, operation: "generate"} + wc := s.newProgressWriter(ctx, GenerateWorkDoneTitle, "running go generate", cancel) defer wc.Close() args := []string{"-x"} if recursive { @@ -53,20 +53,20 @@ func (s *Server) runGenerate(ctx context.Context, dir string, recursive bool) { // event.Print with the operation=generate tag // to distinguish its logs from others. type eventWriter struct { - ctx context.Context + ctx context.Context + operation string } func (ew *eventWriter) Write(p []byte) (n int, err error) { - event.Log(ew.ctx, string(p), tag.Operation.Of("generate")) + event.Log(ew.ctx, string(p), tag.Operation.Of(ew.operation)) 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 { +// to report progress on a command based on the client capabilities. +func (s *Server) newProgressWriter(ctx context.Context, title, message string, cancel func()) io.WriteCloser { if s.supportsWorkDoneProgress { - wd := s.StartWork(ctx, GenerateWorkDoneTitle, "running go generate", cancel) + wd := s.StartWork(ctx, title, message, cancel) return &workDoneWriter{ctx, wd} } mw := &messageWriter{ctx, cancel, s.client} diff --git a/internal/lsp/source/code_lens.go b/internal/lsp/source/code_lens.go index 689a6feabf..21a1ecc908 100644 --- a/internal/lsp/source/code_lens.go +++ b/internal/lsp/source/code_lens.go @@ -6,8 +6,11 @@ package source import ( "context" + "go/ast" "go/token" + "go/types" "path/filepath" + "regexp" "strings" "golang.org/x/tools/internal/lsp/protocol" @@ -15,13 +18,112 @@ import ( // 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 } + + var codeLens []protocol.CodeLens + + if snapshot.View().Options().EnabledCodeLens[CommandGenerate] { + ggcl, err := goGenerateCodeLens(ctx, snapshot, fh, f, m) + if err != nil { + return nil, err + } + codeLens = append(codeLens, ggcl...) + } + + if snapshot.View().Options().EnabledCodeLens[CommandTest] { + rtcl, err := runTestCodeLens(ctx, snapshot, fh, f, m) + if err != nil { + return nil, err + } + codeLens = append(codeLens, rtcl...) + } + + return codeLens, nil +} + +var testMatcher = regexp.MustCompile("^Test[^a-z]") +var benchMatcher = regexp.MustCompile("^Benchmark[^a-z]") + +func runTestCodeLens(ctx context.Context, snapshot Snapshot, fh FileHandle, f *ast.File, m *protocol.ColumnMapper) ([]protocol.CodeLens, error) { + codeLens := make([]protocol.CodeLens, 0) + + pkg, _, err := getParsedFile(ctx, snapshot, fh, WidestPackageHandle) + if err != nil { + return nil, err + } + + if !strings.HasSuffix(fh.Identity().URI.Filename(), "_test.go") { + return nil, nil + } + + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok { + continue + } + + if isTestFunc(fn, pkg) { + fset := snapshot.View().Session().Cache().FileSet() + rng, err := newMappedRange(fset, m, d.Pos(), d.Pos()).Range() + if err != nil { + return nil, err + } + + uri := fh.Identity().URI + codeLens = append(codeLens, protocol.CodeLens{ + Range: rng, + Command: protocol.Command{ + Title: "run test", + Command: "test", + Arguments: []interface{}{fn.Name.Name, uri}, + }, + }) + } + } + + return codeLens, nil +} + +func isTestFunc(fn *ast.FuncDecl, pkg Package) bool { + typesInfo := pkg.GetTypesInfo() + if typesInfo == nil { + return false + } + + sig, ok := typesInfo.ObjectOf(fn.Name).Type().(*types.Signature) + if !ok { + return false + } + + // test funcs should have a single parameter, so we can exit early if that's not the case. + if sig.Params().Len() != 1 { + return false + } + + firstParam, ok := sig.Params().At(0).Type().(*types.Pointer) + if !ok { + return false + } + + firstParamElem, ok := firstParam.Elem().(*types.Named) + if !ok { + return false + } + + firstParamObj := firstParamElem.Obj() + if firstParamObj.Pkg().Path() != "testing" { + return false + } + + firstParamName := firstParamObj.Id() + return (firstParamName == "T" && testMatcher.MatchString(fn.Name.Name)) || + (firstParamName == "B" && benchMatcher.MatchString(fn.Name.Name)) +} + +func goGenerateCodeLens(ctx context.Context, snapshot Snapshot, fh FileHandle, f *ast.File, m *protocol.ColumnMapper) ([]protocol.CodeLens, error) { const ggDirective = "//go:generate" for _, c := range f.Comments { for _, l := range c.List { diff --git a/internal/lsp/source/options.go b/internal/lsp/source/options.go index 363dcc550a..c5f1da0e93 100644 --- a/internal/lsp/source/options.go +++ b/internal/lsp/source/options.go @@ -53,6 +53,8 @@ import ( ) const ( + // CommandGenerate is a gopls command to run `go test` for a specific test function. + CommandTest = "test" // 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. @@ -88,6 +90,7 @@ func DefaultOptions() Options { Sum: {}, }, SupportedCommands: []string{ + CommandTest, // for "go test" commands CommandTidy, // for go.mod files CommandUpgradeDependency, // for go.mod dependency upgrades CommandGenerate, // for "go generate" commands diff --git a/internal/lsp/testdata/lsp/primarymod/codelens/codelens_test.go b/internal/lsp/testdata/lsp/primarymod/codelens/codelens_test.go new file mode 100644 index 0000000000..604d237524 --- /dev/null +++ b/internal/lsp/testdata/lsp/primarymod/codelens/codelens_test.go @@ -0,0 +1,16 @@ +package codelens + +import "testing" + +// no code lens for TestMain +func TestMain(m *testing.M) { +} + +func TestFuncWithCodeLens(t *testing.T) { //@ codelens("func", "run test", "test") +} + +func thisShouldNotHaveACodeLens(t *testing.T) { +} + +func BenchmarkFuncWithCodeLens(b *testing.B) { //@ codelens("func", "run test", "test") +} diff --git a/internal/lsp/testdata/lsp/summary.txt.golden b/internal/lsp/testdata/lsp/summary.txt.golden index 5296bd973c..15c78a40e8 100644 --- a/internal/lsp/testdata/lsp/summary.txt.golden +++ b/internal/lsp/testdata/lsp/summary.txt.golden @@ -1,5 +1,5 @@ -- summary -- -CodeLensCount = 2 +CodeLensCount = 4 CompletionsCount = 240 CompletionSnippetCount = 76 UnimportedCompletionsCount = 6 diff --git a/internal/lsp/tests/tests.go b/internal/lsp/tests/tests.go index 3af5292b47..a36d0f0d57 100644 --- a/internal/lsp/tests/tests.go +++ b/internal/lsp/tests/tests.go @@ -222,6 +222,7 @@ func DefaultOptions() source.Options { }, source.Sum: {}, } + o.UserOptions.EnabledCodeLens[source.CommandTest] = true o.HoverKind = source.SynopsisDocumentation o.InsertTextFormat = protocol.SnippetTextFormat o.CompletionBudget = time.Minute