Skip to main content

slopguard_core/
scanner.rs

1use std::collections::HashMap;
2use std::fs::read_to_string;
3use std::path::{Path, PathBuf};
4
5use ast_grep_config::{
6    CombinedScan, GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_language::SupportLang;
11use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
12use ignore::WalkBuilder;
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_yaml::with::singleton_map_recursive;
16use serde_yaml::{to_value, Value};
17use strum::IntoEnumIterator;
18use thiserror::Error;
19
20use crate::config::Config;
21use crate::disable::filter_disabled;
22use crate::finding::{Finding, ScanResult, ScanStats};
23use crate::rule::{Language, Rule, RuleId, Severity};
24use crate::test_filter::CfgTestRanges;
25
26#[derive(Debug, Error)]
27pub enum ScanError {
28    #[error("failed to compile rule '{id}': {source}")]
29    RuleCompile {
30        id: RuleId,
31        #[source]
32        source: RuleConfigError,
33    },
34
35    #[error("invalid glob pattern: {0}")]
36    Glob(#[from] GlobError),
37}
38
39/// The subset of a slopguard rule that ast-grep understands. slopguard-only
40/// fields (`category`, `fix` as free text, `tests`) are deliberately left out.
41#[derive(Serialize)]
42pub struct AstGrepRule<'a> {
43    pub id: &'a str,
44    pub language: SupportLang,
45    pub severity: &'a Severity,
46    pub message: &'a str,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub note: Option<&'a str>,
49    pub rule: &'a Value,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub files: Option<&'a [String]>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub ignores: Option<&'a [String]>,
54}
55
56/// Rules compiled for scanning, plus a way back to the slopguard rule that
57/// produced each ast-grep config.
58struct CompiledRules<'a> {
59    collection: RuleCollection<SupportLang>,
60    by_id: HashMap<(&'a str, Language), &'a Rule>,
61}
62
63fn extension_to_lang(path: &Path) -> Option<SupportLang> {
64    SupportLang::from_path(path)
65        .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
66}
67
68fn support_lang_to_language(lang: SupportLang) -> Language {
69    match lang {
70        SupportLang::Rust => Language::Rust,
71        _ => Language::TypeScript,
72    }
73}
74
75pub(crate) fn compile_ast_grep_rule(
76    rule: &AstGrepRule,
77) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
78    let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
79        .and_then(singleton_map_recursive::deserialize)
80        .map_err(RuleConfigError::from)?;
81    RuleConfig::try_from(inner, &GlobalRules::default())
82}
83
84fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
85    let ast_grep_rule = AstGrepRule {
86        id: rule.id.as_str(),
87        language: lang,
88        severity: &rule.severity,
89        message: &rule.message,
90        note: rule.note.as_deref(),
91        rule: &rule.rule,
92        files: rule.files.as_deref(),
93        ignores: rule.ignores.as_deref(),
94    };
95    compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
96        id: rule.id.clone(),
97        source,
98    })
99}
100
101fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
102    let configs = rules
103        .iter()
104        .flat_map(|rule| {
105            rule.language
106                .ast_grep_langs()
107                .iter()
108                .map(move |lang| compile_rule(rule, *lang))
109        })
110        .collect::<Result<Vec<_>, _>>()?;
111    Ok(CompiledRules {
112        collection: RuleCollection::try_new(configs)?,
113        by_id: rules
114            .iter()
115            .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
116            .collect::<HashMap<_, _>>(),
117    })
118}
119
120fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
121    let mut builder = GlobSetBuilder::new();
122    for pattern in patterns {
123        builder.add(Glob::new(pattern)?);
124    }
125    builder.build()
126}
127
128fn scan_file(path: &Path, lang: SupportLang, rules: &CompiledRules) -> Vec<Finding> {
129    let applicable = rules.collection.get_rule_from_lang(path, lang);
130    if applicable.is_empty() {
131        return Vec::new();
132    }
133    let Ok(source) = read_to_string(path) else {
134        return Vec::new();
135    };
136
137    let root = lang.ast_grep(&source);
138    let combined = CombinedScan::new(applicable);
139    let result = combined.scan(&root, false);
140
141    let cfg_test = (lang == SupportLang::Rust && !result.matches.is_empty())
142        .then(|| CfgTestRanges::from_root(&root));
143    let cfg_test = cfg_test.as_ref();
144
145    let findings = result
146        .matches
147        .into_iter()
148        .filter_map(|(config, matches)| {
149            let rule_lang = support_lang_to_language(lang);
150            let rule = rules.by_id.get(&(config.id.as_str(), rule_lang))?;
151            Some((*rule, matches))
152        })
153        .flat_map(|(rule, matches)| {
154            let skip_test_code = rule.skip_test_code;
155            matches
156                .into_iter()
157                .map(move |node_match| {
158                    let start = node_match.start_pos();
159                    let end = node_match.end_pos();
160                    Finding {
161                        rule_id: rule.id.clone(),
162                        severity: rule.severity.clone(),
163                        category: rule.category.clone().unwrap_or_default(),
164                        message: rule.message.clone(),
165                        note: rule.note.clone(),
166                        fix: rule.fix.clone(),
167                        file: path.to_path_buf(),
168                        line: start.line() + 1,
169                        column: start.byte_point().1 + 1,
170                        end_line: end.line() + 1,
171                        end_column: end.byte_point().1 + 1,
172                        matched_text: node_match.text().to_string(),
173                    }
174                })
175                .filter(move |f| {
176                    !(skip_test_code && cfg_test.is_some_and(|r| r.contains_line(f.line)))
177                })
178        })
179        .collect();
180
181    filter_disabled(findings, &source)
182}
183
184/// Scan the given paths for rule violations.
185pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
186    let compiled = compile_rules(rules)?;
187    let ignores = build_glob_set(&config.scan.ignores)?;
188
189    let files: Vec<(PathBuf, SupportLang)> = paths
190        .iter()
191        .flat_map(|path| WalkBuilder::new(path).build().flatten())
192        .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
193        .map(|entry| entry.into_path())
194        .filter(|path| !ignores.is_match(path))
195        .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
196        .collect();
197
198    let mut findings: Vec<Finding> = files
199        .par_iter()
200        .flat_map_iter(|(path, lang)| scan_file(path, *lang, &compiled))
201        .collect();
202    findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
203    findings.dedup_by(|a, b| {
204        a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
205    });
206
207    let (errors, warnings) =
208        findings
209            .iter()
210            .fold((0, 0), |(errors, warnings), f| match f.severity {
211                Severity::Error => (errors + 1, warnings),
212                Severity::Warning => (errors, warnings + 1),
213            });
214
215    Ok(ScanResult {
216        findings,
217        stats: ScanStats {
218            errors,
219            warnings,
220            total: errors + warnings,
221            files_scanned: files.len(),
222        },
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use std::fs::{create_dir, write};
229
230    use tempfile::tempdir;
231
232    use super::*;
233    use crate::rule::{load_builtin_rules, parse_rule, RuleId};
234
235    fn unwrap_rule() -> Rule {
236        parse_rule(
237            r#"
238id: test-unwrap
239language: rust
240severity: error
241category: correctness
242message: ".unwrap() forbidden"
243rule:
244  kind: call_expression
245  regex: '\.unwrap\(\)\s*$'
246"#,
247        )
248        .unwrap()
249    }
250
251    fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
252        scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
253    }
254
255    #[test]
256    fn scan_finds_unwrap_violation() {
257        let dir = tempdir().unwrap();
258        write(
259            dir.path().join("main.rs"),
260            "fn main() {\n    foo().unwrap();\n}\n",
261        )
262        .unwrap();
263        let result = scan_dir(dir.path(), &[unwrap_rule()]);
264        assert_eq!(result.findings.len(), 1);
265        let f = &result.findings[0];
266        assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
267        assert_eq!(f.severity, Severity::Error);
268        assert_eq!(f.line, 2);
269        assert!(f.matched_text.contains("unwrap()"));
270    }
271
272    #[test]
273    fn scan_clean_file_no_findings() {
274        let dir = tempdir().unwrap();
275        write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
276        let result = scan_dir(dir.path(), &[unwrap_rule()]);
277        assert_eq!(result.findings.len(), 0);
278        assert_eq!(result.stats.files_scanned, 1);
279    }
280
281    #[test]
282    fn scan_unsupported_language_ignored() {
283        let dir = tempdir().unwrap();
284        write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
285        write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
286        let result = scan_dir(dir.path(), &[unwrap_rule()]);
287        assert_eq!(result.findings.len(), 0);
288        assert_eq!(result.stats.files_scanned, 1);
289    }
290
291    #[test]
292    fn scan_explicit_file_path() {
293        let dir = tempdir().unwrap();
294        let file = dir.path().join("main.rs");
295        write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
296        let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
297        assert_eq!(result.findings.len(), 1);
298        assert_eq!(result.stats.files_scanned, 1);
299    }
300
301    #[test]
302    fn rule_ignores_glob_respected() {
303        let dir = tempdir().unwrap();
304        let tests_dir = dir.path().join("tests");
305        create_dir(&tests_dir).unwrap();
306        write(
307            tests_dir.join("test_main.rs"),
308            "fn t() { foo().unwrap(); }\n",
309        )
310        .unwrap();
311        write(
312            dir.path().join("main.rs"),
313            "fn main() { foo().unwrap(); }\n",
314        )
315        .unwrap();
316        let rule = parse_rule(
317            r#"
318id: ign
319language: rust
320severity: error
321category: correctness
322message: x
323rule:
324  kind: call_expression
325  regex: '\.unwrap\(\)\s*$'
326ignores:
327  - "**/tests/**"
328"#,
329        )
330        .unwrap();
331        let result = scan_dir(dir.path(), &[rule]);
332        assert_eq!(result.findings.len(), 1);
333        assert!(result.findings[0]
334            .file
335            .to_string_lossy()
336            .contains("main.rs"));
337    }
338
339    #[test]
340    fn config_ignores_respected() {
341        let dir = tempdir().unwrap();
342        let gen = dir.path().join("generated");
343        create_dir(&gen).unwrap();
344        write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
345        write(
346            dir.path().join("main.rs"),
347            "fn main() { foo().unwrap(); }\n",
348        )
349        .unwrap();
350        let mut cfg = Config::default();
351        cfg.scan.ignores = vec!["**/generated/**".to_string()];
352        let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
353        assert_eq!(result.stats.files_scanned, 1);
354        assert_eq!(result.findings.len(), 1);
355    }
356
357    #[test]
358    fn config_invalid_glob_is_an_error() {
359        let dir = tempdir().unwrap();
360        let mut cfg = Config::default();
361        cfg.scan.ignores = vec!["[".to_string()];
362        let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
363        assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
364    }
365
366    #[test]
367    fn rule_without_kind_fails_to_compile() {
368        let dir = tempdir().unwrap();
369        let rule = parse_rule(
370            r#"
371id: no-kind
372language: rust
373severity: error
374category: correctness
375message: x
376rule:
377  regex: 'unwrap'
378"#,
379        )
380        .unwrap();
381        let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
382        assert!(
383            matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
384            "{err:?}"
385        );
386    }
387
388    #[test]
389    fn stats_counting_accurate() {
390        let dir = tempdir().unwrap();
391        write(
392            dir.path().join("a.rs"),
393            "fn f() { foo().unwrap(); bar().unwrap(); }\n",
394        )
395        .unwrap();
396        let result = scan_dir(dir.path(), &[unwrap_rule()]);
397        assert_eq!(result.stats.errors, 2);
398        assert_eq!(result.stats.warnings, 0);
399        assert_eq!(result.stats.total, 2);
400        assert_eq!(result.stats.files_scanned, 1);
401    }
402
403    #[test]
404    fn scan_empty_file() {
405        let dir = tempdir().unwrap();
406        write(dir.path().join("empty.rs"), "").unwrap();
407        let result = scan_dir(dir.path(), &[unwrap_rule()]);
408        assert_eq!(result.findings.len(), 0);
409        assert_eq!(result.stats.files_scanned, 1);
410    }
411
412    #[test]
413    fn scan_complex_rule() {
414        let rule = parse_rule(
415            r#"
416id: dbg
417language: rust
418severity: error
419category: security
420message: debug on secret
421rule:
422  kind: attribute_item
423  all:
424    - regex: 'derive\([^)]*Debug'
425    - precedes:
426        kind: struct_item
427        has:
428          kind: field_identifier
429          regex: '^(api_key|password|secret)$'
430          stopBy: end
431"#,
432        )
433        .unwrap();
434        let dir = tempdir().unwrap();
435        write(
436            dir.path().join("t.rs"),
437            "#[derive(Debug)]\nstruct Config {\n    api_key: String,\n}\n",
438        )
439        .unwrap();
440        let result = scan_dir(dir.path(), &[rule]);
441        assert_eq!(result.findings.len(), 1);
442        assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
443    }
444
445    #[test]
446    fn typescript_rule_applies_to_tsx_files() {
447        let rule = parse_rule(
448            r#"
449id: ts-any
450language: typescript
451severity: warning
452category: slop
453message: no any
454rule:
455  kind: predefined_type
456  regex: '^any$'
457"#,
458        )
459        .unwrap();
460        let dir = tempdir().unwrap();
461        write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
462        write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
463        let result = scan_dir(dir.path(), &[rule]);
464        assert_eq!(result.stats.files_scanned, 2);
465        assert_eq!(result.stats.warnings, 2);
466    }
467
468    #[test]
469    fn scan_with_disable_comment_filters_findings() {
470        let dir = tempdir().unwrap();
471        write(
472            dir.path().join("main.rs"),
473            "fn main() {\n    // slopguard-disable-next-line\n    foo().unwrap();\n    bar().unwrap();\n}\n",
474        )
475        .unwrap();
476        let result = scan_dir(dir.path(), &[unwrap_rule()]);
477        assert_eq!(
478            result.findings.len(),
479            1,
480            "only the non-disabled unwrap should remain"
481        );
482        assert_eq!(result.findings[0].line, 4);
483    }
484
485    #[test]
486    fn scan_with_disable_rule_id_filters_only_that_rule() {
487        let dir = tempdir().unwrap();
488        write(
489            dir.path().join("main.rs"),
490            "fn main() {\n    // slopguard-disable-next-line test-unwrap\n    foo().unwrap();\n}\n",
491        )
492        .unwrap();
493        let result = scan_dir(dir.path(), &[unwrap_rule()]);
494        assert_eq!(result.findings.len(), 0);
495    }
496
497    fn unwrap_rule_skipping_test_code() -> Rule {
498        parse_rule(
499            r#"
500id: test-unwrap
501language: rust
502severity: error
503category: correctness
504message: ".unwrap() forbidden"
505rule:
506  kind: call_expression
507  regex: '\.unwrap\(\)\s*$'
508skip_test_code: true
509"#,
510        )
511        .unwrap()
512    }
513
514    #[test]
515    fn cfg_test_block_excluded_by_post_filter() {
516        let dir = tempdir().unwrap();
517        write(
518            dir.path().join("main.rs"),
519            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
520        )
521        .unwrap();
522        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
523        assert_eq!(
524            result.findings.len(),
525            1,
526            "should only find the prod unwrap, not the one inside #[cfg(test)]"
527        );
528        assert_eq!(result.findings[0].line, 2);
529    }
530
531    #[test]
532    fn cfg_test_kept_without_skip_test_code() {
533        let dir = tempdir().unwrap();
534        write(
535            dir.path().join("main.rs"),
536            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
537        )
538        .unwrap();
539        let result = scan_dir(dir.path(), &[unwrap_rule()]);
540        assert_eq!(
541            result.findings.len(),
542            2,
543            "rule without skip_test_code should find both"
544        );
545    }
546
547    #[test]
548    fn all_builtin_rules_compile() {
549        let rules = load_builtin_rules().unwrap();
550        assert!(
551            rules.len() >= 55,
552            "expected at least 55 rules, got {}",
553            rules.len()
554        );
555        let compiled = compile_rules(&rules).expect("all builtin rules should compile");
556        assert_eq!(compiled.by_id.len(), rules.len());
557    }
558}