Skip to main content

fresh_parser_js/
lib.rs

1//! TypeScript to JavaScript transpilation using oxc
2//!
3//! This module provides TypeScript transpilation using oxc_transformer
4//! 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/// Extract plugin dependency names from `import ... from "fresh:plugin/NAME"` statements.
82///
83/// Recognizes all import forms:
84/// - `import type { Foo } from "fresh:plugin/bar"`
85/// - `import { Foo } from "fresh:plugin/bar"`
86/// - `import * as Bar from "fresh:plugin/bar"`
87/// - `import Bar from "fresh:plugin/bar"`
88///
89/// Returns a deduplicated list of plugin names (the part after `fresh:plugin/`).
90pub fn extract_plugin_dependencies(source: &str) -> Vec<String> {
91    let prefix = "fresh:plugin/";
92    let mut deps = Vec::new();
93    let mut seen = HashSet::new();
94
95    for line in source.lines() {
96        let trimmed = line.trim();
97        // Must be an import line with our scheme
98        if !trimmed.starts_with("import ") || !trimmed.contains(prefix) {
99            continue;
100        }
101        // Extract the string between quotes after "from"
102        if let Some(from_idx) = trimmed.find(" from ") {
103            let after_from = &trimmed[from_idx + 6..]; // skip " from "
104            let after_from = after_from.trim();
105            // Extract quoted string (single or double quotes)
106            let quote_char = after_from.chars().next();
107            if let Some(q) = quote_char {
108                if q == '"' || q == '\'' {
109                    if let Some(end) = after_from[1..].find(q) {
110                        let module_path = &after_from[1..1 + end];
111                        if let Some(plugin_name) = module_path.strip_prefix(prefix) {
112                            if !plugin_name.is_empty() && seen.insert(plugin_name.to_string()) {
113                                deps.push(plugin_name.to_string());
114                            }
115                        }
116                    }
117                }
118            }
119        }
120    }
121
122    deps
123}
124
125/// Topological sort of plugins by dependency order (dependencies first).
126///
127/// Returns `Ok(sorted_names)` with plugins in load order, or `Err(cycle)` with
128/// the names of plugins involved in a dependency cycle.
129///
130/// Plugins with no dependencies are sorted alphabetically for determinism.
131pub fn topological_sort_plugins(
132    plugin_names: &[String],
133    dependencies: &std::collections::HashMap<String, Vec<String>>,
134) -> Result<Vec<String>> {
135    use std::collections::HashMap;
136
137    // Build adjacency and in-degree maps
138    let mut in_degree: HashMap<&str, usize> = HashMap::new();
139    let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
140
141    for name in plugin_names {
142        in_degree.entry(name.as_str()).or_insert(0);
143    }
144
145    for name in plugin_names {
146        if let Some(deps) = dependencies.get(name) {
147            for dep in deps {
148                // Only count dependencies on plugins that exist in our set
149                if in_degree.contains_key(dep.as_str()) {
150                    *in_degree.entry(name.as_str()).or_insert(0) += 1;
151                    dependents
152                        .entry(dep.as_str())
153                        .or_default()
154                        .push(name.as_str());
155                } else {
156                    return Err(anyhow!(
157                        "Plugin '{}' depends on '{}', which is not installed or not enabled",
158                        name,
159                        dep
160                    ));
161                }
162            }
163        }
164    }
165
166    // Kahn's algorithm
167    let mut queue: Vec<&str> = in_degree
168        .iter()
169        .filter(|(_, &deg)| deg == 0)
170        .map(|(&name, _)| name)
171        .collect();
172    // Sort the initial queue alphabetically for determinism
173    queue.sort();
174
175    let mut result: Vec<String> = Vec::with_capacity(plugin_names.len());
176
177    while let Some(current) = queue.first().copied() {
178        queue.remove(0);
179        result.push(current.to_string());
180
181        if let Some(deps) = dependents.get(current) {
182            let mut newly_ready = Vec::new();
183            for &dependent in deps {
184                if let Some(deg) = in_degree.get_mut(dependent) {
185                    *deg -= 1;
186                    if *deg == 0 {
187                        newly_ready.push(dependent);
188                    }
189                }
190            }
191            // Sort newly ready plugins alphabetically for determinism
192            newly_ready.sort();
193            queue.extend(newly_ready);
194            queue.sort(); // maintain overall alphabetical order among ready nodes
195        }
196    }
197
198    if result.len() != plugin_names.len() {
199        // Some plugins are in a cycle — find them
200        let in_result: HashSet<&str> = result.iter().map(|s| s.as_str()).collect();
201        let cycle_plugins: Vec<String> = plugin_names
202            .iter()
203            .filter(|n| !in_result.contains(n.as_str()))
204            .cloned()
205            .collect();
206        return Err(anyhow!(
207            "Plugin dependency cycle detected among: {}. These plugins will not be loaded.",
208            cycle_plugins.join(", ")
209        ));
210    }
211
212    Ok(result)
213}
214
215/// Module metadata for scoped bundling
216#[derive(Debug, Clone)]
217struct ModuleMetadata {
218    /// Canonical path to this module
219    path: PathBuf,
220    /// Variable name for this module's exports (e.g., "__mod_panel_manager")
221    var_name: String,
222    /// Named imports from other modules
223    imports: Vec<ImportBinding>,
224    /// Named exports from this module
225    exports: Vec<ExportBinding>,
226    /// Re-exports from other modules
227    reexports: Vec<ReexportBinding>,
228    /// The module's code with import/export statements removed, then transpiled
229    code: String,
230}
231
232#[derive(Debug, Clone)]
233struct ImportBinding {
234    /// Local name used in this module
235    local_name: String,
236    /// Name exported from the source module (None for default import)
237    imported_name: Option<String>,
238    /// Path to the source module (as written, e.g., "./lib/index.ts")
239    source_path: String,
240    /// Whether this is a namespace import (import * as X)
241    is_namespace: bool,
242}
243
244#[derive(Debug, Clone)]
245struct ExportBinding {
246    /// Name this is exported as
247    exported_name: String,
248    /// Local name in this module (might differ for `export { x as y }`)
249    local_name: String,
250}
251
252#[derive(Debug, Clone)]
253struct ReexportBinding {
254    /// Name this is exported as (None for `export *`)
255    exported_name: Option<String>,
256    /// Name in the source module (None for `export *`)
257    source_name: Option<String>,
258    /// Path to the source module
259    source_path: String,
260}
261
262/// Bundle a module and all its local imports into a single file with proper scoping
263/// Each module is wrapped in an IIFE that only exposes its exports
264pub fn bundle_module(entry_path: &Path) -> Result<String> {
265    let mut modules: Vec<ModuleMetadata> = Vec::new();
266    let mut visited = HashSet::new();
267    let mut path_to_var: std::collections::HashMap<PathBuf, String> =
268        std::collections::HashMap::new();
269
270    // First pass: collect all modules in dependency order
271    collect_modules(entry_path, &mut visited, &mut modules, &mut path_to_var)?;
272
273    // Second pass: generate scoped output
274    let mut output = String::new();
275
276    for (i, module) in modules.iter().enumerate() {
277        let is_entry = i == modules.len() - 1;
278        output.push_str(&generate_scoped_module(module, &path_to_var, is_entry)?);
279        output.push('\n');
280    }
281
282    Ok(output)
283}
284
285/// Collect all modules in dependency order (dependencies first)
286fn collect_modules(
287    path: &Path,
288    visited: &mut HashSet<PathBuf>,
289    modules: &mut Vec<ModuleMetadata>,
290    path_to_var: &mut std::collections::HashMap<PathBuf, String>,
291) -> Result<()> {
292    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
293    if visited.contains(&canonical) {
294        return Ok(()); // Already processed (circular import protection)
295    }
296    visited.insert(canonical.clone());
297
298    let source = std::fs::read_to_string(path)
299        .map_err(|e| anyhow!("Failed to read {}: {}", path.display(), e))?;
300
301    // Extract module metadata using AST
302    let (imports, exports, reexports) = extract_module_bindings(&source);
303
304    let parent_dir = path.parent().unwrap_or(Path::new("."));
305
306    // Collect dependencies first (topological order)
307    for import in &imports {
308        if import.source_path.starts_with("./") || import.source_path.starts_with("../") {
309            let resolved = resolve_import(&import.source_path, parent_dir)?;
310            collect_modules(&resolved, visited, modules, path_to_var)?;
311        }
312    }
313    for reexport in &reexports {
314        if reexport.source_path.starts_with("./") || reexport.source_path.starts_with("../") {
315            let resolved = resolve_import(&reexport.source_path, parent_dir)?;
316            collect_modules(&resolved, visited, modules, path_to_var)?;
317        }
318    }
319
320    // Generate variable name for this module
321    let var_name = path_to_module_var(path);
322    path_to_var.insert(canonical.clone(), var_name.clone());
323
324    // Strip imports/exports and transpile
325    let stripped = strip_imports_and_exports(&source);
326    let filename = path.to_str().unwrap_or("unknown.ts");
327    let transpiled = transpile_typescript(&stripped, filename)?;
328
329    modules.push(ModuleMetadata {
330        path: canonical,
331        var_name,
332        imports,
333        exports,
334        reexports,
335        code: transpiled,
336    });
337
338    Ok(())
339}
340
341/// Generate a unique variable name from a path
342fn path_to_module_var(path: &Path) -> String {
343    let name = path
344        .file_stem()
345        .and_then(|s| s.to_str())
346        .unwrap_or("module");
347
348    // Sanitize: replace non-alphanumeric with underscore
349    let sanitized: String = name
350        .chars()
351        .map(|c| if c.is_alphanumeric() { c } else { '_' })
352        .collect();
353
354    // Add hash of full path to ensure uniqueness
355    use std::hash::{Hash, Hasher};
356    let mut hasher = std::collections::hash_map::DefaultHasher::new();
357    path.hash(&mut hasher);
358    let hash = hasher.finish();
359
360    format!("__mod_{}_{:x}", sanitized, hash & 0xFFFF)
361}
362
363/// Generate scoped module code wrapped in IIFE
364fn generate_scoped_module(
365    module: &ModuleMetadata,
366    path_to_var: &std::collections::HashMap<PathBuf, String>,
367    is_entry: bool,
368) -> Result<String> {
369    let mut code = String::new();
370
371    // Start IIFE - entry module doesn't need to export, others do
372    if is_entry {
373        code.push_str("(function() {\n");
374    } else {
375        code.push_str(&format!("const {} = (function() {{\n", module.var_name));
376    }
377
378    // Generate import destructuring from dependencies
379    for import in &module.imports {
380        if let Some(dep_var) = resolve_import_to_var(&import.source_path, &module.path, path_to_var)
381        {
382            if import.is_namespace {
383                // import * as X from "./y"
384                code.push_str(&format!("const {} = {};\n", import.local_name, dep_var));
385            } else if let Some(ref imported_name) = import.imported_name {
386                // import { X } from "./y" or import { X as Y } from "./y"
387                if imported_name == "default" {
388                    code.push_str(&format!(
389                        "const {} = {}.default;\n",
390                        import.local_name, dep_var
391                    ));
392                } else if &import.local_name == imported_name {
393                    code.push_str(&format!("const {{{}}} = {};\n", import.local_name, dep_var));
394                } else {
395                    code.push_str(&format!(
396                        "const {{{}: {}}} = {};\n",
397                        imported_name, import.local_name, dep_var
398                    ));
399                }
400            } else {
401                // import X from "./y" (default import)
402                code.push_str(&format!(
403                    "const {} = {}.default;\n",
404                    import.local_name, dep_var
405                ));
406            }
407        }
408    }
409
410    // Module code
411    code.push_str(&module.code);
412    code.push('\n');
413
414    // Generate return object with exports (skip for entry module)
415    if !is_entry {
416        code.push_str("return {");
417
418        let mut export_parts: Vec<String> = Vec::new();
419
420        // Direct exports
421        for export in &module.exports {
422            if export.exported_name == export.local_name {
423                export_parts.push(export.exported_name.clone());
424            } else {
425                export_parts.push(format!("{}: {}", export.exported_name, export.local_name));
426            }
427        }
428
429        // Re-exports
430        for reexport in &module.reexports {
431            if let Some(dep_var) =
432                resolve_import_to_var(&reexport.source_path, &module.path, path_to_var)
433            {
434                match (&reexport.exported_name, &reexport.source_name) {
435                    (Some(exported), Some(source)) => {
436                        // export { X as Y } from "./z"
437                        export_parts.push(format!("{}: {}.{}", exported, dep_var, source));
438                    }
439                    (Some(exported), None) => {
440                        // export { X } from "./z" (same name)
441                        export_parts.push(format!("{}: {}.{}", exported, dep_var, exported));
442                    }
443                    (None, None) => {
444                        // export * from "./z" - spread all exports
445                        export_parts.push(format!("...{}", dep_var));
446                    }
447                    _ => {}
448                }
449            }
450        }
451
452        code.push_str(&export_parts.join(", "));
453        code.push_str("};\n");
454    }
455
456    // End IIFE
457    code.push_str("})();\n");
458
459    Ok(code)
460}
461
462/// Resolve an import source path to the dependency's variable name
463fn resolve_import_to_var(
464    source_path: &str,
465    importer_path: &Path,
466    path_to_var: &std::collections::HashMap<PathBuf, String>,
467) -> Option<String> {
468    if !source_path.starts_with("./") && !source_path.starts_with("../") {
469        return None; // External import, not bundled
470    }
471
472    let parent_dir = importer_path.parent().unwrap_or(Path::new("."));
473    if let Ok(resolved) = resolve_import(source_path, parent_dir) {
474        let canonical = resolved.canonicalize().unwrap_or(resolved);
475        path_to_var.get(&canonical).cloned()
476    } else {
477        None
478    }
479}
480
481/// Extract import/export bindings from source using AST
482fn extract_module_bindings(
483    source: &str,
484) -> (Vec<ImportBinding>, Vec<ExportBinding>, Vec<ReexportBinding>) {
485    let allocator = Allocator::default();
486    let source_type = SourceType::default()
487        .with_module(true)
488        .with_typescript(true);
489
490    let parser_ret = Parser::new(&allocator, source, source_type).parse();
491    if !parser_ret.errors.is_empty() {
492        return (Vec::new(), Vec::new(), Vec::new());
493    }
494
495    let mut imports = Vec::new();
496    let mut exports = Vec::new();
497    let mut reexports = Vec::new();
498
499    for stmt in &parser_ret.program.body {
500        match stmt {
501            Statement::ImportDeclaration(import_decl) => {
502                let source_path = import_decl.source.value.to_string();
503
504                // Handle specifiers
505                if let Some(specifiers) = &import_decl.specifiers {
506                    for spec in specifiers {
507                        match spec {
508                            oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(s) => {
509                                imports.push(ImportBinding {
510                                    local_name: s.local.name.to_string(),
511                                    imported_name: Some(s.imported.name().to_string()),
512                                    source_path: source_path.clone(),
513                                    is_namespace: false,
514                                });
515                            }
516                            oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
517                                imports.push(ImportBinding {
518                                    local_name: s.local.name.to_string(),
519                                    imported_name: None, // default import
520                                    source_path: source_path.clone(),
521                                    is_namespace: false,
522                                });
523                            }
524                            oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(
525                                s,
526                            ) => {
527                                imports.push(ImportBinding {
528                                    local_name: s.local.name.to_string(),
529                                    imported_name: None,
530                                    source_path: source_path.clone(),
531                                    is_namespace: true,
532                                });
533                            }
534                        }
535                    }
536                }
537            }
538
539            Statement::ExportNamedDeclaration(export_decl) => {
540                if let Some(ref source) = export_decl.source {
541                    // Re-export: export { X } from "./y"
542                    let source_path = source.value.to_string();
543                    for spec in &export_decl.specifiers {
544                        reexports.push(ReexportBinding {
545                            exported_name: Some(spec.exported.name().to_string()),
546                            source_name: Some(spec.local.name().to_string()),
547                            source_path: source_path.clone(),
548                        });
549                    }
550                } else {
551                    // Direct export
552                    if let Some(ref decl) = export_decl.declaration {
553                        // export const/function/class X
554                        for name in get_declaration_names(decl) {
555                            exports.push(ExportBinding {
556                                exported_name: name.clone(),
557                                local_name: name,
558                            });
559                        }
560                    }
561                    // export { X, Y }
562                    for spec in &export_decl.specifiers {
563                        exports.push(ExportBinding {
564                            exported_name: spec.exported.name().to_string(),
565                            local_name: spec.local.name().to_string(),
566                        });
567                    }
568                }
569            }
570
571            Statement::ExportDefaultDeclaration(export_default) => {
572                // export default X
573                match &export_default.declaration {
574                    ExportDefaultDeclarationKind::FunctionDeclaration(f) => {
575                        if let Some(ref id) = f.id {
576                            exports.push(ExportBinding {
577                                exported_name: "default".to_string(),
578                                local_name: id.name.to_string(),
579                            });
580                        }
581                    }
582                    ExportDefaultDeclarationKind::ClassDeclaration(c) => {
583                        if let Some(ref id) = c.id {
584                            exports.push(ExportBinding {
585                                exported_name: "default".to_string(),
586                                local_name: id.name.to_string(),
587                            });
588                        }
589                    }
590                    _ => {
591                        // Anonymous default export - handle specially
592                        exports.push(ExportBinding {
593                            exported_name: "default".to_string(),
594                            local_name: "__default__".to_string(),
595                        });
596                    }
597                }
598            }
599
600            Statement::ExportAllDeclaration(export_all) => {
601                // export * from "./y"
602                reexports.push(ReexportBinding {
603                    exported_name: None,
604                    source_name: None,
605                    source_path: export_all.source.value.to_string(),
606                });
607            }
608
609            _ => {}
610        }
611    }
612
613    (imports, exports, reexports)
614}
615
616/// Get declared names from a declaration
617fn get_declaration_names(decl: &Declaration<'_>) -> Vec<String> {
618    match decl {
619        Declaration::VariableDeclaration(var_decl) => var_decl
620            .declarations
621            .iter()
622            .filter_map(|d| d.id.get_binding_identifier().map(|id| id.name.to_string()))
623            .collect(),
624        Declaration::FunctionDeclaration(f) => {
625            f.id.as_ref()
626                .map(|id| vec![id.name.to_string()])
627                .unwrap_or_default()
628        }
629        Declaration::ClassDeclaration(c) => {
630            c.id.as_ref()
631                .map(|id| vec![id.name.to_string()])
632                .unwrap_or_default()
633        }
634        Declaration::TSEnumDeclaration(e) => {
635            vec![e.id.name.to_string()]
636        }
637        _ => Vec::new(),
638    }
639}
640
641/// Resolve an import path relative to the importing file's directory
642fn resolve_import(import_path: &str, parent_dir: &Path) -> Result<PathBuf> {
643    let base = parent_dir.join(import_path);
644
645    // Try various extensions
646    if base.exists() {
647        return Ok(base);
648    }
649
650    let with_ts = base.with_extension("ts");
651    if with_ts.exists() {
652        return Ok(with_ts);
653    }
654
655    let with_js = base.with_extension("js");
656    if with_js.exists() {
657        return Ok(with_js);
658    }
659
660    // Try index files
661    let index_ts = base.join("index.ts");
662    if index_ts.exists() {
663        return Ok(index_ts);
664    }
665
666    let index_js = base.join("index.js");
667    if index_js.exists() {
668        return Ok(index_js);
669    }
670
671    Err(anyhow!(
672        "Cannot resolve import '{}' from {}",
673        import_path,
674        parent_dir.display()
675    ))
676}
677
678/// Strip import statements and export keywords from source using AST transformation
679/// Converts ES module syntax to plain JavaScript that QuickJS can eval
680pub fn strip_imports_and_exports(source: &str) -> String {
681    let allocator = Allocator::default();
682    // Parse as module with TypeScript to accept import/export and TS syntax
683    let source_type = SourceType::default()
684        .with_module(true)
685        .with_typescript(true);
686
687    let parser_ret = Parser::new(&allocator, source, source_type).parse();
688    if !parser_ret.errors.is_empty() {
689        // If parsing fails, return original source (let transpiler handle errors)
690        return source.to_string();
691    }
692
693    let mut program = parser_ret.program;
694
695    // Transform the AST: remove imports, convert exports to declarations
696    strip_module_syntax_ast(&allocator, &mut program);
697
698    // Generate code from transformed AST
699    let codegen_ret = Codegen::new().build(&program);
700    codegen_ret.code
701}
702
703/// Strip ES module syntax from a program AST
704/// - Removes ImportDeclaration statements
705/// - Converts ExportNamedDeclaration to its inner declaration
706/// - Handles ExportDefaultDeclaration, ExportAllDeclaration
707fn strip_module_syntax_ast<'a>(allocator: &'a Allocator, program: &mut oxc_ast::ast::Program<'a>) {
708    use oxc_allocator::Vec as OxcVec;
709
710    // Collect transformed statements
711    let mut new_body: OxcVec<'a, Statement<'a>> =
712        OxcVec::with_capacity_in(program.body.len(), allocator);
713
714    for stmt in program.body.drain(..) {
715        match stmt {
716            // Remove import declarations entirely
717            Statement::ImportDeclaration(_) => {
718                // Skip - dependency should already be bundled
719            }
720
721            // Convert export named declarations to their inner declaration
722            Statement::ExportNamedDeclaration(export_decl) => {
723                let inner = export_decl.unbox();
724                if let Some(decl) = inner.declaration {
725                    // Export has a declaration - keep just the declaration
726                    // Convert Declaration to Statement
727                    let stmt = declaration_to_statement(decl);
728                    new_body.push(stmt);
729                }
730                // If no declaration (re-export like `export { X } from './y'`), skip
731            }
732
733            // Handle export default
734            Statement::ExportDefaultDeclaration(export_default) => {
735                let inner = export_default.unbox();
736                match inner.declaration {
737                    ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
738                        new_body.push(Statement::FunctionDeclaration(func));
739                    }
740                    ExportDefaultDeclarationKind::ClassDeclaration(class) => {
741                        new_body.push(Statement::ClassDeclaration(class));
742                    }
743                    ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => {
744                        // TypeScript interface - will be removed by transformer
745                    }
746                    _ => {
747                        // Expression exports (export default expr) - skip
748                    }
749                }
750            }
751
752            // Remove export * declarations (re-exports)
753            Statement::ExportAllDeclaration(_) => {
754                // Skip
755            }
756
757            // Keep all other statements unchanged
758            other => {
759                new_body.push(other);
760            }
761        }
762    }
763
764    program.body = new_body;
765}
766
767/// Convert a Declaration to a Statement
768fn declaration_to_statement(decl: Declaration<'_>) -> Statement<'_> {
769    match decl {
770        Declaration::VariableDeclaration(d) => Statement::VariableDeclaration(d),
771        Declaration::FunctionDeclaration(d) => Statement::FunctionDeclaration(d),
772        Declaration::ClassDeclaration(d) => Statement::ClassDeclaration(d),
773        Declaration::TSTypeAliasDeclaration(d) => Statement::TSTypeAliasDeclaration(d),
774        Declaration::TSInterfaceDeclaration(d) => Statement::TSInterfaceDeclaration(d),
775        Declaration::TSEnumDeclaration(d) => Statement::TSEnumDeclaration(d),
776        Declaration::TSModuleDeclaration(d) => Statement::TSModuleDeclaration(d),
777        Declaration::TSImportEqualsDeclaration(d) => Statement::TSImportEqualsDeclaration(d),
778        Declaration::TSGlobalDeclaration(d) => Statement::TSGlobalDeclaration(d),
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[test]
787    fn test_transpile_basic_typescript() {
788        let source = r#"
789            const x: number = 42;
790            function greet(name: string): string {
791                return `Hello, ${name}!`;
792            }
793        "#;
794
795        let result = transpile_typescript(source, "test.ts").unwrap();
796        assert!(result.contains("const x = 42"));
797        assert!(result.contains("function greet(name)"));
798        assert!(!result.contains(": number"));
799        assert!(!result.contains(": string"));
800    }
801
802    #[test]
803    fn test_transpile_interface() {
804        let source = r#"
805            interface User {
806                name: string;
807                age: number;
808            }
809            const user: User = { name: "Alice", age: 30 };
810        "#;
811
812        let result = transpile_typescript(source, "test.ts").unwrap();
813        assert!(!result.contains("interface"));
814        assert!(result.contains("const user = {"));
815    }
816
817    #[test]
818    fn test_transpile_type_alias() {
819        let source = r#"
820            type ID = number | string;
821            const id: ID = 123;
822        "#;
823
824        let result = transpile_typescript(source, "test.ts").unwrap();
825        assert!(!result.contains("type ID"));
826        assert!(result.contains("const id = 123"));
827    }
828
829    #[test]
830    fn test_has_es_imports() {
831        assert!(has_es_imports("import { foo } from './lib'"));
832        assert!(has_es_imports("import foo from 'bar'"));
833        assert!(!has_es_imports("const x = 1;"));
834        // Note: comment detection is a known limitation - simple heuristic doesn't parse JS
835        // This is OK because false positives just mean we bundle when not strictly needed
836        assert!(has_es_imports("// import foo from 'bar'")); // heuristic doesn't parse comments
837    }
838
839    #[test]
840    fn test_extract_module_bindings() {
841        let source = r#"
842            import { foo } from "./lib/utils";
843            import bar from "../shared/bar";
844            import external from "external-package";
845            export { PanelManager } from "./panel-manager.ts";
846            export * from "./types.ts";
847            export const API_VERSION = 1;
848            const x = 1;
849        "#;
850
851        let (imports, exports, reexports) = extract_module_bindings(source);
852
853        // Check imports
854        assert_eq!(imports.len(), 3);
855        assert!(imports
856            .iter()
857            .any(|i| i.source_path == "./lib/utils" && i.local_name == "foo"));
858        assert!(imports
859            .iter()
860            .any(|i| i.source_path == "../shared/bar" && i.local_name == "bar"));
861        assert!(imports.iter().any(|i| i.source_path == "external-package"));
862
863        // Check direct exports
864        assert_eq!(exports.len(), 1);
865        assert!(exports.iter().any(|e| e.exported_name == "API_VERSION"));
866
867        // Check re-exports
868        assert_eq!(reexports.len(), 2);
869        assert!(reexports
870            .iter()
871            .any(|r| r.source_path == "./panel-manager.ts"));
872        assert!(reexports
873            .iter()
874            .any(|r| r.source_path == "./types.ts" && r.exported_name.is_none()));
875        // export *
876    }
877
878    #[test]
879    fn test_extract_module_bindings_multiline() {
880        // Test multi-line exports like in lib/index.ts
881        let source = r#"
882export type {
883    RGB,
884    Location,
885    PanelOptions,
886} from "./types.ts";
887
888export {
889    Finder,
890    defaultFuzzyFilter,
891} from "./finder.ts";
892
893import {
894    something,
895    somethingElse,
896} from "./multiline-import.ts";
897        "#;
898
899        let (imports, _exports, reexports) = extract_module_bindings(source);
900
901        // Check imports handle multi-line
902        assert_eq!(imports.len(), 2);
903        assert!(imports.iter().any(|i| i.local_name == "something"));
904        assert!(imports.iter().any(|i| i.local_name == "somethingElse"));
905
906        // Check re-exports handle multi-line
907        assert_eq!(reexports.len(), 5); // RGB, Location, PanelOptions, Finder, defaultFuzzyFilter
908        assert!(reexports.iter().any(|r| r.source_path == "./types.ts"));
909        assert!(reexports.iter().any(|r| r.source_path == "./finder.ts"));
910    }
911
912    #[test]
913    fn test_strip_imports_and_exports() {
914        let source = r#"import { foo } from "./lib";
915import bar from "../bar";
916export const API_VERSION = 1;
917export function greet() { return "hi"; }
918export interface User { name: string; }
919const x = foo() + bar();"#;
920
921        let stripped = strip_imports_and_exports(source);
922        // Imports are removed entirely
923        assert!(!stripped.contains("import { foo }"));
924        assert!(!stripped.contains("import bar from"));
925        // Exports are converted to regular declarations
926        assert!(!stripped.contains("export const"));
927        assert!(!stripped.contains("export function"));
928        assert!(!stripped.contains("export interface"));
929        // But the declarations themselves remain
930        assert!(stripped.contains("const API_VERSION = 1"));
931        assert!(stripped.contains("function greet()"));
932        assert!(stripped.contains("interface User"));
933        assert!(stripped.contains("const x = foo() + bar();"));
934    }
935
936    #[test]
937    fn test_extract_plugin_dependencies_basic() {
938        let source = r#"
939import type { SomeType } from "fresh:plugin/utility-plugin";
940import { helper } from "fresh:plugin/core-lib";
941const editor = getEditor();
942"#;
943        let deps = extract_plugin_dependencies(source);
944        assert_eq!(deps, vec!["utility-plugin", "core-lib"]);
945    }
946
947    #[test]
948    fn test_extract_plugin_dependencies_various_import_forms() {
949        let source = r#"
950import type { A } from "fresh:plugin/plugin-a";
951import { B } from "fresh:plugin/plugin-b";
952import * as C from "fresh:plugin/plugin-c";
953import D from "fresh:plugin/plugin-d";
954import { E } from './local-file';
955import { F } from "../other-file";
956"#;
957        let deps = extract_plugin_dependencies(source);
958        assert_eq!(deps, vec!["plugin-a", "plugin-b", "plugin-c", "plugin-d"]);
959    }
960
961    #[test]
962    fn test_extract_plugin_dependencies_deduplicates() {
963        let source = r#"
964import type { A } from "fresh:plugin/shared";
965import { B } from "fresh:plugin/shared";
966"#;
967        let deps = extract_plugin_dependencies(source);
968        assert_eq!(deps, vec!["shared"]);
969    }
970
971    #[test]
972    fn test_extract_plugin_dependencies_single_quotes() {
973        let source = r#"
974import type { A } from 'fresh:plugin/single-quoted';
975"#;
976        let deps = extract_plugin_dependencies(source);
977        assert_eq!(deps, vec!["single-quoted"]);
978    }
979
980    #[test]
981    fn test_extract_plugin_dependencies_no_deps() {
982        let source = r#"
983const editor = getEditor();
984import { helper } from "./lib/utils";
985"#;
986        let deps = extract_plugin_dependencies(source);
987        assert!(deps.is_empty());
988    }
989
990    #[test]
991    fn test_topological_sort_no_deps() {
992        let names = vec!["c".to_string(), "a".to_string(), "b".to_string()];
993        let deps = std::collections::HashMap::new();
994        let result = topological_sort_plugins(&names, &deps).unwrap();
995        // Should be alphabetical when no dependencies
996        assert_eq!(result, vec!["a", "b", "c"]);
997    }
998
999    #[test]
1000    fn test_topological_sort_linear_chain() {
1001        let names = vec!["c".to_string(), "b".to_string(), "a".to_string()];
1002        let mut deps = std::collections::HashMap::new();
1003        deps.insert("b".to_string(), vec!["a".to_string()]);
1004        deps.insert("c".to_string(), vec!["b".to_string()]);
1005        let result = topological_sort_plugins(&names, &deps).unwrap();
1006        assert_eq!(result, vec!["a", "b", "c"]);
1007    }
1008
1009    #[test]
1010    fn test_topological_sort_diamond() {
1011        // D depends on B and C; B and C depend on A
1012        let names = vec![
1013            "d".to_string(),
1014            "c".to_string(),
1015            "b".to_string(),
1016            "a".to_string(),
1017        ];
1018        let mut deps = std::collections::HashMap::new();
1019        deps.insert("b".to_string(), vec!["a".to_string()]);
1020        deps.insert("c".to_string(), vec!["a".to_string()]);
1021        deps.insert("d".to_string(), vec!["b".to_string(), "c".to_string()]);
1022        let result = topological_sort_plugins(&names, &deps).unwrap();
1023        // A must come first, then B and C (alphabetical), then D
1024        assert_eq!(result, vec!["a", "b", "c", "d"]);
1025    }
1026
1027    #[test]
1028    fn test_topological_sort_cycle_detection() {
1029        let names = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1030        let mut deps = std::collections::HashMap::new();
1031        deps.insert("a".to_string(), vec!["b".to_string()]);
1032        deps.insert("b".to_string(), vec!["c".to_string()]);
1033        deps.insert("c".to_string(), vec!["a".to_string()]);
1034        let result = topological_sort_plugins(&names, &deps);
1035        assert!(result.is_err());
1036        let err = result.unwrap_err().to_string();
1037        assert!(err.contains("cycle"), "Error should mention cycle: {}", err);
1038    }
1039
1040    #[test]
1041    fn test_topological_sort_missing_dependency() {
1042        let names = vec!["a".to_string()];
1043        let mut deps = std::collections::HashMap::new();
1044        deps.insert("a".to_string(), vec!["nonexistent".to_string()]);
1045        let result = topological_sort_plugins(&names, &deps);
1046        assert!(result.is_err());
1047        let err = result.unwrap_err().to_string();
1048        assert!(
1049            err.contains("not installed"),
1050            "Error should mention missing dep: {}",
1051            err
1052        );
1053    }
1054
1055    #[test]
1056    fn test_topological_sort_independent_plugins_alphabetical() {
1057        // Mix of dependent and independent plugins
1058        let names = vec![
1059            "zebra".to_string(),
1060            "alpha".to_string(),
1061            "beta".to_string(),
1062            "gamma".to_string(),
1063        ];
1064        let mut deps = std::collections::HashMap::new();
1065        deps.insert("gamma".to_string(), vec!["alpha".to_string()]);
1066        let result = topological_sort_plugins(&names, &deps).unwrap();
1067        // alpha must come before gamma; beta and zebra are independent
1068        let alpha_pos = result.iter().position(|s| s == "alpha").unwrap();
1069        let gamma_pos = result.iter().position(|s| s == "gamma").unwrap();
1070        assert!(alpha_pos < gamma_pos);
1071    }
1072}