use std::path::PathBuf;
use std::collections::HashMap;
use std::fs;
use std::env;
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use async_recursion::async_recursion;
use crate::modules::{Module, ModuleResolver};
use crate::error::{Result, DumplingError};
use crate::typescript::{TypeScriptConfig, is_typescript_file, get_declaration_path};
use crate::code_splitting::{CodeSplitter, SplittingConfig, Chunk};
pub async fn bundle(entry: PathBuf, output: PathBuf, format: &str, minify: bool, sourcemap: bool) -> Result<()> {
let current_dir = env::current_dir()?;
let mut bundler = Bundler::new(current_dir);
bundler.bundle(entry, output, format, minify, sourcemap, false).await
}
pub async fn bundle_with_ts(entry: PathBuf, output: PathBuf, format: &str, minify: bool, sourcemap: bool, declarations: bool) -> Result<()> {
let current_dir = env::current_dir()?;
let mut bundler = Bundler::new(current_dir);
bundler.bundle(entry, output, format, minify, sourcemap, declarations).await
}
pub async fn bundle_with_splitting(
entry: PathBuf,
output: PathBuf,
format: &str,
minify: bool,
sourcemap: bool,
declarations: bool,
split: bool,
vendor_size: usize
) -> Result<()> {
let current_dir = env::current_dir()?;
let mut bundler = Bundler::new(current_dir);
bundler.bundle_with_code_splitting(entry, output, format, minify, sourcemap, declarations, split, vendor_size).await
}
#[derive(Debug, Clone)]
pub enum BundleFormat {
Iife, Esm, Cjs, }
impl BundleFormat {
pub fn from_str(s: &str) -> Result<Self> {
match s {
"iife" => Ok(BundleFormat::Iife),
"esm" => Ok(BundleFormat::Esm),
"cjs" => Ok(BundleFormat::Cjs),
_ => Err(DumplingError::Build(format!("Unknown bundle format: {}", s))),
}
}
}
pub struct Bundler {
resolver: ModuleResolver,
root: PathBuf,
}
impl Bundler {
pub fn new(root: PathBuf) -> Self {
Self {
resolver: ModuleResolver::new(root.clone()),
root,
}
}
fn cache_dir(&self) -> PathBuf {
self.root.join(".dumpling").join("cache")
}
fn compute_input_hash(&self, graph: &DependencyGraph) -> u64 {
let mut hasher = DefaultHasher::new();
for module_id in graph.topological_sort().unwrap_or_default() {
if let Some(module) = graph.get_module(&module_id) {
let path_str = module.path.display().to_string();
path_str.hash(&mut hasher);
if let Ok(meta) = fs::metadata(&module.path) {
if let Ok(mtime) = meta.modified() {
format!("{:?}", mtime).hash(&mut hasher);
}
}
}
}
hasher.finish()
}
fn cache_key(&self, output: &PathBuf, format: &str, minify: bool) -> String {
let mut hasher = DefaultHasher::new();
output.display().to_string().hash(&mut hasher);
format.hash(&mut hasher);
minify.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
fn read_cache(&self, cache_key: &str, input_hash: u64) -> Option<String> {
let cache_path = self.cache_dir().join(cache_key);
let hash_file = cache_path.join("input.hash");
let bundle_file = cache_path.join("bundle.js");
let cached_hash: u64 = fs::read_to_string(&hash_file).ok()?.trim().parse().ok()?;
if cached_hash != input_hash {
return None;
}
fs::read_to_string(&bundle_file).ok()
}
fn write_cache(&self, cache_key: &str, input_hash: u64, bundle: &str) -> Result<()> {
let cache_path = self.cache_dir().join(cache_key);
fs::create_dir_all(&cache_path)?;
fs::write(cache_path.join("input.hash"), format!("{}", input_hash))?;
fs::write(cache_path.join("bundle.js"), bundle)?;
Ok(())
}
fn generate_declarations(&self, graph: &DependencyGraph) -> Result<()> {
let ts_transpiler = crate::typescript::TypeScriptTranspiler::new(TypeScriptConfig::default());
for module_id in graph.modules.keys() {
if let Some(module) = graph.get_module(module_id) {
if is_typescript_file(&module.path) {
let declaration_path = get_declaration_path(&module.path);
let ts_source = std::fs::read_to_string(&module.path)?;
match ts_transpiler.generate_declaration(&module.path, &ts_source) {
Ok(declaration) => {
if let Some(parent) = declaration_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&declaration_path, declaration)?;
println!(" Generated declaration: {}", declaration_path.display());
}
Err(e) => {
println!(" Warning: Failed to generate declaration for {}: {}", module.path.display(), e);
}
}
}
}
}
Ok(())
}
pub async fn bundle(
&mut self,
entry: PathBuf,
output: PathBuf,
format: &str,
minify: bool,
sourcemap: bool,
declarations: bool,
) -> Result<()> {
let format_enum = BundleFormat::from_str(format)?;
let mut graph = DependencyGraph::new();
self.build_graph(&entry, &mut graph, None).await?;
let input_hash = self.compute_input_hash(&graph);
let cache_key = self.cache_key(&output, format, minify);
let bundle = if let Some(cached) = self.read_cache(&cache_key, input_hash) {
println!("✓ Using cached bundle for {} -> {}", entry.display(), output.display());
cached
} else {
let bundle = self.generate_bundle(&graph, &format_enum, minify).await?;
self.write_cache(&cache_key, input_hash, &bundle)?;
bundle
};
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut final_bundle = bundle.clone();
if sourcemap {
let map_name = output.file_name().unwrap_or_default().to_string_lossy();
let map_path = format!("{}.map", map_name);
let source_map = self.generate_source_map(&bundle, &entry);
fs::write(output.parent().unwrap().join(&map_path), &source_map)?;
final_bundle.push_str(&format!("\n//# sourceMappingURL={}\n", map_path));
}
fs::write(&output, &final_bundle)?;
if declarations {
self.generate_declarations(&graph)?;
}
println!("✓ Bundled {} -> {} ({})", entry.display(), output.display(), format);
Ok(())
}
fn generate_source_map(&self, _bundle: &str, entry: &PathBuf) -> String {
let version = 3;
let file = entry.file_name().unwrap_or_default().to_string_lossy();
let mapping = "AAAA";
format!(
r#"{{"version":{},"sources":["{}"],"names":[],"mappings":"{}"}}"#,
version, file, mapping
)
}
pub async fn bundle_to_string(
&mut self,
entry: &PathBuf,
format: &str,
minify: bool,
) -> Result<String> {
let format = BundleFormat::from_str(format)?;
let mut graph = DependencyGraph::new();
if let Err(e) = self.build_graph(entry, &mut graph, None).await {
let error_context = crate::error::ErrorContext::new()
.with_file(entry.clone());
return Err(DumplingError::Bundling(format!("Failed to build dependency graph: {}", e))
.with_context(error_context).into());
}
self.generate_bundle(&graph, &format, minify).await
}
#[async_recursion]
async fn build_graph(
&mut self,
path: &PathBuf,
graph: &mut DependencyGraph,
parent_id: Option<String>,
) -> Result<String> {
let module = self.resolver.load_module(path.clone()).await?;
let module_id = module.id.clone();
if parent_id.is_some() && graph.has_circular_dependency(&module_id, parent_id.as_ref().unwrap()) {
return Err(DumplingError::Build(format!(
"Circular dependency detected: {} -> {}",
parent_id.unwrap(),
module_id
)));
}
graph.add_module(module.clone(), parent_id);
let mut resolved_deps = Vec::new();
for dep in &module.dependencies {
let dep_path = self.resolver.resolve(dep, path.parent().unwrap()).await?;
let dep_id = self.build_graph(&dep_path, graph, Some(module_id.clone())).await?;
resolved_deps.push((dep.clone(), dep_id));
}
graph.set_resolved_dependencies(&module_id, resolved_deps);
Ok(module_id)
}
async fn generate_bundle(
&self,
graph: &DependencyGraph,
format: &BundleFormat,
minify: bool,
) -> Result<String> {
let mut code = String::new();
match format {
BundleFormat::Iife => {
code.push_str("(function() {\n");
code.push_str("var __modules = {};\n");
for module_id in graph.topological_sort()? {
let module = graph.get_module(&module_id).unwrap();
let wrapped = self.wrap_module_iife(module, graph)?;
code.push_str(&wrapped);
code.push('\n');
}
let entry_id = graph.entry_id.as_ref().unwrap();
code.push_str(&format!("__modules[\"{}\"]();\n", entry_id));
code.push_str("})();\n");
}
BundleFormat::Esm => {
for module_id in graph.topological_sort()? {
let module = graph.get_module(&module_id).unwrap();
let transformed = self.transform_module_esm(module, graph)?;
code.push_str(&transformed);
code.push('\n');
}
let entry_id = graph.entry_id.as_ref().unwrap();
code.push_str(&format!("import \"./{}\";\n", entry_id));
}
BundleFormat::Cjs => {
code.push_str("var __modules = {};\n");
code.push_str("function require(id) { return __modules[id] ? __modules[id]() : (function(){ throw new Error('Cannot find module \\'' + id + '\\''); })(); }\n");
for module_id in graph.topological_sort()? {
let module = graph.get_module(&module_id).unwrap();
let wrapped = self.wrap_module_cjs(module, graph)?;
code.push_str(&wrapped);
code.push('\n');
}
let entry_id = graph.entry_id.as_ref().unwrap();
code.push_str(&format!("require(\"{}\");\n", entry_id));
}
}
if minify {
code = self.minify_code(&code);
}
Ok(code)
}
fn escape_css_for_js(css: &str) -> String {
css.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn wrap_css_module_iife(&self, module: &Module) -> Result<String> {
let escaped = Self::escape_css_for_js(&module.source);
let hmr_id = Self::css_hmr_id(&module.id);
Ok(format!(
r#"__modules["{}"] = function() {{
if (typeof document !== "undefined") {{
var s = document.createElement("style");
s.setAttribute("data-dumpling-hmr","{}");
s.textContent = "{}";
(document.head || document.documentElement).appendChild(s);
}}
}};"#,
module.id.replace('\\', "\\\\").replace('"', "\\\""),
hmr_id,
escaped
))
}
fn wrap_css_module_cjs(&self, module: &Module) -> Result<String> {
let escaped = Self::escape_css_for_js(&module.source);
let hmr_id = Self::css_hmr_id(&module.id);
Ok(format!(
r#"__modules["{}"] = function() {{
if (typeof document !== "undefined") {{
var s = document.createElement("style");
s.setAttribute("data-dumpling-hmr","{}");
s.textContent = "{}";
(document.head || document.documentElement).appendChild(s);
}}
return {{}};
}};"#,
module.id.replace('\\', "\\\\").replace('"', "\\\""),
hmr_id,
escaped
))
}
fn css_hmr_id(module_id: &str) -> String {
module_id
.split('/')
.last()
.unwrap_or(module_id)
.replace('\\', "/")
}
fn wrap_module_iife(&self, module: &Module, graph: &DependencyGraph) -> Result<String> {
if module.is_css() {
return self.wrap_css_module_iife(module);
}
let mut code = String::new();
let module_id = &module.id;
code.push_str(&format!("__modules[\"{}\"] = function() {{\n", module_id));
let deps = graph.get_resolved_dependencies(module_id);
if !deps.is_empty() {
code.push_str("var require = (function() {\n");
code.push_str(" var __specMap = {\n");
for (i, (spec, dep_id)) in deps.iter().enumerate() {
let comma = if i < deps.len() - 1 { "," } else { "" };
code.push_str(&format!(" \"{}\": \"{}\"{}\n", spec.replace('\\', "\\\\").replace('"', "\\\""), dep_id.replace('\\', "\\\\").replace('"', "\\\""), comma));
}
code.push_str(" };\n");
code.push_str(" return function(spec) {\n");
code.push_str(" if (!(spec in __specMap)) throw new Error('Cannot find module \\'' + spec + '\\'');\n");
code.push_str(" return __modules[__specMap[spec]]();\n");
code.push_str(" };\n");
code.push_str("})();\n");
}
code.push_str(&module.source);
code.push('\n');
code.push_str("};\n");
Ok(code)
}
fn wrap_module_cjs(&self, module: &Module, graph: &DependencyGraph) -> Result<String> {
if module.is_css() {
return self.wrap_css_module_cjs(module);
}
let mut code = String::new();
let module_id = &module.id;
code.push_str(&format!(
"__modules[\"{}\"] = function() {{\n",
module_id
));
code.push_str("var module = { exports: {} };\n");
code.push_str("var exports = module.exports;\n");
let deps = graph.get_resolved_dependencies(module_id);
if !deps.is_empty() {
code.push_str("var require = (function() {\n");
code.push_str(" var __specMap = {\n");
for (i, (spec, dep_id)) in deps.iter().enumerate() {
let comma = if i < deps.len() - 1 { "," } else { "" };
code.push_str(&format!(" \"{}\": \"{}\"{}\n", spec.replace('\\', "\\\\").replace('"', "\\\""), dep_id.replace('\\', "\\\\").replace('"', "\\\""), comma));
}
code.push_str(" };\n");
code.push_str(" return function(spec) {\n");
code.push_str(" if (!(spec in __specMap)) throw new Error('Cannot find module \\'' + spec + '\\'');\n");
code.push_str(" return __modules[__specMap[spec]]();\n");
code.push_str(" };\n");
code.push_str("})();\n");
}
code.push_str(&module.source);
code.push('\n');
code.push_str("return module.exports;\n");
code.push_str("};\n");
Ok(code)
}
fn transform_module_esm(&self, module: &Module, _graph: &DependencyGraph) -> Result<String> {
if module.is_css() {
let escaped = Self::escape_css_for_js(&module.source);
let hmr_id = Self::css_hmr_id(&module.id);
return Ok(format!(
r#"// {}
if (typeof document !== "undefined") {{
var s = document.createElement("style");
s.setAttribute("data-dumpling-hmr","{}");
s.textContent = "{}";
(document.head || document.documentElement).appendChild(s);
}}"#,
module.id,
hmr_id,
escaped
));
}
let mut code = String::new();
code.push_str("// ");
code.push_str(&module.id);
code.push('\n');
code.push_str(&module.source);
Ok(code)
}
fn sanitize_identifier(&self, identifier: &str) -> String {
identifier
.replace("./", "")
.replace("../", "")
.replace("/", "_")
.replace("-", "_")
.replace("@", "_")
.chars()
.map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}
fn minify_code(&self, code: &str) -> String {
code.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.map(|line| line.trim())
.collect::<Vec<_>>()
.join(";")
}
pub async fn bundle_with_code_splitting(
&mut self,
entry: PathBuf,
output: PathBuf,
format: &str,
minify: bool,
sourcemap: bool,
declarations: bool,
split: bool,
vendor_size: usize,
) -> Result<()> {
let format_enum = BundleFormat::from_str(format)?;
let mut graph = DependencyGraph::new();
self.build_graph(&entry, &mut graph, None).await?;
let output_dir = output.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&output_dir)?;
if split {
let mut config = SplittingConfig::default();
config.chunk_size_limit = vendor_size * 1024; config.vendor_chunk = true;
let splitter = CodeSplitter::new(config);
let chunks = splitter.split(&graph, &output_dir)?;
let mut total_size = 0;
for chunk in &chunks {
let chunk_path = output_dir.join(&chunk.file_name);
let chunk_content = self.generate_chunk(&graph, &chunk, &format_enum, minify).await?;
if sourcemap {
let map_name = format!("{}.map", chunk.file_name);
let source_map = self.generate_chunk_source_map(&chunk, &chunk_path);
fs::write(output_dir.join(&map_name), &source_map)?;
let final_content = format!("{}\n//# sourceMappingURL={}\n", chunk_content, map_name);
fs::write(&chunk_path, &final_content)?;
} else {
fs::write(&chunk_path, &chunk_content)?;
}
println!("✓ Generated chunk: {} ({})", chunk.file_name, Self::format_size(chunk.size));
total_size += chunk.size;
}
let loader_path = output_dir.join("chunk-loader.js");
let chunk_ids: Vec<String> = chunks.iter().map(|c| c.id.clone()).collect();
let loader_content = splitter.generate_chunk_loader(&chunks);
fs::write(&loader_path, &loader_content)?;
println!("✓ Generated chunk loader: chunk-loader.js");
self.generate_entry_html(&output_dir, &chunks)?;
println!("✓ Total bundle size: {} ({} chunks)", Self::format_size(total_size), chunks.len());
} else {
self.bundle(entry, output, format, minify, sourcemap, declarations).await?;
}
if declarations {
self.generate_declarations(&graph)?;
}
Ok(())
}
async fn generate_chunk(
&self,
graph: &DependencyGraph,
chunk: &Chunk,
format: &BundleFormat,
minify: bool,
) -> Result<String> {
let mut code = String::new();
match format {
BundleFormat::Esm => {
for module_id in &chunk.modules {
if let Some(module) = graph.get_module(module_id) {
let transformed = self.transform_chunk_module(module, graph)?;
code.push_str(&transformed);
code.push_str("\n");
}
}
}
BundleFormat::Iife | BundleFormat::Cjs => {
code.push_str(&format!("// Chunk: {}\n", chunk.id));
code.push_str("(function() {\n");
for module_id in &chunk.modules {
if let Some(module) = graph.get_module(module_id) {
let wrapped = self.wrap_chunk_module(module, graph, format)?;
code.push_str(&wrapped);
code.push_str("\n");
}
}
code.push_str("})();\n");
}
}
if minify {
code = self.minify_code(&code);
}
Ok(code)
}
fn transform_chunk_module(&self, module: &Module, _graph: &DependencyGraph) -> Result<String> {
let mut code = String::new();
code.push_str(&format!("// Module: {}\n", module.id));
let splitter = CodeSplitter::new(SplittingConfig::default());
let chunk_ids: Vec<String> = vec![];
let transformed_source = splitter.update_dynamic_imports(&module.source, &chunk_ids);
code.push_str(&transformed_source);
Ok(code)
}
fn wrap_chunk_module(&self, module: &Module, graph: &DependencyGraph, format: &BundleFormat) -> Result<String> {
let mut code = String::new();
code.push_str(&format!("// Module: {}\n", module.id));
match format {
BundleFormat::Iife => {
code.push_str("var __modules = __modules || {};\n");
code.push_str(&format!("__modules['{}'] = function() {{\n", module.id));
let deps = graph.get_resolved_dependencies(&module.id);
if !deps.is_empty() {
code.push_str("var require = function(spec) {\n");
code.push_str(" return __modules[spec] ? __modules[spec]() : (function(){ throw new Error('Cannot find module \\'' + spec + '\\''); })();\n");
code.push_str("};\n");
}
code.push_str(&module.source);
code.push_str("\n};\n");
}
BundleFormat::Cjs => {
code.push_str("var __modules = __modules || {};\n");
code.push_str(&format!("__modules['{}'] = function() {{\n", module.id));
let deps = graph.get_resolved_dependencies(&module.id);
if !deps.is_empty() {
code.push_str("var require = function(spec) {\n");
code.push_str(" return __modules[spec] ? __modules[spec]() : (function(){ throw new Error('Cannot find module \\'' + spec + '\\''); })();\n");
code.push_str("};\n");
}
code.push_str("var module = { exports: {} };\n");
code.push_str("var exports = module.exports;\n");
code.push_str(&module.source);
code.push_str("\nreturn module.exports;\n");
code.push_str("};\n");
}
_ => return Err(DumplingError::Build("Unsupported format for code splitting".to_string())),
}
Ok(code)
}
fn generate_chunk_source_map(&self, _chunk: &Chunk, chunk_path: &PathBuf) -> String {
let version = 3;
let file_name = chunk_path.file_name().unwrap_or_default().to_string_lossy();
format!(
r#"{{"version":{},"file":"{}","sources":[],"names":[],"mappings":""}}"#,
version, file_name
)
}
fn format_size(bytes: usize) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
fn generate_entry_html(&self, output_dir: &PathBuf, chunks: &[Chunk]) -> Result<()> {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n");
html.push_str("<html>\n");
html.push_str("<head>\n");
html.push_str(" <meta charset=\"utf-8\">\n");
html.push_str(" <title>Dumpling App</title>\n");
html.push_str("</head>\n");
html.push_str("<body>\n");
if let Some(vendor_chunk) = chunks.iter().find(|c| c.id == "vendor") {
html.push_str(&format!(" <script src=\"{}\"></script>\n", vendor_chunk.file_name));
}
for chunk in chunks {
if chunk.id != "vendor" {
html.push_str(&format!(" <script src=\"{}\"></script>\n", chunk.file_name));
}
}
html.push_str("</body>\n");
html.push_str("</html>\n");
let html_path = output_dir.join("index.html");
fs::write(&html_path, html)?;
println!("✓ Generated entry HTML: index.html");
Ok(())
}
}
#[derive(Debug)]
pub struct DependencyGraph {
modules: HashMap<String, Module>,
dependencies: HashMap<String, Vec<(String, String)>>, reverse_dependencies: HashMap<String, Vec<String>>, pub entry_id: Option<String>,
}
impl DependencyGraph {
pub fn new() -> Self {
Self {
modules: HashMap::new(),
dependencies: HashMap::new(),
reverse_dependencies: HashMap::new(),
entry_id: None,
}
}
pub fn add_module(&mut self, module: Module, parent_id: Option<String>) {
let module_id = module.id.clone();
if self.entry_id.is_none() {
self.entry_id = Some(module_id.clone());
}
self.modules.insert(module_id.clone(), module);
if let Some(parent) = parent_id {
self.reverse_dependencies
.entry(module_id)
.or_insert_with(Vec::new)
.push(parent);
}
}
pub fn set_resolved_dependencies(&mut self, module_id: &str, deps: Vec<(String, String)>) {
self.dependencies.insert(module_id.to_string(), deps);
}
pub fn get_module(&self, module_id: &str) -> Option<&Module> {
self.modules.get(module_id)
}
pub fn get_resolved_dependencies(&self, module_id: &str) -> Vec<(String, String)> {
self.dependencies
.get(module_id)
.cloned()
.unwrap_or_default()
}
pub fn has_circular_dependency(&self, current: &str, target: &str) -> bool {
if current == target {
return true;
}
if let Some(deps) = self.dependencies.get(target) {
for (_, dep_id) in deps {
if self.has_circular_dependency(current, dep_id) {
return true;
}
}
}
false
}
pub fn topological_sort(&self) -> Result<Vec<String>> {
let mut visited = HashMap::new();
let mut temp_visited = HashMap::new();
let mut result = Vec::new();
for module_id in self.modules.keys() {
if !visited.contains_key(module_id) {
self.visit(module_id, &mut visited, &mut temp_visited, &mut result)?;
}
}
result.reverse();
Ok(result)
}
fn visit(
&self,
module_id: &str,
visited: &mut HashMap<String, bool>,
temp_visited: &mut HashMap<String, bool>,
result: &mut Vec<String>,
) -> Result<()> {
if temp_visited.contains_key(module_id) {
return Err(DumplingError::Build("Circular dependency detected".to_string()));
}
if visited.contains_key(module_id) {
return Ok(());
}
temp_visited.insert(module_id.to_string(), true);
if let Some(deps) = self.dependencies.get(module_id) {
for (_, dep_id) in deps {
self.visit(dep_id, visited, temp_visited, result)?;
}
}
temp_visited.remove(module_id);
visited.insert(module_id.to_string(), true);
result.push(module_id.to_string());
Ok(())
}
}