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