1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5#[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 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 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}