Skip to content

Commit 7eebab3

Browse files
adonovangopherbot
authored andcommitted
gopls/internal/golang: support extract variable at top level
This CL causes the refactor.extract.{variable,constant} logic to support extractions outside of statement context, inserting a new package-level declaration. Previously, it returned an error. Also, use "k" as the basis for choosing names for new constants. Also, simplify generateAvailableName et al. Fixes golang/go#70665 Change-Id: I769206b14662e4e70ee04ab6c0040e635ac72820 Reviewed-on: https://go-review.googlesource.com/c/tools/+/633597 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Robert Findley <[email protected]> Auto-Submit: Robert Findley <[email protected]>
1 parent e334696 commit 7eebab3

File tree

9 files changed

+169
-83
lines changed

9 files changed

+169
-83
lines changed

gopls/doc/release/v0.17.0.md

+3
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ When the selection is a constant expression, gopls now offers "Extract
4444
constant" instead of "Extract variable", and generates a `const`
4545
declaration instead of a local variable.
4646

47+
Also, extraction of a constant or variable now works at top-level,
48+
outside of any function.
49+
4750
## Pull diagnostics
4851

4952
When initialized with the option `"pullDiagnostics": true`, gopls will advertise support for the

gopls/internal/golang/extract.go

+85-60
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,16 @@ func extractVariable(fset *token.FileSet, start, end token.Pos, src []byte, file
3535
}
3636
constant := info.Types[expr].Value != nil
3737

38-
// Create new AST node for extracted expression.
38+
// Generate name(s) for new declaration.
39+
baseName := cond(constant, "k", "x")
3940
var lhsNames []string
4041
switch expr := expr.(type) {
4142
case *ast.CallExpr:
4243
tup, ok := info.TypeOf(expr).(*types.Tuple)
4344
if !ok {
4445
// conversion or single-valued call:
4546
// treat it the same as our standard extract variable case.
46-
name, _ := generateAvailableName(expr.Pos(), path, pkg, info, "x", 0)
47+
name, _ := freshName(info, file, expr.Pos(), baseName, 0)
4748
lhsNames = append(lhsNames, name)
4849

4950
} else {
@@ -52,14 +53,14 @@ func extractVariable(fset *token.FileSet, start, end token.Pos, src []byte, file
5253
for range tup.Len() {
5354
// Generate a unique variable for each result.
5455
var name string
55-
name, idx = generateAvailableName(expr.Pos(), path, pkg, info, "x", idx)
56+
name, idx = freshName(info, file, expr.Pos(), baseName, idx)
5657
lhsNames = append(lhsNames, name)
5758
}
5859
}
5960

6061
default:
6162
// TODO: stricter rules for selectorExpr.
62-
name, _ := generateAvailableName(expr.Pos(), path, pkg, info, "x", 0)
63+
name, _ := freshName(info, file, expr.Pos(), baseName, 0)
6364
lhsNames = append(lhsNames, name)
6465
}
6566

@@ -87,20 +88,38 @@ func extractVariable(fset *token.FileSet, start, end token.Pos, src []byte, file
8788
// if x := 1; cond {
8889
// } else if y := x1; cond {
8990
// }
90-
//
91-
// TODO(golang/go#70665): Another bug (or limitation): this
92-
// operation fails at top-level:
93-
// package p
94-
// var x = «1 + 2» // error
95-
insertBeforeStmt := analysisinternal.StmtToInsertVarBefore(path)
96-
if insertBeforeStmt == nil {
97-
return nil, nil, fmt.Errorf("cannot find location to insert extraction")
98-
}
99-
indent, err := calculateIndentation(src, tokFile, insertBeforeStmt)
100-
if err != nil {
101-
return nil, nil, err
91+
var (
92+
insertPos token.Pos
93+
indentation string
94+
stmtOK bool // ok to use ":=" instead of var/const decl?
95+
)
96+
if before := analysisinternal.StmtToInsertVarBefore(path); before != nil {
97+
// Within function: compute appropriate statement indentation.
98+
indent, err := calculateIndentation(src, tokFile, before)
99+
if err != nil {
100+
return nil, nil, err
101+
}
102+
insertPos = before.Pos()
103+
indentation = "\n" + indent
104+
105+
// Currently, we always extract a constant expression
106+
// to a const declaration (and logic in CodeAction
107+
// assumes that we do so); this is conservative because
108+
// it preserves its constant-ness.
109+
//
110+
// In future, constant expressions used only in
111+
// contexts where constant-ness isn't important could
112+
// be profitably extracted to a var declaration or :=
113+
// statement, especially if the latter is the Init of
114+
// an {If,For,Switch}Stmt.
115+
stmtOK = !constant
116+
} else {
117+
// Outside any statement: insert before the current
118+
// declaration, without indentation.
119+
currentDecl := path[len(path)-2]
120+
insertPos = currentDecl.Pos()
121+
indentation = "\n"
102122
}
103-
newLineIndent := "\n" + indent
104123

105124
// Create statement to declare extracted var/const.
106125
//
@@ -121,17 +140,19 @@ func extractVariable(fset *token.FileSet, start, end token.Pos, src []byte, file
121140
//
122141
// Conversely, a short var decl stmt is not valid at top level,
123142
// so when we fix #70665, we'll need to use a var decl.
124-
var declStmt ast.Stmt
125-
if constant {
126-
// const x = expr
127-
declStmt = &ast.DeclStmt{
128-
Decl: &ast.GenDecl{
129-
Tok: token.CONST,
130-
Specs: []ast.Spec{
131-
&ast.ValueSpec{
132-
Names: []*ast.Ident{ast.NewIdent(lhsNames[0])}, // there can be only one
133-
Values: []ast.Expr{expr},
134-
},
143+
var newNode ast.Node
144+
if !stmtOK {
145+
// var/const x1, ..., xn = expr
146+
var names []*ast.Ident
147+
for _, name := range lhsNames {
148+
names = append(names, ast.NewIdent(name))
149+
}
150+
newNode = &ast.GenDecl{
151+
Tok: cond(constant, token.CONST, token.VAR),
152+
Specs: []ast.Spec{
153+
&ast.ValueSpec{
154+
Names: names,
155+
Values: []ast.Expr{expr},
135156
},
136157
},
137158
}
@@ -142,7 +163,7 @@ func extractVariable(fset *token.FileSet, start, end token.Pos, src []byte, file
142163
for _, name := range lhsNames {
143164
lhs = append(lhs, ast.NewIdent(name))
144165
}
145-
declStmt = &ast.AssignStmt{
166+
newNode = &ast.AssignStmt{
146167
Tok: token.DEFINE,
147168
Lhs: lhs,
148169
Rhs: []ast.Expr{expr},
@@ -151,17 +172,17 @@ func extractVariable(fset *token.FileSet, start, end token.Pos, src []byte, file
151172

152173
// Format and indent the declaration.
153174
var buf bytes.Buffer
154-
if err := format.Node(&buf, fset, declStmt); err != nil {
175+
if err := format.Node(&buf, fset, newNode); err != nil {
155176
return nil, nil, err
156177
}
157178
// TODO(adonovan): not sound for `...` string literals containing newlines.
158-
assignment := strings.ReplaceAll(buf.String(), "\n", newLineIndent) + newLineIndent
179+
assignment := strings.ReplaceAll(buf.String(), "\n", indentation) + indentation
159180

160181
return fset, &analysis.SuggestedFix{
161182
TextEdits: []analysis.TextEdit{
162183
{
163-
Pos: insertBeforeStmt.Pos(),
164-
End: insertBeforeStmt.Pos(),
184+
Pos: insertPos,
185+
End: insertPos,
165186
NewText: []byte(assignment),
166187
},
167188
{
@@ -215,40 +236,36 @@ func calculateIndentation(content []byte, tok *token.File, insertBeforeStmt ast.
215236
return string(content[lineOffset:stmtOffset]), nil
216237
}
217238

218-
// generateAvailableName adjusts the new function name until there are no collisions in scope.
219-
// Possible collisions include other function and variable names. Returns the next index to check for prefix.
220-
func generateAvailableName(pos token.Pos, path []ast.Node, pkg *types.Package, info *types.Info, prefix string, idx int) (string, int) {
221-
scopes := CollectScopes(info, path, pos)
222-
scopes = append(scopes, pkg.Scope())
239+
// freshName returns an identifier based on prefix (perhaps with a
240+
// numeric suffix) that is not in scope at the specified position
241+
// within the file. It returns the next numeric suffix to use.
242+
func freshName(info *types.Info, file *ast.File, pos token.Pos, prefix string, idx int) (string, int) {
243+
scope := info.Scopes[file].Innermost(pos)
223244
return generateName(idx, prefix, func(name string) bool {
224-
for _, scope := range scopes {
225-
if scope != nil && scope.Lookup(name) != nil {
226-
return true
227-
}
228-
}
229-
return false
245+
obj, _ := scope.LookupParent(name, pos)
246+
return obj != nil
230247
})
231248
}
232249

233-
// generateNameOutsideOfRange is like generateAvailableName, but ignores names
250+
// freshNameOutsideRange is like [freshName], but ignores names
234251
// declared between start and end for the purposes of detecting conflicts.
235252
//
236253
// This is used for function extraction, where [start, end) will be extracted
237254
// to a new scope.
238-
func generateNameOutsideOfRange(start, end token.Pos, path []ast.Node, pkg *types.Package, info *types.Info, prefix string, idx int) (string, int) {
239-
scopes := CollectScopes(info, path, start)
240-
scopes = append(scopes, pkg.Scope())
255+
func freshNameOutsideRange(info *types.Info, file *ast.File, pos, start, end token.Pos, prefix string, idx int) (string, int) {
256+
scope := info.Scopes[file].Innermost(pos)
241257
return generateName(idx, prefix, func(name string) bool {
242-
for _, scope := range scopes {
243-
if scope != nil {
244-
if obj := scope.Lookup(name); obj != nil {
245-
// Only report a collision if the object declaration was outside the
246-
// extracted range.
247-
if obj.Pos() < start || end <= obj.Pos() {
248-
return true
249-
}
250-
}
258+
// Only report a collision if the object declaration
259+
// was outside the extracted range.
260+
for scope != nil {
261+
obj, declScope := scope.LookupParent(name, pos)
262+
if obj == nil {
263+
return false // undeclared
251264
}
265+
if !(start <= obj.Pos() && obj.Pos() < end) {
266+
return true // declared outside ignored range
267+
}
268+
scope = declScope.Parent()
252269
}
253270
return false
254271
})
@@ -640,7 +657,7 @@ func extractFunctionMethod(fset *token.FileSet, start, end token.Pos, src []byte
640657
// TODO(suzmue): generate a name that does not conflict for "newMethod".
641658
funName = "newMethod"
642659
} else {
643-
funName, _ = generateAvailableName(start, path, pkg, info, "newFunction", 0)
660+
funName, _ = freshName(info, file, start, "newFunction", 0)
644661
}
645662
extractedFunCall := generateFuncCall(hasNonNestedReturn, hasReturnValues, params,
646663
append(returns, getNames(retVars)...), funName, sym, receiverName)
@@ -1290,7 +1307,7 @@ func generateReturnInfo(enclosing *ast.FuncType, pkg *types.Package, path []ast.
12901307
var cond *ast.Ident
12911308
if !hasNonNestedReturns {
12921309
// Generate information for the added bool value.
1293-
name, _ := generateNameOutsideOfRange(start, end, path, pkg, info, "shouldReturn", 0)
1310+
name, _ := freshNameOutsideRange(info, file, path[0].Pos(), start, end, "shouldReturn", 0)
12941311
cond = &ast.Ident{Name: name}
12951312
retVars = append(retVars, &returnVariable{
12961313
name: cond,
@@ -1325,7 +1342,7 @@ func generateReturnInfo(enclosing *ast.FuncType, pkg *types.Package, path []ast.
13251342
} else if n, ok := varNameForType(typ); ok {
13261343
bestName = n
13271344
}
1328-
retName, idx := generateNameOutsideOfRange(start, end, path, pkg, info, bestName, nameIdx[bestName])
1345+
retName, idx := freshNameOutsideRange(info, file, path[0].Pos(), start, end, bestName, nameIdx[bestName])
13291346
nameIdx[bestName] = idx
13301347
z := typesinternal.ZeroExpr(file, pkg, typ)
13311348
if z == nil {
@@ -1540,3 +1557,11 @@ func getDecls(retVars []*returnVariable) []*ast.Field {
15401557
}
15411558
return decls
15421559
}
1560+
1561+
func cond[T any](cond bool, t, f T) T {
1562+
if cond {
1563+
return t
1564+
} else {
1565+
return f
1566+
}
1567+
}

gopls/internal/golang/util.go

+3
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ func findFileInDeps(s metadata.Source, mp *metadata.Package, uri protocol.Docume
132132

133133
// CollectScopes returns all scopes in an ast path, ordered as innermost scope
134134
// first.
135+
//
136+
// TODO(adonovan): move this to golang/completion and simplify to use
137+
// Scopes.Innermost and LookupParent instead.
135138
func CollectScopes(info *types.Info, path []ast.Node, pos token.Pos) []*types.Scope {
136139
// scopes[i], where i<len(path), is the possibly nil Scope of path[i].
137140
var scopes []*types.Scope

gopls/internal/test/marker/testdata/codeaction/extract_variable-if.txt

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ func variable(y int) {
2929

3030
-- @constant/a.go --
3131
@@ -4 +4 @@
32-
+ const x = 1 + 2
32+
+ const k = 1 + 2
3333
@@ -5 +6 @@
3434
- } else if 1 + 2 > 0 { //@ codeaction("1 + 2", "refactor.extract.constant", edit=constant)
35-
+ } else if x > 0 { //@ codeaction("1 + 2", "refactor.extract.constant", edit=constant)
35+
+ } else if k > 0 { //@ codeaction("1 + 2", "refactor.extract.constant", edit=constant)
3636
-- @variable/a.go --
3737
@@ -10 +10 @@
3838
+ x := y + y

gopls/internal/test/marker/testdata/codeaction/extract_variable-inexact.txt

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ func _(ptr *int) {
1717
-- @spaces/a.go --
1818
@@ -4 +4,2 @@
1919
- var _ = 1 + 2 + 3 //@codeaction("1 + 2 ", "refactor.extract.constant", edit=spaces)
20-
+ const x = 1 + 2
21-
+ var _ = x+ 3 //@codeaction("1 + 2 ", "refactor.extract.constant", edit=spaces)
20+
+ const k = 1 + 2
21+
+ var _ = k+ 3 //@codeaction("1 + 2 ", "refactor.extract.constant", edit=spaces)
2222
-- @funclit/a.go --
2323
@@ -5 +5,2 @@
2424
- var _ = func() {} //@codeaction("func() {}", "refactor.extract.variable", edit=funclit)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
This test checks the behavior of the 'extract variable/constant' code action
2+
at top level (outside any function). See issue #70665.
3+
4+
-- a.go --
5+
package a
6+
7+
const length = len("hello") + 2 //@codeaction(`len("hello")`, "refactor.extract.constant", edit=lenhello)
8+
9+
var slice = append([]int{}, 1, 2, 3) //@codeaction("[]int{}", "refactor.extract.variable", edit=sliceliteral)
10+
11+
type SHA256 [32]byte //@codeaction("32", "refactor.extract.constant", edit=arraylen)
12+
13+
func f([2]int) {} //@codeaction("2", "refactor.extract.constant", edit=paramtypearraylen)
14+
15+
-- @lenhello/a.go --
16+
@@ -3 +3,2 @@
17+
-const length = len("hello") + 2 //@codeaction(`len("hello")`, "refactor.extract.constant", edit=lenhello)
18+
+const k = len("hello")
19+
+const length = k + 2 //@codeaction(`len("hello")`, "refactor.extract.constant", edit=lenhello)
20+
-- @sliceliteral/a.go --
21+
@@ -5 +5,2 @@
22+
-var slice = append([]int{}, 1, 2, 3) //@codeaction("[]int{}", "refactor.extract.variable", edit=sliceliteral)
23+
+var x = []int{}
24+
+var slice = append(x, 1, 2, 3) //@codeaction("[]int{}", "refactor.extract.variable", edit=sliceliteral)
25+
-- @arraylen/a.go --
26+
@@ -7 +7,2 @@
27+
-type SHA256 [32]byte //@codeaction("32", "refactor.extract.constant", edit=arraylen)
28+
+const k = 32
29+
+type SHA256 [k]byte //@codeaction("32", "refactor.extract.constant", edit=arraylen)
30+
-- @paramtypearraylen/a.go --
31+
@@ -9 +9,2 @@
32+
-func f([2]int) {} //@codeaction("2", "refactor.extract.constant", edit=paramtypearraylen)
33+
+const k = 2
34+
+func f([k]int) {} //@codeaction("2", "refactor.extract.constant", edit=paramtypearraylen)
35+
-- b/b.go --
36+
package b
37+
38+
// Check that package- and file-level name collisions are avoided.
39+
40+
import x3 "errors"
41+
42+
var x, x1, x2 any // these names are taken already
43+
var _ = x3.New("")
44+
var a, b int
45+
var c = a + b //@codeaction("a + b", "refactor.extract.variable", edit=fresh)
46+
47+
-- @fresh/b/b.go --
48+
@@ -10 +10,2 @@
49+
-var c = a + b //@codeaction("a + b", "refactor.extract.variable", edit=fresh)
50+
+var x4 = a + b
51+
+var c = x4 //@codeaction("a + b", "refactor.extract.variable", edit=fresh)

gopls/internal/test/marker/testdata/codeaction/extract_variable.txt

+8-8
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ func _() {
1515
-- @basic_lit1/basic_lit.go --
1616
@@ -4 +4,2 @@
1717
- var _ = 1 + 2 //@codeaction("1", "refactor.extract.constant", edit=basic_lit1)
18-
+ const x = 1
19-
+ var _ = x + 2 //@codeaction("1", "refactor.extract.constant", edit=basic_lit1)
18+
+ const k = 1
19+
+ var _ = k + 2 //@codeaction("1", "refactor.extract.constant", edit=basic_lit1)
2020
-- @basic_lit2/basic_lit.go --
2121
@@ -5 +5,2 @@
2222
- var _ = 3 + 4 //@codeaction("3 + 4", "refactor.extract.constant", edit=basic_lit2)
23-
+ const x = 3 + 4
24-
+ var _ = x //@codeaction("3 + 4", "refactor.extract.constant", edit=basic_lit2)
23+
+ const k = 3 + 4
24+
+ var _ = k //@codeaction("3 + 4", "refactor.extract.constant", edit=basic_lit2)
2525
-- func_call.go --
2626
package extract
2727

@@ -54,7 +54,7 @@ func _() {
5454
y := ast.CompositeLit{} //@codeaction("ast.CompositeLit{}", "refactor.extract.variable", edit=scope1)
5555
}
5656
if true {
57-
x1 := !false //@codeaction("!false", "refactor.extract.constant", edit=scope2)
57+
x := !false //@codeaction("!false", "refactor.extract.constant", edit=scope2)
5858
}
5959
}
6060

@@ -65,6 +65,6 @@ func _() {
6565
+ y := x //@codeaction("ast.CompositeLit{}", "refactor.extract.variable", edit=scope1)
6666
-- @scope2/scope.go --
6767
@@ -11 +11,2 @@
68-
- x1 := !false //@codeaction("!false", "refactor.extract.constant", edit=scope2)
69-
+ const x = !false
70-
+ x1 := x //@codeaction("!false", "refactor.extract.constant", edit=scope2)
68+
- x := !false //@codeaction("!false", "refactor.extract.constant", edit=scope2)
69+
+ const k = !false
70+
+ x := k //@codeaction("!false", "refactor.extract.constant", edit=scope2)

0 commit comments

Comments
 (0)