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