From 259f4009a8b56e2fc1cc74736e34ae530bf71e9d Mon Sep 17 00:00:00 2001 From: Carson McManus Date: Tue, 23 Dec 2025 12:10:05 -0500 Subject: [PATCH] feat(cli): rule profiler --- .changeset/rare-queens-doubt.md | 5 + Cargo.lock | 1 + crates/biome_analyze/Cargo.toml | 3 + crates/biome_analyze/src/analyzer_plugin.rs | 40 +- crates/biome_analyze/src/lib.rs | 1 + crates/biome_analyze/src/profiling.rs | 597 ++++++++++++++++++ crates/biome_analyze/src/registry.rs | 6 +- ...ing__tests__display_profiles_snapshot.snap | 16 + crates/biome_cli/src/commands/check.rs | 5 + crates/biome_cli/src/commands/lint.rs | 6 + crates/biome_cli/src/commands/mod.rs | 12 + crates/biome_cli/src/lib.rs | 4 + crates/biome_cli/src/reporter/summary.rs | 7 + crates/biome_cli/src/reporter/terminal.rs | 6 + .../main_commands_check/check_help.snap | 4 +- .../main_commands_lint/lint_help.snap | 5 +- 16 files changed, 697 insertions(+), 21 deletions(-) create mode 100644 .changeset/rare-queens-doubt.md create mode 100644 crates/biome_analyze/src/profiling.rs create mode 100644 crates/biome_analyze/src/snapshots/biome_analyze__profiling__tests__display_profiles_snapshot.snap diff --git a/.changeset/rare-queens-doubt.md b/.changeset/rare-queens-doubt.md new file mode 100644 index 000000000000..5beea075e9fc --- /dev/null +++ b/.changeset/rare-queens-doubt.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": minor +--- + +Added the rule profiler behind the `--profile-rules` cli flag. You can now see a report of which lint rules took the longest to execute. diff --git a/Cargo.lock b/Cargo.lock index 941bc6bace9d..28e0cf788723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,7 @@ dependencies = [ "camino", "enumflags2", "indexmap", + "insta", "rustc-hash 2.1.1", "schemars", "serde", diff --git a/crates/biome_analyze/Cargo.toml b/crates/biome_analyze/Cargo.toml index 543d79722167..bf09f589f532 100644 --- a/crates/biome_analyze/Cargo.toml +++ b/crates/biome_analyze/Cargo.toml @@ -28,6 +28,9 @@ rustc-hash = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"], optional = true } +[dev-dependencies] +insta = { workspace = true } + [features] schema = ["biome_console/schema", "dep:schemars", "serde"] serde = ["dep:biome_deserialize", "dep:biome_deserialize_macros", "dep:serde"] diff --git a/crates/biome_analyze/src/analyzer_plugin.rs b/crates/biome_analyze/src/analyzer_plugin.rs index 629d33b85d8f..7051e9122298 100644 --- a/crates/biome_analyze/src/analyzer_plugin.rs +++ b/crates/biome_analyze/src/analyzer_plugin.rs @@ -6,7 +6,9 @@ use std::{fmt::Debug, sync::Arc}; use biome_rowan::{AnySyntaxNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, WalkEvent}; use crate::matcher::SignalRuleKey; -use crate::{DiagnosticSignal, RuleCategory, RuleDiagnostic, SignalEntry, Visitor, VisitorContext}; +use crate::{ + DiagnosticSignal, RuleCategory, RuleDiagnostic, SignalEntry, Visitor, VisitorContext, profiling, +}; /// Slice of analyzer plugins that can be cheaply cloned. pub type AnalyzerPluginSlice<'a> = &'a [Arc>]; @@ -100,24 +102,26 @@ where return; } - let signals = self + let rule_timer = profiling::start_plugin_rule("plugin"); + let diagnostics = self .plugin - .evaluate(node.clone().into(), ctx.options.file_path.clone()) - .into_iter() - .map(|diagnostic| { - let name = diagnostic - .subcategory - .clone() - .unwrap_or_else(|| "anonymous".into()); - - SignalEntry { - text_range: diagnostic.span().unwrap_or_default(), - signal: Box::new(DiagnosticSignal::new(move || diagnostic.clone())), - rule: SignalRuleKey::Plugin(name.into()), - category: RuleCategory::Lint, - instances: Default::default(), - } - }); + .evaluate(node.clone().into(), ctx.options.file_path.clone()); + rule_timer.stop(); + + let signals = diagnostics.into_iter().map(|diagnostic| { + let name = diagnostic + .subcategory + .clone() + .unwrap_or_else(|| "anonymous".into()); + + SignalEntry { + text_range: diagnostic.span().unwrap_or_default(), + signal: Box::new(DiagnosticSignal::new(move || diagnostic.clone())), + rule: SignalRuleKey::Plugin(name.into()), + category: RuleCategory::Lint, + instances: Default::default(), + } + }); ctx.signal_queue.extend(signals); } diff --git a/crates/biome_analyze/src/lib.rs b/crates/biome_analyze/src/lib.rs index 69e468f1c355..7c7bc01977dd 100644 --- a/crates/biome_analyze/src/lib.rs +++ b/crates/biome_analyze/src/lib.rs @@ -13,6 +13,7 @@ pub mod context; mod diagnostics; mod matcher; pub mod options; +pub mod profiling; mod query; mod registry; mod rule; diff --git a/crates/biome_analyze/src/profiling.rs b/crates/biome_analyze/src/profiling.rs new file mode 100644 index 000000000000..a00b4a3037ec --- /dev/null +++ b/crates/biome_analyze/src/profiling.rs @@ -0,0 +1,597 @@ +/*! +Per-rule execution time profiling facilities for the analyzer. + +This module provides a lightweight, opt-in profiler that tracks the cumulative +execution time spent inside each lint rule's `Rule::run` implementation. + +Guidelines and design notes: +- It only measures the time spent executing the lint rule itself, not the time + spent querying/matching nodes or building the rule context. Integration points + should start the timer immediately before invoking `R::run` and let it drop + immediately after `R::run` returns. +- It is concurrency-safe and aggregates timings across threads and files. +- Profiling is disabled by default and must be explicitly enabled at runtime. + When disabled, the overhead is near-zero (a fast boolean check). + +At the end of a run, consumers can call `profiling::snapshot()` to retrieve the +aggregated metrics and print them. + +This module intentionally has no output/printing logic; the CLI/reporters are +responsible for formatting and displaying the results. + +To keep the public API stable and easy to use from other crates in the workspace, +all entry points are exposed as top-level functions under the `profiling` module +namespace: +- `enable`, `disable`, `is_enabled` +- `start_rule`, `start_plugin_rule` +- `record_rule_time` +- `snapshot`, `reset`, `drain_sorted_by_total` +*/ + +use crate::matcher::RuleKey; +use crate::rule::Rule; +use biome_console::markup; +use rustc_hash::FxHashMap; +use std::cmp; +use std::fmt; +use std::hash::{Hash, Hasher}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; + +/// Identifies the origin of a rule for profiling purposes. +/// +/// - Built-in rules are addressed by their group and rule name (e.g. "lint/correctness/noUnusedVars"). +/// - Plugin rules are addressed by the plugin-provided name. +#[derive(Clone, Debug)] +pub enum RuleLabel { + Builtin { + group: &'static str, + rule: &'static str, + }, + Plugin(Box), +} + +impl RuleLabel { + pub fn builtin(group: &'static str, rule: &'static str) -> Self { + Self::Builtin { group, rule } + } + + pub fn plugin(name: impl Into>) -> Self { + Self::Plugin(name.into()) + } + + pub fn as_str_components(&self) -> (&str, &str) { + match self { + Self::Builtin { group, rule } => (group, rule), + Self::Plugin(name) => ("plugin", name.as_ref()), + } + } +} + +impl fmt::Display for RuleLabel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Builtin { group, rule } => write!(f, "{}/{}", group, rule), + Self::Plugin(name) => write!(f, "plugin/{}", name), + } + } +} + +impl biome_console::fmt::Display for RuleLabel { + fn fmt(&self, f: &mut biome_console::fmt::Formatter<'_>) -> std::io::Result<()> { + match self { + Self::Builtin { group, rule } => f.write_markup(markup! { {group}"/"{rule} }), + Self::Plugin(name) => f.write_markup(markup! { "plugin/"{name} }), + } + } +} + +// Manual Eq/Hash that treats labels with identical content as the same key. +impl PartialEq for RuleLabel { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + Self::Builtin { + group: g1, + rule: r1, + }, + Self::Builtin { + group: g2, + rule: r2, + }, + ) => { + // We first check for pointer equality to avoid unnecessary string comparisons + core::ptr::eq(g1, g2) && core::ptr::eq(r1, r2) || (*g1 == *g2 && *r1 == *r2) + } + (Self::Plugin(a), Self::Plugin(b)) => a == b, + _ => false, + } + } +} +impl Eq for RuleLabel {} + +impl Hash for RuleLabel { + fn hash(&self, state: &mut H) { + match self { + Self::Builtin { group, rule } => { + state.write_u8(0); // variant discriminator + state.write(group.as_bytes()); + state.write("/".as_bytes()); // prevent collisions + state.write(rule.as_bytes()); + } + Self::Plugin(name) => { + state.write_u8(1); // variant discriminator + state.write(name.as_bytes()); + } + } + } +} + +/// Aggregated metrics for a single rule. +#[derive(Clone, Debug)] +pub struct RuleProfile { + pub label: RuleLabel, + pub total: Duration, + pub count: u32, + pub min: Duration, + pub max: Duration, +} + +impl RuleProfile { + pub fn avg(&self) -> Duration { + if self.count == 0 { + Duration::ZERO + } else { + self.total / self.count + } + } +} + +/// Internal accumulator used by the global profiler. +#[derive(Clone, Debug)] +struct Metric { + total: Duration, + count: u32, + min: Duration, + max: Duration, +} + +impl Default for Metric { + fn default() -> Self { + Self { + total: Duration::ZERO, + count: 0, + min: Duration::MAX, // start with max so first recorded duration becomes the min + max: Duration::ZERO, // start with zero so first recorded duration becomes the max + } + } +} + +impl Metric { + fn record(&mut self, delta: Duration) { + self.total += delta; + self.count = self.count.saturating_add(1); + self.min = cmp::min(self.min, delta); + self.max = cmp::max(self.max, delta); + } + + fn into_profile(self, label: RuleLabel) -> RuleProfile { + RuleProfile { + label, + total: self.total, + count: self.count, + min: if self.count > 0 { + self.min + } else { + Duration::ZERO + }, + max: self.max, + } + } +} + +/// Global, process-wide profiler state. +/// Aggregates timings across all threads/files. +#[derive(Default)] +struct RuleProfiler { + metrics: FxHashMap, +} + +impl RuleProfiler { + fn record(&mut self, label: RuleLabel, delta: Duration) { + self.metrics.entry(label).or_default().record(delta); + } + + fn snapshot(&self) -> Vec { + self.metrics + .iter() + .map(|(label, metric)| metric.clone().into_profile(label.clone())) + .collect() + } + + fn reset(&mut self) { + self.metrics.clear(); + } +} + +#[cfg(not(target_arch = "wasm32"))] +static PROFILER: Mutex> = Mutex::new(None); +#[cfg(not(target_arch = "wasm32"))] +fn with_profiler(f: impl FnOnce(&mut RuleProfiler) -> R) -> Option { + if let Ok(mut guard) = PROFILER.lock() { + let profiler = guard.get_or_insert_with(RuleProfiler::default); + Some(f(profiler)) + } else { + None + } +} + +#[cfg(target_arch = "wasm32")] +fn with_profiler(_f: impl FnOnce(&mut RuleProfiler) -> R) -> Option { + None +} + +static ENABLED: AtomicBool = AtomicBool::new(false); + +/// Enables rule execution profiling for the current process. +pub fn enable() { + ENABLED.store(true, Ordering::Relaxed); +} + +/// Disables rule execution profiling for the current process. +pub fn disable() { + ENABLED.store(false, Ordering::Relaxed); +} + +/// Returns whether profiling is currently enabled. +pub fn is_enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} + +/// RAII timer that records elapsed time for a rule when dropped. +pub struct RuleRunTimer { + label: Option, + #[cfg(not(target_arch = "wasm32"))] + start: Instant, +} + +impl RuleRunTimer { + fn new_enabled(label: RuleLabel) -> Self { + Self { + label: Some(label), + #[cfg(not(target_arch = "wasm32"))] + start: Instant::now(), + } + } + + fn new_disabled() -> Self { + // We still initialize `start` to a valid Instant to keep struct layout simple, + // but it won't be used as `label` is None. + Self { + label: None, + #[cfg(not(target_arch = "wasm32"))] + start: Instant::now(), + } + } + + /// Consume the timer and manually record the elapsed time (useful when you need explicit control). + pub fn stop(self) { + drop(self) + } +} + +impl Drop for RuleRunTimer { + fn drop(&mut self) { + // We use Drop to record the elapsed time so its impossible to accidentally reuse the timer. + if let Some(label) = self.label.take() { + #[cfg(not(target_arch = "wasm32"))] + let elapsed = self.start.elapsed(); + #[cfg(target_arch = "wasm32")] + let elapsed = Duration::ZERO; + with_profiler(|p| p.record(label, elapsed)); + } + } +} + +/// Starts measuring execution time for a built-in rule `R`. +/// +/// When profiling is disabled, returns a no-op timer with near-zero overhead. +pub fn start_rule() -> RuleRunTimer { + if !is_enabled() { + return RuleRunTimer::new_disabled(); + } + let key: RuleKey = RuleKey::rule::(); + RuleRunTimer::new_enabled(RuleLabel::builtin(key.group(), key.rule_name())) +} + +/// Starts measuring execution time for a plugin rule with the specified `name`. +/// +/// When profiling is disabled, returns a no-op timer with near-zero overhead. +pub fn start_plugin_rule(name: impl Into>) -> RuleRunTimer { + if !is_enabled() { + return RuleRunTimer::new_disabled(); + } + RuleRunTimer::new_enabled(RuleLabel::plugin(name)) +} + +/// Records a duration for the given label, bypassing RAII timers. +/// +/// Useful for one-off custom measurements. +pub fn record_rule_time(label: RuleLabel, delta: Duration) { + if !is_enabled() { + return; + } + with_profiler(|p| p.record(label, delta)); +} + +/// Returns a snapshot of all collected profiles in unspecified order. +pub fn snapshot() -> Vec { + with_profiler(|p| p.snapshot()).unwrap_or_default() +} + +/// Returns all profiles sorted by total time (descending). +pub fn drain_sorted_by_total(reset_after: bool) -> Vec { + let mut profiles = with_profiler(|p| p.snapshot()).unwrap_or_default(); + + profiles.sort_by(|a, b| b.total.cmp(&a.total)); + + if reset_after { + reset(); + } + + profiles +} + +/// Clears all collected metrics. +pub fn reset() { + with_profiler(|p| p.reset()); +} + +/// Utility for formatting a summary of rule profiles for display purposes. +pub struct DisplayProfiles(pub Vec, pub Option); + +impl biome_console::fmt::Display for DisplayProfiles { + fn fmt(&self, f: &mut biome_console::fmt::Formatter<'_>) -> std::io::Result<()> { + let mut profiles = self.0.clone(); + // Sort by total time descending + profiles.sort_by(|a, b| b.total.cmp(&a.total)); + let limit = self.1.unwrap_or(profiles.len()).min(profiles.len()); + + // minimum width of 5, or wider if needed for larger counts + let count_column_width = profiles + .iter() + .map(|p| p.count) + .max() + .unwrap_or(0) + .to_string() + .len() + .max(5); + + // Header + f.write_markup(markup! { + "Rule execution time"" ""(does not include any preprocessing)""\n" + {RuleProfileSummaryHeader { count_column_width }}"\n" + })?; + + // Determine per-column cutoffs for the largest 10% values among displayed rows + // warn_count is the number of entries that make up the top 10% + let warn_count = limit / 10; + + // Collect column values from the displayed slice + let displayed: Vec<_> = profiles.iter().take(limit).collect(); + + let mut totals: Vec<_> = displayed.iter().map(|p| p.total).collect(); + let mut avgs: Vec<_> = displayed.iter().map(|p| p.avg()).collect(); + let mut mins: Vec<_> = displayed.iter().map(|p| p.min).collect(); + let mut maxs: Vec<_> = displayed.iter().map(|p| p.max).collect(); + let mut counts: Vec<_> = displayed.iter().map(|p| p.count).collect(); + + // Compute cutoffs (smallest value among the top 10% when sorted descending) + let totals_cutoff = if warn_count == 0 || totals.is_empty() { + None + } else { + totals.sort_by(|a, b| b.cmp(a)); + totals.get(warn_count - 1).copied() + }; + + let avgs_cutoff = if warn_count == 0 || avgs.is_empty() { + None + } else { + avgs.sort_by(|a, b| b.cmp(a)); + avgs.get(warn_count - 1).copied() + }; + + let mins_cutoff = if warn_count == 0 || mins.is_empty() { + None + } else { + mins.sort_by(|a, b| b.cmp(a)); + mins.get(warn_count - 1).copied() + }; + + let maxs_cutoff = if warn_count == 0 || maxs.is_empty() { + None + } else { + maxs.sort_by(|a, b| b.cmp(a)); + maxs.get(warn_count - 1).copied() + }; + + let counts_cutoff = if warn_count == 0 || counts.is_empty() { + None + } else { + counts.sort_by(|a, b| b.cmp(a)); + counts.get(warn_count - 1).copied() + }; + + for p in profiles.into_iter().take(limit) { + let total = FmtDuration(p.total); + let avg = FmtDuration(p.avg()); + let min = FmtDuration(p.min); + let max = FmtDuration(p.max); + let count = format!("{:>1$}", p.count, count_column_width); + f.write_str(" ")?; + + if totals_cutoff.is_some_and(|c| p.total >= c) { + f.write_markup(markup! { {total} })?; + } else { + f.write_markup(markup! { {total} })?; + } + f.write_str(" ")?; + + if avgs_cutoff.is_some_and(|c| p.avg() >= c) { + f.write_markup(markup! { {avg} })?; + } else { + f.write_markup(markup! { {avg} })?; + } + f.write_str(" ")?; + + if mins_cutoff.is_some_and(|c| p.min >= c) { + f.write_markup(markup! { {min} })?; + } else { + f.write_markup(markup! { {min} })?; + } + f.write_str(" ")?; + + if maxs_cutoff.is_some_and(|c| p.max >= c) { + f.write_markup(markup! { {max} })?; + } else { + f.write_markup(markup! { {max} })?; + } + f.write_str(" ")?; + + if counts_cutoff.is_some_and(|c| p.count >= c) { + f.write_markup(markup! { {count} })?; + } else { + f.write_markup(markup! { {count} })?; + } + f.write_str(" ")?; + + f.write_markup(markup! { + {p.label} "\n" + })?; + } + + Ok(()) + } +} + +struct RuleProfileSummaryHeader { + count_column_width: usize, +} + +impl biome_console::fmt::Display for RuleProfileSummaryHeader { + fn fmt(&self, f: &mut biome_console::fmt::Formatter<'_>) -> std::io::Result<()> { + f.write_fmt(format_args!( + " {:<10} {:<10} {:<10} {:<10} {:) -> std::io::Result<()> { + f.write_fmt(format_args!( + "{:>10.precision$?}", + self.0, + precision = NUM_DECIMAL_PLACES + )) + } +} + +#[cfg(test)] +mod tests { + use super::{DisplayProfiles, RuleLabel, RuleProfile}; + use biome_console::fmt::{Formatter, Termcolor}; + use biome_console::{Markup, markup}; + use biome_diagnostics::termcolor::NoColor; + use std::time::Duration; + + fn render_markup(markup: Markup) -> String { + let mut buffer = Vec::new(); + let mut write = Termcolor(NoColor::new(&mut buffer)); + let mut fmt = Formatter::new(&mut write); + fmt.write_markup(markup).unwrap(); + + String::from_utf8(buffer).unwrap() + } + + fn profile(label: RuleLabel, total: u64, count: u32, min: u64, max: u64) -> RuleProfile { + RuleProfile { + label, + total: Duration::from_secs(total), + count, + min: Duration::from_secs(min), + max: Duration::from_secs(max), + } + } + + #[test] + fn display_profiles_snapshot() { + let profiles = vec![ + profile( + RuleLabel::builtin("lint/complexity", "useSimplerLogic"), + 16, + 8, + 1, + 4, + ), + profile(RuleLabel::plugin("acme/validateApi"), 4, 2, 1, 2), + profile( + RuleLabel::builtin("lint/correctness", "noUnusedVariables"), + 20, + 10, + 1, + 5, + ), + profile( + RuleLabel::builtin("lint/security", "detectHardcodedSecret"), + 10, + 5, + 1, + 3, + ), + profile(RuleLabel::builtin("lint/style", "useConst"), 14, 7, 1, 3), + profile(RuleLabel::plugin("acme/noOddities"), 2, 1, 1, 1), + profile( + RuleLabel::builtin("lint/suspicious", "noDoubleEquals"), + 18, + 9, + 1, + 4, + ), + profile( + RuleLabel::builtin("lint/performance", "useTopLevelRegex"), + 12, + 6, + 1, + 3, + ), + profile(RuleLabel::builtin("lint/a11y", "noAutofocus"), 8, 4, 1, 2), + profile( + RuleLabel::builtin("lint/nursery", "useConsistentOperator"), + 6, + 3, + 1, + 2, + ), + ]; + + let rendered = render_markup(markup! {{ DisplayProfiles(profiles, None) }}); + + insta::assert_snapshot!(rendered); + } +} diff --git a/crates/biome_analyze/src/registry.rs b/crates/biome_analyze/src/registry.rs index a1f6f8fea298..e17ff5426aa8 100644 --- a/crates/biome_analyze/src/registry.rs +++ b/crates/biome_analyze/src/registry.rs @@ -424,7 +424,11 @@ impl RegistryRule { jsx_fragment_factory, )?; - for result in R::run(&ctx) { + let rule_timer = crate::profiling::start_rule::(); + let signals = R::run(&ctx); + rule_timer.stop(); + + for result in signals { let text_range = R::text_range(&ctx, &result).unwrap_or_else(|| params.query.text_range()); diff --git a/crates/biome_analyze/src/snapshots/biome_analyze__profiling__tests__display_profiles_snapshot.snap b/crates/biome_analyze/src/snapshots/biome_analyze__profiling__tests__display_profiles_snapshot.snap new file mode 100644 index 000000000000..cffa61250697 --- /dev/null +++ b/crates/biome_analyze/src/snapshots/biome_analyze__profiling__tests__display_profiles_snapshot.snap @@ -0,0 +1,16 @@ +--- +source: crates/biome_analyze/src/profiling.rs +expression: rendered +--- +Rule execution time (does not include any preprocessing) + total avg min max count rule + 20.000s 2.000s 1.000s 5.000s 10 lint/correctness/noUnusedVariables + 18.000s 2.000s 1.000s 4.000s 9 lint/suspicious/noDoubleEquals + 16.000s 2.000s 1.000s 4.000s 8 lint/complexity/useSimplerLogic + 14.000s 2.000s 1.000s 3.000s 7 lint/style/useConst + 12.000s 2.000s 1.000s 3.000s 6 lint/performance/useTopLevelRegex + 10.000s 2.000s 1.000s 3.000s 5 lint/security/detectHardcodedSecret + 8.000s 2.000s 1.000s 2.000s 4 lint/a11y/noAutofocus + 6.000s 2.000s 1.000s 2.000s 3 lint/nursery/useConsistentOperator + 4.000s 2.000s 1.000s 2.000s 2 plugin/acme/validateApi + 2.000s 2.000s 1.000s 1.000s 1 plugin/acme/noOddities diff --git a/crates/biome_cli/src/commands/check.rs b/crates/biome_cli/src/commands/check.rs index 730f5b26faaa..888322b564db 100644 --- a/crates/biome_cli/src/commands/check.rs +++ b/crates/biome_cli/src/commands/check.rs @@ -44,6 +44,7 @@ pub(crate) struct CheckCommandPayload { pub(crate) css_parser: Option, pub(crate) only: Vec, pub(crate) skip: Vec, + pub(crate) profile_rules: bool, } struct CheckExecution { @@ -179,6 +180,10 @@ impl TraversalCommand for CheckCommandPayload { unsafe_: self.unsafe_, })?; + if self.profile_rules { + biome_analyze::profiling::enable(); + } + Ok(Box::new(CheckExecution { fix_file_mode, stdin_file_path: self.stdin_file_path.clone(), diff --git a/crates/biome_cli/src/commands/lint.rs b/crates/biome_cli/src/commands/lint.rs index 52503236c76f..050742a44281 100644 --- a/crates/biome_cli/src/commands/lint.rs +++ b/crates/biome_cli/src/commands/lint.rs @@ -49,6 +49,7 @@ pub(crate) struct LintCommandPayload { pub(crate) graphql_linter: Option, pub(crate) json_parser: Option, pub(crate) css_parser: Option, + pub(crate) profile_rules: bool, } struct LintExecution { @@ -169,6 +170,11 @@ impl TraversalCommand for LintCommandPayload { suppress: self.suppress, suppression_reason: self.suppression_reason.clone(), })?; + + if self.profile_rules { + biome_analyze::profiling::enable(); + } + Ok(Box::new(LintExecution { fix_file_mode, stdin_file_path: self.stdin_file_path.clone(), diff --git a/crates/biome_cli/src/commands/mod.rs b/crates/biome_cli/src/commands/mod.rs index f2e6ddf33634..b2397c96f089 100644 --- a/crates/biome_cli/src/commands/mod.rs +++ b/crates/biome_cli/src/commands/mod.rs @@ -142,8 +142,15 @@ pub enum BiomeCommand { configuration: Option, #[bpaf(external, hide_usage)] cli_options: CliOptions, + #[bpaf(external, hide_usage)] log_options: LogOptions, + + /// Enable rule profiling output. + /// Captures timing only for rule execution, not preprocessing such as querying or building the semantic model. + #[bpaf(long("profile-rules"), switch)] + profile_rules: bool, + /// Use this option when you want to format code piped from `stdin`, and /// print the output to `stdout`. /// @@ -314,6 +321,11 @@ pub enum BiomeCommand { /// flag and the `defaultBranch` is not set in your biome.json #[bpaf(long("since"), argument("REF"))] since: Option, + /// Enable rule profiling output. + /// Captures timing only for rule execution, not preprocessing such as querying or building the semantic model. + #[bpaf(long("profile-rules"), switch)] + profile_rules: bool, + /// Single file, single path or list of paths #[bpaf(positional("PATH"), many)] paths: Vec, diff --git a/crates/biome_cli/src/lib.rs b/crates/biome_cli/src/lib.rs index 5925738e8514..9b8552639582 100644 --- a/crates/biome_cli/src/lib.rs +++ b/crates/biome_cli/src/lib.rs @@ -99,6 +99,7 @@ impl<'app> CliSession<'app> { log_options, only, skip, + profile_rules, } => run_command( self, &log_options, @@ -122,6 +123,7 @@ impl<'app> CliSession<'app> { css_parser, only, skip, + profile_rules, }), ), BiomeCommand::Lint { @@ -148,6 +150,7 @@ impl<'app> CliSession<'app> { css_parser, json_parser, log_options, + profile_rules, } => run_command( self, &log_options, @@ -174,6 +177,7 @@ impl<'app> CliSession<'app> { graphql_linter, css_parser, json_parser, + profile_rules, }), ), BiomeCommand::Ci { diff --git a/crates/biome_cli/src/reporter/summary.rs b/crates/biome_cli/src/reporter/summary.rs index c06bcf28c03d..2a2ece4bb8fd 100644 --- a/crates/biome_cli/src/reporter/summary.rs +++ b/crates/biome_cli/src/reporter/summary.rs @@ -2,6 +2,8 @@ use crate::reporter::terminal::ConsoleTraversalSummary; use crate::reporter::{EvaluatedPathsDiagnostic, FixedPathsDiagnostic, Reporter, ReporterVisitor}; use crate::runner::execution::Execution; use crate::{DiagnosticsPayload, TraversalSummary}; + +use biome_analyze::profiling::DisplayProfiles; use biome_console::fmt::{Display, Formatter}; use biome_console::{Console, ConsoleExt, MarkupBuf, markup}; use biome_diagnostics::advice::ListAdvice; @@ -69,6 +71,11 @@ impl ReporterVisitor for SummaryReporterVisitor<'_> { {ConsoleTraversalSummary(execution, &summary, verbose)} }); + let profiles = biome_analyze::profiling::drain_sorted_by_total(false); + if !profiles.is_empty() { + self.0.log(markup! {{ DisplayProfiles(profiles, None) }}); + } + Ok(()) } diff --git a/crates/biome_cli/src/reporter/terminal.rs b/crates/biome_cli/src/reporter/terminal.rs index 3f4398e146bd..e1eba41da1c4 100644 --- a/crates/biome_cli/src/reporter/terminal.rs +++ b/crates/biome_cli/src/reporter/terminal.rs @@ -3,6 +3,8 @@ use crate::reporter::{ TraversalSummary, }; use crate::runner::execution::Execution; +use biome_analyze::profiling; +use biome_analyze::profiling::DisplayProfiles; use biome_console::fmt::Formatter; use biome_console::{Console, ConsoleExt, fmt, markup}; use biome_diagnostics::PrintDiagnostic; @@ -65,6 +67,10 @@ impl ReporterVisitor for ConsoleReporterVisitor<'_> { self.0.log(markup! { {ConsoleTraversalSummary(execution, &summary, verbose)} }); + let profiles = profiling::drain_sorted_by_total(false); + if !profiles.is_empty() { + self.0.log(markup! {{ DisplayProfiles(profiles, None) }}); + } Ok(()) } diff --git a/crates/biome_cli/tests/snapshots/main_commands_check/check_help.snap b/crates/biome_cli/tests/snapshots/main_commands_check/check_help.snap index 25b97ed42d89..8f2cb880815f 100644 --- a/crates/biome_cli/tests/snapshots/main_commands_check/check_help.snap +++ b/crates/biome_cli/tests/snapshots/main_commands_check/check_help.snap @@ -8,7 +8,7 @@ expression: redactor(content) Runs formatter, linter and import sorting to the requested files. Usage: check [--write] [--unsafe] [--assist-enabled=] [--enforce-assist=] [ ---format-with-errors=] [--staged] [--changed] [--since=REF] [--only= +--format-with-errors=] [--profile-rules] [--staged] [--changed] [--since=REF] [--only= ]... [--skip=]... [PATH]... Options that changes how the JSON parser behaves @@ -338,6 +338,8 @@ Available options: actions aren't applied. Defaults to `true`. --format-with-errors= Whether formatting should be allowed to proceed if a given file has syntax errors + --profile-rules Enable rule profiling output. Captures timing only for rule execution, + not preprocessing such as querying or building the semantic model. --stdin-file-path=PATH Use this option when you want to format code piped from `stdin`, and print the output to `stdout`. The file doesn't need to exist on disk, what matters is the extension diff --git a/crates/biome_cli/tests/snapshots/main_commands_lint/lint_help.snap b/crates/biome_cli/tests/snapshots/main_commands_lint/lint_help.snap index f7e597ff4761..a3022ae70296 100644 --- a/crates/biome_cli/tests/snapshots/main_commands_lint/lint_help.snap +++ b/crates/biome_cli/tests/snapshots/main_commands_lint/lint_help.snap @@ -8,7 +8,8 @@ expression: redactor(content) Run various checks on a set of files. Usage: lint [--write] [--unsafe] [--suppress] [--reason=STRING] [--only= -]... [--skip=]... [--staged] [--changed] [--since=REF] [PATH]... +]... [--skip=]... [--staged] [--changed] [--since=REF] [--profile-rules] [ +PATH]... Options that changes how the JSON parser behaves --json-parse-allow-comments= Allow parsing comments in `.json` files @@ -132,6 +133,8 @@ Available options: --since=REF Use this to specify the base branch to compare against when you're using the --changed flag and the `defaultBranch` is not set in your biome.json + --profile-rules Enable rule profiling output. Captures timing only for rule execution, + not preprocessing such as querying or building the semantic model. -h, --help Prints help information ```