Skip to content

Commit 386503d

Browse files
committed
gopls/internal/golang: add source code action for add test
This CL is some glue code which build the connection between the LSP "code action request" with second call which compute the actual DocumentChange. AddTest source code action will create a test file if not already exist and insert a random function at the end of the test file. For testing, an internal boolean option "addTestSourceCodeAction" is created and only effective if set explicitly in marker test. For golang/vscode-go#1594 Change-Id: Ie3d9279ea2858805254181608a0d5103afd3a4c6 Reviewed-on: https://go-review.googlesource.com/c/tools/+/621056 Reviewed-by: Robert Findley <[email protected]> Reviewed-by: Alan Donovan <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]>
1 parent 9d6e1a6 commit 386503d

File tree

11 files changed

+266
-51
lines changed

11 files changed

+266
-51
lines changed

gopls/internal/cache/parsego/file.go

+5
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ func (pgf *File) NodeRange(node ast.Node) (protocol.Range, error) {
8989
return pgf.Mapper.NodeRange(pgf.Tok, node)
9090
}
9191

92+
// NodeOffsets returns offsets for the ast.Node.
93+
func (pgf *File) NodeOffsets(node ast.Node) (start int, end int, _ error) {
94+
return safetoken.Offsets(pgf.Tok, node.Pos(), node.End())
95+
}
96+
9297
// NodeMappedRange returns a MappedRange for the ast.Node interval in this file.
9398
// A MappedRange can be converted to any other form.
9499
func (pgf *File) NodeMappedRange(node ast.Node) (protocol.MappedRange, error) {

gopls/internal/golang/addtest.go

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright 2019 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package golang
6+
7+
// This file defines the behavior of the "Add test for FUNC" command.
8+
9+
import (
10+
"bytes"
11+
"context"
12+
"errors"
13+
"fmt"
14+
"go/token"
15+
"os"
16+
"path/filepath"
17+
"strings"
18+
19+
"golang.org/x/tools/gopls/internal/cache"
20+
"golang.org/x/tools/gopls/internal/cache/parsego"
21+
"golang.org/x/tools/gopls/internal/protocol"
22+
)
23+
24+
// AddTestForFunc adds a test for the function enclosing the given input range.
25+
// It creates a _test.go file if one does not already exist.
26+
func AddTestForFunc(ctx context.Context, snapshot *cache.Snapshot, loc protocol.Location) (changes []protocol.DocumentChange, _ error) {
27+
pkg, pgf, err := NarrowestPackageForFile(ctx, snapshot, loc.URI)
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
testBase := strings.TrimSuffix(filepath.Base(loc.URI.Path()), ".go") + "_test.go"
33+
goTestFileURI := protocol.URIFromPath(filepath.Join(loc.URI.Dir().Path(), testBase))
34+
35+
testFH, err := snapshot.ReadFile(ctx, goTestFileURI)
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
// TODO(hxjiang): use a fresh name if the same test function name already
41+
// exist.
42+
43+
var (
44+
// edits contains all the text edits to be applied to the test file.
45+
edits []protocol.TextEdit
46+
// header is the buffer containing the text edit to the beginning of the file.
47+
header bytes.Buffer
48+
)
49+
50+
testPgf, err := snapshot.ParseGo(ctx, testFH, parsego.Header)
51+
if err != nil {
52+
if !errors.Is(err, os.ErrNotExist) {
53+
return nil, err
54+
}
55+
56+
changes = append(changes, protocol.DocumentChangeCreate(goTestFileURI))
57+
58+
// If this test file was created by the gopls, add a copyright header based
59+
// on the originating file.
60+
// Search for something that looks like a copyright header, to replicate
61+
// in the new file.
62+
// TODO(hxjiang): should we refine this heuristic, for example by checking for
63+
// the word 'copyright'?
64+
if groups := pgf.File.Comments; len(groups) > 0 {
65+
// Copyright should appear before package decl and must be the first
66+
// comment group.
67+
// Avoid copying any other comment like package doc or directive comment.
68+
if c := groups[0]; c.Pos() < pgf.File.Package && c != pgf.File.Doc &&
69+
!isDirective(groups[0].List[0].Text) {
70+
start, end, err := pgf.NodeOffsets(c)
71+
if err != nil {
72+
return nil, err
73+
}
74+
header.Write(pgf.Src[start:end])
75+
// One empty line between copyright header and package decl.
76+
header.WriteString("\n\n")
77+
}
78+
}
79+
}
80+
81+
// If the test file does not have package decl, use the originating file to
82+
// determine a package decl for the new file. Prefer xtest package.s
83+
if testPgf == nil || testPgf.File == nil || testPgf.File.Package == token.NoPos {
84+
// One empty line between package decl and rest of the file.
85+
fmt.Fprintf(&header, "package %s_test\n\n", pkg.Types().Name())
86+
}
87+
88+
// Write the copyright and package decl to the beginning of the file.
89+
if text := header.String(); len(text) != 0 {
90+
edits = append(edits, protocol.TextEdit{
91+
Range: protocol.Range{},
92+
NewText: text,
93+
})
94+
}
95+
96+
// TODO(hxjiang): reject if the function/method is unexported.
97+
// TODO(hxjiang): modify existing imports or add new imports.
98+
99+
// If the parse go file is missing, the fileEnd is the file start (zero value).
100+
fileEnd := protocol.Range{}
101+
if testPgf != nil {
102+
fileEnd, err = testPgf.PosRange(testPgf.File.FileEnd, testPgf.File.FileEnd)
103+
if err != nil {
104+
return nil, err
105+
}
106+
}
107+
108+
// test is the buffer containing the text edit to the test function.
109+
var test bytes.Buffer
110+
// TODO(hxjiang): replace test foo function with table-driven test.
111+
test.WriteString("\nfunc TestFoo(*testing.T) {}")
112+
edits = append(edits, protocol.TextEdit{
113+
Range: fileEnd,
114+
NewText: test.String(),
115+
})
116+
return append(changes, protocol.DocumentChangeEdit(testFH, edits)), nil
117+
}

gopls/internal/golang/codeaction.go

+36
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ type codeActionProducer struct {
227227
var codeActionProducers = [...]codeActionProducer{
228228
{kind: protocol.QuickFix, fn: quickFix, needPkg: true},
229229
{kind: protocol.SourceOrganizeImports, fn: sourceOrganizeImports},
230+
{kind: settings.AddTest, fn: addTest, needPkg: true},
230231
{kind: settings.GoAssembly, fn: goAssembly, needPkg: true},
231232
{kind: settings.GoDoc, fn: goDoc, needPkg: true},
232233
{kind: settings.GoFreeSymbols, fn: goFreeSymbols},
@@ -467,6 +468,41 @@ func refactorExtractToNewFile(ctx context.Context, req *codeActionsRequest) erro
467468
return nil
468469
}
469470

471+
// addTest produces "Add a test for FUNC" code actions.
472+
// See [server.commandHandler.AddTest] for command implementation.
473+
func addTest(ctx context.Context, req *codeActionsRequest) error {
474+
// Reject if the feature is turned off.
475+
if !req.snapshot.Options().AddTestSourceCodeAction {
476+
return nil
477+
}
478+
479+
// Reject test package.
480+
if req.pkg.Metadata().ForTest != "" {
481+
return nil
482+
}
483+
484+
path, _ := astutil.PathEnclosingInterval(req.pgf.File, req.start, req.end)
485+
if len(path) < 2 {
486+
return nil
487+
}
488+
489+
decl, ok := path[len(path)-2].(*ast.FuncDecl)
490+
if !ok {
491+
return nil
492+
}
493+
494+
// Don't offer to create tests of "init" or "_".
495+
if decl.Name.Name == "_" || decl.Name.Name == "init" {
496+
return nil
497+
}
498+
499+
cmd := command.NewAddTestCommand("Add a test for "+decl.Name.String(), req.loc)
500+
req.addCommandAction(cmd, true)
501+
502+
// TODO(hxjiang): add code action for generate test for package/file.
503+
return nil
504+
}
505+
470506
// refactorRewriteRemoveUnusedParam produces "Remove unused parameter" code actions.
471507
// See [server.commandHandler.ChangeSignature] for command implementation.
472508
func refactorRewriteRemoveUnusedParam(ctx context.Context, req *codeActionsRequest) error {

gopls/internal/golang/extracttofile.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func findImportEdits(file *ast.File, info *types.Info, start, end token.Pos) (ad
8080
}
8181

8282
// ExtractToNewFile moves selected declarations into a new file.
83-
func ExtractToNewFile(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, rng protocol.Range) (*protocol.WorkspaceEdit, error) {
83+
func ExtractToNewFile(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, rng protocol.Range) ([]protocol.DocumentChange, error) {
8484
errorPrefix := "ExtractToNewFile"
8585

8686
pkg, pgf, err := NarrowestPackageForFile(ctx, snapshot, fh.URI())
@@ -160,15 +160,15 @@ func ExtractToNewFile(ctx context.Context, snapshot *cache.Snapshot, fh file.Han
160160
return nil, err
161161
}
162162

163-
return protocol.NewWorkspaceEdit(
163+
return []protocol.DocumentChange{
164164
// edit the original file
165165
protocol.DocumentChangeEdit(fh, append(importDeletes, protocol.TextEdit{Range: replaceRange, NewText: ""})),
166166
// create a new file
167167
protocol.DocumentChangeCreate(newFile.URI()),
168168
// edit the created file
169169
protocol.DocumentChangeEdit(newFile, []protocol.TextEdit{
170170
{Range: protocol.Range{}, NewText: string(newFileContent)},
171-
})), nil
171+
})}, nil
172172
}
173173

174174
// chooseNewFile chooses a new filename in dir, based on the name of the

gopls/internal/protocol/command/command_gen.go

+16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gopls/internal/protocol/command/interface.go

+3
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ type Interface interface {
224224
// to avoid conflicts with other counters gopls collects.
225225
AddTelemetryCounters(context.Context, AddTelemetryCountersArgs) error
226226

227+
// AddTest: add a test for the selected function
228+
AddTest(context.Context, protocol.Location) (*protocol.WorkspaceEdit, error)
229+
227230
// MaybePromptForTelemetry: Prompt user to enable telemetry
228231
//
229232
// Checks for the right conditions, and then prompts the user

gopls/internal/server/command.go

+24-48
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,24 @@ func (*commandHandler) AddTelemetryCounters(_ context.Context, args command.AddT
275275
return nil
276276
}
277277

278+
func (c *commandHandler) AddTest(ctx context.Context, loc protocol.Location) (*protocol.WorkspaceEdit, error) {
279+
var result *protocol.WorkspaceEdit
280+
err := c.run(ctx, commandConfig{
281+
forURI: loc.URI,
282+
}, func(ctx context.Context, deps commandDeps) error {
283+
if deps.snapshot.FileKind(deps.fh) != file.Go {
284+
return fmt.Errorf("can't add test for non-Go file")
285+
}
286+
docedits, err := golang.AddTestForFunc(ctx, deps.snapshot, loc)
287+
if err != nil {
288+
return err
289+
}
290+
return applyChanges(ctx, c.s.client, docedits)
291+
})
292+
// TODO(hxjiang): move the cursor to the new test once edits applied.
293+
return result, err
294+
}
295+
278296
// commandConfig configures common command set-up and execution.
279297
type commandConfig struct {
280298
requireSave bool // whether all files must be saved for the command to work
@@ -388,16 +406,7 @@ func (c *commandHandler) ApplyFix(ctx context.Context, args command.ApplyFixArgs
388406
result = wsedit
389407
return nil
390408
}
391-
resp, err := c.s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
392-
Edit: *wsedit,
393-
})
394-
if err != nil {
395-
return err
396-
}
397-
if !resp.Applied {
398-
return errors.New(resp.FailureReason)
399-
}
400-
return nil
409+
return applyChanges(ctx, c.s.client, changes)
401410
})
402411
return result, err
403412
}
@@ -622,17 +631,7 @@ func (c *commandHandler) RemoveDependency(ctx context.Context, args command.Remo
622631
if err != nil {
623632
return err
624633
}
625-
response, err := c.s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
626-
Edit: *protocol.NewWorkspaceEdit(
627-
protocol.DocumentChangeEdit(deps.fh, edits)),
628-
})
629-
if err != nil {
630-
return err
631-
}
632-
if !response.Applied {
633-
return fmt.Errorf("edits not applied because of %s", response.FailureReason)
634-
}
635-
return nil
634+
return applyChanges(ctx, c.s.client, []protocol.DocumentChange{protocol.DocumentChangeEdit(deps.fh, edits)})
636635
})
637636
}
638637

@@ -1107,17 +1106,7 @@ func (c *commandHandler) AddImport(ctx context.Context, args command.AddImportAr
11071106
if err != nil {
11081107
return fmt.Errorf("could not add import: %v", err)
11091108
}
1110-
r, err := c.s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
1111-
Edit: *protocol.NewWorkspaceEdit(
1112-
protocol.DocumentChangeEdit(deps.fh, edits)),
1113-
})
1114-
if err != nil {
1115-
return fmt.Errorf("could not apply import edits: %v", err)
1116-
}
1117-
if !r.Applied {
1118-
return fmt.Errorf("failed to apply edits: %v", r.FailureReason)
1119-
}
1120-
return nil
1109+
return applyChanges(ctx, c.s.client, []protocol.DocumentChange{protocol.DocumentChangeEdit(deps.fh, edits)})
11211110
})
11221111
}
11231112

@@ -1126,18 +1115,11 @@ func (c *commandHandler) ExtractToNewFile(ctx context.Context, args protocol.Loc
11261115
progress: "Extract to a new file",
11271116
forURI: args.URI,
11281117
}, func(ctx context.Context, deps commandDeps) error {
1129-
edit, err := golang.ExtractToNewFile(ctx, deps.snapshot, deps.fh, args.Range)
1118+
changes, err := golang.ExtractToNewFile(ctx, deps.snapshot, deps.fh, args.Range)
11301119
if err != nil {
11311120
return err
11321121
}
1133-
resp, err := c.s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{Edit: *edit})
1134-
if err != nil {
1135-
return fmt.Errorf("could not apply edits: %v", err)
1136-
}
1137-
if !resp.Applied {
1138-
return fmt.Errorf("edits not applied: %s", resp.FailureReason)
1139-
}
1140-
return nil
1122+
return applyChanges(ctx, c.s.client, changes)
11411123
})
11421124
}
11431125

@@ -1543,13 +1525,7 @@ func (c *commandHandler) ChangeSignature(ctx context.Context, args command.Chang
15431525
result = wsedit
15441526
return nil
15451527
}
1546-
r, err := c.s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
1547-
Edit: *wsedit,
1548-
})
1549-
if !r.Applied {
1550-
return fmt.Errorf("failed to apply edits: %v", r.FailureReason)
1551-
}
1552-
return nil
1528+
return applyChanges(ctx, c.s.client, docedits)
15531529
})
15541530
return result, err
15551531
}

gopls/internal/settings/codeactionkind.go

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const (
7979
GoDoc protocol.CodeActionKind = "source.doc"
8080
GoFreeSymbols protocol.CodeActionKind = "source.freesymbols"
8181
GoTest protocol.CodeActionKind = "source.test"
82+
AddTest protocol.CodeActionKind = "source.addTest"
8283

8384
// gopls
8485
GoplsDocFeatures protocol.CodeActionKind = "gopls.doc.features"

gopls/internal/settings/default.go

+1
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ func DefaultOptions(overrides ...func(*Options)) *Options {
136136
LinkifyShowMessage: false,
137137
IncludeReplaceInWorkspace: false,
138138
ZeroConfig: true,
139+
AddTestSourceCodeAction: false,
139140
},
140141
}
141142
})

0 commit comments

Comments
 (0)