qf1003.go (5312B)
1 package qf1003 2 3 import ( 4 "fmt" 5 "go/ast" 6 "go/token" 7 "strings" 8 9 "honnef.co/go/tools/analysis/code" 10 "honnef.co/go/tools/analysis/edit" 11 "honnef.co/go/tools/analysis/lint" 12 "honnef.co/go/tools/analysis/report" 13 "honnef.co/go/tools/go/ast/astutil" 14 15 "golang.org/x/tools/go/analysis" 16 "golang.org/x/tools/go/analysis/passes/inspect" 17 ) 18 19 var SCAnalyzer = lint.InitializeAnalyzer(&lint.Analyzer{ 20 Analyzer: &analysis.Analyzer{ 21 Name: "QF1003", 22 Run: run, 23 Requires: []*analysis.Analyzer{inspect.Analyzer}, 24 }, 25 Doc: &lint.RawDocumentation{ 26 Title: "Convert if/else-if chain to tagged switch", 27 Text: ` 28 A series of if/else-if checks comparing the same variable against 29 values can be replaced with a tagged switch.`, 30 Before: ` 31 if x == 1 || x == 2 { 32 ... 33 } else if x == 3 { 34 ... 35 } else { 36 ... 37 }`, 38 39 After: ` 40 switch x { 41 case 1, 2: 42 ... 43 case 3: 44 ... 45 default: 46 ... 47 }`, 48 Since: "2021.1", 49 Severity: lint.SeverityInfo, 50 }, 51 }) 52 53 var Analyzer = SCAnalyzer.Analyzer 54 55 func run(pass *analysis.Pass) (any, error) { 56 nodeLoop: 57 for c := range code.Cursor(pass).Preorder((*ast.IfStmt)(nil)) { 58 node := c.Node() 59 if _, ok := c.Parent().Node().(*ast.IfStmt); ok { 60 // this if statement is part of an if-else chain 61 continue 62 } 63 ifstmt := node.(*ast.IfStmt) 64 65 m := map[ast.Expr][]*ast.BinaryExpr{} 66 for item := ifstmt; item != nil; { 67 if item.Init != nil { 68 continue nodeLoop 69 } 70 if item.Body == nil { 71 continue nodeLoop 72 } 73 74 skip := false 75 ast.Inspect(item.Body, func(node ast.Node) bool { 76 if branch, ok := node.(*ast.BranchStmt); ok && branch.Tok != token.GOTO { 77 skip = true 78 return false 79 } 80 return true 81 }) 82 if skip { 83 continue nodeLoop 84 } 85 86 var pairs []*ast.BinaryExpr 87 if !findSwitchPairs(pass, item.Cond, &pairs) { 88 continue nodeLoop 89 } 90 m[item.Cond] = pairs 91 switch els := item.Else.(type) { 92 case *ast.IfStmt: 93 item = els 94 case *ast.BlockStmt, nil: 95 item = nil 96 default: 97 panic(fmt.Sprintf("unreachable: %T", els)) 98 } 99 } 100 101 var x ast.Expr 102 for _, pair := range m { 103 if len(pair) == 0 { 104 continue 105 } 106 if x == nil { 107 x = pair[0].X 108 } else { 109 if !astutil.Equal(x, pair[0].X) { 110 continue nodeLoop 111 } 112 } 113 } 114 if x == nil { 115 // shouldn't happen 116 continue nodeLoop 117 } 118 119 // We require at least two 'if' to make this suggestion, to 120 // avoid clutter in the editor. 121 if len(m) < 2 { 122 continue nodeLoop 123 } 124 125 // Note that we insert the switch statement as the first text edit instead of the last one so that gopls has an 126 // easier time converting it to an LSP-conforming edit. 127 // 128 // Specifically: 129 // > Text edits ranges must never overlap, that means no part of the original 130 // > document must be manipulated by more than one edit. However, it is 131 // > possible that multiple edits have the same start position: multiple 132 // > inserts, or any number of inserts followed by a single remove or replace 133 // > edit. If multiple inserts have the same position, the order in the array 134 // > defines the order in which the inserted strings appear in the resulting 135 // > text. 136 // 137 // See https://go.dev/issue/63930 138 // 139 // FIXME this edit forces the first case to begin in column 0 because we ignore indentation. try to fix that. 140 edits := []analysis.TextEdit{edit.ReplaceWithString(edit.Range{ifstmt.If, ifstmt.If}, fmt.Sprintf("switch %s {\n", report.Render(pass, x)))} 141 for item := ifstmt; item != nil; { 142 var end token.Pos 143 if item.Else != nil { 144 end = item.Else.Pos() 145 } else { 146 // delete up to but not including the closing brace. 147 end = item.Body.Rbrace 148 } 149 150 var conds []string 151 for _, cond := range m[item.Cond] { 152 y := cond.Y 153 if p, ok := y.(*ast.ParenExpr); ok { 154 y = p.X 155 } 156 conds = append(conds, report.Render(pass, y)) 157 } 158 sconds := strings.Join(conds, ", ") 159 edits = append(edits, 160 edit.ReplaceWithString(edit.Range{item.If, item.Body.Lbrace + 1}, "case "+sconds+":"), 161 edit.Delete(edit.Range{item.Body.Rbrace, end})) 162 163 switch els := item.Else.(type) { 164 case *ast.IfStmt: 165 item = els 166 case *ast.BlockStmt: 167 edits = append(edits, edit.ReplaceWithString(edit.Range{els.Lbrace, els.Lbrace + 1}, "default:")) 168 item = nil 169 case nil: 170 item = nil 171 default: 172 panic(fmt.Sprintf("unreachable: %T", els)) 173 } 174 } 175 report.Report(pass, ifstmt, fmt.Sprintf("could use tagged switch on %s", report.Render(pass, x)), 176 report.Fixes(edit.Fix("Replace with tagged switch", edits...)), 177 report.ShortRange()) 178 } 179 return nil, nil 180 } 181 182 func findSwitchPairs(pass *analysis.Pass, expr ast.Expr, pairs *[]*ast.BinaryExpr) bool { 183 binexpr, ok := ast.Unparen(expr).(*ast.BinaryExpr) 184 if !ok { 185 return false 186 } 187 switch binexpr.Op { 188 case token.EQL: 189 if code.MayHaveSideEffects(pass, binexpr.X, nil) || code.MayHaveSideEffects(pass, binexpr.Y, nil) { 190 return false 191 } 192 // syntactic identity should suffice. we do not allow side 193 // effects in the case clauses, so there should be no way for 194 // values to change. 195 if len(*pairs) > 0 && !astutil.Equal(binexpr.X, (*pairs)[0].X) { 196 return false 197 } 198 *pairs = append(*pairs, binexpr) 199 return true 200 case token.LOR: 201 return findSwitchPairs(pass, binexpr.X, pairs) && findSwitchPairs(pass, binexpr.Y, pairs) 202 default: 203 return false 204 } 205 }