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
5/// Scans files line-by-line for a literal string or regex match.
6///
7/// Useful for banning code patterns like `style={{`, `console.log(`, `// @ts-ignore`, etc.
8/// When `regex` is true in the config, the pattern is treated as a regular expression.
9#[derive(Debug)]
10pub struct BannedPatternRule {
11    id: String,
12    severity: Severity,
13    message: String,
14    suggest: Option<String>,
15    glob: Option<String>,
16    pattern: String,
17    compiled_regex: Option<Regex>,
18}
19
20impl BannedPatternRule {
21    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
22        let pattern = config
23            .pattern
24            .as_ref()
25            .filter(|p| !p.is_empty())
26            .ok_or_else(|| RuleBuildError::MissingField(config.id.clone(), "pattern"))?
27            .clone();
28
29        let compiled_regex = if config.regex {
30            let re = Regex::new(&pattern)
31                .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
32            Some(re)
33        } else {
34            None
35        };
36
37        Ok(Self {
38            id: config.id.clone(),
39            severity: config.severity,
40            message: config.message.clone(),
41            suggest: config.suggest.clone(),
42            glob: config.glob.clone(),
43            pattern,
44            compiled_regex,
45        })
46    }
47}
48
49impl Rule for BannedPatternRule {
50    fn id(&self) -> &str {
51        &self.id
52    }
53
54    fn severity(&self) -> Severity {
55        self.severity
56    }
57
58    fn file_glob(&self) -> Option<&str> {
59        self.glob.as_deref()
60    }
61
62    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
63        let mut violations = Vec::new();
64
65        for (line_idx, line) in ctx.content.lines().enumerate() {
66            if let Some(ref re) = self.compiled_regex {
67                // Regex mode: report each match
68                for m in re.find_iter(line) {
69                    violations.push(Violation {
70                        rule_id: self.id.clone(),
71                        severity: self.severity,
72                        file: ctx.file_path.to_path_buf(),
73                        line: Some(line_idx + 1),
74                        column: Some(m.start() + 1),
75                        message: self.message.clone(),
76                        suggest: self.suggest.clone(),
77                        source_line: Some(line.to_string()),
78                        fix: None,
79                    });
80                }
81            } else {
82                // Literal mode: find all occurrences
83                let pat = self.pattern.as_str();
84                let pat_len = pat.len();
85                let mut search_start = 0;
86                while let Some(pos) = line[search_start..].find(pat) {
87                    let col = search_start + pos;
88                    violations.push(Violation {
89                        rule_id: self.id.clone(),
90                        severity: self.severity,
91                        file: ctx.file_path.to_path_buf(),
92                        line: Some(line_idx + 1),
93                        column: Some(col + 1),
94                        message: self.message.clone(),
95                        suggest: self.suggest.clone(),
96                        source_line: Some(line.to_string()),
97                        fix: None,
98                    });
99                    search_start = col + pat_len;
100                }
101            }
102        }
103
104        violations
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::path::Path;
112
113    fn make_config(pattern: &str, regex: bool) -> RuleConfig {
114        RuleConfig {
115            id: "test-banned-pattern".into(),
116            severity: Severity::Warning,
117            message: "banned pattern found".into(),
118            suggest: Some("remove this pattern".into()),
119            pattern: Some(pattern.to_string()),
120            regex,
121            ..Default::default()
122        }
123    }
124
125    fn check(rule: &BannedPatternRule, content: &str) -> Vec<Violation> {
126        let ctx = ScanContext {
127            file_path: Path::new("test.tsx"),
128            content,
129        };
130        rule.check_file(&ctx)
131    }
132
133    #[test]
134    fn literal_match() {
135        let config = make_config("style={{", false);
136        let rule = BannedPatternRule::new(&config).unwrap();
137        let violations = check(&rule, r#"<div style={{ color: "red" }}>"#);
138        assert_eq!(violations.len(), 1);
139        assert_eq!(violations[0].line, Some(1));
140        assert_eq!(violations[0].column, Some(6));
141    }
142
143    #[test]
144    fn literal_multiple_matches_per_line() {
145        let config = make_config("TODO", false);
146        let rule = BannedPatternRule::new(&config).unwrap();
147        let violations = check(&rule, "// TODO fix this TODO");
148        assert_eq!(violations.len(), 2);
149        assert_eq!(violations[0].column, Some(4));
150        assert_eq!(violations[1].column, Some(18));
151    }
152
153    #[test]
154    fn literal_no_match() {
155        let config = make_config("style={{", false);
156        let rule = BannedPatternRule::new(&config).unwrap();
157        let violations = check(&rule, r#"<div className="bg-white">"#);
158        assert!(violations.is_empty());
159    }
160
161    #[test]
162    fn literal_multiline() {
163        let config = make_config("console.log(", false);
164        let rule = BannedPatternRule::new(&config).unwrap();
165        let content = "const x = 1;\nconsole.log(x);\nconst y = 2;";
166        let violations = check(&rule, content);
167        assert_eq!(violations.len(), 1);
168        assert_eq!(violations[0].line, Some(2));
169        assert_eq!(violations[0].column, Some(1));
170    }
171
172    #[test]
173    fn regex_match() {
174        let config = make_config(r"console\.(log|debug)\(", true);
175        let rule = BannedPatternRule::new(&config).unwrap();
176        let content = "console.log('hi');\nconsole.debug('x');\nconsole.error('e');";
177        let violations = check(&rule, content);
178        assert_eq!(violations.len(), 2);
179        assert_eq!(violations[0].line, Some(1));
180        assert_eq!(violations[1].line, Some(2));
181    }
182
183    #[test]
184    fn regex_no_match() {
185        let config = make_config(r"console\.log\(", true);
186        let rule = BannedPatternRule::new(&config).unwrap();
187        let violations = check(&rule, "console.error('e');");
188        assert!(violations.is_empty());
189    }
190
191    #[test]
192    fn invalid_regex_error() {
193        let config = make_config(r"(unclosed", true);
194        let err = BannedPatternRule::new(&config).unwrap_err();
195        assert!(matches!(err, RuleBuildError::InvalidRegex(_, _)));
196    }
197
198    #[test]
199    fn missing_pattern_error() {
200        let config = RuleConfig {
201            id: "test".into(),
202            severity: Severity::Warning,
203            message: "test".into(),
204            ..Default::default()
205        };
206        let err = BannedPatternRule::new(&config).unwrap_err();
207        assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
208    }
209
210    #[test]
211    fn empty_pattern_error() {
212        let config = make_config("", false);
213        let err = BannedPatternRule::new(&config).unwrap_err();
214        assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
215    }
216
217    #[test]
218    fn violation_metadata() {
219        let config = make_config("style={{", false);
220        let rule = BannedPatternRule::new(&config).unwrap();
221        let violations = check(&rule, r#"<div style={{ color: "red" }}>"#);
222        assert_eq!(violations[0].rule_id, "test-banned-pattern");
223        assert_eq!(violations[0].severity, Severity::Warning);
224        assert_eq!(violations[0].message, "banned pattern found");
225        assert_eq!(violations[0].suggest.as_deref(), Some("remove this pattern"));
226        assert!(violations[0].source_line.is_some());
227    }
228}