Skip to content

Commit ec61ad3

Browse files
wallesfindleyr
authored andcommitted
gopls/internal/lsp/source: add invert-if-condition refactoring
![invert-if-condition](https://user-images.githubusercontent.com/158201/229582714-ce187c0a-db0e-4f7f-8889-e7ceb9b7e07f.gif) This PR adds the "Invert `if` Condition" refactoring, as illustrated in the attached Gif ^. It's ideal for code like this: ```go func something(i int) int { if i > 14 { // Imagine lots of code here, deeply indented, computing this value... result := 99 return result } else { return 0 } } ``` Which this refactoring will simplify into this, which I find easier to read: ```go func something(i int) int { if i <= 14 { return 0 } // Imagine lots of code here, deeply indented, computing this value... result := 99 return result } ``` **Describe alternatives you've considered** I keep doing this by hand... **Additional context** When working with Java in IntelliJ I used this refactoring a lot and I miss it in Go / VSCode, so I'm certain I will use this in VSCode as well. Fixes #2557 Change-Id: I06a7fb9fd7ecc2383b751e3a20b25a6a912adbfa GitHub-Last-Rev: 18e1e3f GitHub-Pull-Request: #433 Reviewed-on: https://go-review.googlesource.com/c/tools/+/481695 Reviewed-by: Alan Donovan <[email protected]> Run-TryBot: Robert Findley <[email protected]> Reviewed-by: Robert Findley <[email protected]> TryBot-Result: Gopher Robot <[email protected]>
1 parent 5283a01 commit ec61ad3

27 files changed

+680
-14
lines changed

gopls/internal/lsp/code_action.go

+48
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,14 @@ func (s *Server) codeAction(ctx context.Context, params *protocol.CodeActionPara
255255
codeActions = append(codeActions, fixes...)
256256
}
257257

258+
if wanted[protocol.RefactorRewrite] {
259+
fixes, err := refactoringFixes(ctx, snapshot, uri, params.Range)
260+
if err != nil {
261+
return nil, err
262+
}
263+
codeActions = append(codeActions, fixes...)
264+
}
265+
258266
default:
259267
// Unsupported file kind for a code action.
260268
return nil, nil
@@ -395,6 +403,46 @@ func extractionFixes(ctx context.Context, snapshot source.Snapshot, uri span.URI
395403
return actions, nil
396404
}
397405

406+
func refactoringFixes(ctx context.Context, snapshot source.Snapshot, uri span.URI, rng protocol.Range) ([]protocol.CodeAction, error) {
407+
fh, err := snapshot.ReadFile(ctx, uri)
408+
if err != nil {
409+
return nil, err
410+
}
411+
412+
pgf, err := snapshot.ParseGo(ctx, fh, source.ParseFull)
413+
if err != nil {
414+
return nil, err
415+
}
416+
417+
start, end, err := pgf.RangePos(rng)
418+
if err != nil {
419+
return nil, err
420+
}
421+
422+
var commands []protocol.Command
423+
if _, ok, _ := source.CanInvertIfCondition(pgf.File, start, end); ok {
424+
cmd, err := command.NewApplyFixCommand("Invert if condition", command.ApplyFixArgs{
425+
URI: protocol.URIFromSpanURI(uri),
426+
Fix: source.InvertIfCondition,
427+
Range: rng,
428+
})
429+
if err != nil {
430+
return nil, err
431+
}
432+
commands = append(commands, cmd)
433+
}
434+
435+
var actions []protocol.CodeAction
436+
for i := range commands {
437+
actions = append(actions, protocol.CodeAction{
438+
Title: commands[i].Title,
439+
Kind: protocol.RefactorRewrite,
440+
Command: &commands[i],
441+
})
442+
}
443+
return actions, nil
444+
}
445+
398446
func documentChanges(fh source.FileHandle, edits []protocol.TextEdit) []protocol.DocumentChanges {
399447
return []protocol.DocumentChanges{
400448
{

gopls/internal/lsp/source/fix.go

+14-12
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,24 @@ type (
3434
)
3535

3636
const (
37-
FillStruct = "fill_struct"
38-
StubMethods = "stub_methods"
39-
UndeclaredName = "undeclared_name"
40-
ExtractVariable = "extract_variable"
41-
ExtractFunction = "extract_function"
42-
ExtractMethod = "extract_method"
37+
FillStruct = "fill_struct"
38+
StubMethods = "stub_methods"
39+
UndeclaredName = "undeclared_name"
40+
ExtractVariable = "extract_variable"
41+
ExtractFunction = "extract_function"
42+
ExtractMethod = "extract_method"
43+
InvertIfCondition = "invert_if_condition"
4344
)
4445

4546
// suggestedFixes maps a suggested fix command id to its handler.
4647
var suggestedFixes = map[string]SuggestedFixFunc{
47-
FillStruct: singleFile(fillstruct.SuggestedFix),
48-
UndeclaredName: singleFile(undeclaredname.SuggestedFix),
49-
ExtractVariable: singleFile(extractVariable),
50-
ExtractFunction: singleFile(extractFunction),
51-
ExtractMethod: singleFile(extractMethod),
52-
StubMethods: stubSuggestedFixFunc,
48+
FillStruct: singleFile(fillstruct.SuggestedFix),
49+
UndeclaredName: singleFile(undeclaredname.SuggestedFix),
50+
ExtractVariable: singleFile(extractVariable),
51+
ExtractFunction: singleFile(extractFunction),
52+
ExtractMethod: singleFile(extractMethod),
53+
InvertIfCondition: singleFile(invertIfCondition),
54+
StubMethods: stubSuggestedFixFunc,
5355
}
5456

5557
// singleFile calls analyzers that expect inputs for a single file
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// Copyright 2023 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 source
6+
7+
import (
8+
"fmt"
9+
"go/ast"
10+
"go/token"
11+
"go/types"
12+
"strings"
13+
14+
"golang.org/x/tools/go/analysis"
15+
"golang.org/x/tools/go/ast/astutil"
16+
"golang.org/x/tools/gopls/internal/lsp/safetoken"
17+
)
18+
19+
// invertIfCondition is a singleFileFixFunc that inverts an if/else statement
20+
func invertIfCondition(fset *token.FileSet, start, end token.Pos, src []byte, file *ast.File, _ *types.Package, _ *types.Info) (*analysis.SuggestedFix, error) {
21+
ifStatement, _, err := CanInvertIfCondition(file, start, end)
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
var replaceElse analysis.TextEdit
27+
28+
endsWithReturn, err := endsWithReturn(ifStatement.Else)
29+
if err != nil {
30+
return nil, err
31+
}
32+
33+
if endsWithReturn {
34+
// Replace the whole else part with an empty line and an unindented
35+
// version of the original if body
36+
sourcePos := safetoken.StartPosition(fset, ifStatement.Pos())
37+
38+
indent := sourcePos.Column - 1
39+
if indent < 0 {
40+
indent = 0
41+
}
42+
43+
standaloneBodyText := ifBodyToStandaloneCode(fset, ifStatement.Body, src)
44+
replaceElse = analysis.TextEdit{
45+
Pos: ifStatement.Body.Rbrace + 1, // 1 == len("}")
46+
End: ifStatement.End(),
47+
NewText: []byte("\n\n" + strings.Repeat("\t", indent) + standaloneBodyText),
48+
}
49+
} else {
50+
// Replace the else body text with the if body text
51+
bodyStart := safetoken.StartPosition(fset, ifStatement.Body.Lbrace)
52+
bodyEnd := safetoken.EndPosition(fset, ifStatement.Body.Rbrace+1) // 1 == len("}")
53+
bodyText := src[bodyStart.Offset:bodyEnd.Offset]
54+
replaceElse = analysis.TextEdit{
55+
Pos: ifStatement.Else.Pos(),
56+
End: ifStatement.Else.End(),
57+
NewText: bodyText,
58+
}
59+
}
60+
61+
// Replace the if text with the else text
62+
elsePosInSource := safetoken.StartPosition(fset, ifStatement.Else.Pos())
63+
elseEndInSource := safetoken.EndPosition(fset, ifStatement.Else.End())
64+
elseText := src[elsePosInSource.Offset:elseEndInSource.Offset]
65+
replaceBodyWithElse := analysis.TextEdit{
66+
Pos: ifStatement.Body.Pos(),
67+
End: ifStatement.Body.End(),
68+
NewText: elseText,
69+
}
70+
71+
// Replace the if condition with its inverse
72+
inverseCondition, err := invertCondition(fset, ifStatement.Cond, src)
73+
if err != nil {
74+
return nil, err
75+
}
76+
replaceConditionWithInverse := analysis.TextEdit{
77+
Pos: ifStatement.Cond.Pos(),
78+
End: ifStatement.Cond.End(),
79+
NewText: inverseCondition,
80+
}
81+
82+
// Return a SuggestedFix with just that TextEdit in there
83+
return &analysis.SuggestedFix{
84+
TextEdits: []analysis.TextEdit{
85+
replaceConditionWithInverse,
86+
replaceBodyWithElse,
87+
replaceElse,
88+
},
89+
}, nil
90+
}
91+
92+
func endsWithReturn(elseBranch ast.Stmt) (bool, error) {
93+
elseBlock, isBlockStatement := elseBranch.(*ast.BlockStmt)
94+
if !isBlockStatement {
95+
return false, fmt.Errorf("Unable to figure out whether this ends with return: %T", elseBranch)
96+
}
97+
98+
if len(elseBlock.List) == 0 {
99+
// Empty blocks don't end in returns
100+
return false, nil
101+
}
102+
103+
lastStatement := elseBlock.List[len(elseBlock.List)-1]
104+
105+
_, lastStatementIsReturn := lastStatement.(*ast.ReturnStmt)
106+
return lastStatementIsReturn, nil
107+
}
108+
109+
// Turn { fmt.Println("Hello") } into just fmt.Println("Hello"), with one less
110+
// level of indentation.
111+
//
112+
// The first line of the result will not be indented, but all of the following
113+
// lines will.
114+
func ifBodyToStandaloneCode(fset *token.FileSet, ifBody *ast.BlockStmt, src []byte) string {
115+
// Get the whole body (without the surrounding braces) as a string
116+
bodyStart := safetoken.StartPosition(fset, ifBody.Lbrace+1) // 1 == len("}")
117+
bodyEnd := safetoken.EndPosition(fset, ifBody.Rbrace)
118+
bodyWithoutBraces := string(src[bodyStart.Offset:bodyEnd.Offset])
119+
bodyWithoutBraces = strings.TrimSpace(bodyWithoutBraces)
120+
121+
// Unindent
122+
bodyWithoutBraces = strings.ReplaceAll(bodyWithoutBraces, "\n\t", "\n")
123+
124+
return bodyWithoutBraces
125+
}
126+
127+
func invertCondition(fset *token.FileSet, cond ast.Expr, src []byte) ([]byte, error) {
128+
condStart := safetoken.StartPosition(fset, cond.Pos())
129+
condEnd := safetoken.EndPosition(fset, cond.End())
130+
oldText := string(src[condStart.Offset:condEnd.Offset])
131+
132+
switch expr := cond.(type) {
133+
case *ast.Ident, *ast.ParenExpr, *ast.CallExpr, *ast.StarExpr, *ast.IndexExpr, *ast.IndexListExpr, *ast.SelectorExpr:
134+
newText := "!" + oldText
135+
if oldText == "true" {
136+
newText = "false"
137+
} else if oldText == "false" {
138+
newText = "true"
139+
}
140+
141+
return []byte(newText), nil
142+
143+
case *ast.UnaryExpr:
144+
if expr.Op != token.NOT {
145+
// This should never happen
146+
return dumbInvert(fset, cond, src), nil
147+
}
148+
149+
inverse := expr.X
150+
if p, isParen := inverse.(*ast.ParenExpr); isParen {
151+
// We got !(x), remove the parentheses with the ! so we get just "x"
152+
inverse = p.X
153+
154+
start := safetoken.StartPosition(fset, inverse.Pos())
155+
end := safetoken.EndPosition(fset, inverse.End())
156+
if start.Line != end.Line {
157+
// The expression is multi-line, so we can't remove the parentheses
158+
inverse = expr.X
159+
}
160+
}
161+
162+
start := safetoken.StartPosition(fset, inverse.Pos())
163+
end := safetoken.EndPosition(fset, inverse.End())
164+
textWithoutNot := src[start.Offset:end.Offset]
165+
166+
return textWithoutNot, nil
167+
168+
case *ast.BinaryExpr:
169+
// These inversions are unsound for floating point NaN, but that's ok.
170+
negations := map[token.Token]string{
171+
token.EQL: "!=",
172+
token.LSS: ">=",
173+
token.GTR: "<=",
174+
token.NEQ: "==",
175+
token.LEQ: ">",
176+
token.GEQ: "<",
177+
}
178+
179+
negation, negationFound := negations[expr.Op]
180+
if !negationFound {
181+
return invertAndOr(fset, expr, src)
182+
}
183+
184+
xPosInSource := safetoken.StartPosition(fset, expr.X.Pos())
185+
opPosInSource := safetoken.StartPosition(fset, expr.OpPos)
186+
yPosInSource := safetoken.StartPosition(fset, expr.Y.Pos())
187+
188+
textBeforeOp := string(src[xPosInSource.Offset:opPosInSource.Offset])
189+
190+
oldOpWithTrailingWhitespace := string(src[opPosInSource.Offset:yPosInSource.Offset])
191+
newOpWithTrailingWhitespace := negation + oldOpWithTrailingWhitespace[len(expr.Op.String()):]
192+
193+
textAfterOp := string(src[yPosInSource.Offset:condEnd.Offset])
194+
195+
return []byte(textBeforeOp + newOpWithTrailingWhitespace + textAfterOp), nil
196+
}
197+
198+
return dumbInvert(fset, cond, src), nil
199+
}
200+
201+
// dumbInvert is a fallback, inverting cond into !(cond).
202+
func dumbInvert(fset *token.FileSet, expr ast.Expr, src []byte) []byte {
203+
start := safetoken.StartPosition(fset, expr.Pos())
204+
end := safetoken.EndPosition(fset, expr.End())
205+
text := string(src[start.Offset:end.Offset])
206+
return []byte("!(" + text + ")")
207+
}
208+
209+
func invertAndOr(fset *token.FileSet, expr *ast.BinaryExpr, src []byte) ([]byte, error) {
210+
if expr.Op != token.LAND && expr.Op != token.LOR {
211+
// Neither AND nor OR, don't know how to invert this
212+
return dumbInvert(fset, expr, src), nil
213+
}
214+
215+
oppositeOp := "&&"
216+
if expr.Op == token.LAND {
217+
oppositeOp = "||"
218+
}
219+
220+
xEndInSource := safetoken.EndPosition(fset, expr.X.End())
221+
opPosInSource := safetoken.StartPosition(fset, expr.OpPos)
222+
whitespaceAfterBefore := src[xEndInSource.Offset:opPosInSource.Offset]
223+
224+
invertedBefore, err := invertCondition(fset, expr.X, src)
225+
if err != nil {
226+
return nil, err
227+
}
228+
229+
invertedAfter, err := invertCondition(fset, expr.Y, src)
230+
if err != nil {
231+
return nil, err
232+
}
233+
234+
yPosInSource := safetoken.StartPosition(fset, expr.Y.Pos())
235+
236+
oldOpWithTrailingWhitespace := string(src[opPosInSource.Offset:yPosInSource.Offset])
237+
newOpWithTrailingWhitespace := oppositeOp + oldOpWithTrailingWhitespace[len(expr.Op.String()):]
238+
239+
return []byte(string(invertedBefore) + string(whitespaceAfterBefore) + newOpWithTrailingWhitespace + string(invertedAfter)), nil
240+
}
241+
242+
// CanInvertIfCondition reports whether we can do invert-if-condition on the
243+
// code in the given range
244+
func CanInvertIfCondition(file *ast.File, start, end token.Pos) (*ast.IfStmt, bool, error) {
245+
path, _ := astutil.PathEnclosingInterval(file, start, end)
246+
for _, node := range path {
247+
stmt, isIfStatement := node.(*ast.IfStmt)
248+
if !isIfStatement {
249+
continue
250+
}
251+
252+
if stmt.Else == nil {
253+
// Can't invert conditions without else clauses
254+
return nil, false, fmt.Errorf("else clause required")
255+
}
256+
257+
if _, hasElseIf := stmt.Else.(*ast.IfStmt); hasElseIf {
258+
// Can't invert conditions with else-if clauses, unclear what that
259+
// would look like
260+
return nil, false, fmt.Errorf("else-if not supported")
261+
}
262+
263+
return stmt, true, nil
264+
}
265+
266+
return nil, false, fmt.Errorf("not an if statement")
267+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package invertifcondition
2+
3+
import (
4+
"fmt"
5+
)
6+
7+
func Boolean() {
8+
b := true
9+
if b { //@suggestedfix("if b", "refactor.rewrite", "")
10+
fmt.Println("A")
11+
} else {
12+
fmt.Println("B")
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- suggestedfix_boolean_9_2 --
2+
package invertifcondition
3+
4+
import (
5+
"fmt"
6+
)
7+
8+
func Boolean() {
9+
b := true
10+
if !b {
11+
fmt.Println("B")
12+
} else { //@suggestedfix("if b", "refactor.rewrite", "")
13+
fmt.Println("A")
14+
}
15+
}
16+

0 commit comments

Comments
 (0)