diff --git a/changelog.d/10128-runtime-data-imports.md b/changelog.d/10128-runtime-data-imports.md new file mode 100644 index 0000000000..a757cfb5a1 --- /dev/null +++ b/changelog.d/10128-runtime-data-imports.md @@ -0,0 +1,9 @@ +Fix dynamic imports of runtime data-file paths with `with: { type }` attributes +(#10104). Absolute paths and `file://` URLs support TOML, JSON, text, and file +loaders and return a namespace with a `default` export. Invalid TOML or JSON +rejects with `SyntaxError`; runtime code modules keep their deferred error. + +Preserve import options through HIR traversal, closure/async transforms, +codegen, and cache hashing. Optimized runtimes retain the TOML parser even +without a Bun import. Regression coverage includes OpenCode's legacy TOML +configuration migration, filename URL decoding, and option evaluation order. diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 09901e3340..f6e9c4edf8 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -9,7 +9,7 @@ use perry_hir::types::Type as HirType; use perry_hir::Expr; use crate::nanbox::{double_literal, POINTER_MASK_I64}; -use crate::rooting::{with_rooted_accumulator, Arg, Repr}; +use crate::rooting::{with_rooted_accumulator, with_rooted_group, Arg, Repr}; use crate::types::{DOUBLE, I32, I64, PTR}; use super::{ @@ -566,6 +566,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::DynamicImport { paths, arg, + options, deferred_error, synchronous, .. @@ -577,191 +578,207 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if *synchronous { return lower_dynamic_require(ctx, paths, arg); } - // #5230: a non-resolvable (runtime-computed) specifier was - // *deferred* (the default, non-strict policy — analog of #5206's - // eval deferral). Evaluate the arg, then hand the runtime value to - // the deferred-fallback helper (#6660): a specifier that names a - // node BUILTIN at runtime (`imp("node:os")` through a helper the - // resolver couldn't fold) resolves to the builtin namespace like - // Node; anything else rejects with the descriptive deferral - // `Error` so `await import(spec)` throws only if this site is - // actually reached, instead of failing the whole build. - if let Some(msg) = deferred_error { - let spec_val = lower_expr(ctx, arg)?; - let msg_val = lower_expr(ctx, &Expr::String(msg.clone()))?; - return Ok(ctx.block().call( - DOUBLE, - "js_module_dynamic_import_deferred", - &[(DOUBLE, &spec_val), (DOUBLE, &msg_val)], - )); - } + with_rooted_group(ctx, 3, |ctx, roots| { + // Both arguments are evaluated once, in order. Keep the specifier + // live across option evaluation and options live across hooks/init. + let spec = roots.lower(ctx, arg, true)?; + let options = + roots.lower(ctx, options.as_deref().unwrap_or(&Expr::Undefined), true)?; + // #5230: a non-resolvable (runtime-computed) specifier was + // *deferred* (the default, non-strict policy — analog of #5206's + // eval deferral). Evaluate the arg, then hand the runtime value to + // the deferred-fallback helper (#6660): a specifier that names a + // node BUILTIN at runtime (`imp("node:os")` through a helper the + // resolver couldn't fold) resolves to the builtin namespace like + // Node; anything else rejects with the descriptive deferral + // `Error` so `await import(spec)` throws only if this site is + // actually reached, instead of failing the whole build. + if let Some(msg) = deferred_error { + let msg_val = lower_expr(ctx, &Expr::String(msg.clone()))?; + let spec_val = roots.reread(ctx, spec)?; + let options_val = roots.reread(ctx, options)?; + return Ok(ctx.block().call( + DOUBLE, + "js_module_dynamic_import_deferred", + &[ + (DOUBLE, &spec_val), + (DOUBLE, &options_val), + (DOUBLE, &msg_val), + ], + )); + } - // Defensive: an empty `paths` list means the resolver pass - // failed to populate this node, which `collect_modules` - // should have raised as a compile error. Fall through to the - // runtime fallback (#6660: builtin-or-`ERR_MODULE_NOT_FOUND` - // rejection — historically this arm rejected with literal - // `undefined`, which surfaced as a reasonless - // `Uncaught (in promise) undefined`) rather than crashing the IR. - if paths.is_empty() { - let spec_val = lower_expr(ctx, arg)?; - let hooked = ctx.block().call( + // Defensive: an empty `paths` list means the resolver pass + // failed to populate this node, which `collect_modules` + // should have raised as a compile error. Fall through to the + // runtime fallback (#6660: builtin-or-`ERR_MODULE_NOT_FOUND` + // rejection — historically this arm rejected with literal + // `undefined`, which surfaced as a reasonless + // `Uncaught (in promise) undefined`) rather than crashing the IR. + if paths.is_empty() { + let spec_val = roots.reread(ctx, spec)?; + let hooked = ctx.block().call( + DOUBLE, + "js_module_dynamic_import_apply_hooks", + &[(DOUBLE, &spec_val)], + ); + let options_val = roots.reread(ctx, options)?; + return Ok(ctx.block().call( + DOUBLE, + "js_module_dynamic_import_fallback", + &[(DOUBLE, &hooked), (DOUBLE, &options_val)], + )); + } + + // Evaluate the runtime path string, apply registered loader hooks, + // then emit a chain of `js_string_equals` compares. Do this even + // for a single statically-resolved candidate: TypeScript types are + // erased at runtime and a hook may rewrite the specifier, so the + // candidate count does not prove that the runtime value matches. + // Skipping the compare here used to silently initialize the sole + // candidate for `load("./other.ts" as any)` and for hook redirects. + // Each + // successful compare resolves to its corresponding + // namespace global. The final fallback emits a rejected + // promise. + let raw_path_val = roots.reread(ctx, spec)?; + let path_val = ctx.block().call( DOUBLE, "js_module_dynamic_import_apply_hooks", - &[(DOUBLE, &spec_val)], + &[(DOUBLE, &raw_path_val)], ); - return Ok(ctx.block().call( - DOUBLE, - "js_module_dynamic_import_fallback", - &[(DOUBLE, &hooked)], - )); - } + let path = roots.adopt_emitted(ctx, Repr::Boxed, &path_val, true); + // Result phi slot: every successful match stores the + // promise (NaN-boxed POINTER_TAG f64) here, then jumps to + // a join block which loads and returns. Using an alloca + // keeps the IR straightforward without proper phi nodes. + let result_slot = ctx.block().alloca(DOUBLE); + let join_block_idx = ctx.new_block("dynamic_import_join"); - // Evaluate the runtime path string, apply registered loader hooks, - // then emit a chain of `js_string_equals` compares. Do this even - // for a single statically-resolved candidate: TypeScript types are - // erased at runtime and a hook may rewrite the specifier, so the - // candidate count does not prove that the runtime value matches. - // Skipping the compare here used to silently initialize the sole - // candidate for `load("./other.ts" as any)` and for hook redirects. - // Each - // successful compare resolves to its corresponding - // namespace global. The final fallback emits a rejected - // promise. - let raw_path_val = lower_expr(ctx, arg)?; - let path_val = ctx.block().call( - DOUBLE, - "js_module_dynamic_import_apply_hooks", - &[(DOUBLE, &raw_path_val)], - ); - // Result phi slot: every successful match stores the - // promise (NaN-boxed POINTER_TAG f64) here, then jumps to - // a join block which loads and returns. Using an alloca - // keeps the IR straightforward without proper phi nodes. - let result_slot = ctx.block().alloca(DOUBLE); - let join_block_idx = ctx.new_block("dynamic_import_join"); + // Unbox the path argument once into an i64 StringHeader*. + let path_handle = + ctx.block() + .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &path_val)]); - // Unbox the path argument once into an i64 StringHeader*. - let path_handle = - ctx.block() - .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &path_val)]); + // Pre-resolve target prefixes so we can skip paths that + // don't have a known target (driver dropped them). + let resolved: Vec<(String, String)> = paths + .iter() + .filter_map(|p| { + ctx.dynamic_import_path_to_prefix + .get(p) + .cloned() + .map(|tgt| (p.clone(), tgt)) + }) + .collect(); - // Pre-resolve target prefixes so we can skip paths that - // don't have a known target (driver dropped them). - let resolved: Vec<(String, String)> = paths - .iter() - .filter_map(|p| { - ctx.dynamic_import_path_to_prefix - .get(p) - .cloned() - .map(|tgt| (p.clone(), tgt)) - }) - .collect(); + for (i, (path_str, target_prefix)) in resolved.iter().enumerate() { + // Intern the path string so the compare against the + // runtime arg works on real StringHeader pointers. + let key_idx = ctx.strings.intern(path_str); + let key_entry = ctx.strings.entry(key_idx); + let key_handle_global = format!("@{}", key_entry.handle_global); - for (i, (path_str, target_prefix)) in resolved.iter().enumerate() { - // Intern the path string so the compare against the - // runtime arg works on real StringHeader pointers. - let key_idx = ctx.strings.intern(path_str); - let key_entry = ctx.strings.entry(key_idx); - let key_handle_global = format!("@{}", key_entry.handle_global); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_handle = + blk.call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &key_box)]); + let eq_i32 = blk.call( + I32, + "js_string_equals", + &[(I64, &path_handle), (I64, &key_handle)], + ); + let cond = blk.icmp_ne(I32, &eq_i32, "0"); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_handle = - blk.call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &key_box)]); - let eq_i32 = blk.call( - I32, - "js_string_equals", - &[(I64, &path_handle), (I64, &key_handle)], - ); - let cond = blk.icmp_ne(I32, &eq_i32, "0"); + let match_block_idx = ctx.new_block(&format!("dyn_import_match_{}", i)); + let next_label = if i + 1 < resolved.len() { + ctx.new_block(&format!("dyn_import_next_{}", i)) + } else { + ctx.new_block(&format!("dyn_import_reject_{}", i)) + }; + let match_label = ctx.block_label(match_block_idx); + let next_label_str = ctx.block_label(next_label); + ctx.block().cond_br(&cond, &match_label, &next_label_str); - let match_block_idx = ctx.new_block(&format!("dyn_import_match_{}", i)); - let next_label = if i + 1 < resolved.len() { - ctx.new_block(&format!("dyn_import_next_{}", i)) - } else { - ctx.new_block(&format!("dyn_import_reject_{}", i)) - }; - let match_label = ctx.block_label(match_block_idx); - let next_label_str = ctx.block_label(next_label); - ctx.block().cond_br(&cond, &match_label, &next_label_str); + // Match arm — call target's __init (idempotent), load + // namespace, wrap in promise, store into result_slot, + // branch to join. Issue #753: the init call is the + // only thing that triggers a Deferred target's body + // and namespace populator; for Eager targets the + // guard short-circuits. + ctx.current_block = match_block_idx; + let join_label = ctx.block_label(join_block_idx); + // #1671: known node-submodule target (sentinel prefix) → + // build its namespace via the runtime helper rather than a + // compiled-module init + namespace global. + let ns_val = if let Some(key) = target_prefix.strip_prefix("__node_submod__") { + let key = key.to_string(); + let submod_label = emit_string_literal_global(ctx, &key); + let submod_len = key.len(); + let install_sym = crate::nm_install::nm_submod_install_symbol(&key); + let blk = ctx.block(); + if let Some(s) = install_sym { + blk.call_void(s, &[]); + } + blk.call( + DOUBLE, + "js_node_submodule_namespace", + &[(PTR, &submod_label), (I32, &submod_len.to_string())], + ) + } else if let Some(name) = target_prefix.strip_prefix("__native_mod__") { + // #1673: general native builtin target in a multi-path + // (`import(cond ? 'node:crypto' : './local.ts')`) chain. + let name = name.to_string(); + let mod_label = emit_string_literal_global(ctx, &name); + let mod_len = name.len(); + let blk = ctx.block(); + if let Some(s) = crate::nm_install::nm_install_symbol(&name) { + blk.call_void(s, &[]); + } + if name == "wasi" { + blk.call(DOUBLE, "js_wasi_emit_warning", &[]); + } + blk.call( + DOUBLE, + "js_create_native_module_namespace", + &[(PTR, &mod_label), (I64, &mod_len.to_string())], + ) + } else { + let blk = ctx.block(); + blk.call_void(&format!("{}__init", target_prefix), &[]); + blk.load(DOUBLE, &format!("@__perry_ns_{}", target_prefix)) + }; + let blk = ctx.block(); + let promise = blk.call(I64, "js_promise_resolved", &[(DOUBLE, &ns_val)]); + let boxed = nanbox_pointer_inline(blk, &promise); + blk.store(DOUBLE, &boxed, &result_slot); + blk.br(&join_label); - // Match arm — call target's __init (idempotent), load - // namespace, wrap in promise, store into result_slot, - // branch to join. Issue #753: the init call is the - // only thing that triggers a Deferred target's body - // and namespace populator; for Eager targets the - // guard short-circuits. - ctx.current_block = match_block_idx; + // Move to the next compare block (or fallthrough to + // rejection on the last iteration). + ctx.current_block = next_label; + } + + // No-match fallthrough: runtime fallback (#6660) — a builtin + // specifier resolves like Node, everything else rejects with + // `ERR_MODULE_NOT_FOUND` (this arm used to reject with literal + // `undefined`). let join_label = ctx.block_label(join_block_idx); - // #1671: known node-submodule target (sentinel prefix) → - // build its namespace via the runtime helper rather than a - // compiled-module init + namespace global. - let ns_val = if let Some(key) = target_prefix.strip_prefix("__node_submod__") { - let key = key.to_string(); - let submod_label = emit_string_literal_global(ctx, &key); - let submod_len = key.len(); - let install_sym = crate::nm_install::nm_submod_install_symbol(&key); - let blk = ctx.block(); - if let Some(s) = install_sym { - blk.call_void(s, &[]); - } - blk.call( - DOUBLE, - "js_node_submodule_namespace", - &[(PTR, &submod_label), (I32, &submod_len.to_string())], - ) - } else if let Some(name) = target_prefix.strip_prefix("__native_mod__") { - // #1673: general native builtin target in a multi-path - // (`import(cond ? 'node:crypto' : './local.ts')`) chain. - let name = name.to_string(); - let mod_label = emit_string_literal_global(ctx, &name); - let mod_len = name.len(); - let blk = ctx.block(); - if let Some(s) = crate::nm_install::nm_install_symbol(&name) { - blk.call_void(s, &[]); - } - if name == "wasi" { - blk.call(DOUBLE, "js_wasi_emit_warning", &[]); - } - blk.call( - DOUBLE, - "js_create_native_module_namespace", - &[(PTR, &mod_label), (I64, &mod_len.to_string())], - ) - } else { - let blk = ctx.block(); - blk.call_void(&format!("{}__init", target_prefix), &[]); - blk.load(DOUBLE, &format!("@__perry_ns_{}", target_prefix)) - }; + let path_val = roots.reread_emitted(ctx, path); + let options_val = roots.reread(ctx, options)?; let blk = ctx.block(); - let promise = blk.call(I64, "js_promise_resolved", &[(DOUBLE, &ns_val)]); - let boxed = nanbox_pointer_inline(blk, &promise); - blk.store(DOUBLE, &boxed, &result_slot); + let fallback = blk.call( + DOUBLE, + "js_module_dynamic_import_fallback", + &[(DOUBLE, &path_val), (DOUBLE, &options_val)], + ); + blk.store(DOUBLE, &fallback, &result_slot); blk.br(&join_label); - // Move to the next compare block (or fallthrough to - // rejection on the last iteration). - ctx.current_block = next_label; - } - - // No-match fallthrough: runtime fallback (#6660) — a builtin - // specifier resolves like Node, everything else rejects with - // `ERR_MODULE_NOT_FOUND` (this arm used to reject with literal - // `undefined`). - let join_label = ctx.block_label(join_block_idx); - let blk = ctx.block(); - let fallback = blk.call( - DOUBLE, - "js_module_dynamic_import_fallback", - &[(DOUBLE, &path_val)], - ); - blk.store(DOUBLE, &fallback, &result_slot); - blk.br(&join_label); - - // Join: load result and return. - ctx.current_block = join_block_idx; - Ok(ctx.block().load(DOUBLE, &result_slot)) + // Join: load result and return. + ctx.current_block = join_block_idx; + Ok(ctx.block().load(DOUBLE, &result_slot)) + }) } // -------- ExternFuncRef as a value -------- diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index e221ce098f..d90926ea87 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1620,12 +1620,16 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // `ERR_MODULE_NOT_FOUND` Error (never literal `undefined`). The deferred // variant carries the #5230 compile-time deferral message for unknown // modules. - module.declare_function("js_module_dynamic_import_fallback", DOUBLE, &[DOUBLE]); module.declare_function( - "js_module_dynamic_import_deferred", + "js_module_dynamic_import_fallback", DOUBLE, &[DOUBLE, DOUBLE], ); + module.declare_function( + "js_module_dynamic_import_deferred", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE], + ); // #6644: `module.createRequire(...)` devirt entry — arms the nm/submod // install-all hooks before delegating (see js_process_get_builtin_module_devirt). module.declare_function("js_module_create_require_devirt", DOUBLE, &[DOUBLE]); diff --git a/crates/perry-hir/src/dynamic_import/tests.rs b/crates/perry-hir/src/dynamic_import/tests.rs index 05131df530..4b30dc7420 100644 --- a/crates/perry-hir/src/dynamic_import/tests.rs +++ b/crates/perry-hir/src/dynamic_import/tests.rs @@ -758,6 +758,7 @@ fn dynamic_import_visitors_keep_closure_and_toplevel_order_in_lockstep() { Expr::DynamicImport { paths: vec![], arg: Box::new(Expr::String(path.to_string())), + options: None, byte_offset: 0, deferred_error: None, synchronous: false, @@ -790,7 +791,20 @@ fn dynamic_import_visitors_keep_closure_and_toplevel_order_in_lockstep() { is_generator: false, is_strict: false, })); - module.init.push(Stmt::Expr(dynamic_import("toplevel"))); + let mut outer = dynamic_import("toplevel"); + if let Expr::DynamicImport { options, .. } = &mut outer { + *options = Some(Box::new(dynamic_import("options"))); + } + module.init.push(Stmt::Expr(outer)); + let with_options_hash = crate::stable_hash::hash_module(&module); + let mut without_options = module.clone(); + if let Some(Stmt::Expr(Expr::DynamicImport { options, .. })) = without_options.init.last_mut() { + *options = None; + } + assert_ne!( + with_options_hash, + crate::stable_hash::hash_module(&without_options) + ); let mut immutable = Vec::new(); for_each_dynamic_import(&module, &mut |expr| immutable.push(path(expr))); @@ -798,7 +812,7 @@ fn dynamic_import_visitors_keep_closure_and_toplevel_order_in_lockstep() { let mut mutable = Vec::new(); for_each_dynamic_import_mut(&mut module, &mut |expr| mutable.push(path(expr))); - assert_eq!(immutable, ["closure", "toplevel"]); + assert_eq!(immutable, ["closure", "toplevel", "options"]); assert_eq!(mutable, immutable); } diff --git a/crates/perry-hir/src/dynamic_import/visitors.rs b/crates/perry-hir/src/dynamic_import/visitors.rs index 191dc30e95..72a1ee254e 100644 --- a/crates/perry-hir/src/dynamic_import/visitors.rs +++ b/crates/perry-hir/src/dynamic_import/visitors.rs @@ -422,8 +422,11 @@ fn visit_expr_for_dyn_imports(expr: &mut Expr, f: &mut F) { f(expr); // After f mutates the node, still descend into the (possibly // unchanged) `arg` so nested dynamic imports are visited. - if let Expr::DynamicImport { arg, .. } = expr { + if let Expr::DynamicImport { arg, options, .. } = expr { visit_expr_for_dyn_imports(arg, f); + if let Some(options) = options { + visit_expr_for_dyn_imports(options, f); + } } return; } @@ -438,11 +441,14 @@ fn visit_expr_for_dyn_imports(expr: &mut Expr, f: &mut F) { } fn visit_expr_for_dyn_imports_ref(expr: &Expr, f: &mut F) { - if let Expr::DynamicImport { arg, .. } = expr { + if let Expr::DynamicImport { arg, options, .. } = expr { f(expr); // Mirror the `_mut` sibling: after reporting the node, still descend // into the `arg` so nested dynamic imports are visited. visit_expr_for_dyn_imports_ref(arg, f); + if let Some(options) = options { + visit_expr_for_dyn_imports_ref(options, f); + } return; } // Closure bodies — descend manually (the walker intentionally doesn't). diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 375066fd0e..b5b69c0456 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -2722,6 +2722,8 @@ pub enum Expr { DynamicImport { paths: Vec, arg: Box, + /// Runtime import options (`{ with: { type: "toml" } }`, etc.). + options: Option>, /// Byte offset (`span.lo.0`) of the `import(...)` call in its module's /// source, captured at lowering time. Used by the driver to resolve a /// `file:line` for the #5230 deferred-site notice (HIR `Expr` carries no diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs index 63244f1c91..03ffa406a2 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs @@ -150,6 +150,7 @@ pub(crate) fn try_dynamic_require( Ok(Some(Expr::DynamicImport { paths: Vec::new(), arg: Box::new(arg), + options: None, byte_offset: call.span.lo.0, deferred_error: None, synchronous: true, @@ -214,6 +215,7 @@ pub(crate) fn try_import_meta_require( Ok(Some(Expr::DynamicImport { paths: Vec::new(), arg: Box::new(lower_expr(ctx, arg)?), + options: None, byte_offset: call.span.lo.0, deferred_error: None, synchronous: true, diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index ed277d5c23..8af6aff797 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -783,13 +783,14 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result { tag(h, 12054); module.as_ref().hash(h); name.as_ref().hash(h); } Expr::WebAssemblyInstantiate { bytes, imports } => { tag(h, 12028); bytes.as_ref().hash(h); imports.hash(h); } Expr::WebAssemblyCallExport { instance, name, args, } => { tag(h, 12029); instance.as_ref().hash(h); name.as_ref().hash(h); args.hash(h); } - Expr::DynamicImport { paths, arg, byte_offset, deferred_error, synchronous } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); byte_offset.hash(h); deferred_error.hash(h); synchronous.hash(h); } + Expr::DynamicImport { paths, arg, options, byte_offset, deferred_error, synchronous } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); options.hash(h); byte_offset.hash(h); deferred_error.hash(h); synchronous.hash(h); } Expr::WorkerNew { paths, filename, options, is_eval } => { tag(h, 12055); for p in paths { p.hash(h); } diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index b4a15663b4..fe78215c1f 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -1916,9 +1916,12 @@ where } } - // Issue #100: dynamic import() — descend into the path arg. - Expr::DynamicImport { arg, .. } => { + // Import options can contain local references, calls, and nested imports. + Expr::DynamicImport { arg, options, .. } => { f(arg); + if let Some(options) = options { + f(options); + } } Expr::WorkerNew { filename, options, .. diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index dc2ca8c855..56cea7d120 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -1876,9 +1876,12 @@ where } } } - // Issue #100: dynamic import() — descend into the path arg. - Expr::DynamicImport { arg, .. } => { + // Import options can contain local references, calls, and nested imports. + Expr::DynamicImport { arg, options, .. } => { f(arg); + if let Some(options) = options { + f(options); + } } Expr::WorkerNew { filename, options, .. diff --git a/crates/perry-runtime/src/bun_compat/cli_utils.rs b/crates/perry-runtime/src/bun_compat/cli_utils.rs index 88a4f44d0c..be66817430 100644 --- a/crates/perry-runtime/src/bun_compat/cli_utils.rs +++ b/crates/perry-runtime/src/bun_compat/cli_utils.rs @@ -143,24 +143,37 @@ pub fn js_bun_yaml() -> f64 { extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { let source = value_to_string(input); + match toml_parse_result(&source) { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + } +} + +/// Shared by Bun.TOML.parse and the runtime import loader. Returning errors +/// lets import() reject its promise without throwing through Rust I/O frames. +pub(crate) fn toml_parse_result(source: &str) -> Result { // `Value::from_str` in toml 1.x parses a single TOML value expression; // Bun.TOML.parse consumes a complete document, whose root is a table. - let parsed = match toml::from_str::(&source) { + let parsed = match toml::from_str::(source) { Ok(parsed) => parsed, - Err(error) => crate::exception::js_throw(syntax_error_value(&format!( - "Failed to parse TOML: {error}" - ))), + Err(error) => { + return Err(syntax_error_value(&format!( + "Failed to parse TOML: {error}" + ))) + } }; let json = match serde_json::to_string(&parsed) { Ok(json) => json, - Err(error) => crate::exception::js_throw(syntax_error_value(&format!( - "Failed to convert TOML value: {error}" - ))), + Err(error) => { + return Err(syntax_error_value(&format!( + "Failed to convert TOML value: {error}" + ))) + } }; let source = js_string_from_bytes(json.as_ptr(), json.len() as u32); match unsafe { crate::json::js_json_parse_result(source) } { - Ok(value) => f64::from_bits(value.bits()), - Err(error) => crate::exception::js_throw(error), + Ok(value) => Ok(f64::from_bits(value.bits())), + Err(error) => Err(error), } } diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index e5551bece7..d2dc4f3085 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -4,6 +4,8 @@ //! public function shape. Full CommonJS file/package resolution remains in the //! compiler-side CJS wrapper and future `Module._*` work. +mod data_import; + use crate::closure::{ js_closure_alloc, js_closure_get_capture_f64, js_closure_set_capture_f64, js_register_closure_arity, ClosureHeader, @@ -1215,7 +1217,9 @@ static KEEP_JS_MODULE_AMBIENT_REQUIRE_APPLY: extern "C" fn(f64) -> f64 = /// `deferred_note` carries the compile-time deferral message for #5230 sites /// (runtime-computed specifier, non-strict policy) so a genuinely unknown /// module still reports the site's `file:line`. -fn dynamic_import_fallback_promise(spec: f64, deferred_note: Option) -> f64 { +fn dynamic_import_fallback_promise(spec: f64, options: f64, deferred_note: Option) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let options = scope.root_nanbox_f64(options); // Arm the install-all hooks the way `getBuiltinModule`'s devirt entry does // (#6644): the namespace handed back below must dispatch methods even when // no static import of the module exists anywhere in the program. Codegen @@ -1233,6 +1237,21 @@ fn dynamic_import_fallback_promise(spec: f64, deferred_note: Option) -> crate::exception::string_header_to_string(crate::value::js_jsvalue_to_string(spec)) }, }; + match data_import::load(&spec_str, options.get_nanbox_f64()) { + Ok(Some(namespace)) => { + let namespace = scope.root_nanbox_f64(namespace); + return js_nanbox_pointer( + crate::promise::js_promise_resolved(namespace.get_nanbox_f64()) as i64, + ); + } + Err(error) => { + let error = scope.root_nanbox_f64(error); + return js_nanbox_pointer( + crate::promise::js_promise_rejected(error.get_nanbox_f64()) as i64 + ); + } + Ok(None) => {} + } if let Some(module_name) = supported_require_builtin(&spec_str) { let scope = crate::gc::RuntimeHandleScope::new(); let ns_handle = scope.root_nanbox_f64(require_builtin_value(module_name)); @@ -1306,14 +1325,14 @@ fn dynamic_import_javascript_data_url(specifier: &str) -> Option { /// arms (#6660). Returns a NaN-boxed promise; never throws synchronously /// (`import()` always rejects, per spec). #[no_mangle] -pub extern "C" fn js_module_dynamic_import_fallback(spec: f64) -> f64 { - dynamic_import_fallback_promise(spec, None) +pub extern "C" fn js_module_dynamic_import_fallback(spec: f64, options: f64) -> f64 { + dynamic_import_fallback_promise(spec, options, None) } /// Keepalive anchor (same pattern as the ambient-require anchors above). #[cfg(feature = "keepalive-anchors")] #[used] -static KEEP_JS_MODULE_DYNAMIC_IMPORT_FALLBACK: extern "C" fn(f64) -> f64 = +static KEEP_JS_MODULE_DYNAMIC_IMPORT_FALLBACK: extern "C" fn(f64, f64) -> f64 = js_module_dynamic_import_fallback; /// Codegen entry for #5230 *deferred* dynamic-import sites (runtime-computed @@ -1322,20 +1341,20 @@ static KEEP_JS_MODULE_DYNAMIC_IMPORT_FALLBACK: extern "C" fn(f64) -> f64 = /// deferral message (which names the site's `file:line`) instead of the /// generic `Cannot find module` text. `msg` is the NaN-boxed deferral string. #[no_mangle] -pub extern "C" fn js_module_dynamic_import_deferred(spec: f64, msg: f64) -> f64 { +pub extern "C" fn js_module_dynamic_import_deferred(spec: f64, options: f64, msg: f64) -> f64 { let note = { let jv = JSValue::from_bits(msg.to_bits()); let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; unsafe { crate::string::js_string_key_bytes(jv, &mut sso) } .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) }; - dynamic_import_fallback_promise(spec, note) + dynamic_import_fallback_promise(spec, options, note) } /// Keepalive anchor (same pattern as the ambient-require anchors above). #[cfg(feature = "keepalive-anchors")] #[used] -static KEEP_JS_MODULE_DYNAMIC_IMPORT_DEFERRED: extern "C" fn(f64, f64) -> f64 = +static KEEP_JS_MODULE_DYNAMIC_IMPORT_DEFERRED: extern "C" fn(f64, f64, f64) -> f64 = js_module_dynamic_import_deferred; /// #6651 family regression guard: createRequire's resolver must never drift diff --git a/crates/perry-runtime/src/module_require/data_import.rs b/crates/perry-runtime/src/module_require/data_import.rs new file mode 100644 index 0000000000..376e9b7de5 --- /dev/null +++ b/crates/perry-runtime/src/module_require/data_import.rs @@ -0,0 +1,98 @@ +//! Runtime data-file loaders for import attributes (#10104). + +use super::{js_nanbox_pointer, js_string_from_bytes, set_field_rooted, string_value, undefined}; +use crate::gc::RuntimeHandleScope; +use crate::value::JSValue; +use std::path::Path; + +fn string_bytes(value: f64) -> Option { + let value = JSValue::from_bits(value.to_bits()); + let mut sso = [0; crate::value::SHORT_STRING_MAX_LEN]; + // SAFETY: the helper validates the value's string representation. + unsafe { crate::string::js_string_key_bytes(value, &mut sso) } + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) +} + +fn property(value: f64, key: &[u8]) -> Result { + // Catch accessors here so errors reject import() and the caller's root + // scopes are dropped normally, even with the longjmp exception transport. + crate::exception::catch_js_throw(|| unsafe { + crate::value::js_get_property(value, key.as_ptr() as i64, key.len() as i64) + }) +} + +fn io_error(path: &str, error: std::io::Error) -> f64 { + let code = match error.kind() { + std::io::ErrorKind::NotFound => "ENOENT", + std::io::ErrorKind::PermissionDenied => "EACCES", + std::io::ErrorKind::IsADirectory => "EISDIR", + _ => "EIO", + }; + let message = format!("{code}: cannot import '{path}': {error}"); + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + crate::node_submodules::register_error_code_pub(message, code); + js_nanbox_pointer(crate::error::js_error_new_with_message(message) as i64) +} + +/// `None` leaves builtin/code-module resolution to the existing fallback. +pub(super) fn load(specifier: &str, options: f64) -> Result, f64> { + if JSValue::from_bits(options.to_bits()).is_undefined() { + return Ok(None); + } + let scope = RuntimeHandleScope::new(); + let attributes = scope.root_nanbox_f64(property(options, b"with")?); + if JSValue::from_bits(attributes.get_nanbox_f64().to_bits()).is_undefined() { + return Ok(None); + } + let loader = string_bytes(property(attributes.get_nanbox_f64(), b"type")?); + let Some(loader @ ("toml" | "json" | "text" | "file")) = loader.as_deref() else { + return Ok(None); + }; + let path = if specifier.starts_with("file://") { + let url = scope.root_nanbox_f64(string_value(specifier)); + let decoded = crate::exception::catch_js_throw(|| { + crate::url::js_url_file_url_to_path(url.get_nanbox_f64(), undefined()) + })?; + string_bytes(decoded).expect("fileURLToPath returns a string") + } else if Path::new(specifier).is_absolute() { + specifier.to_owned() + } else { + return Ok(None); + }; + + let value = if loader == "file" { + let metadata = std::fs::metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.is_dir() { + return Err(io_error(&path, std::io::ErrorKind::IsADirectory.into())); + } + string_value(&path) + } else { + let bytes = std::fs::read(&path).map_err(|error| io_error(&path, error))?; + let source = String::from_utf8_lossy(&bytes); + match loader { + "text" => string_value(&source), + "json" => { + let source = source.strip_prefix('\u{feff}').unwrap_or(&source); + let source = js_string_from_bytes(source.as_ptr(), source.len() as u32); + // SAFETY: source is a live runtime string; parse_result returns + // a SyntaxError value instead of throwing on invalid JSON. + unsafe { crate::json::js_json_parse_result(source) } + .map(|value| f64::from_bits(value.bits()))? + } + #[cfg(feature = "bun-cli-utils")] + "toml" => crate::bun_compat::toml_parse_result(&source)?, + // Optimized builds retain bun-cli-utils for sites with options. + // A deliberately minimal runtime still uses the deferred error. + _ => return Ok(None), + } + }; + let value = scope.root_nanbox_f64(value); + let namespace = scope.root_raw_mut_ptr(crate::object::js_object_alloc_null_proto(0, 1)); + set_field_rooted(&namespace, "default", value.get_nanbox_f64()); + Ok(Some(namespace.with_mut_ptr( + |object: *mut crate::object::ObjectHeader| js_nanbox_pointer(object as i64), + ))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-runtime/src/module_require/data_import/tests.rs b/crates/perry-runtime/src/module_require/data_import/tests.rs new file mode 100644 index 0000000000..a5fd853da1 --- /dev/null +++ b/crates/perry-runtime/src/module_require/data_import/tests.rs @@ -0,0 +1,157 @@ +use super::*; +use crate::module_require::{js_module_dynamic_import_deferred, js_module_dynamic_import_fallback}; + +fn json(source: &str) -> f64 { + let source = js_string_from_bytes(source.as_ptr(), source.len() as u32); + unsafe { crate::json::js_json_parse_result(source) } + .map(|value| f64::from_bits(value.bits())) + .expect("valid test JSON") +} + +fn settled(promise: f64, state: i32) -> f64 { + let promise = + JSValue::from_bits(promise.to_bits()).as_pointer::() as *mut _; + assert_eq!(crate::promise::js_promise_state(promise), state); + crate::promise::js_promise_result(promise) +} + +fn error_code(value: f64) -> Option<&'static str> { + crate::node_submodules::error_code_for_error( + JSValue::from_bits(value.to_bits()).as_pointer::(), + ) +} + +#[test] +fn runtime_data_import_loaders_and_rejections() { + let directory = std::env::temp_dir().join(format!( + "perry-data-import-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&directory).unwrap(); + let scope = RuntimeHandleScope::new(); + let path = directory.join("config space # %.data"); + let path = path.to_str().unwrap(); + let file_url = crate::url::node_compat::path_to_file_url_string(path, cfg!(windows)); + let note = scope.root_nanbox_f64(string_value("deferred test site")); + + for (loader, contents) in [ + ("json", "{\"answer\":42}"), + ("text", "hello π\n"), + ("file", "asset"), + ] { + std::fs::write(path, contents).unwrap(); + let options = + scope.root_nanbox_f64(json(&format!("{{\"with\":{{\"type\":\"{loader}\"}}}}"))); + for specifier in [path, file_url.as_str()] { + let specifier = scope.root_nanbox_f64(string_value(specifier)); + for deferred in [false, true] { + let promise = if deferred { + js_module_dynamic_import_deferred( + specifier.get_nanbox_f64(), + options.get_nanbox_f64(), + note.get_nanbox_f64(), + ) + } else { + js_module_dynamic_import_fallback( + specifier.get_nanbox_f64(), + options.get_nanbox_f64(), + ) + }; + let namespace = scope.root_nanbox_f64(settled(promise, 1)); + let value = property(namespace.get_nanbox_f64(), b"default").unwrap(); + match loader { + "json" => assert_eq!(property(value, b"answer").unwrap(), 42.0), + "text" => assert_eq!(string_bytes(value).as_deref(), Some(contents)), + "file" => assert_eq!(string_bytes(value).as_deref(), Some(path)), + _ => unreachable!(), + } + } + } + } + + #[cfg(feature = "bun-cli-utils")] + { + std::fs::write(path, "provider = \"anthropic\"\n[settings]\nretry = 3\n").unwrap(); + let specifier = scope.root_nanbox_f64(string_value(&file_url)); + let options = scope.root_nanbox_f64(json(r#"{"with":{"type":"toml"}}"#)); + let promise = js_module_dynamic_import_deferred( + specifier.get_nanbox_f64(), + options.get_nanbox_f64(), + note.get_nanbox_f64(), + ); + let namespace = scope.root_nanbox_f64(settled(promise, 1)); + let table = + scope.root_nanbox_f64(property(namespace.get_nanbox_f64(), b"default").unwrap()); + assert_eq!( + string_bytes(property(table.get_nanbox_f64(), b"provider").unwrap()).as_deref(), + Some("anthropic") + ); + let settings = property(table.get_nanbox_f64(), b"settings").unwrap(); + assert_eq!(property(settings, b"retry").unwrap(), 3.0); + } + + for loader in ["json", "toml"] { + if loader == "toml" && !cfg!(feature = "bun-cli-utils") { + continue; + } + std::fs::write(path, "invalid = [").unwrap(); + let options = + scope.root_nanbox_f64(json(&format!("{{\"with\":{{\"type\":\"{loader}\"}}}}"))); + let specifier = scope.root_nanbox_f64(string_value(&file_url)); + let promise = + js_module_dynamic_import_fallback(specifier.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + string_bytes(property(error.get_nanbox_f64(), b"name").unwrap()).as_deref(), + Some("SyntaxError") + ); + } + + let options = scope.root_nanbox_f64(json(r#"{"with":{"type":"text"}}"#)); + for specifier in [ + "./relative.data", + "https://example.invalid/data", + "unknown-package", + ] { + let specifier = scope.root_nanbox_f64(string_value(specifier)); + let promise = + js_module_dynamic_import_fallback(specifier.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + error_code(error.get_nanbox_f64()), + Some("ERR_MODULE_NOT_FOUND") + ); + } + // Existing runtime files without a supported data attribute remain deferred. + let specifier = scope.root_nanbox_f64(string_value(path)); + let promise = js_module_dynamic_import_deferred( + specifier.get_nanbox_f64(), + undefined(), + note.get_nanbox_f64(), + ); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + string_bytes(property(error.get_nanbox_f64(), b"message").unwrap()).as_deref(), + Some("deferred test site") + ); + + std::fs::remove_file(path).unwrap(); + // URL conversion failures reject the promise instead of throwing here. + let invalid_url = scope.root_nanbox_f64(string_value(&file_url.replace("%20", "%2F"))); + let promise = + js_module_dynamic_import_fallback(invalid_url.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!( + string_bytes(property(error.get_nanbox_f64(), b"name").unwrap()).as_deref(), + Some("TypeError") + ); + let promise = + js_module_dynamic_import_fallback(specifier.get_nanbox_f64(), options.get_nanbox_f64()); + let error = scope.root_nanbox_f64(settled(promise, 2)); + assert_eq!(error_code(error.get_nanbox_f64()), Some("ENOENT")); + std::fs::remove_dir(directory).unwrap(); +} diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 879c542fde..c0d149c90d 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -1483,6 +1483,7 @@ mod tests { vec![Stmt::Return(Some(Expr::DynamicImport { paths: vec!["./alpha".to_string()], arg: Box::new(Expr::String("./alpha".to_string())), + options: None, byte_offset: 0, deferred_error: None, synchronous: true, diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 4b29ebdca6..8247d97149 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -790,12 +790,36 @@ fn collect_module_one( if let perry_hir::Expr::DynamicImport { paths, arg, + options, byte_offset, synchronous, .. } = expr { let synchronous = *synchronous; + ctx.uses_dynamic_import_options |= options.is_some(); + let may_load_data = match options.as_deref() { + None | Some(perry_hir::Expr::Undefined) => false, + Some(perry_hir::Expr::Object(fields)) => fields.iter().any(|(key, value)| { + key == "with" + && match value { + perry_hir::Expr::Object(attributes) => { + attributes.iter().any(|(key, value)| { + key == "type" + && match value { + perry_hir::Expr::String(loader) => matches!( + loader.as_str(), + "toml" | "json" | "text" | "file" + ), + _ => true, + } + }) + } + _ => true, + } + }), + _ => true, + }; if !paths.is_empty() { // Already resolved (e.g. a second pass on the same module). return; @@ -822,6 +846,14 @@ fn collect_module_one( return; } for p in &set { + // Data files selected by import attributes are read at + // runtime, including literal absolute paths. Do not + // feed their contents to the TypeScript compiler. + if may_load_data + && (p.starts_with("file://") || std::path::Path::new(p).is_absolute()) + { + continue; + } if p.starts_with("data:text/javascript,") { ctx.uses_data_url_dynamic_import = true; } diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index 6e34d3783c..b2e25ccde3 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -131,7 +131,7 @@ pub(crate) fn auto_optimized_cache_key( tokio_bindings.sort_unstable(); tokio_bindings.dedup(); format!( - "{}|{}|{}|wasm={}|napi={}|regex={}|temporal={}|ee={}|url={}|norm={}|seg={}|loc={}|intlns={}|gns={}{}{}{}{}{}{}{}{}{}|diag={}|dgram={}|http2={}|nodetest={}|dyneval={}|tokio={}|sizeopt={}|anchors={}|v={}", + "{}|{}|{}|wasm={}|napi={}|regex={}|temporal={}|ee={}|url={}|norm={}|seg={}|loc={}|intlns={}|gns={}{}{}{}{}{}{}{}{}{}|diag={}|dgram={}|http2={}|nodetest={}|dyneval={}|importopts={}|tokio={}|sizeopt={}|anchors={}|v={}", feature_arg, panic_abort_safe, target_str, @@ -169,6 +169,7 @@ pub(crate) fn auto_optimized_cache_key( perry_hir::has_deferred_dynamic_code_sites() || ctx.native_module_imports.contains("vm") || ctx.uses_data_url_dynamic_import, + ctx.uses_dynamic_import_options, tokio_bindings.join(","), format!( "{}{}{}", @@ -221,7 +222,10 @@ pub(crate) fn auto_optimized_cross_features( if !ctx.native_addons.is_empty() { cross_features.push("perry-runtime/node-api-host".to_string()); } - if ctx.bun_platform || ctx.native_module_imports.contains("bun") { + if ctx.bun_platform + || ctx.native_module_imports.contains("bun") + || ctx.uses_dynamic_import_options + { cross_features.push("perry-runtime/bun-cli-utils".to_string()); } // Binary-size feature gating (kept in sync with the inline list on `main`): diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 8743028e78..ebb015fd59 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1202,6 +1202,22 @@ fn bun_usage_enables_cli_utility_runtime_pack() { ); } +#[test] +fn dynamic_import_options_retain_toml_and_change_cache_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let without = CompilationContext::new(dir.path().to_path_buf()); + let mut with = CompilationContext::new(dir.path().to_path_buf()); + with.uses_dynamic_import_options = true; + let cross = auto_optimized_cross_features(&with, &std::collections::BTreeSet::new(), &[]); + assert!(cross + .iter() + .any(|feature| feature == "perry-runtime/bun-cli-utils")); + assert_ne!( + auto_optimized_cache_key("", true, false, None, &with, &[]), + auto_optimized_cache_key("", true, false, None, &without, &[]), + ); +} + #[test] fn data_url_dynamic_import_enables_dyn_eval_and_changes_cache_key() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 5d7f27ea1a..28af24b81a 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -795,6 +795,8 @@ pub struct CompilationContext { /// The runtime evaluates these modules through the dyn-eval interpreter, /// so auto-optimized archives must retain that otherwise optional feature. pub uses_data_url_dynamic_import: bool, + /// Import options can select a runtime TOML loader without a Bun import. + pub uses_dynamic_import_options: bool, /// Whether any TS module calls global `fetch()` (which routes to /// reqwest in perry-stdlib's http-client feature). pub uses_fetch: bool, @@ -1246,6 +1248,7 @@ impl CompilationContext { geisterhand_port: 7676, native_module_imports: BTreeSet::new(), uses_data_url_dynamic_import: false, + uses_dynamic_import_options: false, uses_fetch: false, uses_crypto_builtins: false, uses_zlib_brotli: false, diff --git a/crates/perry/tests/issue_10104_runtime_data_imports.rs b/crates/perry/tests/issue_10104_runtime_data_imports.rs new file mode 100644 index 0000000000..4b3d2be48f --- /dev/null +++ b/crates/perry/tests/issue_10104_runtime_data_imports.rs @@ -0,0 +1,109 @@ +//! Runtime import attributes and OpenCode's legacy TOML config migration. +use std::process::Command; + +fn compile_and_run(source: &str) -> String { + let directory = tempfile::tempdir().unwrap(); + let entry = directory.path().join("main.ts"); + let binary = directory + .path() + .join(if cfg!(windows) { "main.exe" } else { "main" }); + let source = source.replace( + "\"__LITERAL_DATA_PATH__\"", + &serde_json::to_string(&directory.path().join("literal-data")).unwrap(), + ); + std::fs::write(&entry, source).unwrap(); + let mut compiler = Command::new(env!("CARGO_BIN_EXE_perry")); + // #7354: LLVM RS4GC does not support Windows exception funclets yet. + // Exercise the supported shadow-root path for async rejection tests. + if cfg!(windows) { + compiler.env("PERRY_RS4GC", "0"); + } + let compile = compiler + .current_dir(directory.path()) + .args(["compile", "--no-cache"]) + .arg(&entry) + .arg("-o") + .arg(&binary) + .output() + .unwrap(); + assert!( + compile.status.success(), + "compile failed: {}", + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(binary) + .current_dir(directory.path()) + .output() + .unwrap(); + assert!( + run.status.success(), + "run failed: {}\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert!( + run.stderr.is_empty(), + "unexpected stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).unwrap().replace("\r\n", "\n") +} + +#[test] +fn runtime_data_loaders_and_legacy_migration_match_bun() { + let output = compile_and_run(include_str!( + "../../../test-files/test_dynamic_import_data_10104.ts" + )); + assert_eq!( + output, + concat!( + "toml anthropic claude-sonnet-4-5 dark\n", + "json 42\njson url true\n", + "text \"hello π\\n\"\nfile true\n", + "options 42 so\n", + "bad toml true\nbad json SyntaxError true\n", + "migrated true\n", + "{\n \"model\": \"anthropic/claude-sonnet-4-5\",\n", + " \"$schema\": \"https://opencode.ai/config.json\",\n", + " \"theme\": \"dark\"\n}\n", + ) + ); +} + +#[test] +fn literal_absolute_data_path_is_read_after_compilation() { + let output = compile_and_run( + r#" +import { writeFileSync } from "node:fs"; +writeFileSync("__LITERAL_DATA_PATH__", "answer = 42\n"); +const mod = await import("__LITERAL_DATA_PATH__", { with: { type: "toml" } }); +console.log(mod.default.answer); +"#, + ); + assert_eq!(output, "42\n"); +} + +#[test] +fn runtime_code_imports_keep_the_deferred_error() { + let output = compile_and_run( + r#" +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +const source = join(process.cwd(), "runtime-code.js"); +writeFileSync(source, "throw new Error('code must not execute')"); +const load = (specifier: string, options?: any) => import(specifier, options); +await load(pathToFileURL(source).href).catch((error: any) => console.log(error.code)); +await load(source, { with: { type: "javascript" } }).catch((error: any) => console.log(error.code)); +await load("./runtime-code.js", { with: { type: "text" } }).catch((error: any) => console.log(error.code)); +const badToml = join(process.cwd(), "broken.toml"); +writeFileSync(badToml, "broken = ["); +await load(pathToFileURL(badToml).href, { with: { type: "toml" } }) + .catch((error: any) => console.log(error.name, error instanceof SyntaxError)); +"#, + ); + assert_eq!( + output, + "ERR_MODULE_NOT_FOUND\nERR_MODULE_NOT_FOUND\nERR_MODULE_NOT_FOUND\nSyntaxError true\n" + ); +} diff --git a/docs/src/language/limitations.md b/docs/src/language/limitations.md index 902ed31cdc..53a4aee84c 100644 --- a/docs/src/language/limitations.md +++ b/docs/src/language/limitations.md @@ -55,7 +55,7 @@ build, while still failing loudly (and catchably) if that path runs. ### Dynamic `import()` with a runtime-computed specifier (#5230) -A dynamic `import(spec)` whose `spec` is only known at runtime (a plugin loader +For code modules, a dynamic `import(spec)` whose `spec` is only known at runtime (a plugin loader building a path from a variable) is subject to the **same defer/notice/strict policy** as `eval`. By default it compiles to a rejected `Promise` carrying a descriptive `Error` (so `await import(spec)` throws *only if reached*), is @@ -78,6 +78,29 @@ async function loadPlugin(name: string) { } ``` +Data files can be loaded at runtime using import attributes (#10104). The +specifier must be an absolute filesystem path or a `file://` URL, and the +result has a `default` export: + +| Import attribute `type` | Default export | +|---|---| +| `"toml"` | Parsed TOML table, using the same parser as `Bun.TOML.parse` | +| `"json"` | Parsed JSON value | +| `"text"` | File contents as a string | +| `"file"` | Filesystem path as a string | + +```typescript,no-test +import { pathToFileURL } from "node:url"; +const { default: config } = await import(pathToFileURL(configPath).href, { + with: { type: "toml" }, +}); +``` + +TOML and JSON parse failures reject with `SyntaxError`. Missing files reject +with an I/O error. These loaders do not load runtime code modules or resolve +relative paths, package names, or network URLs. Strict mode still rejects +runtime-computed specifiers at compile time as described below. + ### Strict mode: refuse at compile time To make every runtime-unknown site a hard compile-time error instead, opt into diff --git a/test-files/test_dynamic_import_data_10104.ts b/test-files/test_dynamic_import_data_10104.ts new file mode 100644 index 0000000000..4560dacecc --- /dev/null +++ b/test-files/test_dynamic_import_data_10104.ts @@ -0,0 +1,62 @@ +// Bun 1.3.14 oracle, also run by crates/perry/tests/issue_10104_runtime_data_imports.rs. +import { writeFileSync, readFileSync, existsSync } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; + +const directory = process.cwd(); +const legacy = path.join(directory, "config space # %.legacy"); +writeFileSync(legacy, 'provider = "anthropic"\nmodel = "claude-sonnet-4-5"\ntheme = "dark"\n'); + +const load = (specifier: string, type: string) => import(specifier, { with: { type } }); +const table = await load(pathToFileURL(legacy).href, "toml"); +console.log("toml", table.default.provider, table.default.model, table.default.theme); + +const jsonPath = path.join(directory, "data.json"); +writeFileSync(jsonPath, '{"answer":42,"nested":{"enabled":true}}'); +const type = "json"; +const captured = (specifier: string) => import(specifier, { with: { type } }); +console.log("json", (await captured(jsonPath)).default.answer); +console.log("json url", (await load(pathToFileURL(jsonPath).href, "json")).default.nested.enabled); + +const textPath = path.join(directory, "content space # %.data"); +writeFileSync(textPath, "hello π\n"); +console.log("text", JSON.stringify((await load(pathToFileURL(textPath).href, "text")).default)); +const assetPath = path.join(directory, "asset space # %.data"); +writeFileSync(assetPath, "asset"); +console.log("file", path.normalize((await load(pathToFileURL(assetPath).href, "file")).default) === assetPath); + +let order = ""; +function specifier() { order += "s"; return jsonPath; } +function options() { order += "o"; return { with: { type: "json" } }; } +console.log("options", (await import(specifier(), options())).default.answer, order); + +const badToml = path.join(directory, "bad.toml"); +const badJson = path.join(directory, "bad.json"); +writeFileSync(badToml, "broken = ["); +writeFileSync(badJson, "{broken"); +await load(pathToFileURL(badToml).href, "toml").catch((error: any) => { + // Bun 1.3.14 wraps TOML loader diagnostics in BuildMessage; Perry's + // requested SyntaxError contract is asserted separately in the Rust suite. + console.log("bad toml", !!error); +}); +await load(badJson, "json").catch((error: any) => { + console.log("bad json", error.name, error instanceof SyntaxError); +}); + +// OpenCode's legacy migration: destructure the imported default, combine the +// provider/model, write config.json, and remove the old file. The swallowing +// catch is intentional: the pre-fix runtime silently skipped this migration. +let result: any = {}; +await import(pathToFileURL(legacy).href, { with: { type: "toml" } }) + .then(async (mod) => { + const { provider, model, ...rest } = mod.default; + if (provider && model) result.model = `${provider}/${model}`; + result["$schema"] = "https://opencode.ai/config.json"; + result = Object.assign(result, rest); + await fs.writeFile(path.join(directory, "config.json"), JSON.stringify(result, null, 2)); + await fs.unlink(legacy); + }) + .catch(() => {}); +console.log("migrated", !existsSync(legacy)); +console.log(readFileSync(path.join(directory, "config.json"), "utf8"));