Library/Fn/Bundle/
Builder.rs1use std::{
6 collections::{HashMap, hash_map::DefaultHasher},
7 hash::{Hash, Hasher},
8 path::Path,
9};
10
11use super::{BundleConfig, BundleEntry, BundleMode, BundleResult};
12
13pub struct BundleBuilder {
15 config:BundleConfig,
16
17 module_graph:HashMap<String, Vec<String>>,
19
20 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 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 pub fn build(&mut self) -> anyhow::Result<BundleResult> {
36 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 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 let output = self.compile_file(source_path)?;
64
65 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 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 let paths:Vec<_> = self.config.entries.iter().filter(|e| Path::new(e).exists()).cloned().collect();
95
96 for entry in paths {
98 let source_path = Path::new(&entry);
99
100 self.build_module_graph(source_path)?;
102
103 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 if self.config.tree_shaking {
115 all_content = self.apply_tree_shaking(all_content);
116 }
117
118 let output_filename = self.generate_output_filename();
120
121 let output_path = Path::new(&self.config.output_dir).join(&output_filename);
122
123 std::fs::write(&output_path, &all_content)?;
125
126 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 fn build_watch(&mut self) -> anyhow::Result<BundleResult> {
146 self.build_bundle()
149 }
150
151 fn build_with_esbuild(&mut self) -> anyhow::Result<BundleResult> {
153 let wrapper = super::ESBuild::EsbuildWrapper::new();
155
156 wrapper.build(&self.config)
157 }
158
159 fn compile_file(&self, source_path:&Path) -> anyhow::Result<String> {
161 let content = std::fs::read_to_string(source_path)?;
162
163 Ok(content)
169 }
170
171 fn build_module_graph(&mut self, entry:&Path) -> anyhow::Result<()> {
173 let content = std::fs::read_to_string(entry)?;
174
175 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 fn apply_tree_shaking(&self, content:String) -> String {
205 let mut result = String::new();
208
209 for line in content.lines() {
210 let trimmed = line.trim();
211
212 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 fn generate_output_filename(&self) -> String {
227 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 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 pub fn module_graph(&self) -> &HashMap<String, Vec<String>> { &self.module_graph }
252
253 pub fn processed(&self) -> &[String] { &self.processed }
255}
256
257pub 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}