Skip to content

Commit 46ec4ff

Browse files
committed
gopls/internal: add code action "move to a 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
1 parent c111c4d commit 46ec4ff

File tree

10 files changed

+843
-6
lines changed

10 files changed

+843
-6
lines changed

gopls/internal/cache/snapshot.go

+16
Original file line numberDiff line numberDiff line change
@@ -1658,6 +1658,22 @@ func inVendor(uri protocol.DocumentURI) bool {
16581658
return found && strings.Contains(after, "/")
16591659
}
16601660

1661+
// Sandbox clones the receiver, applying given file modifications as overlays.
1662+
func (s *Snapshot) Sandbox(ctx, bgCtx context.Context, modifications []file.Modification) *Snapshot {
1663+
updatedFiles := make(map[protocol.DocumentURI]file.Handle)
1664+
for _, m := range modifications {
1665+
updatedFiles[m.URI] = &overlay{
1666+
uri: m.URI,
1667+
content: m.Text,
1668+
}
1669+
}
1670+
cloned, _ := s.clone(ctx, bgCtx, StateChange{
1671+
Modifications: modifications,
1672+
Files: updatedFiles,
1673+
}, func() {})
1674+
return cloned
1675+
}
1676+
16611677
// clone copies state from the receiver into a new Snapshot, applying the given
16621678
// state changes.
16631679
//

gopls/internal/golang/codeaction.go

+5
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ func CodeActions(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle,
8686
return nil, err
8787
}
8888
actions = append(actions, extractions...)
89+
moves, err := getMoveToNewFileCodeAction(pgf, rng, snapshot.Options())
90+
if err != nil {
91+
return nil, err
92+
}
93+
actions = append(actions, moves)
8994
}
9095
}
9196

gopls/internal/golang/format.go

+32
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,38 @@ func Format(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) ([]pr
9292
return computeTextEdits(ctx, pgf, formatted)
9393
}
9494

95+
func formatImportsBytes(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) ([]byte, error) {
96+
_, done := event.Start(ctx, "golang.formatImportsBytes")
97+
defer done()
98+
99+
errorPrefix := "formatImportsBytes"
100+
101+
text, err := fh.Content()
102+
if err != nil {
103+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
104+
}
105+
106+
var out []byte
107+
if err := snapshot.RunProcessEnvFunc(ctx, func(ctx context.Context, opts *imports.Options) error {
108+
fixes, err := imports.FixImports(ctx, fh.URI().Path(), text, opts)
109+
if err != nil {
110+
return fmt.Errorf("%s: %w", errorPrefix, err)
111+
}
112+
out, err = imports.ApplyFixes(fixes, fh.URI().Path(), text, opts, parsego.Full)
113+
if err != nil {
114+
return fmt.Errorf("%s: %w", errorPrefix, err)
115+
}
116+
return nil
117+
}); err != nil {
118+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
119+
}
120+
out, err = format.Source(out)
121+
if err != nil {
122+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
123+
}
124+
return out, nil
125+
}
126+
95127
func formatSource(ctx context.Context, fh file.Handle) ([]byte, error) {
96128
_, done := event.Start(ctx, "golang.formatSource")
97129
defer done()
+325
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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 "move to a new file".
8+
9+
import (
10+
"context"
11+
"errors"
12+
"fmt"
13+
"go/ast"
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/file"
22+
"golang.org/x/tools/gopls/internal/protocol"
23+
"golang.org/x/tools/gopls/internal/protocol/command"
24+
"golang.org/x/tools/gopls/internal/settings"
25+
)
26+
27+
func getMoveToNewFileCodeAction(pgf *parsego.File, rng protocol.Range, _ *settings.Options) (protocol.CodeAction, error) {
28+
ok := canMoveToANewFile(pgf, rng)
29+
if !ok {
30+
return protocol.CodeAction{}, nil
31+
}
32+
cmd, err := command.NewMoveToANewFileCommand("m", command.MoveToANewFileArgs{URI: pgf.URI, Range: rng})
33+
if err != nil {
34+
return protocol.CodeAction{}, err
35+
}
36+
return protocol.CodeAction{
37+
Title: "Move to a new file",
38+
Kind: protocol.RefactorExtract,
39+
Command: &cmd,
40+
}, nil
41+
}
42+
43+
// canMoveToANewFile reports whether the code in the given range can be move to a new file.
44+
func canMoveToANewFile(pgf *parsego.File, rng protocol.Range) bool {
45+
_, err := moveToANewFileInternal(context.Background(), nil, nil, pgf, rng, true)
46+
if err != nil {
47+
return false
48+
} else {
49+
return true
50+
}
51+
}
52+
53+
// MoveToANewFile moves selected declarations into a new file.
54+
func MoveToANewFile(
55+
ctx context.Context,
56+
snapshot *cache.Snapshot,
57+
fh file.Handle,
58+
rng protocol.Range,
59+
) (*protocol.WorkspaceEdit, error) {
60+
pgf, err := snapshot.ParseGo(ctx, fh, parsego.Full)
61+
if err != nil {
62+
return nil, fmt.Errorf("MoveToANewFile: %w", err)
63+
}
64+
return moveToANewFileInternal(ctx, snapshot, fh, pgf, rng, false)
65+
}
66+
67+
// moveToANewFileInternal moves selected declarations into a new file.
68+
func moveToANewFileInternal(
69+
ctx context.Context,
70+
snapshot *cache.Snapshot,
71+
fh file.Handle,
72+
pgf *parsego.File,
73+
rng protocol.Range,
74+
dry bool,
75+
) (*protocol.WorkspaceEdit, error) {
76+
errorPrefix := "moveToANewFileInternal"
77+
78+
start, end, err := pgf.RangePos(rng)
79+
if err != nil {
80+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
81+
}
82+
83+
start, end, filename, err := findRangeAndFilename(pgf, start, end)
84+
if err != nil {
85+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
86+
}
87+
88+
if dry {
89+
return nil, nil
90+
}
91+
92+
start, end = adjustRangeForEmptyLines(pgf, start, end)
93+
94+
createFileURI, err := resolveCreateFileURI(pgf.URI.Dir().Path(), filename)
95+
if err != nil {
96+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
97+
}
98+
99+
replaceRange, err := pgf.PosRange(start, end)
100+
if err != nil {
101+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
102+
}
103+
104+
start -= pgf.File.FileStart
105+
end -= pgf.File.FileStart
106+
107+
modifiedText := make([]byte, 0)
108+
modifiedText = append(modifiedText, pgf.Src[:start]...)
109+
modifiedText = append(modifiedText, pgf.Src[end:]...)
110+
111+
packageName := pgf.File.Name.Name
112+
createFileTextWithoutImports := []byte("package " + packageName + "\n" + string(pgf.Src[start:end]))
113+
114+
modifications := []file.Modification{
115+
{
116+
URI: fh.URI(),
117+
Action: file.Change,
118+
Text: modifiedText,
119+
},
120+
{
121+
URI: createFileURI,
122+
Action: file.Change,
123+
Text: createFileTextWithoutImports,
124+
},
125+
}
126+
127+
// apply edits into a cloned snapshot and calculate import edits
128+
snapshot = snapshot.Sandbox(ctx, ctx, modifications)
129+
newFh, err := snapshot.ReadFile(ctx, fh.URI())
130+
if err != nil {
131+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
132+
}
133+
134+
newPgf, err := snapshot.ParseGo(ctx, newFh, parsego.Full)
135+
if err != nil {
136+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
137+
}
138+
139+
importEdits, _, err := allImportsFixes(ctx, snapshot, newPgf)
140+
if err != nil {
141+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
142+
}
143+
144+
createFh, err := snapshot.ReadFile(ctx, createFileURI)
145+
if err != nil {
146+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
147+
}
148+
149+
createFileText, err := formatImportsBytes(ctx, snapshot, createFh)
150+
if err != nil {
151+
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
152+
}
153+
154+
return &protocol.WorkspaceEdit{
155+
DocumentChanges: []protocol.DocumentChanges{
156+
{
157+
// original file edits
158+
TextDocumentEdit: &protocol.TextDocumentEdit{
159+
TextDocument: protocol.OptionalVersionedTextDocumentIdentifier{
160+
TextDocumentIdentifier: protocol.TextDocumentIdentifier{
161+
URI: fh.URI(),
162+
},
163+
Version: fh.Version(),
164+
},
165+
Edits: protocol.AsAnnotatedTextEdits(
166+
append(
167+
importEdits,
168+
protocol.TextEdit{
169+
Range: replaceRange,
170+
NewText: "",
171+
}),
172+
),
173+
},
174+
},
175+
{
176+
CreateFile: &protocol.CreateFile{
177+
Kind: "create",
178+
URI: createFileURI,
179+
},
180+
},
181+
{
182+
// created file edits
183+
TextDocumentEdit: &protocol.TextDocumentEdit{
184+
TextDocument: protocol.OptionalVersionedTextDocumentIdentifier{
185+
TextDocumentIdentifier: protocol.TextDocumentIdentifier{
186+
URI: createFileURI,
187+
},
188+
Version: 0,
189+
},
190+
Edits: protocol.AsAnnotatedTextEdits([]protocol.TextEdit{
191+
{
192+
Range: protocol.Range{},
193+
NewText: string(createFileText),
194+
},
195+
})},
196+
},
197+
},
198+
}, nil
199+
}
200+
201+
// resolveCreateFileURI checks that basename.go does not exists in dir, otherwise
202+
// select basename.{1,2,3,4,5}.go as filename.
203+
func resolveCreateFileURI(dir string, basename string) (protocol.DocumentURI, error) {
204+
basename = strings.ToLower(basename)
205+
newPath := filepath.Join(dir, basename+".go")
206+
for count := 1; ; count++ {
207+
if _, err := os.Stat(newPath); errors.Is(err, os.ErrNotExist) {
208+
break
209+
}
210+
if count >= 5 {
211+
return "", fmt.Errorf("resolveNewFileURI: exceeded retry limit")
212+
}
213+
filename := fmt.Sprintf("%s.%d.go", basename, count)
214+
newPath = filepath.Join(dir, filename)
215+
}
216+
return protocol.URIFromPath(newPath), nil
217+
}
218+
219+
// findRangeAndFilename checks the selection is valid and extends range as needed and returns adjusted
220+
// range and selected filename.
221+
func findRangeAndFilename(pgf *parsego.File, start, end token.Pos) (token.Pos, token.Pos, string, error) {
222+
if intersect(start, end, pgf.File.Package, pgf.File.Name.End()) {
223+
return 0, 0, "", errors.New("selection cannot intersect a package declaration")
224+
}
225+
firstName := ""
226+
for _, node := range pgf.File.Decls {
227+
if intersect(start, end, node.Pos(), node.End()) {
228+
if v, ok := node.(*ast.GenDecl); ok && v.Tok == token.IMPORT {
229+
return 0, 0, "", errors.New("selection cannot intersect an import declaration")
230+
}
231+
if _, ok := node.(*ast.BadDecl); ok {
232+
return 0, 0, "", errors.New("selection cannot intersect a bad declaration")
233+
}
234+
// should work when only selecting keyword "func" or function name
235+
if v, ok := node.(*ast.FuncDecl); ok && contain(v.Pos(), v.Name.End(), start, end) {
236+
start, end = v.Pos(), v.End()
237+
}
238+
// should work when only selecting keyword "type", "var", "const"
239+
if v, ok := node.(*ast.GenDecl); ok && (v.Tok == token.TYPE && contain(v.Pos(), v.Pos()+4, start, end) ||
240+
v.Tok == token.CONST && contain(v.Pos(), v.Pos()+5, start, end) ||
241+
v.Tok == token.VAR && contain(v.Pos(), v.Pos()+3, start, end)) {
242+
start, end = v.Pos(), v.End()
243+
}
244+
if !contain(start, end, node.Pos(), node.End()) {
245+
return 0, 0, "", errors.New("selection cannot partially intersect a node")
246+
} else {
247+
if firstName == "" {
248+
firstName = getNodeName(node)
249+
}
250+
// extends selection to docs comments
251+
if c := getCommentGroup(node); c != nil {
252+
if c.Pos() < start {
253+
start = c.Pos()
254+
}
255+
}
256+
}
257+
}
258+
}
259+
for _, node := range pgf.File.Comments {
260+
if intersect(start, end, node.Pos(), node.End()) {
261+
if !contain(start, end, node.Pos(), node.End()) {
262+
return 0, 0, "", errors.New("selection cannot partially intersect a comment")
263+
}
264+
}
265+
}
266+
if firstName == "" {
267+
return 0, 0, "", errors.New("nothing selected")
268+
}
269+
return start, end, firstName, nil
270+
}
271+
272+
func adjustRangeForEmptyLines(pgf *parsego.File, start, end token.Pos) (token.Pos, token.Pos) {
273+
i := int(end)
274+
for ; i-int(pgf.File.FileStart) < len(pgf.Src); i++ {
275+
c := pgf.Src[i-int(pgf.File.FileStart)]
276+
if c == ' ' || c == '\t' || c == '\n' {
277+
continue
278+
} else {
279+
break
280+
}
281+
}
282+
return start, token.Pos(i)
283+
}
284+
285+
func getCommentGroup(node ast.Node) *ast.CommentGroup {
286+
switch n := node.(type) {
287+
case *ast.GenDecl:
288+
return n.Doc
289+
case *ast.FuncDecl:
290+
return n.Doc
291+
}
292+
return nil
293+
}
294+
295+
// getNodeName returns the first func name or variable name
296+
func getNodeName(node ast.Node) string {
297+
switch n := node.(type) {
298+
case *ast.FuncDecl:
299+
return n.Name.Name
300+
case *ast.GenDecl:
301+
if len(n.Specs) == 0 {
302+
return ""
303+
}
304+
switch m := n.Specs[0].(type) {
305+
case *ast.TypeSpec:
306+
return m.Name.Name
307+
case *ast.ValueSpec:
308+
if len(m.Names) == 0 {
309+
return ""
310+
}
311+
return m.Names[0].Name
312+
}
313+
}
314+
return ""
315+
}
316+
317+
// intersect checks if [a, b) and [c, d) intersect, assuming a <= b and c <= d
318+
func intersect(a, b, c, d token.Pos) bool {
319+
return !(b <= c || d <= a)
320+
}
321+
322+
// contain checks if [a, b) contains [c, d), assuming a <= b and c <= d
323+
func contain(a, b, c, d token.Pos) bool {
324+
return a <= c && d <= b
325+
}

0 commit comments

Comments
 (0)