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#[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 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 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#[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}