Skip to content

Commit bbdc81d

Browse files
fatanugrahagopherbot
authored andcommitted
gopls: implement code action to split/group lines
Implement a code action to easily refactor function arguments, return values, and composite literal elements into separate lines or a single line. This feature would be particularly helpful when working on large business-related features that often require long variable names. The ability to quickly split or group lines would significantly accelerate refactoring efforts to ensure code adheres to specified character limits. Fixes golang/go#65156 Change-Id: I00aaa1377735f14808424e8e99fa0b7eeab90a7a GitHub-Last-Rev: d3c5b57 GitHub-Pull-Request: #470 Reviewed-on: https://go-review.googlesource.com/c/tools/+/558475 Reviewed-by: Alan Donovan <[email protected]> Auto-Submit: Alan Donovan <[email protected]> Reviewed-by: Robert Findley <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]>
1 parent 4db4579 commit bbdc81d

File tree

7 files changed

+728
-8
lines changed

7 files changed

+728
-8
lines changed

gopls/internal/golang/codeaction.go

+26
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,32 @@ func getRewriteCodeActions(pkg *cache.Package, pgf *parsego.File, fh file.Handle
304304
commands = append(commands, cmd)
305305
}
306306

307+
if msg, ok, _ := CanSplitLines(pgf.File, pkg.FileSet(), start, end); ok {
308+
cmd, err := command.NewApplyFixCommand(msg, command.ApplyFixArgs{
309+
Fix: fixSplitLines,
310+
URI: pgf.URI,
311+
Range: rng,
312+
ResolveEdits: supportsResolveEdits(options),
313+
})
314+
if err != nil {
315+
return nil, err
316+
}
317+
commands = append(commands, cmd)
318+
}
319+
320+
if msg, ok, _ := CanJoinLines(pgf.File, pkg.FileSet(), start, end); ok {
321+
cmd, err := command.NewApplyFixCommand(msg, command.ApplyFixArgs{
322+
Fix: fixJoinLines,
323+
URI: pgf.URI,
324+
Range: rng,
325+
ResolveEdits: supportsResolveEdits(options),
326+
})
327+
if err != nil {
328+
return nil, err
329+
}
330+
commands = append(commands, cmd)
331+
}
332+
307333
// N.B.: an inspector only pays for itself after ~5 passes, which means we're
308334
// currently not getting a good deal on this inspection.
309335
//

gopls/internal/golang/fix.go

+4
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ const (
6464
fixExtractMethod = "extract_method"
6565
fixInlineCall = "inline_call"
6666
fixInvertIfCondition = "invert_if_condition"
67+
fixSplitLines = "split_lines"
68+
fixJoinLines = "join_lines"
6769
)
6870

6971
// ApplyFix applies the specified kind of suggested fix to the given
@@ -115,6 +117,8 @@ func ApplyFix(ctx context.Context, fix string, snapshot *cache.Snapshot, fh file
115117
fixExtractVariable: singleFile(extractVariable),
116118
fixInlineCall: inlineCall,
117119
fixInvertIfCondition: singleFile(invertIfCondition),
120+
fixSplitLines: singleFile(splitLines),
121+
fixJoinLines: singleFile(joinLines),
118122
}
119123
fixer, ok := fixers[fix]
120124
if !ok {

gopls/internal/golang/lines.go

+261
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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 refactorings for splitting lists of elements
8+
// (arguments, literals, etc) across multiple lines, and joining
9+
// them into a single line.
10+
11+
import (
12+
"bytes"
13+
"go/ast"
14+
"go/token"
15+
"go/types"
16+
"sort"
17+
"strings"
18+
19+
"golang.org/x/tools/go/analysis"
20+
"golang.org/x/tools/go/ast/astutil"
21+
"golang.org/x/tools/gopls/internal/util/safetoken"
22+
"golang.org/x/tools/gopls/internal/util/slices"
23+
)
24+
25+
// CanSplitLines checks whether we can split lists of elements inside an enclosing curly bracket/parens into separate
26+
// lines.
27+
func CanSplitLines(file *ast.File, fset *token.FileSet, start, end token.Pos) (string, bool, error) {
28+
itemType, items, comments, _, _, _ := findSplitJoinTarget(fset, file, nil, start, end)
29+
if itemType == "" {
30+
return "", false, nil
31+
}
32+
33+
if !canSplitJoinLines(items, comments) {
34+
return "", false, nil
35+
}
36+
37+
for i := 1; i < len(items); i++ {
38+
prevLine := safetoken.EndPosition(fset, items[i-1].End()).Line
39+
curLine := safetoken.StartPosition(fset, items[i].Pos()).Line
40+
if prevLine == curLine {
41+
return "Split " + itemType + " into separate lines", true, nil
42+
}
43+
}
44+
45+
return "", false, nil
46+
}
47+
48+
// CanJoinLines checks whether we can join lists of elements inside an enclosing curly bracket/parens into a single line.
49+
func CanJoinLines(file *ast.File, fset *token.FileSet, start, end token.Pos) (string, bool, error) {
50+
itemType, items, comments, _, _, _ := findSplitJoinTarget(fset, file, nil, start, end)
51+
if itemType == "" {
52+
return "", false, nil
53+
}
54+
55+
if !canSplitJoinLines(items, comments) {
56+
return "", false, nil
57+
}
58+
59+
for i := 1; i < len(items); i++ {
60+
prevLine := safetoken.EndPosition(fset, items[i-1].End()).Line
61+
curLine := safetoken.StartPosition(fset, items[i].Pos()).Line
62+
if prevLine != curLine {
63+
return "Join " + itemType + " into one line", true, nil
64+
}
65+
}
66+
67+
return "", false, nil
68+
}
69+
70+
// canSplitJoinLines determines whether we should split/join the lines or not.
71+
func canSplitJoinLines(items []ast.Node, comments []*ast.CommentGroup) bool {
72+
if len(items) <= 1 {
73+
return false
74+
}
75+
76+
for _, cg := range comments {
77+
if !strings.HasPrefix(cg.List[0].Text, "/*") {
78+
return false // can't split/join lists containing "//" comments
79+
}
80+
}
81+
82+
return true
83+
}
84+
85+
// splitLines is a singleFile fixer.
86+
func splitLines(fset *token.FileSet, start, end token.Pos, src []byte, file *ast.File, _ *types.Package, _ *types.Info) (*token.FileSet, *analysis.SuggestedFix, error) {
87+
itemType, items, comments, indent, braceOpen, braceClose := findSplitJoinTarget(fset, file, src, start, end)
88+
if itemType == "" {
89+
return nil, nil, nil // no fix available
90+
}
91+
92+
return fset, processLines(fset, items, comments, src, braceOpen, braceClose, ",\n", "\n", ",\n"+indent, indent+"\t"), nil
93+
}
94+
95+
// joinLines is a singleFile fixer.
96+
func joinLines(fset *token.FileSet, start, end token.Pos, src []byte, file *ast.File, _ *types.Package, _ *types.Info) (*token.FileSet, *analysis.SuggestedFix, error) {
97+
itemType, items, comments, _, braceOpen, braceClose := findSplitJoinTarget(fset, file, src, start, end)
98+
if itemType == "" {
99+
return nil, nil, nil // no fix available
100+
}
101+
102+
return fset, processLines(fset, items, comments, src, braceOpen, braceClose, ", ", "", "", ""), nil
103+
}
104+
105+
// processLines is the common operation for both split and join lines because this split/join operation is
106+
// essentially a transformation of the separating whitespace.
107+
func processLines(fset *token.FileSet, items []ast.Node, comments []*ast.CommentGroup, src []byte, braceOpen, braceClose token.Pos, sep, prefix, suffix, indent string) *analysis.SuggestedFix {
108+
nodes := slices.Clone(items)
109+
110+
// box *ast.CommentGroup to ast.Node for easier processing later.
111+
for _, cg := range comments {
112+
nodes = append(nodes, cg)
113+
}
114+
115+
// Sort to interleave comments and nodes.
116+
sort.Slice(nodes, func(i, j int) bool {
117+
return nodes[i].Pos() < nodes[j].Pos()
118+
})
119+
120+
edits := []analysis.TextEdit{
121+
{
122+
Pos: token.Pos(int(braceOpen) + len("{")),
123+
End: nodes[0].Pos(),
124+
NewText: []byte(prefix + indent),
125+
},
126+
{
127+
Pos: nodes[len(nodes)-1].End(),
128+
End: braceClose,
129+
NewText: []byte(suffix),
130+
},
131+
}
132+
133+
for i := 1; i < len(nodes); i++ {
134+
pos, end := nodes[i-1].End(), nodes[i].Pos()
135+
if pos > end {
136+
// this will happen if we have a /*-style comment inside of a Field
137+
// e.g. `a /*comment here */ int`
138+
//
139+
// we will ignore as we only care about finding the field delimiter.
140+
continue
141+
}
142+
143+
// at this point, the `,` token in between 2 nodes here must be the field delimiter.
144+
posOffset := safetoken.EndPosition(fset, pos).Offset
145+
endOffset := safetoken.StartPosition(fset, end).Offset
146+
if bytes.IndexByte(src[posOffset:endOffset], ',') == -1 {
147+
// nodes[i] or nodes[i-1] is a comment hence no delimiter in between
148+
// in such case, do nothing.
149+
continue
150+
}
151+
152+
edits = append(edits, analysis.TextEdit{Pos: pos, End: end, NewText: []byte(sep + indent)})
153+
}
154+
155+
return &analysis.SuggestedFix{TextEdits: edits}
156+
}
157+
158+
// findSplitJoinTarget returns the first curly bracket/parens that encloses the current cursor.
159+
func findSplitJoinTarget(fset *token.FileSet, file *ast.File, src []byte, start, end token.Pos) (itemType string, items []ast.Node, comments []*ast.CommentGroup, indent string, open, close token.Pos) {
160+
isCursorInside := func(nodePos, nodeEnd token.Pos) bool {
161+
return nodePos < start && end < nodeEnd
162+
}
163+
164+
findTarget := func() (targetType string, target ast.Node, open, close token.Pos) {
165+
path, _ := astutil.PathEnclosingInterval(file, start, end)
166+
for _, node := range path {
167+
switch node := node.(type) {
168+
case *ast.FuncDecl:
169+
// target struct method declarations.
170+
// function (...) someMethod(a int, b int, c int) (d int, e, int) {}
171+
params := node.Type.Params
172+
if isCursorInside(params.Opening, params.Closing) {
173+
return "parameters", params, params.Opening, params.Closing
174+
}
175+
176+
results := node.Type.Results
177+
if results != nil && isCursorInside(results.Opening, results.Closing) {
178+
return "return values", results, results.Opening, results.Closing
179+
}
180+
case *ast.FuncType:
181+
// target function signature args and result.
182+
// type someFunc func (a int, b int, c int) (d int, e int)
183+
params := node.Params
184+
if isCursorInside(params.Opening, params.Closing) {
185+
return "parameters", params, params.Opening, params.Closing
186+
}
187+
188+
results := node.Results
189+
if results != nil && isCursorInside(results.Opening, results.Closing) {
190+
return "return values", results, results.Opening, results.Closing
191+
}
192+
case *ast.CallExpr:
193+
// target function calls.
194+
// someFunction(a, b, c)
195+
if isCursorInside(node.Lparen, node.Rparen) {
196+
return "parameters", node, node.Lparen, node.Rparen
197+
}
198+
case *ast.CompositeLit:
199+
// target composite lit instantiation (structs, maps, arrays).
200+
// A{b: 1, c: 2, d: 3}
201+
if isCursorInside(node.Lbrace, node.Rbrace) {
202+
return "elements", node, node.Lbrace, node.Rbrace
203+
}
204+
}
205+
}
206+
207+
return "", nil, 0, 0
208+
}
209+
210+
targetType, targetNode, open, close := findTarget()
211+
if targetType == "" {
212+
return "", nil, nil, "", 0, 0
213+
}
214+
215+
switch node := targetNode.(type) {
216+
case *ast.FieldList:
217+
for _, field := range node.List {
218+
items = append(items, field)
219+
}
220+
case *ast.CallExpr:
221+
for _, arg := range node.Args {
222+
items = append(items, arg)
223+
}
224+
case *ast.CompositeLit:
225+
for _, arg := range node.Elts {
226+
items = append(items, arg)
227+
}
228+
}
229+
230+
// preserve comments separately as it's not part of the targetNode AST.
231+
for _, cg := range file.Comments {
232+
if open <= cg.Pos() && cg.Pos() < close {
233+
comments = append(comments, cg)
234+
}
235+
}
236+
237+
// indent is the leading whitespace before the opening curly bracket/paren.
238+
//
239+
// in case where we don't have access to src yet i.e. src == nil
240+
// it's fine to return incorrect indent because we don't need it yet.
241+
indent = ""
242+
if len(src) > 0 {
243+
var pos token.Pos
244+
switch node := targetNode.(type) {
245+
case *ast.FieldList:
246+
pos = node.Opening
247+
case *ast.CallExpr:
248+
pos = node.Lparen
249+
case *ast.CompositeLit:
250+
pos = node.Lbrace
251+
}
252+
253+
split := bytes.Split(src, []byte("\n"))
254+
targetLineNumber := safetoken.StartPosition(fset, pos).Line
255+
firstLine := string(split[targetLineNumber-1])
256+
trimmed := strings.TrimSpace(string(firstLine))
257+
indent = firstLine[:strings.Index(firstLine, trimmed)]
258+
}
259+
260+
return targetType, items, comments, indent, open, close
261+
}

0 commit comments

Comments
 (0)