Skip to main content

fresh_parser_js/
lib.rs

1//! TypeScript to JavaScript transpilation using oxc
2//!
3//! This module provides TypeScript transpilation without deno_ast,
4//! using the oxc toolchain for parsing, transformation, and code generation.
5
6use 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
17/// Transpile TypeScript source code to JavaScript
18pub 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    // Parse
23    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    // Semantic analysis (required for transformer)
32    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    // Get scoping info for transformer
40    let scoping = semantic_ret.semantic.into_scoping();
41
42    // Transform (strip TypeScript types)
43    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    // Generate JavaScript
57    let codegen_ret = Codegen::new().build(&program);
58
59    Ok(codegen_ret.code)
60}
61
62/// Check if source contains ES module syntax (imports or exports)
63/// This determines if the code needs bundling to work with QuickJS eval
64pub fn has_es_module_syntax(source: &str) -> bool {
65    // Check for imports: import X from "...", import { X } from "...", import * as X from "..."
66    let has_imports = source.contains("import ") && source.contains(" from ");
67    // Check for exports: export const, export function, export class, export interface, etc.
68    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
75/// Check if source contains ES module imports (import ... from ...)
76/// Kept for backwards compatibility
77pub fn has_es_imports(source: &str) -> bool {
78    source.contains("import ") && source.contains(" from ")
79}
80
81/// Module metadata for scoped bundling
82#[derive(Debug, Clone)]
83struct ModuleMetadata {
84    /// Canonical path to this module
85    path: PathBuf,
86    /// Variable name for this module's exports (e.g., "__mod_panel_manager")
87    var_name: String,
88    /// Named imports from other modules
89    imports: Vec<ImportBinding>,
90    /// Named exports from this module
91    exports: Vec<ExportBinding>,
92    /// Re-exports from other modules
93    reexports: Vec<ReexportBinding>,
94    /// The module's code with import/export statements removed, then transpiled
95    code: String,
96}
97
98#[derive(Debug, Clone)]
99struct ImportBinding {
100    /// Local name used in this module
101    local_name: String,
102    /// Name exported from the source module (None for default import)
103    imported_name: Option<String>,
104    /// Path to the source module (as written, e.g., "./lib/index.ts")
105    source_path: String,
106    /// Whether this is a namespace import (import * as X)
107    is_namespace: bool,
108}
109
110#[derive(Debug, Clone)]
111struct ExportBinding {
112    /// Name this is exported as
113    exported_name: String,
114    /// Local name in this module (might differ for `export { x as y }`)
115    local_name: String,
116}
117
118#[derive(Debug, Clone)]
119struct ReexportBinding {
120    /// Name this is exported as (None for `export *`)
121    exported_name: Option<String>,
122    /// Name in the source module (None for `export *`)
123    source_name: Option<String>,
124    /// Path to the source module
125    source_path: String,
126}
127
128/// Bundle a module and all its local imports into a single file with proper scoping
129/// Each module is wrapped in an IIFE that only exposes its exports
130pub 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    // First pass: collect all modules in dependency order
137    collect_modules(entry_path, &mut visited, &mut modules, &mut path_to_var)?;
138
139    // Second pass: generate scoped output
140    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
151/// Collect all modules in dependency order (dependencies first)
152fn 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(()); // Already processed (circular import protection)
161    }
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    // Extract module metadata using AST
168    let (imports, exports, reexports) = extract_module_bindings(&source);
169
170    let parent_dir = path.parent().unwrap_or(Path::new("."));
171
172    // Collect dependencies first (topological order)
173    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    // Generate variable name for this module
187    let var_name = path_to_module_var(path);
188    path_to_var.insert(canonical.clone(), var_name.clone());
189
190    // Strip imports/exports and transpile
191    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
207/// Generate a unique variable name from a path
208fn 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    // Sanitize: replace non-alphanumeric with underscore
215    let sanitized: String = name
216        .chars()
217        .map(|c| if c.is_alphanumeric() { c } else { '_' })
218        .collect();
219
220    // Add hash of full path to ensure uniqueness
221    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
229/// Generate scoped module code wrapped in IIFE
230fn 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    // Start IIFE - entry module doesn't need to export, others do
238    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    // Generate import destructuring from dependencies
245    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                // import * as X from "./y"
250                code.push_str(&format!("const {} = {};\n", import.local_name, dep_var));
251            } else if let Some(ref imported_name) = import.imported_name {
252                // import { X } from "./y" or import { X as Y } from "./y"
253                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                // import X from "./y" (default import)
268                code.push_str(&format!(
269                    "const {} = {}.default;\n",
270                    import.local_name, dep_var
271                ));
272            }
273        }
274    }
275
276    // Module code
277    code.push_str(&module.code);
278    code.push('\n');
279
280    // Generate return object with exports (skip for entry module)
281    if !is_entry {
282        code.push_str("return {");
283
284        let mut export_parts: Vec<String> = Vec::new();
285
286        // Direct exports
287        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        // Re-exports
296        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 { X as Y } from "./z"
303                        export_parts.push(format!("{}: {}.{}", exported, dep_var, source));
304                    }
305                    (Some(exported), None) => {
306                        // export { X } from "./z" (same name)
307                        export_parts.push(format!("{}: {}.{}", exported, dep_var, exported));
308                    }
309                    (None, None) => {
310                        // export * from "./z" - spread all exports
311                        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    // End IIFE
323    code.push_str("})();\n");
324
325    Ok(code)
326}
327
328/// Resolve an import source path to the dependency's variable name
329fn 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; // External import, not bundled
336    }
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
347/// Extract import/export bindings from source using AST
348fn 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                // Handle specifiers
371                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, // default import
386                                    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                    // Re-export: export { X } from "./y"
408                    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                    // Direct export
418                    if let Some(ref decl) = export_decl.declaration {
419                        // export const/function/class X
420                        for name in get_declaration_names(decl) {
421                            exports.push(ExportBinding {
422                                exported_name: name.clone(),
423                                local_name: name,
424                            });
425                        }
426                    }
427                    // export { X, Y }
428                    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                // export default X
439                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                        // Anonymous default export - handle specially
458                        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                // export * from "./y"
468                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
482/// Get declared names from a declaration
483fn 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
507/// Resolve an import path relative to the importing file's directory
508fn resolve_import(import_path: &str, parent_dir: &Path) -> Result<PathBuf> {
509    let base = parent_dir.join(import_path);
510
511    // Try various extensions
512    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    // Try index files
527    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
544/// Strip import statements and export keywords from source using AST transformation
545/// Converts ES module syntax to plain JavaScript that QuickJS can eval
546pub fn strip_imports_and_exports(source: &str) -> String {
547    let allocator = Allocator::default();
548    // Parse as module with TypeScript to accept import/export and TS syntax
549    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        // If parsing fails, return original source (let transpiler handle errors)
556        return source.to_string();
557    }
558
559    let mut program = parser_ret.program;
560
561    // Transform the AST: remove imports, convert exports to declarations
562    strip_module_syntax_ast(&allocator, &mut program);
563
564    // Generate code from transformed AST
565    let codegen_ret = Codegen::new().build(&program);
566    codegen_ret.code
567}
568
569/// Strip ES module syntax from a program AST
570/// - Removes ImportDeclaration statements
571/// - Converts ExportNamedDeclaration to its inner declaration
572/// - Handles ExportDefaultDeclaration, ExportAllDeclaration
573fn strip_module_syntax_ast<'a>(allocator: &'a Allocator, program: &mut oxc_ast::ast::Program<'a>) {
574    use oxc_allocator::Vec as OxcVec;
575
576    // Collect transformed statements
577    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            // Remove import declarations entirely
583            Statement::ImportDeclaration(_) => {
584                // Skip - dependency should already be bundled
585            }
586
587            // Convert export named declarations to their inner declaration
588            Statement::ExportNamedDeclaration(export_decl) => {
589                let inner = export_decl.unbox();
590                if let Some(decl) = inner.declaration {
591                    // Export has a declaration - keep just the declaration
592                    // Convert Declaration to Statement
593                    let stmt = declaration_to_statement(decl);
594                    new_body.push(stmt);
595                }
596                // If no declaration (re-export like `export { X } from './y'`), skip
597            }
598
599            // Handle export default
600            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                        // TypeScript interface - will be removed by transformer
611                    }
612                    _ => {
613                        // Expression exports (export default expr) - skip
614                    }
615                }
616            }
617
618            // Remove export * declarations (re-exports)
619            Statement::ExportAllDeclaration(_) => {
620                // Skip
621            }
622
623            // Keep all other statements unchanged
624            other => {
625                new_body.push(other);
626            }
627        }
628    }
629
630    program.body = new_body;
631}
632
633/// Convert a Declaration to a Statement
634fn 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        // Note: comment detection is a known limitation - simple heuristic doesn't parse JS
701        // This is OK because false positives just mean we bundle when not strictly needed
702        assert!(has_es_imports("// import foo from 'bar'")); // heuristic doesn't parse comments
703    }
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        // Check imports
720        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        // Check direct exports
730        assert_eq!(exports.len(), 1);
731        assert!(exports.iter().any(|e| e.exported_name == "API_VERSION"));
732
733        // Check re-exports
734        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        // export *
742    }
743
744    #[test]
745    fn test_extract_module_bindings_multiline() {
746        // Test multi-line exports like in lib/index.ts
747        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        // Check imports handle multi-line
768        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        // Check re-exports handle multi-line
773        assert_eq!(reexports.len(), 5); // RGB, Location, PanelOptions, Finder, defaultFuzzyFilter
774        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        // Imports are removed entirely
789        assert!(!stripped.contains("import { foo }"));
790        assert!(!stripped.contains("import bar from"));
791        // Exports are converted to regular declarations
792        assert!(!stripped.contains("export const"));
793        assert!(!stripped.contains("export function"));
794        assert!(!stripped.contains("export interface"));
795        // But the declarations themselves remain
796        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}