Skip to main content

react_auditor/
scanner.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use globset::GlobBuilder;
6use ignore::WalkBuilder;
7use indicatif::{ProgressBar, ProgressStyle};
8use oxc_allocator::Allocator;
9use oxc_parser::Parser;
10use oxc_semantic::SemanticBuilder;
11use oxc_span::SourceType;
12use rayon::prelude::*;
13
14use crate::cache::Cache;
15use crate::rules::{RuleRegistry, Violation};
16
17#[derive(Debug, Clone)]
18pub struct ScanResult {
19    pub file_path: String,
20    pub violations: Vec<Violation>,
21}
22
23pub struct Scanner {
24    pub files: Vec<String>,
25    pub registry: RuleRegistry,
26    pub severity_overrides: HashMap<String, String>,
27    pub category_filter: Option<Vec<String>>,
28    pub use_cache: bool,
29    pub file_type_overrides: HashMap<String, HashMap<String, String>>,
30    ignore_patterns: Vec<String>,
31}
32
33impl Scanner {
34    pub fn new(
35        files: Vec<String>,
36        severity_overrides: HashMap<String, String>,
37        category_filter: Option<Vec<String>>,
38        ignore_patterns: Vec<String>,
39    ) -> Self {
40        Self {
41            files,
42            registry: RuleRegistry::new(),
43            severity_overrides,
44            category_filter,
45            use_cache: true,
46            file_type_overrides: HashMap::new(),
47            ignore_patterns,
48        }
49    }
50
51    fn is_ignored(&self, path: &Path) -> bool {
52        if self.ignore_patterns.is_empty() {
53            return false;
54        }
55        let path_str = path.to_string_lossy();
56        self.ignore_patterns.iter().any(|pattern| {
57            if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
58                let matcher = glob.compile_matcher();
59                matcher.is_match(path_str.as_ref())
60            } else {
61                false
62            }
63        })
64    }
65
66    fn walk_files(&self, root: &Path) -> Vec<String> {
67        let mut files = Vec::new();
68        for result in WalkBuilder::new(root).standard_filters(true).build() {
69            if let Ok(entry) = result
70                && entry.file_type().map(|t| t.is_file()).unwrap_or(false)
71                && let Some(ext) = entry.path().extension().and_then(|e| e.to_str())
72                && matches!(ext, "js" | "jsx" | "ts" | "tsx")
73                && !self.is_ignored(entry.path())
74            {
75                files.push(entry.path().to_string_lossy().to_string());
76            }
77        }
78        files
79    }
80
81    fn compute_merged_overrides_map(&self) -> HashMap<String, HashMap<String, String>> {
82        let mut map = HashMap::new();
83        for ext in self.file_type_overrides.keys() {
84            let mut merged = self.severity_overrides.clone();
85            if let Some(overrides) = self.file_type_overrides.get(ext.as_str()) {
86                for (rule_id, severity) in overrides {
87                    merged.insert(rule_id.clone(), severity.clone());
88                }
89            }
90            map.insert(ext.clone(), merged);
91        }
92        map
93    }
94
95    pub fn scan(&self) -> Result<Vec<ScanResult>> {
96        let mut cache = Cache::load();
97        let all_paths = self.resolve_files()?;
98
99        // Pre-compute merged overrides per extension
100        let merged_map = self.compute_merged_overrides_map();
101
102        // Filter to only scan files that changed or weren't cached as clean
103        let paths: Vec<String> = if self.use_cache {
104            all_paths
105                .into_iter()
106                .filter(|p| !cache.is_unchanged_clean(Path::new(p)))
107                .collect()
108        } else {
109            all_paths
110        };
111
112        let total = paths.len();
113
114        let pb = if total > 1 {
115            let bar = ProgressBar::new(total as u64);
116            bar.set_style(
117                ProgressStyle::default_bar()
118                    .template("{spinner:.green} [{bar:32.cyan/blue}] {pos}/{len}  {msg}")
119                    .unwrap()
120                    .progress_chars("=> "),
121            );
122            bar.set_message("scanning...");
123            Some(bar)
124        } else {
125            None
126        };
127
128        let results: Vec<ScanResult> = paths
129            .par_iter()
130            .filter_map(|path_str| {
131                if let Some(ref bar) = pb {
132                    bar.set_message(path_str.to_string());
133                }
134
135                let path = Path::new(path_str);
136                let content = match std::fs::read_to_string(path) {
137                    Ok(c) => c,
138                    Err(_) => {
139                        if let Some(ref bar) = pb {
140                            bar.inc(1);
141                        }
142                        return None;
143                    }
144                };
145
146                let source_type = SourceType::from_path(path).unwrap_or_default();
147                let allocator = Allocator::default();
148                let ret = Parser::new(&allocator, &content, source_type).parse();
149
150                if !ret.diagnostics.is_empty() {
151                    if let Some(ref bar) = pb {
152                        bar.inc(1);
153                    }
154                    return None;
155                }
156
157                let program = allocator.alloc(ret.program);
158                let semantic = SemanticBuilder::new().build(program);
159
160                let overrides = if merged_map.is_empty() {
161                    &self.severity_overrides
162                } else {
163                    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
164                    merged_map.get(ext).unwrap_or(&self.severity_overrides)
165                };
166
167                let violations = self.registry.run_rules(
168                    program,
169                    &semantic.semantic,
170                    &content,
171                    path_str,
172                    overrides,
173                    self.category_filter.as_ref(),
174                );
175
176                if let Some(ref bar) = pb {
177                    bar.inc(1);
178                }
179
180                if violations.is_empty() {
181                    None
182                } else {
183                    Some(ScanResult {
184                        file_path: path_str.clone(),
185                        violations,
186                    })
187                }
188            })
189            .collect();
190
191        // Update cache for scanned files
192        for result in &results {
193            cache.mark_dirty(Path::new(&result.file_path));
194        }
195        for path_str in &paths {
196            if !results.iter().any(|r| r.file_path == *path_str) {
197                cache.mark_clean(Path::new(path_str));
198            }
199        }
200        cache.save();
201
202        if let Some(bar) = pb {
203            let v = results.iter().map(|r| r.violations.len()).sum::<usize>();
204            bar.finish_with_message(format!("{v} violation(s) in {} file(s)", results.len()));
205        }
206
207        Ok(results)
208    }
209
210    /// Scan a specific list of file paths without resolving globs / applying ignores.
211    pub fn scan_paths(&self, paths: &[String]) -> Result<Vec<ScanResult>> {
212        let total = paths.len();
213        let merged_map = self.compute_merged_overrides_map();
214
215        let pb = if total > 1 {
216            let bar = ProgressBar::new(total as u64);
217            bar.set_style(
218                ProgressStyle::default_bar()
219                    .template("{spinner:.green} [{bar:32.cyan/blue}] {pos}/{len}  {msg}")
220                    .unwrap()
221                    .progress_chars("=> "),
222            );
223            bar.set_message("scanning...");
224            Some(bar)
225        } else {
226            None
227        };
228
229        let results: Vec<ScanResult> = paths
230            .par_iter()
231            .filter_map(|path_str| {
232                if let Some(ref bar) = pb {
233                    bar.set_message(path_str.to_string());
234                }
235
236                let path = Path::new(path_str);
237                let content = std::fs::read_to_string(path).ok()?;
238                let source_type = SourceType::from_path(path).unwrap_or_default();
239                let allocator = Allocator::default();
240                let ret = Parser::new(&allocator, &content, source_type).parse();
241
242                if !ret.diagnostics.is_empty() {
243                    if let Some(ref bar) = pb {
244                        bar.inc(1);
245                    }
246                    return None;
247                }
248
249                let program = allocator.alloc(ret.program);
250                let semantic = SemanticBuilder::new().build(program);
251
252                let overrides = if merged_map.is_empty() {
253                    &self.severity_overrides
254                } else {
255                    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
256                    merged_map.get(ext).unwrap_or(&self.severity_overrides)
257                };
258
259                let violations = self.registry.run_rules(
260                    program,
261                    &semantic.semantic,
262                    &content,
263                    path_str,
264                    overrides,
265                    self.category_filter.as_ref(),
266                );
267
268                if let Some(ref bar) = pb {
269                    bar.inc(1);
270                }
271
272                if violations.is_empty() {
273                    None
274                } else {
275                    Some(ScanResult {
276                        file_path: path_str.clone(),
277                        violations,
278                    })
279                }
280            })
281            .collect();
282
283        if let Some(bar) = pb {
284            let v = results.iter().map(|r| r.violations.len()).sum::<usize>();
285            bar.finish_with_message(format!("{v} violation(s) in {} file(s)", results.len()));
286        }
287
288        Ok(results)
289    }
290
291    fn resolve_files(&self) -> Result<Vec<String>> {
292        let mut files = Vec::new();
293
294        if self.files.is_empty() {
295            files = self.walk_files(Path::new("src"));
296        } else {
297            for pattern in &self.files {
298                let path = Path::new(pattern);
299                if path.is_file() {
300                    files.push(pattern.clone());
301                } else if path.is_dir() {
302                    files.extend(self.walk_files(path));
303                } else {
304                    let glob_pattern = globset::Glob::new(pattern)
305                        .with_context(|| format!("Invalid glob pattern: {pattern}"))?
306                        .compile_matcher();
307
308                    for entry in WalkBuilder::new(".").standard_filters(true).build() {
309                        if let Ok(entry) = entry
310                            && entry.file_type().map(|t| t.is_file()).unwrap_or(false)
311                            && glob_pattern.is_match(entry.path())
312                            && !self.is_ignored(entry.path())
313                        {
314                            files.push(entry.path().to_string_lossy().to_string());
315                        }
316                    }
317                }
318            }
319        }
320
321        Ok(files)
322    }
323
324    pub fn apply_fixes(&self, results: &[ScanResult]) -> Result<usize> {
325        let mut total = 0;
326
327        for result in results {
328            let path = Path::new(&result.file_path);
329            let source = std::fs::read_to_string(path)
330                .with_context(|| format!("Failed to read {}", result.file_path))?;
331            let mut fixed = source.clone();
332
333            for v in result.violations.iter().rev() {
334                let Some(rule) = self.registry.get_rule(&v.rule_id) else {
335                    continue;
336                };
337                let Some(fix) = rule.fix(&v.to_finding(), &fixed) else {
338                    continue;
339                };
340
341                if fix.end <= fixed.len() {
342                    fixed.replace_range(fix.start..fix.end, &fix.replacement);
343                    total += 1;
344                }
345            }
346
347            if total > 0 {
348                std::fs::write(path, &fixed)
349                    .with_context(|| format!("Failed to write {}", result.file_path))?;
350            }
351        }
352
353        Ok(total)
354    }
355}