diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6e14cb4..dd95a7f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -46,6 +46,55 @@ flowchart TD `codescythe::run_and_fix(cwd, config_path)` runs the same analysis, then applies supported unused-file and export removals. +## Profiling + +Profiling is compiled behind the `profiling` Cargo feature so default release +builds do not carry profiler timers, counters, or resolver hot-path branches. +Build the profiling binary explicitly, then set `CODESCYTHE_PROFILE=1` to print +stage timings and high-level counters to stderr. The profile output is +intentionally outside JSON stdout, so it can be used with `--json` and +redirected independently: + +```sh +bazel build -c opt //crates/codescythe_cli:codescythe_profiling +CODESCYTHE_PROFILE=1 bazel-bin/crates/codescythe_cli/codescythe_profiling \ + --json --directory --config \ + > report.json 2> profile.txt +``` + +The profiler reports discovery, entry classification, resolver setup, reachable +graph traversal, issue construction, test scans, optional explanation work, and +finalization. It also breaks graph traversal into frontier parse time and +frontier inspection time, and includes resolver call counts, cache hits/misses, +classification counts, and uncached resolver wall time. The CLI adds JSON +serialization timing for JSON output. + +The current Kibana fixture is a useful stress test because its benchmark config +marks every configured source root as an entry. A representative local run on +May 22, 2026 processed 85,936 project files, treated all 85,936 as entries, +parsed all 85,936, found no unused files, and reported 50,869 unused exports. +The same run spent 12.18s total: 1.18s in project discovery, 10.41s walking the +reachable graph, 81ms building issue maps, and 13ms serializing JSON. Graph +frontier parsing accounted for 4.03s, while frontier inspection accounted for +6.38s. The resolver saw 1,715,145 calls, served 75.9% from the analysis-local +resolution cache, and still spent 4.77s in uncached resolution. + +Those numbers shape the optimization priorities: + +- File reads and parsing are not repeated for the same file inside one analysis + run; `FileCache` parses each file at most once. +- JSON serialization is not a meaningful bottleneck for the current 7 MB Kibana + report. +- Since the benchmark config makes the whole project reachable, entry pruning + cannot help this fixture. It can still matter for real configs with narrower + entries. +- Resolver work remains important even after memoization because Kibana still + has more than 400k unique importer/specifier misses in the resolution cache. +- Small object-copy, line/column, and output-format experiments have not shown a + reliable memory win; the higher-value work is reducing resolver misses, + reducing graph inspection work, or changing the configured entry surface when + coverage allows it. + ## Config Loading `load_config` accepts an analysis root and an optional config path. diff --git a/crates/codescythe/BUILD.bazel b/crates/codescythe/BUILD.bazel index 4cb1619..00b3f76 100644 --- a/crates/codescythe/BUILD.bazel +++ b/crates/codescythe/BUILD.bazel @@ -22,23 +22,38 @@ COMMON_DEPS = [ VERSION = "0.4.12" # x-release-please-version +LIB_SRCS = [ + "analyze/discovery.rs", + "analyze/doctor.rs", + "analyze/explain.rs", + "analyze/graph.rs", + "analyze/parse.rs", + "analyze/profile.rs", + "analyze/resolver.rs", + "analyze/tests.rs", + "analyze/util.rs", + "analyze.rs", + "config.rs", + "fix.rs", + "lib.rs", +] + rust_library( name = "codescythe", - srcs = [ - "analyze/discovery.rs", - "analyze/doctor.rs", - "analyze/explain.rs", - "analyze/graph.rs", - "analyze/parse.rs", - "analyze/resolver.rs", - "analyze/tests.rs", - "analyze/util.rs", - "analyze.rs", - "config.rs", - "fix.rs", - "lib.rs", - ], + srcs = LIB_SRCS, + compile_data = ["//:config_schema"], + crate_name = "codescythe", + edition = "2024", + version = VERSION, + visibility = ["//visibility:public"], + deps = COMMON_DEPS, +) + +rust_library( + name = "codescythe_profiling", + srcs = LIB_SRCS, compile_data = ["//:config_schema"], + crate_features = ["profiling"], crate_name = "codescythe", edition = "2024", version = VERSION, diff --git a/crates/codescythe/Cargo.toml b/crates/codescythe/Cargo.toml index 4d863bd..5a65a11 100644 --- a/crates/codescythe/Cargo.toml +++ b/crates/codescythe/Cargo.toml @@ -10,6 +10,10 @@ description = "Focused TypeScript dead-code analysis and removal core" [lib] path = "lib.rs" +[features] +default = [] +profiling = [] + [dependencies] anyhow.workspace = true globset.workspace = true diff --git a/crates/codescythe/analyze.rs b/crates/codescythe/analyze.rs index 2354c1e..7be5f9b 100644 --- a/crates/codescythe/analyze.rs +++ b/crates/codescythe/analyze.rs @@ -3,6 +3,7 @@ mod doctor; mod explain; mod graph; mod parse; +mod profile; mod resolver; mod util; @@ -23,6 +24,9 @@ use graph::{ mark_source_file, mark_used_export, mark_used_file, }; use parse::{ExportInfo, FileCache, FileData}; +use profile::AnalysisProfile; +#[cfg(feature = "profiling")] +use profile::AnalysisProfileReport; use resolver::{ ImportResolution, ModuleResolver, UnresolvedImportPolicy, package_import_keys, source_alias_ignore_warnings, source_alias_mappings, @@ -292,26 +296,50 @@ pub fn analyze_path( config: &CodescytheConfig, options: AnalysisOptions, ) -> Result { + let mut profile = AnalysisProfile::new(); let cwd = absolute_normalize_path(cwd)?; if !cwd.exists() { anyhow::bail!("analysis root does not exist: {}", cwd.display()); } - let project_files = discover_project_files(&cwd, config)?; - let entry_files = discover_entry_files(&cwd, config, &project_files)?; - let test_file_indexes = discover_test_file_indexes(&cwd, config, &project_files)?; + let project_files = profile.time("discover project files", || { + discover_project_files(&cwd, config) + })?; + let entry_files = profile.time("discover entry files", || { + discover_entry_files(&cwd, config, &project_files) + })?; + let test_file_indexes = profile.time("classify test files", || { + discover_test_file_indexes(&cwd, config, &project_files) + })?; let entry_set = entry_files.iter().cloned().collect::>(); let total_files = project_files.len(); - let index_by_path = project_files - .iter() - .enumerate() - .map(|(index, path)| (normalize_path(path), index)) - .collect::>(); - let module_resolver = ModuleResolver::new(&cwd, &project_files, config)?; - let unresolved_policy = UnresolvedImportPolicy::new(config)?; - let alias_mappings = source_alias_mappings(&cwd, config)?; - let source_alias_ignore_warnings = source_alias_ignore_warnings(config, &alias_mappings)?; - let mut files = FileCache::new(&cwd, project_files)?; + let ( + index_by_path, + module_resolver, + unresolved_policy, + alias_mappings, + source_alias_ignore_warnings, + mut files, + ) = profile.time("build indexes and resolver", || { + let index_by_path = project_files + .iter() + .enumerate() + .map(|(index, path)| (normalize_path(path), index)) + .collect::>(); + let module_resolver = ModuleResolver::new(&cwd, &project_files, config)?; + let unresolved_policy = UnresolvedImportPolicy::new(config)?; + let alias_mappings = source_alias_mappings(&cwd, config)?; + let source_alias_ignore_warnings = source_alias_ignore_warnings(config, &alias_mappings)?; + let files = FileCache::new(&cwd, project_files)?; + Ok::<_, anyhow::Error>(( + index_by_path, + module_resolver, + unresolved_policy, + alias_mappings, + source_alias_ignore_warnings, + files, + )) + })?; let mut entry_indexes = HashSet::::new(); let mut used_files = UsedFiles::new(); @@ -339,11 +367,16 @@ pub fn analyze_path( } } + let graph_started = profile.start(); while !queue.is_empty() { let batch = queue.drain(..).collect::>(); + profile.record_frontier(batch.len()); queued_files.clear(); + let parse_started = profile.start(); files.parse_many(&batch)?; + profile.record_frontier_parse(parse_started); + let inspect_started = profile.start(); for index in batch { let file = files.get(index)?.clone(); let public_entry = entry_indexes.contains(&index) && !config.include_entry_exports; @@ -570,8 +603,11 @@ pub fn analyze_path( } } } + profile.record_frontier_inspect(inspect_started); } + profile.record("walk reachable graph", graph_started); + let issue_started = profile.start(); let mut issues = Issues::default(); let mut unused_file_indexes = HashSet::::new(); @@ -630,14 +666,17 @@ pub fn analyze_path( } } } + profile.record("build unused file/export issues", issue_started); - let live_test_support_files = discover_live_test_support_files( - &mut files, - &module_resolver, - &test_file_indexes, - &unused_file_indexes, - &used_files, - )?; + let live_test_support_files = profile.time("scan live test support", || { + discover_live_test_support_files( + &mut files, + &module_resolver, + &test_file_indexes, + &unused_file_indexes, + &used_files, + ) + })?; for index in &live_test_support_files { let relative = files.relative(*index); issues.files.remove(&relative); @@ -645,13 +684,15 @@ pub fn analyze_path( unused_file_indexes.remove(index); } - let removable_test_files = discover_removable_test_files( - &mut files, - &module_resolver, - &test_file_indexes, - &unused_file_indexes, - &issues.exports, - )?; + let removable_test_files = profile.time("scan removable tests", || { + discover_removable_test_files( + &mut files, + &module_resolver, + &test_file_indexes, + &unused_file_indexes, + &issues.exports, + ) + })?; for index in removable_test_files { let relative = files.relative(index); issues.files.insert( @@ -665,17 +706,19 @@ pub fn analyze_path( let mut effective_used_files = used_files.clone(); effective_used_files.extend(live_test_support_files.iter().copied()); - let export_usage = if options.verbose || options.explain_export.is_some() { - Some(build_export_usage_index( - &mut files, - &module_resolver, - &effective_used_files, - &entry_indexes, - &test_file_indexes, - )?) - } else { - None - }; + let export_usage = profile.time("build export usage explanations", || { + if options.verbose || options.explain_export.is_some() { + Ok(Some(build_export_usage_index( + &mut files, + &module_resolver, + &effective_used_files, + &entry_indexes, + &test_file_indexes, + )?)) + } else { + Ok(None) + } + })?; if options.verbose { if let Some(export_usage) = &export_usage { @@ -690,20 +733,23 @@ pub fn analyze_path( } } - let explain_export = if let Some(request) = &options.explain_export { - Some(explain_requested_export( - request, - &mut files, - &issues, - &effective_used_files, - export_usage.as_ref(), - &alias_mappings, - &ignored_unresolved_imports_by_pattern, - )?) - } else { - None - }; + let explain_export = profile.time("explain requested export", || { + if let Some(request) = &options.explain_export { + Ok(Some(explain_requested_export( + request, + &mut files, + &issues, + &effective_used_files, + export_usage.as_ref(), + &alias_mappings, + &ignored_unresolved_imports_by_pattern, + )?)) + } else { + Ok(None) + } + })?; + let finalize_started = profile.start(); issues.unresolved = unresolved .into_iter() .map(|(file, imports)| { @@ -749,6 +795,22 @@ pub fn analyze_path( BTreeMap::new() }; + profile.record("finalize report", finalize_started); + #[cfg(feature = "profiling")] + profile.print(AnalysisProfileReport { + project_files: total_files, + entry_files: entry_indexes.len(), + test_files: test_file_indexes.len(), + parsed_files: files.parsed_count(), + used_files: effective_used_files.len(), + used_exports: used_exports.values().map(HashSet::len).sum(), + issue_files: counters.files, + issue_exports: counters.exports, + unresolved: counters.unresolved, + ignored_unresolved: counters.ignored_unresolved, + resolver: module_resolver.profile_stats(), + }); + Ok(Analysis { issues, counters, diff --git a/crates/codescythe/analyze/parse.rs b/crates/codescythe/analyze/parse.rs index 1f76bf0..9804933 100644 --- a/crates/codescythe/analyze/parse.rs +++ b/crates/codescythe/analyze/parse.rs @@ -108,6 +108,11 @@ impl FileCache { Ok(()) } + #[cfg(feature = "profiling")] + pub(super) fn parsed_count(&self) -> usize { + self.parsed.iter().filter(|file| file.is_some()).count() + } + pub(super) fn relative(&self, index: usize) -> String { relative_path(&self.cwd, &self.paths[index]) } diff --git a/crates/codescythe/analyze/profile.rs b/crates/codescythe/analyze/profile.rs new file mode 100644 index 0000000..647a052 --- /dev/null +++ b/crates/codescythe/analyze/profile.rs @@ -0,0 +1,209 @@ +use anyhow::Result; + +#[cfg(feature = "profiling")] +mod enabled { + use super::*; + use crate::analyze::resolver::ResolverProfileStats; + use std::{ + env, + time::{Duration, Instant}, + }; + + const PROFILE_ENV: &str = "CODESCYTHE_PROFILE"; + + pub(crate) struct AnalysisProfile { + enabled: bool, + started: Option, + stages: Vec, + frontier_batches: usize, + frontier_files: usize, + frontier_parse: Duration, + frontier_inspect: Duration, + } + + pub(crate) struct AnalysisProfileTimer(Option); + + struct AnalysisProfileStage { + name: &'static str, + duration: Duration, + } + + pub(crate) struct AnalysisProfileReport { + pub(crate) project_files: usize, + pub(crate) entry_files: usize, + pub(crate) test_files: usize, + pub(crate) parsed_files: usize, + pub(crate) used_files: usize, + pub(crate) used_exports: usize, + pub(crate) issue_files: usize, + pub(crate) issue_exports: usize, + pub(crate) unresolved: usize, + pub(crate) ignored_unresolved: usize, + pub(crate) resolver: Option, + } + + impl AnalysisProfile { + pub(crate) fn new() -> Self { + let enabled = profile_enabled(); + Self { + enabled, + started: enabled.then(Instant::now), + stages: Vec::new(), + frontier_batches: 0, + frontier_files: 0, + frontier_parse: Duration::ZERO, + frontier_inspect: Duration::ZERO, + } + } + + pub(crate) fn start(&self) -> AnalysisProfileTimer { + AnalysisProfileTimer(self.enabled.then(Instant::now)) + } + + pub(crate) fn time( + &mut self, + name: &'static str, + run: impl FnOnce() -> Result, + ) -> Result { + let started = self.start(); + let result = run(); + self.record(name, started); + result + } + + pub(crate) fn record(&mut self, name: &'static str, started: AnalysisProfileTimer) { + if let Some(started) = started.0 { + self.stages.push(AnalysisProfileStage { + name, + duration: started.elapsed(), + }); + } + } + + pub(crate) fn record_frontier(&mut self, files: usize) { + if self.enabled { + self.frontier_batches += 1; + self.frontier_files += files; + } + } + + pub(crate) fn record_frontier_parse(&mut self, started: AnalysisProfileTimer) { + if let Some(started) = started.0 { + self.frontier_parse += started.elapsed(); + } + } + + pub(crate) fn record_frontier_inspect(&mut self, started: AnalysisProfileTimer) { + if let Some(started) = started.0 { + self.frontier_inspect += started.elapsed(); + } + } + + pub(crate) fn print(&self, report: AnalysisProfileReport) { + if !self.enabled { + return; + } + + eprintln!("codescythe profile:"); + eprintln!( + " total: {}", + format_duration(self.started.expect("profile is enabled").elapsed()) + ); + eprintln!(" stages:"); + for stage in &self.stages { + eprintln!(" {:<34} {}", stage.name, format_duration(stage.duration)); + } + eprintln!( + " graph frontiers: batches={}, files={}, parse={}, inspect={}", + self.frontier_batches, + self.frontier_files, + format_duration(self.frontier_parse), + format_duration(self.frontier_inspect) + ); + eprintln!( + " files: project={}, entries={}, tests={}, parsed={}, used={}", + report.project_files, + report.entry_files, + report.test_files, + report.parsed_files, + report.used_files + ); + eprintln!( + " exports/issues: used_exports={}, unused_files={}, unused_exports={}, unresolved={}, ignored_unresolved={}", + report.used_exports, + report.issue_files, + report.issue_exports, + report.unresolved, + report.ignored_unresolved + ); + if let Some(resolver) = report.resolver { + eprintln!( + " resolver: calls={}, cache_hits={}, cache_misses={}, hit_rate={:.1}%, project={}, external={}, unresolved={}, time={}", + resolver.calls, + resolver.cache_hits, + resolver.cache_misses, + resolver.hit_rate(), + resolver.project, + resolver.external, + resolver.unresolved, + format_duration(resolver.resolve_time) + ); + } + } + } + + pub(crate) fn profile_enabled() -> bool { + env::var(PROFILE_ENV) + .ok() + .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "FALSE")) + } + + fn format_duration(duration: Duration) -> String { + let millis = duration.as_secs_f64() * 1000.0; + if millis >= 1000.0 { + format!("{:.2}s", millis / 1000.0) + } else { + format!("{millis:.1}ms") + } + } +} + +#[cfg(not(feature = "profiling"))] +mod disabled { + use super::*; + + pub(crate) struct AnalysisProfile; + + pub(crate) struct AnalysisProfileTimer; + + impl AnalysisProfile { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) fn start(&self) -> AnalysisProfileTimer { + AnalysisProfileTimer + } + + pub(crate) fn time( + &mut self, + _name: &'static str, + run: impl FnOnce() -> Result, + ) -> Result { + run() + } + + pub(crate) fn record(&mut self, _name: &'static str, _started: AnalysisProfileTimer) {} + + pub(crate) fn record_frontier(&mut self, _files: usize) {} + + pub(crate) fn record_frontier_parse(&mut self, _started: AnalysisProfileTimer) {} + + pub(crate) fn record_frontier_inspect(&mut self, _started: AnalysisProfileTimer) {} + } +} + +#[cfg(not(feature = "profiling"))] +pub(super) use disabled::AnalysisProfile; +#[cfg(feature = "profiling")] +pub(super) use enabled::{AnalysisProfile, AnalysisProfileReport, profile_enabled}; diff --git a/crates/codescythe/analyze/resolver.rs b/crates/codescythe/analyze/resolver.rs index 0135351..b11e3c1 100644 --- a/crates/codescythe/analyze/resolver.rs +++ b/crates/codescythe/analyze/resolver.rs @@ -1,11 +1,40 @@ +#[cfg(feature = "profiling")] +use super::profile::profile_enabled; use super::*; use std::cell::RefCell; +#[cfg(feature = "profiling")] +use std::time::{Duration, Instant}; + pub(super) struct ModuleResolver { resolver: ResolverGeneric, index_by_path: HashMap, resolution_cache: RefCell>, + #[cfg(feature = "profiling")] + profile: Option>, +} + +#[cfg(feature = "profiling")] +#[derive(Clone, Copy, Default)] +pub(super) struct ResolverProfileStats { + pub(super) calls: usize, + pub(super) cache_hits: usize, + pub(super) cache_misses: usize, + pub(super) project: usize, + pub(super) external: usize, + pub(super) unresolved: usize, + pub(super) resolve_time: Duration, +} + +#[cfg(feature = "profiling")] +impl ResolverProfileStats { + pub(super) fn hit_rate(&self) -> f64 { + if self.calls == 0 { + return 0.0; + } + (self.cache_hits as f64 / self.calls as f64) * 100.0 + } } struct IgnoredResolverMetadataFileSystem { @@ -154,22 +183,54 @@ impl ModuleResolver { resolver, index_by_path, resolution_cache: RefCell::new(HashMap::new()), + #[cfg(feature = "profiling")] + profile: profile_enabled().then(|| RefCell::new(ResolverProfileStats::default())), }) } pub(super) fn resolve(&self, from: &FileData, specifier: &str) -> Result { + #[cfg(feature = "profiling")] + if let Some(profile) = &self.profile { + profile.borrow_mut().calls += 1; + } + let cache_key = (from.relative.clone(), specifier.to_string()); if let Some(resolution) = self.resolution_cache.borrow().get(&cache_key) { + #[cfg(feature = "profiling")] + if let Some(profile) = &self.profile { + profile.borrow_mut().cache_hits += 1; + } return Ok(*resolution); } - let resolution = self.resolve_uncached(from, specifier)?; + #[cfg(feature = "profiling")] + let started = self.profile.as_ref().map(|_| Instant::now()); + let resolution = self.resolve_uncached(from, specifier); + #[cfg(feature = "profiling")] + if let (Some(profile), Some(started)) = (&self.profile, started) { + let mut profile = profile.borrow_mut(); + profile.cache_misses += 1; + profile.resolve_time += started.elapsed(); + match resolution { + Ok(ImportResolution::Project(_)) => profile.project += 1, + Ok(ImportResolution::External) => profile.external += 1, + Ok(ImportResolution::Unresolved) => profile.unresolved += 1, + Err(_) => {} + } + } + + let resolution = resolution?; self.resolution_cache .borrow_mut() .insert(cache_key, resolution); Ok(resolution) } + #[cfg(feature = "profiling")] + pub(super) fn profile_stats(&self) -> Option { + self.profile.as_ref().map(|profile| *profile.borrow()) + } + fn resolve_uncached(&self, from: &FileData, specifier: &str) -> Result { match self.resolver.resolve_file(&from.path, specifier) { Ok(resolution) => { diff --git a/crates/codescythe_cli/BUILD.bazel b/crates/codescythe_cli/BUILD.bazel index 024c693..b90614e 100644 --- a/crates/codescythe_cli/BUILD.bazel +++ b/crates/codescythe_cli/BUILD.bazel @@ -24,6 +24,22 @@ rust_binary( ], ) +rust_binary( + name = "codescythe_profiling", + srcs = ["main.rs"], + crate_features = ["profiling"], + crate_name = "codescythe", + edition = "2024", + version = VERSION, + visibility = ["//visibility:public"], + deps = [ + "//crates/codescythe:codescythe_profiling", + "@crates//:anyhow", + "@crates//:clap", + "@crates//:serde_json", + ], +) + rust_test( name = "codescythe_cli_test", srcs = ["main.rs"], @@ -44,6 +60,7 @@ rust_test( crate_name = "codescythe_cli_e2e_test", data = [ ":codescythe", + ":codescythe_profiling", "//tests/fixtures/cli-fix-output:fixtures", "//tests/fixtures/cli-verbose-output:fixtures", "//tests/fixtures/oxc-resolution:fixtures", diff --git a/crates/codescythe_cli/Cargo.toml b/crates/codescythe_cli/Cargo.toml index 8b5d97b..63fe07b 100644 --- a/crates/codescythe_cli/Cargo.toml +++ b/crates/codescythe_cli/Cargo.toml @@ -11,6 +11,10 @@ description = "Codescythe command-line interface" name = "codescythe" path = "main.rs" +[features] +default = [] +profiling = ["codescythe/profiling"] + [dependencies] anyhow.workspace = true clap.workspace = true diff --git a/crates/codescythe_cli/e2e.rs b/crates/codescythe_cli/e2e.rs index 729b76c..777ca99 100644 --- a/crates/codescythe_cli/e2e.rs +++ b/crates/codescythe_cli/e2e.rs @@ -77,6 +77,50 @@ fn cli_resolves_oxc_resolution_fixture() { .contains_key("unusedExtension")); } +#[test] +fn cli_profile_writes_to_stderr_without_polluting_json() { + let output = Command::new(runfile("crates/codescythe_cli/codescythe_profiling")) + .env("CODESCYTHE_PROFILE", "1") + .args([ + "-C", + path_arg(&runfile("tests/fixtures/oxc-resolution")), + "--json", + ]) + .output() + .expect("failed to run codescythe CLI"); + + assert_eq!(output.status.code(), Some(1), "{}", output_text(&output)); + let analysis: Value = + serde_json::from_slice(&output.stdout).expect("CLI stdout should remain JSON"); + assert_eq!(analysis["counters"]["unresolved"], 0); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("codescythe profile:"), "{stderr}"); + assert!(stderr.contains("codescythe cli profile:"), "{stderr}"); + assert!(stderr.contains("json serialization"), "{stderr}"); +} + +#[test] +fn cli_default_build_ignores_profile_env() { + let output = Command::new(runfile("crates/codescythe_cli/codescythe")) + .env("CODESCYTHE_PROFILE", "1") + .args([ + "-C", + path_arg(&runfile("tests/fixtures/oxc-resolution")), + "--json", + ]) + .output() + .expect("failed to run codescythe CLI"); + + assert_eq!(output.status.code(), Some(1), "{}", output_text(&output)); + serde_json::from_slice::(&output.stdout).expect("CLI stdout should remain JSON"); + assert!( + output.stderr.is_empty(), + "unexpected stderr from default build: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn cli_tracks_tests_as_leaf_files_and_fixes_removed_code_tests() { let cli = runfile("crates/codescythe_cli/codescythe"); diff --git a/crates/codescythe_cli/main.rs b/crates/codescythe_cli/main.rs index 8bc06f3..b7da7d5 100644 --- a/crates/codescythe_cli/main.rs +++ b/crates/codescythe_cli/main.rs @@ -4,9 +4,15 @@ use std::{ process::ExitCode, }; +#[cfg(feature = "profiling")] +use std::time::{Duration, Instant}; + use anyhow::Result; use clap::{Parser, Subcommand}; +#[cfg(feature = "profiling")] +const PROFILE_ENV: &str = "CODESCYTHE_PROFILE"; + #[derive(Debug, Parser)] #[command(version, about = "Find focused TypeScript dead code")] struct Args { @@ -87,7 +93,9 @@ fn run() -> Result { }, )?; if args.json { + let started = start_profile_timer(); println!("{}", serde_json::to_string(&result)?); + print_profile_stage("json serialization", started); } else { println!( "Removed {} unused exports from {} files and {} unused files", @@ -132,7 +140,9 @@ fn run() -> Result { )); } if args.json { + let started = start_profile_timer(); println!("{}", serde_json::to_string(&analysis)?); + print_profile_stage("json serialization", started); } else { print_text_report(&analysis); } @@ -149,7 +159,9 @@ fn run_command(command: Command) -> Result { let cwd = analysis_root(args.directory.as_deref(), config)?; let result = codescythe::doctor(&cwd, config)?; if args.json { + let started = start_profile_timer(); println!("{}", serde_json::to_string(&result)?); + print_profile_stage("json serialization", started); } else { print_doctor_report(&result); } @@ -158,6 +170,51 @@ fn run_command(command: Command) -> Result { } } +#[cfg(feature = "profiling")] +struct CliProfileTimer(Option); + +#[cfg(not(feature = "profiling"))] +struct CliProfileTimer; + +#[cfg(feature = "profiling")] +fn start_profile_timer() -> CliProfileTimer { + CliProfileTimer(profile_enabled().then(Instant::now)) +} + +#[cfg(not(feature = "profiling"))] +fn start_profile_timer() -> CliProfileTimer { + CliProfileTimer +} + +#[cfg(feature = "profiling")] +fn print_profile_stage(name: &str, started: CliProfileTimer) { + let Some(started) = started.0 else { + return; + }; + eprintln!("codescythe cli profile:"); + eprintln!(" {name}: {}", format_duration(started.elapsed())); +} + +#[cfg(not(feature = "profiling"))] +fn print_profile_stage(_name: &str, _started: CliProfileTimer) {} + +#[cfg(feature = "profiling")] +fn profile_enabled() -> bool { + env::var(PROFILE_ENV) + .ok() + .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "FALSE")) +} + +#[cfg(feature = "profiling")] +fn format_duration(duration: Duration) -> String { + let millis = duration.as_secs_f64() * 1000.0; + if millis >= 1000.0 { + format!("{:.2}s", millis / 1000.0) + } else { + format!("{millis:.1}ms") + } +} + fn print_text_report(analysis: &codescythe::Analysis) { if !analysis.source_alias_ignore_warnings.is_empty() { println!("Warnings ({})", analysis.source_alias_ignore_warnings.len());