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