1package ruleguard 2 3import ( 4 "bytes" 5 "fmt" 6 "go/ast" 7 "go/printer" 8 "io/ioutil" 9 "path/filepath" 10 "strconv" 11 "strings" 12 13 "github.com/quasilyte/go-ruleguard/internal/mvdan.cc/gogrep" 14) 15 16type rulesRunner struct { 17 ctx *Context 18 rules *GoRuleSet 19 20 filename string 21 imports map[string]struct{} 22 src []byte 23} 24 25func newRulesRunner(ctx *Context, rules *GoRuleSet) *rulesRunner { 26 return &rulesRunner{ 27 ctx: ctx, 28 rules: rules, 29 } 30} 31 32func (rr *rulesRunner) nodeText(n ast.Node) []byte { 33 from := rr.ctx.Fset.Position(n.Pos()).Offset 34 to := rr.ctx.Fset.Position(n.End()).Offset 35 src := rr.fileBytes() 36 if (from >= 0 && int(from) < len(src)) && (to >= 0 && int(to) < len(src)) { 37 return src[from:to] 38 } 39 // Fallback to the printer. 40 var buf bytes.Buffer 41 if err := printer.Fprint(&buf, rr.ctx.Fset, n); err != nil { 42 panic(err) 43 } 44 return buf.Bytes() 45} 46 47func (rr *rulesRunner) fileBytes() []byte { 48 if rr.src != nil { 49 return rr.src 50 } 51 52 // TODO(quasilyte): re-use src slice? 53 src, err := ioutil.ReadFile(rr.filename) 54 if err != nil || src == nil { 55 // Assign a zero-length slice so rr.src 56 // is never nil during the second fileBytes call. 57 rr.src = make([]byte, 0) 58 } else { 59 rr.src = src 60 } 61 return rr.src 62} 63 64func (rr *rulesRunner) run(f *ast.File) error { 65 // TODO(quasilyte): run local rules as well. 66 67 rr.filename = rr.ctx.Fset.Position(f.Pos()).Filename 68 rr.collectImports(f) 69 70 for _, rule := range rr.rules.universal.uncategorized { 71 rule.pat.Match(f, func(m gogrep.MatchData) { 72 rr.handleMatch(rule, m) 73 }) 74 } 75 76 if rr.rules.universal.categorizedNum != 0 { 77 ast.Inspect(f, func(n ast.Node) bool { 78 cat := categorizeNode(n) 79 for _, rule := range rr.rules.universal.rulesByCategory[cat] { 80 matched := false 81 rule.pat.MatchNode(n, func(m gogrep.MatchData) { 82 matched = rr.handleMatch(rule, m) 83 }) 84 if matched { 85 break 86 } 87 } 88 return true 89 }) 90 } 91 92 return nil 93} 94 95func (rr *rulesRunner) reject(rule goRule, reason, sub string, m gogrep.MatchData) { 96 // Note: we accept reason and sub args instead of formatted or 97 // concatenated string so it's cheaper for us to call this 98 // function is debugging is not enabled. 99 100 if rule.group != rr.ctx.Debug { 101 return // This rule is not being debugged 102 } 103 104 pos := rr.ctx.Fset.Position(m.Node.Pos()) 105 if sub != "" { 106 reason = "$" + sub + " " + reason 107 } 108 rr.ctx.DebugPrint(fmt.Sprintf("%s:%d: rejected by %s:%d (%s)", 109 pos.Filename, pos.Line, filepath.Base(rule.filename), rule.line, reason)) 110 for name, node := range m.Values { 111 var expr ast.Expr 112 switch node := node.(type) { 113 case ast.Expr: 114 expr = node 115 case *ast.ExprStmt: 116 expr = node.X 117 default: 118 continue 119 } 120 121 typ := rr.ctx.Types.TypeOf(expr) 122 s := strings.ReplaceAll(sprintNode(rr.ctx.Fset, expr), "\n", `\n`) 123 rr.ctx.DebugPrint(fmt.Sprintf(" $%s %s: %s", name, typ, s)) 124 } 125} 126 127func (rr *rulesRunner) handleMatch(rule goRule, m gogrep.MatchData) bool { 128 for _, neededImport := range rule.filter.fileImports { 129 if _, ok := rr.imports[neededImport]; !ok { 130 rr.reject(rule, "file imports filter", "", m) 131 return false 132 } 133 } 134 135 // TODO(quasilyte): do not run filename check for every match. 136 // Exclude rules for the file that will never match due to the 137 // file-scoped filters. Same goes for the fileImports filter 138 // and ideas proposed in #78. Most rules do not have file-scoped 139 // filters, so we don't loose much here, but we can optimize 140 // this file filters in the future. 141 if rule.filter.filenamePred != nil && !rule.filter.filenamePred(rr.filename) { 142 rr.reject(rule, "file name filter", "", m) 143 return false 144 } 145 146 for name, node := range m.Values { 147 var expr ast.Expr 148 switch node := node.(type) { 149 case ast.Expr: 150 expr = node 151 case *ast.ExprStmt: 152 expr = node.X 153 default: 154 continue 155 } 156 157 filter, ok := rule.filter.sub[name] 158 if !ok { 159 continue 160 } 161 if filter.typePred != nil { 162 typ := rr.ctx.Types.TypeOf(expr) 163 q := typeQuery{x: typ, ctx: rr.ctx} 164 if !filter.typePred(q) { 165 rr.reject(rule, "type filter", name, m) 166 return false 167 } 168 } 169 if filter.textPred != nil { 170 if !filter.textPred(string(rr.nodeText(expr))) { 171 rr.reject(rule, "text filter", name, m) 172 return false 173 } 174 } 175 switch filter.addressable { 176 case bool3true: 177 if !isAddressable(rr.ctx.Types, expr) { 178 rr.reject(rule, "is not addressable", name, m) 179 return false 180 } 181 case bool3false: 182 if isAddressable(rr.ctx.Types, expr) { 183 rr.reject(rule, "is addressable", name, m) 184 return false 185 } 186 } 187 switch filter.pure { 188 case bool3true: 189 if !isPure(rr.ctx.Types, expr) { 190 rr.reject(rule, "is not pure", name, m) 191 return false 192 } 193 case bool3false: 194 if isPure(rr.ctx.Types, expr) { 195 rr.reject(rule, "is pure", name, m) 196 return false 197 } 198 } 199 switch filter.constant { 200 case bool3true: 201 if !isConstant(rr.ctx.Types, expr) { 202 rr.reject(rule, "is not const", name, m) 203 return false 204 } 205 case bool3false: 206 if isConstant(rr.ctx.Types, expr) { 207 rr.reject(rule, "is const", name, m) 208 return false 209 } 210 } 211 } 212 213 prefix := "" 214 if rule.severity != "" { 215 prefix = rule.severity + ": " 216 } 217 message := prefix + rr.renderMessage(rule.msg, m.Node, m.Values, true) 218 node := m.Node 219 if rule.location != "" { 220 node = m.Values[rule.location] 221 } 222 var suggestion *Suggestion 223 if rule.suggestion != "" { 224 suggestion = &Suggestion{ 225 Replacement: []byte(rr.renderMessage(rule.suggestion, m.Node, m.Values, false)), 226 From: node.Pos(), 227 To: node.End(), 228 } 229 } 230 info := GoRuleInfo{ 231 Filename: rule.filename, 232 } 233 rr.ctx.Report(info, node, message, suggestion) 234 return true 235} 236 237func (rr *rulesRunner) collectImports(f *ast.File) { 238 rr.imports = make(map[string]struct{}, len(f.Imports)) 239 for _, spec := range f.Imports { 240 s, err := strconv.Unquote(spec.Path.Value) 241 if err != nil { 242 continue 243 } 244 rr.imports[s] = struct{}{} 245 } 246} 247 248func (rr *rulesRunner) renderMessage(msg string, n ast.Node, nodes map[string]ast.Node, truncate bool) string { 249 var buf strings.Builder 250 if strings.Contains(msg, "$$") { 251 buf.Write(rr.nodeText(n)) 252 msg = strings.ReplaceAll(msg, "$$", buf.String()) 253 } 254 if len(nodes) == 0 { 255 return msg 256 } 257 for name, n := range nodes { 258 key := "$" + name 259 if !strings.Contains(msg, key) { 260 continue 261 } 262 buf.Reset() 263 buf.Write(rr.nodeText(n)) 264 // Don't interpolate strings that are too long. 265 var replacement string 266 if truncate && buf.Len() > 60 { 267 replacement = key 268 } else { 269 replacement = buf.String() 270 } 271 msg = strings.ReplaceAll(msg, key, replacement) 272 } 273 return msg 274} 275