Skip to main content

Library/Fn/Bundle/
Builder.rs

1//! Bundle builder
2//!
3//! Orchestrates the bundling process using Rest compiler + optional esbuild.
4
5use std::{
6	collections::{HashMap, hash_map::DefaultHasher},
7	hash::{Hash, Hasher},
8	path::Path,
9};
10
11use super::{BundleConfig, BundleEntry, BundleMode, BundleResult};
12
13/// Builds bundles from input files
14pub struct BundleBuilder {
15	config:BundleConfig,
16
17	/// Cached module graph
18	module_graph:HashMap<String, Vec<String>>,
19
20	/// Processed files
21	processed:Vec<String>,
22}
23
24impl BundleBuilder {
25	pub fn new(config:BundleConfig) -> Self { Self { config, module_graph:HashMap::new(), processed:Vec::new() } }
26
27	/// Add an entry point to the bundle
28	pub fn add_entry(&mut self, entry:BundleEntry) {
29		if entry.is_entry && !self.config.entries.contains(&entry.source) {
30			self.config.entries.push(entry.source.clone());
31		}
32	}
33
34	/// Build the bundle
35	pub fn build(&mut self) -> anyhow::Result<BundleResult> {
36		// Ensure output directory exists
37		std::fs::create_dir_all(&self.config.output_dir)?;
38
39		match self.config.mode {
40			BundleMode::SingleFile => self.build_single_file(),
41
42			BundleMode::Bundle => self.build_bundle(),
43
44			BundleMode::Watch => self.build_watch(),
45
46			BundleMode::Esbuild => self.build_with_esbuild(),
47		}
48	}
49
50	/// Build in single-file mode (current behavior)
51	fn build_single_file(&mut self) -> anyhow::Result<BundleResult> {
52		let mut bundled_files = Vec::new();
53
54		for entry in &self.config.entries {
55			let source_path = Path::new(entry);
56
57			if !source_path.exists() {
58				continue;
59			}
60
61			// Compile using Rest compiler (simulated here - actual implementation
62			// would use the Compiler from Struct/SWC.rs)
63			let output = self.compile_file(source_path)?;
64
65			// Write output
66			let output_filename = source_path
67				.file_name()
68				.and_then(|n| n.to_str())
69				.unwrap_or("output.js")
70				.replace(".ts", ".js");
71
72			let output_path = Path::new(&self.config.output_dir).join(&output_filename);
73
74			std::fs::write(&output_path, &output)?;
75
76			bundled_files.push(output_path.to_string_lossy().to_string());
77		}
78
79		Ok(BundleResult {
80			output_path:self.config.output_dir.clone(),
81			source_map_path:None,
82			bundled_files,
83			hash:self.compute_hash(),
84		})
85	}
86
87	/// Build a bundle from multiple files
88	fn build_bundle(&mut self) -> anyhow::Result<BundleResult> {
89		let mut bundled_files = Vec::new();
90
91		let mut all_content = String::new();
92
93		// Collect paths first to avoid borrow issues
94		let paths:Vec<_> = self.config.entries.iter().filter(|e| Path::new(e).exists()).cloned().collect();
95
96		// Process each entry
97		for entry in paths {
98			let source_path = Path::new(&entry);
99
100			// Build module graph
101			self.build_module_graph(source_path)?;
102
103			// Compile and collect content
104			let content = self.compile_file(source_path)?;
105
106			all_content.push_str(&content);
107
108			all_content.push_str("\n");
109
110			bundled_files.push(entry);
111		}
112
113		// Apply tree-shaking if enabled
114		if self.config.tree_shaking {
115			all_content = self.apply_tree_shaking(all_content);
116		}
117
118		// Generate output filename
119		let output_filename = self.generate_output_filename();
120
121		let output_path = Path::new(&self.config.output_dir).join(&output_filename);
122
123		// Write bundle
124		std::fs::write(&output_path, &all_content)?;
125
126		// Generate source map if enabled
127		let source_map_path = if self.config.source_map {
128			let map_path =
129				Path::new(&self.config.output_dir).join(format!("{}.map", output_filename.replace(".js", "")));
130
131			Some(map_path.to_string_lossy().to_string())
132		} else {
133			None
134		};
135
136		Ok(BundleResult {
137			output_path:output_path.to_string_lossy().to_string(),
138			source_map_path,
139			bundled_files,
140			hash:self.compute_hash(),
141		})
142	}
143
144	/// Build in watch mode
145	fn build_watch(&mut self) -> anyhow::Result<BundleResult> {
146		// For watch mode, build once and set up file watching
147		// The actual watching would be handled by the caller
148		self.build_bundle()
149	}
150
151	/// Build using esbuild wrapper
152	fn build_with_esbuild(&mut self) -> anyhow::Result<BundleResult> {
153		// Import and use the esbuild wrapper
154		let wrapper = super::ESBuild::EsbuildWrapper::new();
155
156		wrapper.build(&self.config)
157	}
158
159	/// Compile a single file using Rest
160	fn compile_file(&self, source_path:&Path) -> anyhow::Result<String> {
161		let content = std::fs::read_to_string(source_path)?;
162
163		// In a full implementation, this would:
164		// 1. Parse with SWC
165		// 2. Apply transforms
166		// 3. Generate output
167		// For now, return a placeholder
168		Ok(content)
169	}
170
171	/// Build module graph for dependencies
172	fn build_module_graph(&mut self, entry:&Path) -> anyhow::Result<()> {
173		let content = std::fs::read_to_string(entry)?;
174
175		// Extract imports (simplified)
176		let mut deps = Vec::new();
177
178		for line in content.lines() {
179			let trimmed = line.trim();
180
181			if trimmed.starts_with("import ") {
182				if let Some(from_idx) = trimmed.find("from") {
183					let path_part = &trimmed[from_idx + 4..];
184
185					if let Some(quote_start) = path_part.find('"') {
186						let path_end = path_part[quote_start + 1..].find('"');
187
188						if let Some(end) = path_end {
189							let import_path = &path_part[quote_start + 1..quote_start + 1 + end];
190
191							deps.push(import_path.to_string());
192						}
193					}
194				}
195			}
196		}
197
198		self.module_graph.insert(entry.to_string_lossy().to_string(), deps);
199
200		Ok(())
201	}
202
203	/// Apply tree-shaking to bundle content
204	fn apply_tree_shaking(&self, content:String) -> String {
205		// Simplified tree-shaking: remove comments and whitespace
206		// A full implementation would analyze the AST for used exports
207		let mut result = String::new();
208
209		for line in content.lines() {
210			let trimmed = line.trim();
211
212			// Skip empty lines and single-line comments
213			if trimmed.is_empty() || trimmed.starts_with("//") {
214				continue;
215			}
216
217			result.push_str(line);
218
219			result.push('\n');
220		}
221
222		result
223	}
224
225	/// Generate output filename from config
226	fn generate_output_filename(&self) -> String {
227		// Use first entry name or default
228		let name = self
229			.config
230			.entries
231			.first()
232			.and_then(|e| Path::new(e).file_stem())
233			.and_then(|n| n.to_str())
234			.unwrap_or("bundle");
235
236		self.config.output_file.replace("{name}", name)
237	}
238
239	/// Compute hash of bundle for cache invalidation
240	fn compute_hash(&self) -> String {
241		let mut hasher = DefaultHasher::new();
242
243		for entry in &self.config.entries {
244			entry.hash(&mut hasher);
245		}
246
247		format!("{:x}", hasher.finish())
248	}
249
250	/// Get the module graph
251	pub fn module_graph(&self) -> &HashMap<String, Vec<String>> { &self.module_graph }
252
253	/// Get processed files
254	pub fn processed(&self) -> &[String] { &self.processed }
255}
256
257/// Convenience function to create and build a bundle
258pub fn build_bundle(config:BundleConfig) -> anyhow::Result<BundleResult> {
259	let mut builder = BundleBuilder::new(config);
260
261	builder.build()
262}
263
264#[cfg(test)]
265mod tests {
266
267	use super::*;
268
269	#[test]
270	fn test_builder_creation() {
271		let config = BundleConfig::single_file();
272
273		let builder = BundleBuilder::new(config);
274
275		assert!(builder.processed.is_empty());
276	}
277
278	#[test]
279	fn test_add_entry() {
280		let config = BundleConfig::bundle();
281
282		let mut builder = BundleBuilder::new(config);
283
284		builder.add_entry(BundleEntry::entry("src/index.ts"));
285
286		assert_eq!(builder.config.entries.len(), 1);
287	}
288
289	#[test]
290	fn test_output_filename() {
291		let config = BundleConfig::bundle().with_output_file("{name}.bundle.js");
292
293		let builder = BundleBuilder::new(config);
294
295		let filename = builder.generate_output_filename();
296
297		assert_eq!(filename, "index.bundle.js");
298	}
299
300	#[test]
301	fn test_hash_computation() {
302		let config = BundleConfig::bundle().add_entry("src/index.ts").add_entry("src/util.ts");
303
304		let builder = BundleBuilder::new(config);
305
306		let hash = builder.compute_hash();
307
308		assert!(!hash.is_empty());
309	}
310}