Skip to main content

code_baseline/rules/
banned_pattern.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4use std::ops::Range;
5
6/// Scans files line-by-line for a literal string or regex match.
7///
8/// Useful for banning code patterns like `style={{`, `console.log(`, `// @ts-ignore`, etc.
9/// When `regex` is true in the config, the pattern is treated as a regular expression.
10#[derive(Debug)]
11pub struct BannedPatternRule {
12    id: String,
13    severity: Severity,
14    message: String,
15    suggest: Option<String>,
16    glob: Option<String>,
17    pattern: String,
18    compiled_regex: Option<Regex>,
19    skip_strings: bool,
20}
21
22impl BannedPatternRule {
23    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
24        let pattern = config
25            .pattern
26            .as_ref()
27            .filter(|p| !p.is_empty())
28            .ok_or_else(|| RuleBuildError::MissingField(config.id.clone(), "pattern"))?
29            .clone();
30
31        let compiled_regex = if config.regex {
32            let re = Regex::new(&pattern)
33                .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
34            Some(re)
35        } else {
36            None
37        };
38
39        Ok(Self {
40            id: config.id.clone(),
41            severity: config.severity,
42            message: config.message.clone(),
43            suggest: config.suggest.clone(),
44            glob: config.glob.clone(),
45            pattern,
46            compiled_regex,
47            skip_strings: config.skip_strings,
48        })
49    }
50}
51
52impl Rule for BannedPatternRule {
53    fn id(&self) -> &str {
54        &self.id
55    }
56
57    fn severity(&self) -> Severity {
58        self.severity
59    }
60
61    fn file_glob(&self) -> Option<&str> {
62        self.glob.as_deref()
63    }
64
65    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
66        let mut violations = Vec::new();
67
68let line_offsets: Vec<usize> = if self.skip_strings {
69            std::iter::once(0)
70                .chain(ctx.content.match_indices('\n').map(|(i, _)| i + 1))
71                .collect()
72        } else {
73            Vec::new()
74        };
75
76        for (line_idx, line) in ctx.content.lines().enumerate() {
77            if let Some(ref re) = self.compiled_regex {
78                // Regex mode: report each match
79                for m in re.find_iter(line) {
80                    violations.push(Violation {
81                        rule_id: self.id.clone(),
82                        severity: self.severity,
83                        file: ctx.file_path.to_path_buf(),
84                        line: Some(line_idx + 1),
85                        column: Some(m.start() + 1),
86                        message: self.message.clone(),
87                        suggest: self.suggest.clone(),
88                        source_line: Some(line.to_string()),
89                        fix: None,
90                    });
91                }
92            } else {
93                // Literal mode: find all occurrences
94                let pat = self.pattern.as_str();
95                let pat_len = pat.len();
96                let mut search_start = 0;
97                while let Some(pos) = line[search_start..].find(pat) {
98                    let col = search_start + pos;
99                    violations.push(Violation {
100                        rule_id: self.id.clone(),
101                        severity: self.severity,
102                        file: ctx.file_path.to_path_buf(),
103                        line: Some(line_idx + 1),
104                        column: Some(col + 1),
105                        message: self.message.clone(),
106                        suggest: self.suggest.clone(),
107                        source_line: Some(line.to_string()),
108                        fix: None,
109                    });
110                    search_start = col + pat_len;
111                }
112            }
113        }
114
115if self.skip_strings {
116            if let Some(tree) = crate::rules::ast::parse_file(ctx.file_path, ctx.content) {
117                let string_ranges = collect_string_ranges(&tree, ctx.content);
118                violations.retain(|v| {
119                    let byte_offset = match (v.line, v.column) {
120                        (Some(line), Some(col)) => line_offsets[line - 1] + (col - 1),
121                        _ => return true,
122                    };
123                    !string_ranges
124                        .iter()
125                        .any(|range: &Range<usize>| range.contains(&byte_offset))
126                });
127            }
128        }
129
130        violations
131    }
132}
133
134/// Walk the tree-sitter AST and collect byte ranges of `string` and `template_string` nodes.
135fn collect_string_ranges(tree: &tree_sitter::Tree, _source: &str) -> Vec<Range<usize>> {
136    let mut ranges = Vec::new();
137    let mut cursor = tree.walk();
138    collect_string_ranges_recursive(&mut cursor, &mut ranges);
139    ranges
140}
141
142fn collect_string_ranges_recursive(
143    cursor: &mut tree_sitter::TreeCursor,
144    ranges: &mut Vec<Range<usize>>,
145) {
146    loop {
147        let node = cursor.node();
148        let kind = node.kind();
149        if kind == "string" || kind == "template_string" {
150            ranges.push(node.start_byte()..node.end_byte());
151        } else if cursor.goto_first_child() {
152            collect_string_ranges_recursive(cursor, ranges);
153            cursor.goto_parent();
154        }
155        if !cursor.goto_next_sibling() {
156            break;
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use std::path::Path;
165
166    fn make_config(pattern: &str, regex: bool) -> RuleConfig {
167        RuleConfig {
168            id: "test-banned-pattern".into(),
169            severity: Severity::Warning,
170            message: "banned pattern found".into(),
171            suggest: Some("remove this pattern".into()),
172            pattern: Some(pattern.to_string()),
173            regex,
174            ..Default::default()
175        }
176    }
177
178    fn check(rule: &BannedPatternRule, content: &str) -> Vec<Violation> {
179        let ctx = ScanContext {
180            file_path: Path::new("test.tsx"),
181            content,
182        };
183        rule.check_file(&ctx)
184    }
185
186    #[test]
187    fn literal_match() {
188        let config = make_config("style={{", false);
189        let rule = BannedPatternRule::new(&config).unwrap();
190        let violations = check(&rule, r#"<div style={{ color: "red" }}>"#);
191        assert_eq!(violations.len(), 1);
192        assert_eq!(violations[0].line, Some(1));
193        assert_eq!(violations[0].column, Some(6));
194    }
195
196    #[test]
197    fn literal_multiple_matches_per_line() {
198        let config = make_config("TODO", false);
199        let rule = BannedPatternRule::new(&config).unwrap();
200        let violations = check(&rule, "// TODO fix this TODO");
201        assert_eq!(violations.len(), 2);
202        assert_eq!(violations[0].column, Some(4));
203        assert_eq!(violations[1].column, Some(18));
204    }
205
206    #[test]
207    fn literal_no_match() {
208        let config = make_config("style={{", false);
209        let rule = BannedPatternRule::new(&config).unwrap();
210        let violations = check(&rule, r#"<div className="bg-white">"#);
211        assert!(violations.is_empty());
212    }
213
214    #[test]
215    fn literal_multiline() {
216        let config = make_config("console.log(", false);
217        let rule = BannedPatternRule::new(&config).unwrap();
218        let content = "const x = 1;\nconsole.log(x);\nconst y = 2;";
219        let violations = check(&rule, content);
220        assert_eq!(violations.len(), 1);
221        assert_eq!(violations[0].line, Some(2));
222        assert_eq!(violations[0].column, Some(1));
223    }
224
225    #[test]
226    fn regex_match() {
227        let config = make_config(r"console\.(log|debug)\(", true);
228        let rule = BannedPatternRule::new(&config).unwrap();
229        let content = "console.log('hi');\nconsole.debug('x');\nconsole.error('e');";
230        let violations = check(&rule, content);
231        assert_eq!(violations.len(), 2);
232        assert_eq!(violations[0].line, Some(1));
233        assert_eq!(violations[1].line, Some(2));
234    }
235
236    #[test]
237    fn regex_no_match() {
238        let config = make_config(r"console\.log\(", true);
239        let rule = BannedPatternRule::new(&config).unwrap();
240        let violations = check(&rule, "console.error('e');");
241        assert!(violations.is_empty());
242    }
243
244    #[test]
245    fn invalid_regex_error() {
246        let config = make_config(r"(unclosed", true);
247        let err = BannedPatternRule::new(&config).unwrap_err();
248        assert!(matches!(err, RuleBuildError::InvalidRegex(_, _)));
249    }
250
251    #[test]
252    fn missing_pattern_error() {
253        let config = RuleConfig {
254            id: "test".into(),
255            severity: Severity::Warning,
256            message: "test".into(),
257            ..Default::default()
258        };
259        let err = BannedPatternRule::new(&config).unwrap_err();
260        assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
261    }
262
263    #[test]
264    fn empty_pattern_error() {
265        let config = make_config("", false);
266        let err = BannedPatternRule::new(&config).unwrap_err();
267        assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
268    }
269
270    #[test]
271    fn violation_metadata() {
272        let config = make_config("style={{", false);
273        let rule = BannedPatternRule::new(&config).unwrap();
274        let violations = check(&rule, r#"<div style={{ color: "red" }}>"#);
275        assert_eq!(violations[0].rule_id, "test-banned-pattern");
276        assert_eq!(violations[0].severity, Severity::Warning);
277        assert_eq!(violations[0].message, "banned pattern found");
278        assert_eq!(violations[0].suggest.as_deref(), Some("remove this pattern"));
279        assert!(violations[0].source_line.is_some());
280    }
281
282    mod skip_strings {
283        use super::*;
284
285        fn make_skip_config(pattern: &str, regex: bool, skip_strings: bool) -> RuleConfig {
286            RuleConfig {
287                id: "test-skip-strings".into(),
288                severity: Severity::Warning,
289                message: "banned pattern found".into(),
290                pattern: Some(pattern.to_string()),
291                regex,
292                skip_strings,
293                ..Default::default()
294            }
295        }
296
297        #[test]
298        fn skip_strings_inside_template_literal() {
299            let config = make_skip_config("process.env", false, true);
300            let rule = BannedPatternRule::new(&config).unwrap();
301            let content = "const docs = `Use process.env.SECRET for config`;";
302            let ctx = ScanContext {
303                file_path: Path::new("test.tsx"),
304                content,
305            };
306            let violations = rule.check_file(&ctx);
307            assert!(violations.is_empty());
308        }
309
310        #[test]
311        fn skip_strings_outside_template_literal() {
312            let config = make_skip_config("process.env", false, true);
313            let rule = BannedPatternRule::new(&config).unwrap();
314            let content = "const val = process.env.SECRET;";
315            let ctx = ScanContext {
316                file_path: Path::new("test.tsx"),
317                content,
318            };
319            let violations = rule.check_file(&ctx);
320            assert_eq!(violations.len(), 1);
321        }
322
323        #[test]
324        fn skip_strings_inside_regular_string() {
325            let config = make_skip_config("process.env", false, true);
326            let rule = BannedPatternRule::new(&config).unwrap();
327            let content = r#"const msg = "Use process.env.SECRET";"#;
328            let ctx = ScanContext {
329                file_path: Path::new("test.tsx"),
330                content,
331            };
332            let violations = rule.check_file(&ctx);
333            assert!(violations.is_empty());
334        }
335
336        #[test]
337        fn skip_strings_false_still_flags() {
338            let config = make_skip_config("process.env", false, false);
339            let rule = BannedPatternRule::new(&config).unwrap();
340            let content = "const docs = `Use process.env.SECRET for config`;";
341            let ctx = ScanContext {
342                file_path: Path::new("test.tsx"),
343                content,
344            };
345            let violations = rule.check_file(&ctx);
346            assert_eq!(violations.len(), 1);
347        }
348    }
349}