use std::{borrow::Cow, path::Path, sync::Arc};
use arcstr::ArcStr;
use rolldown_common::{EmittedAsset, Output, OutputChunk};
use rolldown_plugin::{HookNoopReturn, HookUsage, Plugin, PluginContext};
use rolldown_utils::rustc_hash::FxHashMapExt;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::Serialize;
use sugar_path::SugarPath;
mod render_markdown;
use render_markdown::render_markdown;
#[derive(Debug, Default)]
pub struct BundleAnalyzerPlugin {
pub file_name: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AnalyzeData {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) meta: Option<AnalyzeMeta>,
pub(crate) chunks: Vec<ChunkData>,
pub(crate) modules: Vec<ModuleData>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AnalyzeMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) bundler: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) timestamp: Option<u64>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChunkData {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) size: usize,
#[serde(rename = "type")]
pub(crate) chunk_type: ChunkType,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) module_indices: Option<Vec<usize>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) entry_module: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) imports: Option<Vec<ImportRelation>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) reachable_module_indices: Option<Vec<usize>>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ChunkType {
StaticEntry,
DynamicEntry,
Common,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ImportRelation {
pub(crate) target_chunk_index: usize,
#[serde(rename = "type")]
pub(crate) import_type: ImportType,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ImportType {
Static,
Dynamic,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModuleData {
pub(crate) id: String,
pub(crate) path: String,
pub(crate) size: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) importers: Option<Vec<usize>>,
}
impl Plugin for BundleAnalyzerPlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed("builtin:bundle-analyzer")
}
fn register_hook_usage(&self) -> HookUsage {
HookUsage::GenerateBundle
}
async fn generate_bundle(
&self,
ctx: &PluginContext,
args: &mut rolldown_plugin::HookGenerateBundleArgs<'_>,
) -> HookNoopReturn {
let analyze_data = self.build_analyze_data(ctx, args.bundle);
let is_markdown = self.format.as_deref() == Some("md");
let (content, default_filename) = if is_markdown {
(render_markdown(&analyze_data), arcstr::literal!("analyze-data.md"))
} else {
(serde_json::to_string_pretty(&analyze_data)?, arcstr::literal!("analyze-data.json"))
};
ctx
.emit_file_async(EmittedAsset {
file_name: Some(self.file_name.as_ref().map_or(default_filename, ArcStr::from)),
source: content.into(),
..Default::default()
})
.await?;
Ok(())
}
}
impl BundleAnalyzerPlugin {
fn build_analyze_data(&self, ctx: &PluginContext, bundle: &[Output]) -> AnalyzeData {
let cwd = ctx.cwd();
let chunks: Vec<&Arc<OutputChunk>> = bundle
.iter()
.filter_map(|output| match output {
Output::Chunk(chunk) => Some(chunk),
Output::Asset(_) => None,
})
.collect();
let chunk_filename_to_idx: FxHashMap<&str, usize> =
chunks.iter().enumerate().map(|(idx, chunk)| (chunk.filename.as_str(), idx)).collect();
let mut module_id_to_idx: FxHashMap<&str, usize> = FxHashMap::default();
let mut modules_data: Vec<ModuleData> = Vec::new();
for chunk in &chunks {
for module_id in &chunk.module_ids {
if !module_id_to_idx.contains_key(module_id.as_str()) {
let idx = modules_data.len();
module_id_to_idx.insert(module_id.as_str(), idx);
let module_info = ctx.get_module_info(module_id.as_str());
let size =
module_info.as_ref().and_then(|info| info.code.as_ref().map(|c| c.len())).unwrap_or(0);
modules_data.push(ModuleData {
id: format!("mod-{idx}"),
path: stabilize_module_id(module_id, cwd),
size,
importers: None, });
}
}
}
let mut module_importers: FxHashMap<usize, FxHashSet<usize>> =
FxHashMap::with_capacity(modules_data.len());
for (module_id, &module_idx) in &module_id_to_idx {
if let Some(info) = ctx.get_module_info(module_id) {
for importer_id in &info.importers {
if let Some(&importer_idx) = module_id_to_idx.get(importer_id.as_str()) {
module_importers.entry(module_idx).or_default().insert(importer_idx);
}
}
for importer_id in &info.dynamic_importers {
if let Some(&importer_idx) = module_id_to_idx.get(importer_id.as_str()) {
module_importers.entry(module_idx).or_default().insert(importer_idx);
}
}
}
}
for (module_idx, module_data) in modules_data.iter_mut().enumerate() {
if let Some(importers) = module_importers.get(&module_idx).filter(|i| !i.is_empty()) {
let mut importers_vec: Vec<usize> = importers.iter().copied().collect();
importers_vec.sort_unstable();
module_data.importers = Some(importers_vec);
}
}
let module_dependencies = self.build_module_dependencies(ctx, &module_id_to_idx);
let mut chunks_data: Vec<ChunkData> = Vec::with_capacity(chunks.len());
for (chunk_idx, chunk) in chunks.iter().enumerate() {
let chunk_type = if chunk.is_entry {
ChunkType::StaticEntry
} else if chunk.is_dynamic_entry {
ChunkType::DynamicEntry
} else {
ChunkType::Common
};
let module_indices: Vec<usize> = chunk
.module_ids
.iter()
.filter_map(|id| module_id_to_idx.get(id.as_str()).copied())
.collect();
let entry_module =
chunk.facade_module_id.as_ref().and_then(|id| module_id_to_idx.get(id.as_str()).copied());
let mut imports: Vec<ImportRelation> = Vec::new();
for import_filename in &chunk.imports {
if let Some(&target_idx) = chunk_filename_to_idx.get(import_filename.as_str())
&& target_idx != chunk_idx
{
imports.push(ImportRelation {
target_chunk_index: target_idx,
import_type: ImportType::Static,
});
}
}
for import_filename in &chunk.dynamic_imports {
if let Some(&target_idx) = chunk_filename_to_idx.get(import_filename.as_str())
&& target_idx != chunk_idx
{
imports.push(ImportRelation {
target_chunk_index: target_idx,
import_type: ImportType::Dynamic,
});
}
}
let reachable_module_indices = if chunk.is_entry || chunk.is_dynamic_entry {
entry_module.map(|entry_idx| {
let mut reachable = self.compute_reachable_modules(entry_idx, &module_dependencies);
reachable.sort_unstable();
reachable
})
} else {
None
};
chunks_data.push(ChunkData {
id: format!("chunk-{}", chunk.name),
name: chunk.filename.to_string(),
size: chunk.code.len(),
chunk_type,
module_indices: if module_indices.is_empty() { None } else { Some(module_indices) },
entry_module,
imports: if imports.is_empty() { None } else { Some(imports) },
reachable_module_indices,
});
}
AnalyzeData {
meta: Some(AnalyzeMeta {
bundler: Some("rolldown".to_string()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
timestamp: Some(epoch_millis()),
}),
chunks: chunks_data,
modules: modules_data,
}
}
fn build_module_dependencies(
&self,
ctx: &PluginContext,
module_id_to_idx: &FxHashMap<&str, usize>,
) -> FxHashMap<usize, FxHashSet<usize>> {
let mut dependencies: FxHashMap<usize, FxHashSet<usize>> =
FxHashMap::with_capacity(module_id_to_idx.len());
for (module_id, &module_idx) in module_id_to_idx {
if let Some(info) = ctx.get_module_info(module_id) {
let mut deps = FxHashSet::default();
for imported_id in &info.imported_ids {
if let Some(&imported_idx) = module_id_to_idx.get(imported_id.as_str()) {
deps.insert(imported_idx);
}
}
for imported_id in &info.dynamically_imported_ids {
if let Some(&imported_idx) = module_id_to_idx.get(imported_id.as_str()) {
deps.insert(imported_idx);
}
}
if !deps.is_empty() {
dependencies.insert(module_idx, deps);
}
}
}
dependencies
}
fn compute_reachable_modules(
&self,
entry_module_idx: usize,
dependencies: &FxHashMap<usize, FxHashSet<usize>>,
) -> Vec<usize> {
let mut visited: FxHashSet<usize> = FxHashSet::default();
let mut stack: Vec<usize> = vec![entry_module_idx];
while let Some(module_idx) = stack.pop() {
if visited.contains(&module_idx) {
continue;
}
visited.insert(module_idx);
if let Some(deps) = dependencies.get(&module_idx) {
for &dep_idx in deps {
if !visited.contains(&dep_idx) {
stack.push(dep_idx);
}
}
}
}
visited.into_iter().collect()
}
}
fn stabilize_module_id(id: &str, cwd: &Path) -> String {
let path = Path::new(id);
if path.is_absolute() {
path.relative(cwd).to_slash().map_or_else(|| id.to_string(), |s| s.to_string())
} else if id.starts_with('\0') {
id.replace('\0', "\\0")
} else {
id.to_string()
}
}
fn epoch_millis() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
}