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