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