1use anyhow::{anyhow, Result};
7use oxc_allocator::Allocator;
8use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement};
9use oxc_codegen::Codegen;
10use oxc_parser::Parser;
11use oxc_semantic::SemanticBuilder;
12use oxc_span::SourceType;
13use oxc_transformer::{TransformOptions, Transformer};
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17pub fn transpile_typescript(source: &str, filename: &str) -> Result<String> {
19 let allocator = Allocator::default();
20 let source_type = SourceType::from_path(filename).unwrap_or_default();
21
22 let parser_ret = Parser::new(&allocator, source, source_type).parse();
24 if !parser_ret.errors.is_empty() {
25 let errors: Vec<String> = parser_ret.errors.iter().map(|e| e.to_string()).collect();
26 return Err(anyhow!("TypeScript parse errors: {}", errors.join("; ")));
27 }
28
29 let mut program = parser_ret.program;
30
31 let semantic_ret = SemanticBuilder::new().build(&program);
33
34 if !semantic_ret.errors.is_empty() {
35 let errors: Vec<String> = semantic_ret.errors.iter().map(|e| e.to_string()).collect();
36 return Err(anyhow!("Semantic errors: {}", errors.join("; ")));
37 }
38
39 let scoping = semantic_ret.semantic.into_scoping();
41
42 let transform_options = TransformOptions::default();
44 let transformer_ret = Transformer::new(&allocator, Path::new(filename), &transform_options)
45 .build_with_scoping(scoping, &mut program);
46
47 if !transformer_ret.errors.is_empty() {
48 let errors: Vec<String> = transformer_ret
49 .errors
50 .iter()
51 .map(|e| e.to_string())
52 .collect();
53 return Err(anyhow!("Transform errors: {}", errors.join("; ")));
54 }
55
56 let codegen_ret = Codegen::new().build(&program);
58
59 Ok(codegen_ret.code)
60}
61
62pub fn has_es_module_syntax(source: &str) -> bool {
65 let has_imports = source.contains("import ") && source.contains(" from ");
67 let has_exports = source.lines().any(|line| {
69 let trimmed = line.trim();
70 trimmed.starts_with("export ")
71 });
72 has_imports || has_exports
73}
74
75pub fn has_es_imports(source: &str) -> bool {
78 source.contains("import ") && source.contains(" from ")
79}
80
81#[derive(Debug, Clone)]
83struct ModuleMetadata {
84 path: PathBuf,
86 var_name: String,
88 imports: Vec<ImportBinding>,
90 exports: Vec<ExportBinding>,
92 reexports: Vec<ReexportBinding>,
94 code: String,
96}
97
98#[derive(Debug, Clone)]
99struct ImportBinding {
100 local_name: String,
102 imported_name: Option<String>,
104 source_path: String,
106 is_namespace: bool,
108}
109
110#[derive(Debug, Clone)]
111struct ExportBinding {
112 exported_name: String,
114 local_name: String,
116}
117
118#[derive(Debug, Clone)]
119struct ReexportBinding {
120 exported_name: Option<String>,
122 source_name: Option<String>,
124 source_path: String,
126}
127
128pub fn bundle_module(entry_path: &Path) -> Result<String> {
131 let mut modules: Vec<ModuleMetadata> = Vec::new();
132 let mut visited = HashSet::new();
133 let mut path_to_var: std::collections::HashMap<PathBuf, String> =
134 std::collections::HashMap::new();
135
136 collect_modules(entry_path, &mut visited, &mut modules, &mut path_to_var)?;
138
139 let mut output = String::new();
141
142 for (i, module) in modules.iter().enumerate() {
143 let is_entry = i == modules.len() - 1;
144 output.push_str(&generate_scoped_module(module, &path_to_var, is_entry)?);
145 output.push('\n');
146 }
147
148 Ok(output)
149}
150
151fn collect_modules(
153 path: &Path,
154 visited: &mut HashSet<PathBuf>,
155 modules: &mut Vec<ModuleMetadata>,
156 path_to_var: &mut std::collections::HashMap<PathBuf, String>,
157) -> Result<()> {
158 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
159 if visited.contains(&canonical) {
160 return Ok(()); }
162 visited.insert(canonical.clone());
163
164 let source = std::fs::read_to_string(path)
165 .map_err(|e| anyhow!("Failed to read {}: {}", path.display(), e))?;
166
167 let (imports, exports, reexports) = extract_module_bindings(&source);
169
170 let parent_dir = path.parent().unwrap_or(Path::new("."));
171
172 for import in &imports {
174 if import.source_path.starts_with("./") || import.source_path.starts_with("../") {
175 let resolved = resolve_import(&import.source_path, parent_dir)?;
176 collect_modules(&resolved, visited, modules, path_to_var)?;
177 }
178 }
179 for reexport in &reexports {
180 if reexport.source_path.starts_with("./") || reexport.source_path.starts_with("../") {
181 let resolved = resolve_import(&reexport.source_path, parent_dir)?;
182 collect_modules(&resolved, visited, modules, path_to_var)?;
183 }
184 }
185
186 let var_name = path_to_module_var(path);
188 path_to_var.insert(canonical.clone(), var_name.clone());
189
190 let stripped = strip_imports_and_exports(&source);
192 let filename = path.to_str().unwrap_or("unknown.ts");
193 let transpiled = transpile_typescript(&stripped, filename)?;
194
195 modules.push(ModuleMetadata {
196 path: canonical,
197 var_name,
198 imports,
199 exports,
200 reexports,
201 code: transpiled,
202 });
203
204 Ok(())
205}
206
207fn path_to_module_var(path: &Path) -> String {
209 let name = path
210 .file_stem()
211 .and_then(|s| s.to_str())
212 .unwrap_or("module");
213
214 let sanitized: String = name
216 .chars()
217 .map(|c| if c.is_alphanumeric() { c } else { '_' })
218 .collect();
219
220 use std::hash::{Hash, Hasher};
222 let mut hasher = std::collections::hash_map::DefaultHasher::new();
223 path.hash(&mut hasher);
224 let hash = hasher.finish();
225
226 format!("__mod_{}_{:x}", sanitized, hash & 0xFFFF)
227}
228
229fn generate_scoped_module(
231 module: &ModuleMetadata,
232 path_to_var: &std::collections::HashMap<PathBuf, String>,
233 is_entry: bool,
234) -> Result<String> {
235 let mut code = String::new();
236
237 if is_entry {
239 code.push_str("(function() {\n");
240 } else {
241 code.push_str(&format!("const {} = (function() {{\n", module.var_name));
242 }
243
244 for import in &module.imports {
246 if let Some(dep_var) = resolve_import_to_var(&import.source_path, &module.path, path_to_var)
247 {
248 if import.is_namespace {
249 code.push_str(&format!("const {} = {};\n", import.local_name, dep_var));
251 } else if let Some(ref imported_name) = import.imported_name {
252 if imported_name == "default" {
254 code.push_str(&format!(
255 "const {} = {}.default;\n",
256 import.local_name, dep_var
257 ));
258 } else if &import.local_name == imported_name {
259 code.push_str(&format!("const {{{}}} = {};\n", import.local_name, dep_var));
260 } else {
261 code.push_str(&format!(
262 "const {{{}: {}}} = {};\n",
263 imported_name, import.local_name, dep_var
264 ));
265 }
266 } else {
267 code.push_str(&format!(
269 "const {} = {}.default;\n",
270 import.local_name, dep_var
271 ));
272 }
273 }
274 }
275
276 code.push_str(&module.code);
278 code.push('\n');
279
280 if !is_entry {
282 code.push_str("return {");
283
284 let mut export_parts: Vec<String> = Vec::new();
285
286 for export in &module.exports {
288 if export.exported_name == export.local_name {
289 export_parts.push(export.exported_name.clone());
290 } else {
291 export_parts.push(format!("{}: {}", export.exported_name, export.local_name));
292 }
293 }
294
295 for reexport in &module.reexports {
297 if let Some(dep_var) =
298 resolve_import_to_var(&reexport.source_path, &module.path, path_to_var)
299 {
300 match (&reexport.exported_name, &reexport.source_name) {
301 (Some(exported), Some(source)) => {
302 export_parts.push(format!("{}: {}.{}", exported, dep_var, source));
304 }
305 (Some(exported), None) => {
306 export_parts.push(format!("{}: {}.{}", exported, dep_var, exported));
308 }
309 (None, None) => {
310 export_parts.push(format!("...{}", dep_var));
312 }
313 _ => {}
314 }
315 }
316 }
317
318 code.push_str(&export_parts.join(", "));
319 code.push_str("};\n");
320 }
321
322 code.push_str("})();\n");
324
325 Ok(code)
326}
327
328fn resolve_import_to_var(
330 source_path: &str,
331 importer_path: &Path,
332 path_to_var: &std::collections::HashMap<PathBuf, String>,
333) -> Option<String> {
334 if !source_path.starts_with("./") && !source_path.starts_with("../") {
335 return None; }
337
338 let parent_dir = importer_path.parent().unwrap_or(Path::new("."));
339 if let Ok(resolved) = resolve_import(source_path, parent_dir) {
340 let canonical = resolved.canonicalize().unwrap_or(resolved);
341 path_to_var.get(&canonical).cloned()
342 } else {
343 None
344 }
345}
346
347fn extract_module_bindings(
349 source: &str,
350) -> (Vec<ImportBinding>, Vec<ExportBinding>, Vec<ReexportBinding>) {
351 let allocator = Allocator::default();
352 let source_type = SourceType::default()
353 .with_module(true)
354 .with_typescript(true);
355
356 let parser_ret = Parser::new(&allocator, source, source_type).parse();
357 if !parser_ret.errors.is_empty() {
358 return (Vec::new(), Vec::new(), Vec::new());
359 }
360
361 let mut imports = Vec::new();
362 let mut exports = Vec::new();
363 let mut reexports = Vec::new();
364
365 for stmt in &parser_ret.program.body {
366 match stmt {
367 Statement::ImportDeclaration(import_decl) => {
368 let source_path = import_decl.source.value.to_string();
369
370 if let Some(specifiers) = &import_decl.specifiers {
372 for spec in specifiers {
373 match spec {
374 oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(s) => {
375 imports.push(ImportBinding {
376 local_name: s.local.name.to_string(),
377 imported_name: Some(s.imported.name().to_string()),
378 source_path: source_path.clone(),
379 is_namespace: false,
380 });
381 }
382 oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
383 imports.push(ImportBinding {
384 local_name: s.local.name.to_string(),
385 imported_name: None, source_path: source_path.clone(),
387 is_namespace: false,
388 });
389 }
390 oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(
391 s,
392 ) => {
393 imports.push(ImportBinding {
394 local_name: s.local.name.to_string(),
395 imported_name: None,
396 source_path: source_path.clone(),
397 is_namespace: true,
398 });
399 }
400 }
401 }
402 }
403 }
404
405 Statement::ExportNamedDeclaration(export_decl) => {
406 if let Some(ref source) = export_decl.source {
407 let source_path = source.value.to_string();
409 for spec in &export_decl.specifiers {
410 reexports.push(ReexportBinding {
411 exported_name: Some(spec.exported.name().to_string()),
412 source_name: Some(spec.local.name().to_string()),
413 source_path: source_path.clone(),
414 });
415 }
416 } else {
417 if let Some(ref decl) = export_decl.declaration {
419 for name in get_declaration_names(decl) {
421 exports.push(ExportBinding {
422 exported_name: name.clone(),
423 local_name: name,
424 });
425 }
426 }
427 for spec in &export_decl.specifiers {
429 exports.push(ExportBinding {
430 exported_name: spec.exported.name().to_string(),
431 local_name: spec.local.name().to_string(),
432 });
433 }
434 }
435 }
436
437 Statement::ExportDefaultDeclaration(export_default) => {
438 match &export_default.declaration {
440 ExportDefaultDeclarationKind::FunctionDeclaration(f) => {
441 if let Some(ref id) = f.id {
442 exports.push(ExportBinding {
443 exported_name: "default".to_string(),
444 local_name: id.name.to_string(),
445 });
446 }
447 }
448 ExportDefaultDeclarationKind::ClassDeclaration(c) => {
449 if let Some(ref id) = c.id {
450 exports.push(ExportBinding {
451 exported_name: "default".to_string(),
452 local_name: id.name.to_string(),
453 });
454 }
455 }
456 _ => {
457 exports.push(ExportBinding {
459 exported_name: "default".to_string(),
460 local_name: "__default__".to_string(),
461 });
462 }
463 }
464 }
465
466 Statement::ExportAllDeclaration(export_all) => {
467 reexports.push(ReexportBinding {
469 exported_name: None,
470 source_name: None,
471 source_path: export_all.source.value.to_string(),
472 });
473 }
474
475 _ => {}
476 }
477 }
478
479 (imports, exports, reexports)
480}
481
482fn get_declaration_names(decl: &Declaration<'_>) -> Vec<String> {
484 match decl {
485 Declaration::VariableDeclaration(var_decl) => var_decl
486 .declarations
487 .iter()
488 .filter_map(|d| d.id.get_binding_identifier().map(|id| id.name.to_string()))
489 .collect(),
490 Declaration::FunctionDeclaration(f) => {
491 f.id.as_ref()
492 .map(|id| vec![id.name.to_string()])
493 .unwrap_or_default()
494 }
495 Declaration::ClassDeclaration(c) => {
496 c.id.as_ref()
497 .map(|id| vec![id.name.to_string()])
498 .unwrap_or_default()
499 }
500 Declaration::TSEnumDeclaration(e) => {
501 vec![e.id.name.to_string()]
502 }
503 _ => Vec::new(),
504 }
505}
506
507fn resolve_import(import_path: &str, parent_dir: &Path) -> Result<PathBuf> {
509 let base = parent_dir.join(import_path);
510
511 if base.exists() {
513 return Ok(base);
514 }
515
516 let with_ts = base.with_extension("ts");
517 if with_ts.exists() {
518 return Ok(with_ts);
519 }
520
521 let with_js = base.with_extension("js");
522 if with_js.exists() {
523 return Ok(with_js);
524 }
525
526 let index_ts = base.join("index.ts");
528 if index_ts.exists() {
529 return Ok(index_ts);
530 }
531
532 let index_js = base.join("index.js");
533 if index_js.exists() {
534 return Ok(index_js);
535 }
536
537 Err(anyhow!(
538 "Cannot resolve import '{}' from {}",
539 import_path,
540 parent_dir.display()
541 ))
542}
543
544pub fn strip_imports_and_exports(source: &str) -> String {
547 let allocator = Allocator::default();
548 let source_type = SourceType::default()
550 .with_module(true)
551 .with_typescript(true);
552
553 let parser_ret = Parser::new(&allocator, source, source_type).parse();
554 if !parser_ret.errors.is_empty() {
555 return source.to_string();
557 }
558
559 let mut program = parser_ret.program;
560
561 strip_module_syntax_ast(&allocator, &mut program);
563
564 let codegen_ret = Codegen::new().build(&program);
566 codegen_ret.code
567}
568
569fn strip_module_syntax_ast<'a>(allocator: &'a Allocator, program: &mut oxc_ast::ast::Program<'a>) {
574 use oxc_allocator::Vec as OxcVec;
575
576 let mut new_body: OxcVec<'a, Statement<'a>> =
578 OxcVec::with_capacity_in(program.body.len(), allocator);
579
580 for stmt in program.body.drain(..) {
581 match stmt {
582 Statement::ImportDeclaration(_) => {
584 }
586
587 Statement::ExportNamedDeclaration(export_decl) => {
589 let inner = export_decl.unbox();
590 if let Some(decl) = inner.declaration {
591 let stmt = declaration_to_statement(decl);
594 new_body.push(stmt);
595 }
596 }
598
599 Statement::ExportDefaultDeclaration(export_default) => {
601 let inner = export_default.unbox();
602 match inner.declaration {
603 ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
604 new_body.push(Statement::FunctionDeclaration(func));
605 }
606 ExportDefaultDeclarationKind::ClassDeclaration(class) => {
607 new_body.push(Statement::ClassDeclaration(class));
608 }
609 ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => {
610 }
612 _ => {
613 }
615 }
616 }
617
618 Statement::ExportAllDeclaration(_) => {
620 }
622
623 other => {
625 new_body.push(other);
626 }
627 }
628 }
629
630 program.body = new_body;
631}
632
633fn declaration_to_statement(decl: Declaration<'_>) -> Statement<'_> {
635 match decl {
636 Declaration::VariableDeclaration(d) => Statement::VariableDeclaration(d),
637 Declaration::FunctionDeclaration(d) => Statement::FunctionDeclaration(d),
638 Declaration::ClassDeclaration(d) => Statement::ClassDeclaration(d),
639 Declaration::TSTypeAliasDeclaration(d) => Statement::TSTypeAliasDeclaration(d),
640 Declaration::TSInterfaceDeclaration(d) => Statement::TSInterfaceDeclaration(d),
641 Declaration::TSEnumDeclaration(d) => Statement::TSEnumDeclaration(d),
642 Declaration::TSModuleDeclaration(d) => Statement::TSModuleDeclaration(d),
643 Declaration::TSImportEqualsDeclaration(d) => Statement::TSImportEqualsDeclaration(d),
644 Declaration::TSGlobalDeclaration(d) => Statement::TSGlobalDeclaration(d),
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn test_transpile_basic_typescript() {
654 let source = r#"
655 const x: number = 42;
656 function greet(name: string): string {
657 return `Hello, ${name}!`;
658 }
659 "#;
660
661 let result = transpile_typescript(source, "test.ts").unwrap();
662 assert!(result.contains("const x = 42"));
663 assert!(result.contains("function greet(name)"));
664 assert!(!result.contains(": number"));
665 assert!(!result.contains(": string"));
666 }
667
668 #[test]
669 fn test_transpile_interface() {
670 let source = r#"
671 interface User {
672 name: string;
673 age: number;
674 }
675 const user: User = { name: "Alice", age: 30 };
676 "#;
677
678 let result = transpile_typescript(source, "test.ts").unwrap();
679 assert!(!result.contains("interface"));
680 assert!(result.contains("const user = {"));
681 }
682
683 #[test]
684 fn test_transpile_type_alias() {
685 let source = r#"
686 type ID = number | string;
687 const id: ID = 123;
688 "#;
689
690 let result = transpile_typescript(source, "test.ts").unwrap();
691 assert!(!result.contains("type ID"));
692 assert!(result.contains("const id = 123"));
693 }
694
695 #[test]
696 fn test_has_es_imports() {
697 assert!(has_es_imports("import { foo } from './lib'"));
698 assert!(has_es_imports("import foo from 'bar'"));
699 assert!(!has_es_imports("const x = 1;"));
700 assert!(has_es_imports("// import foo from 'bar'")); }
704
705 #[test]
706 fn test_extract_module_bindings() {
707 let source = r#"
708 import { foo } from "./lib/utils";
709 import bar from "../shared/bar";
710 import external from "external-package";
711 export { PanelManager } from "./panel-manager.ts";
712 export * from "./types.ts";
713 export const API_VERSION = 1;
714 const x = 1;
715 "#;
716
717 let (imports, exports, reexports) = extract_module_bindings(source);
718
719 assert_eq!(imports.len(), 3);
721 assert!(imports
722 .iter()
723 .any(|i| i.source_path == "./lib/utils" && i.local_name == "foo"));
724 assert!(imports
725 .iter()
726 .any(|i| i.source_path == "../shared/bar" && i.local_name == "bar"));
727 assert!(imports.iter().any(|i| i.source_path == "external-package"));
728
729 assert_eq!(exports.len(), 1);
731 assert!(exports.iter().any(|e| e.exported_name == "API_VERSION"));
732
733 assert_eq!(reexports.len(), 2);
735 assert!(reexports
736 .iter()
737 .any(|r| r.source_path == "./panel-manager.ts"));
738 assert!(reexports
739 .iter()
740 .any(|r| r.source_path == "./types.ts" && r.exported_name.is_none()));
741 }
743
744 #[test]
745 fn test_extract_module_bindings_multiline() {
746 let source = r#"
748export type {
749 RGB,
750 Location,
751 PanelOptions,
752} from "./types.ts";
753
754export {
755 Finder,
756 defaultFuzzyFilter,
757} from "./finder.ts";
758
759import {
760 something,
761 somethingElse,
762} from "./multiline-import.ts";
763 "#;
764
765 let (imports, _exports, reexports) = extract_module_bindings(source);
766
767 assert_eq!(imports.len(), 2);
769 assert!(imports.iter().any(|i| i.local_name == "something"));
770 assert!(imports.iter().any(|i| i.local_name == "somethingElse"));
771
772 assert_eq!(reexports.len(), 5); assert!(reexports.iter().any(|r| r.source_path == "./types.ts"));
775 assert!(reexports.iter().any(|r| r.source_path == "./finder.ts"));
776 }
777
778 #[test]
779 fn test_strip_imports_and_exports() {
780 let source = r#"import { foo } from "./lib";
781import bar from "../bar";
782export const API_VERSION = 1;
783export function greet() { return "hi"; }
784export interface User { name: string; }
785const x = foo() + bar();"#;
786
787 let stripped = strip_imports_and_exports(source);
788 assert!(!stripped.contains("import { foo }"));
790 assert!(!stripped.contains("import bar from"));
791 assert!(!stripped.contains("export const"));
793 assert!(!stripped.contains("export function"));
794 assert!(!stripped.contains("export interface"));
795 assert!(stripped.contains("const API_VERSION = 1"));
797 assert!(stripped.contains("function greet()"));
798 assert!(stripped.contains("interface User"));
799 assert!(stripped.contains("const x = foo() + bar();"));
800 }
801}