Skip to content

Commit 3151270

Browse files
golopotgopherbot
authored andcommitted
gopls/internal: add code action "extract declarations to new file"
This code action moves selected code sections to a newly created file within the same package. The created filename is chosen as the first {function, type, const, var} name encountered. In addition, import declarations are added or removed as needed. Fixes golang/go#65707 Change-Id: I3fd45afd3569e4e0cee17798a48bde6916eb57b8 GitHub-Last-Rev: e551a8a GitHub-Pull-Request: #479 Reviewed-on: https://go-review.googlesource.com/c/tools/+/565895 Auto-Submit: Alan Donovan <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Robert Findley <[email protected]> Reviewed-by: Alan Donovan <[email protected]>
1 parent 850c7c3 commit 3151270

File tree

11 files changed

+706
-8
lines changed

11 files changed

+706
-8
lines changed

gopls/doc/commands.md

+22
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,28 @@ Args:
291291
}
292292
```
293293

294+
## `gopls.extract_to_new_file`: **Move selected declarations to a new file**
295+
296+
Used by the code action of the same name.
297+
298+
Args:
299+
300+
```
301+
{
302+
"uri": string,
303+
"range": {
304+
"start": {
305+
"line": uint32,
306+
"character": uint32,
307+
},
308+
"end": {
309+
"line": uint32,
310+
"character": uint32,
311+
},
312+
},
313+
}
314+
```
315+
294316
## `gopls.fetch_vulncheck_result`: **Get known vulncheck result**
295317

296318
Fetch the result of latest vulnerability check (`govulncheck`).

gopls/doc/release/v0.17.0.md

+17
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,20 @@
55
The `fieldalignment` analyzer, previously disabled by default, has
66
been removed: it is redundant with the hover size/offset information
77
displayed by v0.16.0 and its diagnostics were confusing.
8+
9+
10+
# New features
11+
12+
## Extract declarations to new file
13+
Gopls now offers another code action, "Extract declarations to new file",
14+
which moves selected code sections to a newly created file within the
15+
same package. The created filename is chosen as the first {function, type,
16+
const, var} name encountered. In addition, import declarations are added or
17+
removed as needed.
18+
19+
The user can invoke this code action by selecting a function name, the keywords
20+
`func`, `const`, `var`, `type`, or by placing the caret on them without selecting,
21+
or by selecting a whole declaration or multiple declrations.
22+
23+
In order to avoid ambiguity and surprise about what to extract, some kinds
24+
of paritial selection of a declration cannot invoke this code action.

gopls/internal/doc/api.json

+7
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,13 @@
10101010
"ArgDoc": "{\n\t// Any document URI within the relevant module.\n\t\"URI\": string,\n\t// The version to pass to `go mod edit -go`.\n\t\"Version\": string,\n}",
10111011
"ResultDoc": ""
10121012
},
1013+
{
1014+
"Command": "gopls.extract_to_new_file",
1015+
"Title": "Move selected declarations to a new file",
1016+
"Doc": "Used by the code action of the same name.",
1017+
"ArgDoc": "{\n\t\"uri\": string,\n\t\"range\": {\n\t\t\"start\": {\n\t\t\t\"line\": uint32,\n\t\t\t\"character\": uint32,\n\t\t},\n\t\t\"end\": {\n\t\t\t\"line\": uint32,\n\t\t\t\"character\": uint32,\n\t\t},\n\t},\n}",
1018+
"ResultDoc": ""
1019+
},
10131020
{
10141021
"Command": "gopls.fetch_vulncheck_result",
10151022
"Title": "Get known vulncheck result",

gopls/internal/golang/codeaction.go

+10-4
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,6 @@ func fixedByImportFix(fix *imports.ImportFix, diagnostics []protocol.Diagnostic)
240240

241241
// getExtractCodeActions returns any refactor.extract code actions for the selection.
242242
func getExtractCodeActions(pgf *parsego.File, rng protocol.Range, options *settings.Options) ([]protocol.CodeAction, error) {
243-
if rng.Start == rng.End {
244-
return nil, nil
245-
}
246-
247243
start, end, err := pgf.RangePos(rng)
248244
if err != nil {
249245
return nil, err
@@ -286,6 +282,16 @@ func getExtractCodeActions(pgf *parsego.File, rng protocol.Range, options *setti
286282
}
287283
commands = append(commands, cmd)
288284
}
285+
if canExtractToNewFile(pgf, start, end) {
286+
cmd, err := command.NewExtractToNewFileCommand(
287+
"Extract declarations to new file",
288+
protocol.Location{URI: pgf.URI, Range: rng},
289+
)
290+
if err != nil {
291+
return nil, err
292+
}
293+
commands = append(commands, cmd)
294+
}
289295
var actions []protocol.CodeAction
290296
for i := range commands {
291297
actions = append(actions, newCodeAction(commands[i].Title, protocol.RefactorExtract, &commands[i], nil, options))
+302
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// Copyright 2024 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 code action "Extract declarations to new file".
8+
9+
import (
10+
"bytes"
11+
"context"
12+
"errors"
13+
"fmt"
14+
"go/ast"
15+
"go/format"
16+
"go/token"
17+
"go/types"
18+
"os"
19+
"path/filepath"
20+
"strings"
21+
22+
"golang.org/x/tools/gopls/internal/cache"
23+
"golang.org/x/tools/gopls/internal/cache/parsego"
24+
"golang.org/x/tools/gopls/internal/file"
25+
"golang.org/x/tools/gopls/internal/protocol"
26+
"golang.org/x/tools/gopls/internal/util/bug"
27+
"golang.org/x/tools/gopls/internal/util/safetoken"
28+
"golang.org/x/tools/gopls/internal/util/typesutil"
29+
)
30+
31+
// canExtractToNewFile reports whether the code in the given range can be extracted to a new file.
32+
func canExtractToNewFile(pgf *parsego.File, start, end token.Pos) bool {
33+
_, _, _, ok := selectedToplevelDecls(pgf, start, end)
34+
return ok
35+
}
36+
37+
// findImportEdits finds imports specs that needs to be added to the new file
38+
// or deleted from the old file if the range is extracted to a new file.
39+
//
40+
// TODO: handle dot imports.
41+
func findImportEdits(file *ast.File, info *types.Info, start, end token.Pos) (adds, deletes []*ast.ImportSpec, _ error) {
42+
// make a map from a pkgName to its references
43+
pkgNameReferences := make(map[*types.PkgName][]*ast.Ident)
44+
for ident, use := range info.Uses {
45+
if pkgName, ok := use.(*types.PkgName); ok {
46+
pkgNameReferences[pkgName] = append(pkgNameReferences[pkgName], ident)
47+
}
48+
}
49+
50+
// PkgName referenced in the extracted selection must be
51+
// imported in the new file.
52+
// PkgName only referenced in the extracted selection must be
53+
// deleted from the original file.
54+
for _, spec := range file.Imports {
55+
if spec.Name != nil && spec.Name.Name == "." {
56+
// TODO: support dot imports.
57+
return nil, nil, errors.New("\"extract to new file\" does not support files containing dot imports")
58+
}
59+
pkgName, ok := typesutil.ImportedPkgName(info, spec)
60+
if !ok {
61+
continue
62+
}
63+
usedInSelection := false
64+
usedInNonSelection := false
65+
for _, ident := range pkgNameReferences[pkgName] {
66+
if posRangeContains(start, end, ident.Pos(), ident.End()) {
67+
usedInSelection = true
68+
} else {
69+
usedInNonSelection = true
70+
}
71+
}
72+
if usedInSelection {
73+
adds = append(adds, spec)
74+
}
75+
if usedInSelection && !usedInNonSelection {
76+
deletes = append(deletes, spec)
77+
}
78+
}
79+
80+
return adds, deletes, nil
81+
}
82+
83+
// ExtractToNewFile moves selected declarations into a new file.
84+
func ExtractToNewFile(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, rng protocol.Range) (*protocol.WorkspaceEdit, error) {
85+
errorPrefix := "ExtractToNewFile"
86+
87+
pkg, pgf, err := NarrowestPackageForFile(ctx, snapshot, fh.URI())
88+
if err != nil {
89+
return nil, err
90+
}
91+
92+
start, end, err := pgf.RangePos(rng)
93+
if err != nil {
94+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
95+
}
96+
97+
start, end, firstSymbol, ok := selectedToplevelDecls(pgf, start, end)
98+
if !ok {
99+
return nil, bug.Errorf("invalid selection")
100+
}
101+
102+
// select trailing empty lines
103+
offset, err := safetoken.Offset(pgf.Tok, end)
104+
if err != nil {
105+
return nil, err
106+
}
107+
rest := pgf.Src[offset:]
108+
end += token.Pos(len(rest) - len(bytes.TrimLeft(rest, " \t\n")))
109+
110+
replaceRange, err := pgf.PosRange(start, end)
111+
if err != nil {
112+
return nil, bug.Errorf("invalid range: %v", err)
113+
}
114+
115+
adds, deletes, err := findImportEdits(pgf.File, pkg.TypesInfo(), start, end)
116+
if err != nil {
117+
return nil, err
118+
}
119+
120+
var importDeletes []protocol.TextEdit
121+
// For unparenthesised declarations like `import "fmt"` we remove
122+
// the whole declaration because simply removing importSpec leaves
123+
// `import \n`, which does not compile.
124+
// For parenthesised declarations like `import ("fmt"\n "log")`
125+
// we only remove the ImportSpec, because removing the whole declaration
126+
// might remove other ImportsSpecs we don't want to touch.
127+
unparenthesizedImports := unparenthesizedImports(pgf)
128+
for _, importSpec := range deletes {
129+
if decl := unparenthesizedImports[importSpec]; decl != nil {
130+
importDeletes = append(importDeletes, removeNode(pgf, decl))
131+
} else {
132+
importDeletes = append(importDeletes, removeNode(pgf, importSpec))
133+
}
134+
}
135+
136+
var buf bytes.Buffer
137+
fmt.Fprintf(&buf, "package %s\n", pgf.File.Name.Name)
138+
if len(adds) > 0 {
139+
buf.WriteString("import (")
140+
for _, importSpec := range adds {
141+
if importSpec.Name != nil {
142+
fmt.Fprintf(&buf, "%s %s\n", importSpec.Name.Name, importSpec.Path.Value)
143+
} else {
144+
fmt.Fprintf(&buf, "%s\n", importSpec.Path.Value)
145+
}
146+
}
147+
buf.WriteString(")\n")
148+
}
149+
150+
newFile, err := chooseNewFile(ctx, snapshot, pgf.URI.Dir().Path(), firstSymbol)
151+
if err != nil {
152+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
153+
}
154+
155+
fileStart := pgf.Tok.Pos(0) // TODO(adonovan): use go1.20 pgf.File.FileStart
156+
buf.Write(pgf.Src[start-fileStart : end-fileStart])
157+
158+
// TODO: attempt to duplicate the copyright header, if any.
159+
newFileContent, err := format.Source(buf.Bytes())
160+
if err != nil {
161+
return nil, err
162+
}
163+
164+
return protocol.NewWorkspaceEdit(
165+
// edit the original file
166+
protocol.DocumentChangeEdit(fh, append(importDeletes, protocol.TextEdit{Range: replaceRange, NewText: ""})),
167+
// create a new file
168+
protocol.DocumentChangeCreate(newFile.URI()),
169+
// edit the created file
170+
protocol.DocumentChangeEdit(newFile, []protocol.TextEdit{
171+
{Range: protocol.Range{}, NewText: string(newFileContent)},
172+
})), nil
173+
}
174+
175+
// chooseNewFile chooses a new filename in dir, based on the name of the
176+
// first extracted symbol, and if necessary to disambiguate, a numeric suffix.
177+
func chooseNewFile(ctx context.Context, snapshot *cache.Snapshot, dir string, firstSymbol string) (file.Handle, error) {
178+
basename := strings.ToLower(firstSymbol)
179+
newPath := protocol.URIFromPath(filepath.Join(dir, basename+".go"))
180+
for count := 1; count < 5; count++ {
181+
fh, err := snapshot.ReadFile(ctx, newPath)
182+
if err != nil {
183+
return nil, err // canceled
184+
}
185+
if _, err := fh.Content(); errors.Is(err, os.ErrNotExist) {
186+
return fh, nil
187+
}
188+
filename := fmt.Sprintf("%s.%d.go", basename, count)
189+
newPath = protocol.URIFromPath(filepath.Join(dir, filename))
190+
}
191+
return nil, fmt.Errorf("chooseNewFileURI: exceeded retry limit")
192+
}
193+
194+
// selectedToplevelDecls returns the lexical extent of the top-level
195+
// declarations enclosed by [start, end), along with the name of the
196+
// first declaration. The returned boolean reports whether the selection
197+
// should be offered a code action to extract the declarations.
198+
func selectedToplevelDecls(pgf *parsego.File, start, end token.Pos) (token.Pos, token.Pos, string, bool) {
199+
// selection cannot intersect a package declaration
200+
if posRangeIntersects(start, end, pgf.File.Package, pgf.File.Name.End()) {
201+
return 0, 0, "", false
202+
}
203+
firstName := ""
204+
for _, decl := range pgf.File.Decls {
205+
if posRangeIntersects(start, end, decl.Pos(), decl.End()) {
206+
var id *ast.Ident
207+
switch v := decl.(type) {
208+
case *ast.BadDecl:
209+
return 0, 0, "", false
210+
case *ast.FuncDecl:
211+
// if only selecting keyword "func" or function name, extend selection to the
212+
// whole function
213+
if posRangeContains(v.Pos(), v.Name.End(), start, end) {
214+
start, end = v.Pos(), v.End()
215+
}
216+
id = v.Name
217+
case *ast.GenDecl:
218+
// selection cannot intersect an import declaration
219+
if v.Tok == token.IMPORT {
220+
return 0, 0, "", false
221+
}
222+
// if only selecting keyword "type", "const", or "var", extend selection to the
223+
// whole declaration
224+
if v.Tok == token.TYPE && posRangeContains(v.Pos(), v.Pos()+token.Pos(len("type")), start, end) ||
225+
v.Tok == token.CONST && posRangeContains(v.Pos(), v.Pos()+token.Pos(len("const")), start, end) ||
226+
v.Tok == token.VAR && posRangeContains(v.Pos(), v.Pos()+token.Pos(len("var")), start, end) {
227+
start, end = v.Pos(), v.End()
228+
}
229+
if len(v.Specs) > 0 {
230+
switch spec := v.Specs[0].(type) {
231+
case *ast.TypeSpec:
232+
id = spec.Name
233+
case *ast.ValueSpec:
234+
id = spec.Names[0]
235+
}
236+
}
237+
}
238+
// selection cannot partially intersect a node
239+
if !posRangeContains(start, end, decl.Pos(), decl.End()) {
240+
return 0, 0, "", false
241+
}
242+
if id != nil && firstName == "" {
243+
// may be "_"
244+
firstName = id.Name
245+
}
246+
// extends selection to docs comments
247+
var c *ast.CommentGroup
248+
switch decl := decl.(type) {
249+
case *ast.GenDecl:
250+
c = decl.Doc
251+
case *ast.FuncDecl:
252+
c = decl.Doc
253+
}
254+
if c != nil && c.Pos() < start {
255+
start = c.Pos()
256+
}
257+
}
258+
}
259+
for _, comment := range pgf.File.Comments {
260+
if posRangeIntersects(start, end, comment.Pos(), comment.End()) {
261+
if !posRangeContains(start, end, comment.Pos(), comment.End()) {
262+
// selection cannot partially intersect a comment
263+
return 0, 0, "", false
264+
}
265+
}
266+
}
267+
if firstName == "" {
268+
return 0, 0, "", false
269+
}
270+
return start, end, firstName, true
271+
}
272+
273+
// unparenthesizedImports returns a map from each unparenthesized ImportSpec
274+
// to its enclosing declaration (which may need to be deleted too).
275+
func unparenthesizedImports(pgf *parsego.File) map[*ast.ImportSpec]*ast.GenDecl {
276+
decls := make(map[*ast.ImportSpec]*ast.GenDecl)
277+
for _, decl := range pgf.File.Decls {
278+
if decl, ok := decl.(*ast.GenDecl); ok && decl.Tok == token.IMPORT && !decl.Lparen.IsValid() {
279+
decls[decl.Specs[0].(*ast.ImportSpec)] = decl
280+
}
281+
}
282+
return decls
283+
}
284+
285+
// removeNode returns a TextEdit that removes the node.
286+
func removeNode(pgf *parsego.File, node ast.Node) protocol.TextEdit {
287+
rng, err := pgf.NodeRange(node)
288+
if err != nil {
289+
bug.Reportf("removeNode: %v", err)
290+
}
291+
return protocol.TextEdit{Range: rng, NewText: ""}
292+
}
293+
294+
// posRangeIntersects checks if [a, b) and [c, d) intersects, assuming a <= b and c <= d.
295+
func posRangeIntersects(a, b, c, d token.Pos) bool {
296+
return !(b <= c || d <= a)
297+
}
298+
299+
// posRangeContains checks if [a, b) contains [c, d), assuming a <= b and c <= d.
300+
func posRangeContains(a, b, c, d token.Pos) bool {
301+
return a <= c && d <= b
302+
}

0 commit comments

Comments
 (0)