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 let merged_map = self.compute_merged_overrides_map();
101
102 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 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 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}