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