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