use std::fmt::Write as _;
use super::BuildEvent;
use crate::{types::diagnostic_options::DiagnosticOptions, types::event_kind::EventKind};
const MIN_ROW_MS: f64 = 1_000.0;
const MAX_MEASURED_ROWS: usize = 12;
const MAX_UNMEASURABLE_ROWS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginTimingKind {
Plugin,
OutputOption,
InputOption,
}
#[derive(Debug, Clone)]
pub struct PluginTiming {
pub owner: String,
pub kind: PluginTimingKind,
pub hook: String,
pub calls: u32,
pub ms: f64,
pub max_in_flight: u32,
pub overlap_ms: f64,
pub rankable: bool,
}
#[derive(Debug, Clone, Default)]
pub struct PluginTimingsMeasurement {
pub busy_ms: f64,
pub rows: Vec<PluginTiming>,
}
#[derive(Debug)]
pub struct PluginTimings {
build_ms: f64,
busy_ms: f64,
measured: Vec<PluginTiming>,
unmeasurable: Vec<PluginTiming>,
measured_hidden: usize,
unmeasurable_hidden: usize,
}
impl PluginTimings {
pub fn new(build_ms: f64, measurement: PluginTimingsMeasurement) -> Option<Self> {
let (rankable, overlapped): (Vec<_>, Vec<_>) =
measurement.rows.into_iter().partition(|row| row.rankable);
let mut measured = rankable.into_iter().filter(|row| row.ms >= MIN_ROW_MS).collect::<Vec<_>>();
measured.sort_by(|a, b| b.ms.total_cmp(&a.ms));
let measured_hidden = measured.len().saturating_sub(MAX_MEASURED_ROWS);
measured.truncate(MAX_MEASURED_ROWS);
let mut unmeasurable = overlapped;
unmeasurable.sort_by_key(|row| std::cmp::Reverse(row.max_in_flight));
let unmeasurable_hidden = unmeasurable.len().saturating_sub(MAX_UNMEASURABLE_ROWS);
unmeasurable.truncate(MAX_UNMEASURABLE_ROWS);
if measured.is_empty() && unmeasurable.is_empty() {
return None;
}
Some(Self {
build_ms,
busy_ms: measurement.busy_ms,
measured,
unmeasurable,
measured_hidden,
unmeasurable_hidden,
})
}
}
impl BuildEvent for PluginTimings {
fn kind(&self) -> EventKind {
EventKind::PluginTimings
}
fn message(&self, _opts: &DiagnosticOptions) -> String {
const DOC_LINK: &str = "https://rolldown.rs/reference/InputOptions.checks#plugintimings";
let share = |ms: f64| -> u64 {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let percent = (ms / self.build_ms * 100.0).round() as u64;
percent.min(100)
};
let mut out = format!(
"Your build spent {}% of {} inside plugin hooks ({}).",
share(self.busy_ms),
format_duration(self.build_ms),
format_duration(self.busy_ms)
);
if !self.measured.is_empty() {
out.push_str(
"\nMeasured inside the callback, so queue time is excluded and time the callback \
itself awaited is not:",
);
let mut listed_ms = 0.0;
for row in &self.measured {
listed_ms += row.ms;
let _ = write!(
out,
"\n - {} {} ({}%, {}, {} call{})",
row.owner,
row.hook,
share(row.ms),
format_duration(row.ms),
row.calls,
plural(row.calls)
);
}
if self.measured_hidden > 0 {
let _ = write!(out, "\n … and {} more below 1s or past the cap", self.measured_hidden);
}
if !self.unmeasurable.is_empty() || self.measured_hidden > 0 {
let _ = write!(
out,
"\nThose rows are {}% of the build; the rest of the {}% is below.",
share(listed_ms),
share(self.busy_ms)
);
}
}
if !self.unmeasurable.is_empty() {
let total = self.unmeasurable.len() + self.unmeasurable_hidden;
let _ = write!(
out,
"\nNot measurable — {} hook{} whose calls overlap, so elapsed time covers work other \
calls were doing. Profile with `node --cpu-prof`:",
total,
plural(u32::try_from(total).unwrap_or(u32::MAX))
);
for row in &self.unmeasurable {
let _ =
write!(out, "\n - {} {} ({} call{})", row.owner, row.hook, row.calls, plural(row.calls));
}
if self.unmeasurable_hidden > 0 {
let _ = write!(out, "\n … and {} more", self.unmeasurable_hidden);
}
}
let _ = write!(out, "\nSee {DOC_LINK} for more details.");
out
}
}
fn plural(count: u32) -> &'static str {
if count == 1 { "" } else { "s" }
}
fn format_duration(ms: f64) -> String {
if ms >= 1_000.0 { format!("{:.1}s", ms / 1_000.0) } else { format!("{}ms", ms.round()) }
}
#[cfg(test)]
mod tests {
use super::*;
fn row(owner: &str, hook: &str, ms: f64, calls: u32, max_in_flight: u32) -> PluginTiming {
PluginTiming {
owner: owner.to_string(),
kind: PluginTimingKind::Plugin,
hook: hook.to_string(),
calls,
ms,
max_in_flight,
overlap_ms: 0.0,
rankable: max_in_flight == 1,
}
}
fn render(build_ms: f64, busy_ms: f64, rows: Vec<PluginTiming>) -> Option<String> {
PluginTimings::new(build_ms, PluginTimingsMeasurement { busy_ms, rows })
.map(|timings| timings.message(&DiagnosticOptions::default()))
}
#[test]
fn ranks_measured_rows_and_names_the_ones_it_cannot_measure() {
let message = render(
10_000.0,
8_000.0,
vec![
row("slow-plugin", "transform", 6_000.0, 500, 1),
row("output options", "codeSplitting groups[].name", 2_000.0, 900, 1),
row("async-plugin", "resolveId", 47_000.0, 3_000, 42),
row("tiny-plugin", "buildStart", 5.0, 1, 1),
],
)
.unwrap();
let rows = message.lines().filter(|line| line.starts_with(" - ")).collect::<Vec<_>>();
assert_eq!(
rows,
vec![
" - slow-plugin transform (60%, 6.0s, 500 calls)",
" - output options codeSplitting groups[].name (20%, 2.0s, 900 calls)",
" - async-plugin resolveId (3000 calls)",
]
);
assert!(message.contains("80% of 10.0s"));
assert!(!message.contains("47.0s"));
assert!(!message.contains("tiny-plugin"));
}
#[test]
fn a_single_call_is_not_pluralised() {
let message = render(10_000.0, 8_000.0, vec![row("p", "buildStart", 2_000.0, 1, 1)]).unwrap();
assert!(message.contains("1 call)"), "{message}");
assert!(!message.contains("1 calls"), "{message}");
}
#[test]
fn says_what_the_listed_rows_add_up_to() {
let message = render(
10_000.0,
9_600.0,
vec![row("p", "transform", 5_000.0, 3, 1), row("q", "resolveId", 4_000.0, 9, 12)],
)
.unwrap();
assert!(message.contains("Those rows are 50% of the build"), "{message}");
assert!(message.contains("96%"), "{message}");
}
#[test]
fn says_how_many_rows_it_dropped() {
let measured = (0..u32::try_from(MAX_MEASURED_ROWS).unwrap() + 3)
.map(|i| row("p", "transform", f64::from(2_000 + i), 1, 1));
let overlapped = (0..u32::try_from(MAX_UNMEASURABLE_ROWS).unwrap() + 2)
.map(|i| row("q", "resolveId", 9_000.0, 1, 2 + i));
let message = render(100_000.0, 90_000.0, measured.chain(overlapped).collect()).unwrap();
assert!(message.contains("… and 3 more below 1s or past the cap"), "{message}");
assert!(message.contains("… and 2 more"), "{message}");
assert!(message.contains("5 hooks whose calls overlap"), "{message}");
}
#[test]
fn says_nothing_when_every_row_is_below_the_floor() {
assert!(render(10_000.0, 8_000.0, vec![row("tiny", "buildStart", 5.0, 1, 1)]).is_none());
}
#[test]
fn says_nothing_when_there_is_nothing_to_say() {
assert!(render(10_000.0, 0.0, vec![]).is_none());
}
#[test]
fn a_span_outrunning_the_build_is_clamped() {
let message =
render(4_000.0, 6_000.0, vec![row("late", "closeBundle", 6_000.0, 1, 1)]).unwrap();
assert!(message.contains("100% of 4.0s"));
assert!(!message.contains("150%"));
}
}