mod analyzer;
mod baseline;
mod changed_files;
mod cli;
mod custom_rules;
mod file_loader;
mod orchestrator;
mod parser;
mod reporter;
mod rules;
mod utils;
use std::fs;
use std::io::Write as _;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
use clap::Parser as ClapParser;
use oxc_allocator::Allocator;
use rayon::prelude::*;
use crate::{
analyzer::analyze,
baseline::{filter_baseline, load_baseline},
changed_files::get_changed_files,
cli::{Cli, FailOn, OutputFormat},
custom_rules::{find_default_rules_file, load_custom_rules, run_custom_rules},
file_loader::collect_files,
orchestrator::run_external_tools,
parser::parse_file,
reporter::{print_stats_box, report_html, report_json, report_sarif, report_text},
rules::Issue,
};
fn fmt_ms(ms: u128) -> String {
if ms >= 1000 {
format!("{:.2}s", ms as f64 / 1000.0)
} else {
format!("{ms}ms")
}
}
fn main() {
let cli = Cli::parse();
let total_start = Instant::now();
eprintln!("react-perf-analyzer v{}", env!("CARGO_PKG_VERSION"));
eprintln!("Scanning: {}", cli.path.display());
eprintln!();
eprint!(" 📂 Discovering files...");
let _ = std::io::stderr().flush();
let t = Instant::now();
let files = collect_files(&cli.path, cli.include_tests);
let discover_ms = t.elapsed().as_millis();
if files.is_empty() {
eprintln!(
"\r ⚠ No JS/TS/JSX files found under '{}'.",
cli.path.display()
);
std::process::exit(0);
}
eprintln!(
"\r 📂 Found {} file(s) in {}{}",
files.len(),
fmt_ms(discover_ms),
" ".repeat(20)
);
let files = if cli.only_changed {
let changed = get_changed_files(&cli.path);
if changed.is_empty() {
eprintln!(" ✓ No changed JS/TS/JSX files — nothing to analyze.");
std::process::exit(0);
}
let changed_set: std::collections::HashSet<_> = changed.into_iter().collect();
let filtered: Vec<_> = files
.into_iter()
.filter(|f| changed_set.contains(f.as_path()))
.collect();
if filtered.is_empty() {
eprintln!(" ✓ No changed JS/TS/JSX files in scope — nothing to analyze.");
std::process::exit(0);
}
eprintln!(
" ⚡ --only-changed: {} changed file(s) to analyze",
filtered.len()
);
filtered
} else {
files
};
let file_count = files.len();
let max_lines = cli.max_component_lines;
let category = cli.category.clone();
let custom_rule_set: Vec<custom_rules::CompiledRule> = {
let rules_path = cli
.rules
.clone()
.or_else(|| find_default_rules_file(&cli.path));
match rules_path {
Some(ref p) => {
let (compiled, errors) = load_custom_rules(p);
for err in &errors {
eprintln!(" ⚠ custom rule: {err}");
}
if !compiled.is_empty() {
eprintln!(
" 📏 Custom rules: {} rule(s) from '{}'",
compiled.len(),
p.display()
);
}
compiled
}
None => vec![],
}
};
let processed = Arc::new(AtomicUsize::new(0));
let done_flag = Arc::new(AtomicBool::new(false));
let prog_count = processed.clone();
let prog_done = done_flag.clone();
let progress_thread = std::thread::spawn(move || loop {
if prog_done.load(Ordering::Relaxed) {
break;
}
let n = prog_count.load(Ordering::Relaxed);
eprint!("\r 🔬 Analyzing files {n}/{file_count}");
let _ = std::io::stderr().flush();
std::thread::sleep(std::time::Duration::from_millis(80));
});
let t = Instant::now();
let all_issues: Vec<Issue> = files
.par_iter()
.flat_map(|path| {
let source_text = match fs::read_to_string(path) {
Ok(s) => s,
Err(err) => {
eprintln!("\n Warning: could not read '{}': {err}", path.display());
processed.fetch_add(1, Ordering::Relaxed);
return vec![];
}
};
let allocator = Allocator::default();
let program = match parse_file(&allocator, path, &source_text) {
Ok(p) => p,
Err(err) => {
eprintln!(
"\n Warning: failed to parse '{}': {}",
err.file,
err.messages.join("; ")
);
processed.fetch_add(1, Ordering::Relaxed);
return vec![];
}
};
let mut file_issues = analyze(&program, &source_text, path, max_lines, &category);
if !custom_rule_set.is_empty() {
file_issues.extend(run_custom_rules(&custom_rule_set, &source_text, path));
}
processed.fetch_add(1, Ordering::Relaxed);
file_issues
})
.collect();
done_flag.store(true, Ordering::Relaxed);
let _ = progress_thread.join();
let analyze_ms = t.elapsed().as_millis();
eprint!(
"\r ✅ Analyzed {file_count} file(s) — {} issue(s) in {}{}",
all_issues.len(),
fmt_ms(analyze_ms),
" ".repeat(20)
);
eprintln!();
let mut all_issues = all_issues;
let external_ran = cli.external;
if cli.external {
let ext = run_external_tools(&cli.path);
for (tool, reason) in &ext.tools_skipped {
eprintln!(" ⚠ {tool}: {reason}");
}
all_issues.extend(ext.issues);
} else {
eprintln!(" ⏭ External tools not enabled (pass --external to run oxlint + cargo-audit)");
}
let all_issues = if let Some(ref baseline_path) = cli.baseline {
let entries = load_baseline(baseline_path);
filter_baseline(all_issues, &entries)
} else {
all_issues
};
eprintln!();
let issue_count = match cli.format {
OutputFormat::Text => {
let count = report_text(&all_issues);
if let Some(ref out_path) = cli.output {
let _ = fs::write(out_path, "");
}
count
}
OutputFormat::Json => {
let count = report_json(&all_issues);
reporter::print_summary(&all_issues);
count
}
OutputFormat::Html => {
let html = report_html(&all_issues, &cli.path, file_count, external_ran);
let out_path = cli
.output
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("react-perf-report.html"));
match fs::write(&out_path, &html) {
Ok(_) => {
let abs_path =
std::fs::canonicalize(&out_path).unwrap_or_else(|_| out_path.clone());
eprintln!("✅ HTML report → {}", out_path.display());
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(&abs_path).spawn();
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(&abs_path)
.spawn();
}
Err(e) => {
eprintln!("Error writing HTML report to '{}': {e}", out_path.display());
std::process::exit(2);
}
}
all_issues.len()
}
OutputFormat::Sarif => {
let sarif = report_sarif(&all_issues, env!("CARGO_PKG_VERSION"));
let out_path = cli
.output
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("results.sarif"));
match fs::write(&out_path, &sarif) {
Ok(_) => eprintln!("✅ SARIF report → {}", out_path.display()),
Err(e) => {
eprintln!("Error writing SARIF to '{}': {e}", out_path.display());
std::process::exit(2);
}
}
all_issues.len()
}
};
let total_ms = total_start.elapsed().as_millis();
let our_count = all_issues
.iter()
.filter(|i| matches!(i.source, crate::rules::IssueSource::ReactPerfAnalyzer))
.count();
let oxlint_count = all_issues
.iter()
.filter(|i| matches!(i.source, crate::rules::IssueSource::OxcLinter))
.count();
let audit_count = all_issues
.iter()
.filter(|i| matches!(i.source, crate::rules::IssueSource::CargoAudit))
.count();
let affected_files = {
let mut files: Vec<_> = all_issues.iter().map(|i| &i.file).collect();
files.sort_unstable();
files.dedup();
files.len()
};
eprintln!();
print_stats_box(
issue_count,
file_count,
affected_files,
our_count,
external_ran,
oxlint_count,
audit_count,
);
eprintln!(" Total time: {}", fmt_ms(total_ms));
let should_fail = match cli.fail_on {
FailOn::None => issue_count > 0,
ref threshold => {
let min_sev = threshold.as_severity().unwrap();
all_issues.iter().any(|i| i.severity >= min_sev)
}
};
std::process::exit(if should_fail { 1 } else { 0 });
}