Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions changelog.d/7189-export-star-as-namespace-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
`export * as ns from "./m.ts"` now appears on the re-exporting module's
namespace object.

The binding existed, but only under one of the two ways of reaching it:

```ts
// mid.ts
export * as deep from "./leaf.ts";

// main.ts
import { deep } from "./mid.ts"; // worked
import * as B from "./mid.ts";
B.deep; // undefined
Object.keys(B); // "deep" missing entirely
```

The practical cost was zod. Its `external.ts` re-exports four sub-namespaces
exactly this way, so `z.coerce`, `z.iso`, `z.core` and `z.locales` were all
undefined and any code touching them died with "Cannot read properties of
undefined".

Two things were missing. A namespace alias has no declaring binding to point
at — it names a whole module, not something inside one — so it never entered
the export surface the compiler builds for a namespace import, which is why
`Object.keys` did not list it. And every way of resolving a namespace member
ended in a symbol to call, which an alias does not have, so the read fell
through to a generic property lookup on an object that had no such key.

The fix reuses the object the compiler already knows how to build. Perry
already emits a populated namespace object for any module reachable by a
dynamic `import()`, and that populator already handles nested namespaces
recursively. So a namespace re-export target now gets one too, and `B.deep`
loads it. Nesting several levels deep works for the same reason: it is the
same populator all the way down, not a second implementation that has to
reproduce the first one's behaviour.

Members reached through the object behave normally — `B.deep.alpha`,
`B.deep.beta()`, and `Object.keys(B.deep)` all match Node.

Closes #7189.
33 changes: 30 additions & 3 deletions crates/perry-codegen/src/codegen/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1433,9 +1433,36 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
// `BTreeSet` so a multi-path site that resolves to N targets emits
// N declarations exactly once even when multiple `paths` arrays
// share entries.
if !cross_module.dynamic_import_path_to_prefix.is_empty() {
let mut foreign_prefixes: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
// #7189: nested namespace members (`export * as ns from "./m.ts"`, read as
// `B.ns` through a static `import * as B`) load the SAME
// `@__perry_ns_<prefix>` global a dynamic import would, so they need the
// same extern declaration. Without it the module references a global it
// never declared and LLVM refuses to parse the IR — which is a good way for
// this to fail, since it fails at build time and names the missing global.
let mut nested_prefixes: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for key in &cross_module.namespace_member_nested {
if let Some(prefix) = cross_module.namespace_member_prefixes.get(key) {
if prefix != module_prefix.as_str() {
nested_prefixes.insert(prefix.clone());
}
}
}
// The other direction: this module's OWN populator has a nested entry for
// each `export * as ns` it declares, and that entry's value is the source
// module's namespace global. Before #7189 this could not come up, because a
// module only got a populator by being a dynamic-import target and those
// already declare their targets. Now that a namespace re-exporter gets one
// too, its source needs declaring on the same terms.
for entry in &cross_module.namespace_entries {
if let crate::NamespaceEntryKind::NestedNamespace { source_prefix } = &entry.kind {
if source_prefix != module_prefix.as_str() {
nested_prefixes.insert(source_prefix.clone());
}
}
}
if !cross_module.dynamic_import_path_to_prefix.is_empty() || !nested_prefixes.is_empty() {
let mut foreign_prefixes: std::collections::BTreeSet<String> = nested_prefixes;
for prefix in cross_module.dynamic_import_path_to_prefix.values() {
if prefix != module_prefix.as_str() {
foreign_prefixes.insert(prefix.clone());
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ pub(super) fn compile_closure(
object_literal_locals: HashSet::new(),
namespace_imports: &cross_module.namespace_imports,
namespace_member_prefixes: &cross_module.namespace_member_prefixes,
namespace_member_nested: &cross_module.namespace_member_nested,
namespace_member_origin_names: &cross_module.namespace_member_origin_names,
imported_async_funcs: &cross_module.imported_async_funcs,
local_async_funcs: &cross_module.local_async_funcs,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/emission_order_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ fn ir_opts() -> CompileOptions {
verify_native_regions: false,
disable_buffer_fast_path: false,
namespace_imports: Vec::new(),
namespace_member_nested: Vec::new(),
imported_classes: Vec::new(),
imported_enums: Vec::new(),
imported_async_funcs: std::collections::HashSet::new(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,7 @@ pub(super) fn compile_module_entry(
object_literal_locals: HashSet::new(),
namespace_imports: &cross_module.namespace_imports,
namespace_member_prefixes: &cross_module.namespace_member_prefixes,
namespace_member_nested: &cross_module.namespace_member_nested,
namespace_member_origin_names: &cross_module.namespace_member_origin_names,
imported_async_funcs: &cross_module.imported_async_funcs,
local_async_funcs: &cross_module.local_async_funcs,
Expand Down Expand Up @@ -1424,6 +1425,7 @@ pub(super) fn compile_module_entry(
object_literal_locals: HashSet::new(),
namespace_imports: &cross_module.namespace_imports,
namespace_member_prefixes: &cross_module.namespace_member_prefixes,
namespace_member_nested: &cross_module.namespace_member_nested,
namespace_member_origin_names: &cross_module.namespace_member_origin_names,
imported_async_funcs: &cross_module.imported_async_funcs,
local_async_funcs: &cross_module.local_async_funcs,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/entry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ fn entry_opts(output_type: &str) -> CompileOptions {
verify_native_regions: false,
disable_buffer_fast_path: false,
namespace_imports: Vec::new(),
namespace_member_nested: Vec::new(),
imported_classes: Vec::new(),
imported_enums: Vec::new(),
imported_async_funcs: std::collections::HashSet::new(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,7 @@ pub(super) fn compile_function(
object_literal_locals: HashSet::new(),
namespace_imports: &cross_module.namespace_imports,
namespace_member_prefixes: &cross_module.namespace_member_prefixes,
namespace_member_nested: &cross_module.namespace_member_nested,
namespace_member_origin_names: &cross_module.namespace_member_origin_names,
imported_async_funcs: &cross_module.imported_async_funcs,
local_async_funcs: &cross_module.local_async_funcs,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ pub(super) fn compile_method(
object_literal_locals: HashSet::new(),
namespace_imports: &cross_module.namespace_imports,
namespace_member_prefixes: &cross_module.namespace_member_prefixes,
namespace_member_nested: &cross_module.namespace_member_nested,
namespace_member_origin_names: &cross_module.namespace_member_origin_names,
imported_async_funcs: &cross_module.imported_async_funcs,
local_async_funcs: &cross_module.local_async_funcs,
Expand Down Expand Up @@ -1540,6 +1541,7 @@ pub(super) fn compile_static_method(
object_literal_locals: HashSet::new(),
namespace_imports: &cross_module.namespace_imports,
namespace_member_prefixes: &cross_module.namespace_member_prefixes,
namespace_member_nested: &cross_module.namespace_member_nested,
namespace_member_origin_names: &cross_module.namespace_member_origin_names,
imported_async_funcs: &cross_module.imported_async_funcs,
local_async_funcs: &cross_module.local_async_funcs,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>

let mut cross_module = CrossModuleCtx {
namespace_imports: opts.namespace_imports.iter().cloned().collect(),
namespace_member_nested: opts.namespace_member_nested.iter().cloned().collect(),
namespace_member_prefixes: opts.namespace_member_prefixes,
namespace_member_origin_names: opts.namespace_member_origin_names,
imported_async_funcs: opts.imported_async_funcs,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/number_exactness_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ fn ir_opts() -> CompileOptions {
verify_native_regions: false,
disable_buffer_fast_path: false,
namespace_imports: Vec::new(),
namespace_member_nested: Vec::new(),
imported_classes: Vec::new(),
imported_enums: Vec::new(),
imported_async_funcs: std::collections::HashSet::new(),
Expand Down
18 changes: 18 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ pub struct CompileOptions {
/// Codegen uses this to know that `X.foo()` should be dispatched as
/// a cross-module call rather than an object method call.
pub namespace_imports: Vec<String>,
/// #7189: `(namespace local, member)` pairs whose member is itself a MODULE
/// NAMESPACE, from `export * as ns from "./m.ts"` in the imported module.
///
/// These have no `perry_fn_<mod>__<name>` symbol to call, because the member
/// is a whole module rather than a binding inside one. They resolve instead
/// to that module's `@__perry_ns_<prefix>` global — the same object a
/// dynamic `import()` of it would hand back — with the prefix coming from
/// `namespace_member_prefixes` under the same key.
pub namespace_member_nested: Vec<(String, String)>,
/// Imported class definitions from other native modules, keyed by
/// the local alias (or original name when no alias). Each entry
/// carries the class HIR, the module prefix of its origin, and an
Expand Down Expand Up @@ -565,6 +574,15 @@ impl ImportedCtor {
/// Built once in `compile_module` from `CompileOptions`.
pub(crate) struct CrossModuleCtx {
pub namespace_imports: std::collections::HashSet<String>,
/// #7189: `(namespace local, member)` pairs whose member is itself a MODULE
/// NAMESPACE, from `export * as ns from "./m.ts"` in the imported module.
///
/// These have no `perry_fn_<mod>__<name>` symbol to call, because the member
/// is a whole module rather than a binding inside one. They resolve instead
/// to that module's `@__perry_ns_<prefix>` global — the same object a
/// dynamic `import()` of it would hand back — with the prefix coming from
/// `namespace_member_prefixes` under the same key.
pub namespace_member_nested: std::collections::HashSet<(String, String)>,
/// Issue #680: per-namespace member resolution. See doc on
/// `CompileOptions::namespace_member_prefixes`.
pub namespace_member_prefixes: std::collections::HashMap<(String, String), String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ fn ir_opts(is_entry: bool) -> CompileOptions {
verify_native_regions: false,
disable_buffer_fast_path: false,
namespace_imports: Vec::new(),
namespace_member_nested: Vec::new(),
imported_classes: Vec::new(),
imported_enums: Vec::new(),
imported_async_funcs: std::collections::HashSet::new(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ fn ir_opts() -> CompileOptions {
verify_native_regions: false,
disable_buffer_fast_path: false,
namespace_imports: Vec::new(),
namespace_member_nested: Vec::new(),
imported_classes: Vec::new(),
imported_enums: Vec::new(),
imported_async_funcs: std::collections::HashSet::new(),
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use super::{
/// on the current block: a native submodule (`__node_submod__<key>`), a native
/// builtin (`__native_mod__<name>`), or a compiled module (`<prefix>__init` +
/// `@__perry_ns_<prefix>`). Returns a NaN-boxed `DOUBLE` value id.
fn namespace_value_for_prefix(ctx: &mut FnCtx<'_>, prefix: &str) -> String {
pub(crate) fn namespace_value_for_prefix(ctx: &mut FnCtx<'_>, prefix: &str) -> String {
if let Some(key) = prefix.strip_prefix("__node_submod__") {
let key = key.to_string();
let submod_label = emit_string_literal_global(ctx, &key);
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,10 @@ pub(crate) struct FnCtx<'a> {
/// by namespace member access lowering to disambiguate when the same
/// export name appears in multiple `import * as X / Y` sources.
pub namespace_member_prefixes: &'a std::collections::HashMap<(String, String), String>,
/// #7189: `(namespace local, member)` pairs whose member is another
/// module's namespace object rather than a binding. See the doc on
/// `CompileOptions::namespace_member_nested`.
pub namespace_member_nested: &'a std::collections::HashSet<(String, String)>,
/// Issue #5924: per-namespace origin-name resolution. Keyed by
/// `(namespace_local_name, member_name)` → `origin_name`. Consulted
/// before `import_function_origin_names` when computing the symbol
Expand Down
31 changes: 31 additions & 0 deletions crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,37 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// (the registry duplication bug was the first).
if let Expr::ExternFuncRef { name, .. } = object.as_ref() {
if ctx.namespace_imports.contains(name) {
// #7189: `B.deep` where the imported module says
// `export * as deep from "./m.ts"`. The member's value is
// another module's namespace OBJECT, so there is no
// `perry_fn_<mod>__deep` symbol to call — every arm below
// resolves to a symbol, and this member does not have one.
//
// The object it should produce is the one
// `@__perry_ns_<prefix>` already holds, built by the same
// populator a dynamic `import()` of that module would use.
// Reusing it means nested re-exports (a namespace whose own
// exports include another `export * as`) come out right
// without a second implementation, because the populator
// already recurses.
//
// First, ahead of the class and member-prefix arms: a
// namespace alias can collide with a class or function name
// exported elsewhere, and the alias is what the source said.
if ctx
.namespace_member_nested
.contains(&(name.clone(), property.to_string()))
{
if let Some(prefix) = ctx
.namespace_member_prefixes
.get(&(name.clone(), property.to_string()))
.cloned()
{
return Ok(crate::expr::dyn_extern_i18n::namespace_value_for_prefix(
ctx, &prefix,
));
}
}
// Issue #841: namespace member access for the five
// recognized Node submodules — `import * as ns from
// "node:timers/promises"; ns.setTimeout`. Resolve
Expand Down
93 changes: 93 additions & 0 deletions crates/perry-codegen/src/expr/property_get/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ fn ir_opts(debug_locations: bool, module_source: Option<&str>) -> CompileOptions
verify_native_regions: false,
disable_buffer_fast_path: false,
namespace_imports: Vec::new(),
namespace_member_nested: Vec::new(),
imported_classes: Vec::new(),
imported_enums: Vec::new(),
imported_async_funcs: std::collections::HashSet::new(),
Expand Down Expand Up @@ -267,3 +268,95 @@ fn generic_property_get_tries_ways_before_calling_the_miss_handler() {
"the megamorphic gate must read the way-state word:\n{ir}"
);
}

/// #7189 — `B.ns` where the imported module says `export * as ns from "./m.ts"`.
///
/// The member's value is another module's namespace OBJECT, so there is no
/// `perry_fn_<mod>__ns` symbol for it. Every other namespace-member arm
/// resolves to a symbol, so before this the read fell through to the generic
/// path and produced `undefined` — which is how `z.coerce`, `z.iso`, `z.core`
/// and `z.locales` all came back undefined under zod.
mod nested_namespace_members {
use super::*;

fn nested_opts() -> CompileOptions {
let mut opts = ir_opts(false, None);
opts.namespace_imports = vec!["B".to_string()];
opts.namespace_member_prefixes
.insert(("B".to_string(), "deep".to_string()), "ns2_ts".to_string());
opts.namespace_member_prefixes
.insert(("B".to_string(), "gamma".to_string()), "ns3_ts".to_string());
opts.namespace_member_nested = vec![("B".to_string(), "deep".to_string())];
opts
}

fn module_reading(member: &str) -> Module {
let mut m = Module::new("nsmain.ts");
m.init = vec![Stmt::Expr(Expr::PropertyGet {
object: Box::new(Expr::ExternFuncRef {
name: "B".to_string(),
param_types: Vec::new(),
return_type: perry_hir::types::Type::Any,
}),
property: member.to_string(),
byte_offset: 0,
})];
m.init_kind = ModuleInitKind::Eager;
m
}

fn emit_read(member: &str) -> String {
String::from_utf8(compile_module(&module_reading(member), nested_opts()).unwrap())
.expect("LLVM IR should be UTF-8")
}

/// Slice out the body that actually runs the module's statements.
///
/// Assertions have to be made HERE and not against the whole module. The
/// declaration pass emits `@__perry_ns_ns2_ts = external` on its own, so a
/// test that searched the whole IR passed with the read-site fix removed —
/// it was confirming the declaration existed, not that anything used it.
///
/// For an entry module the statements land in `@main`; `<mod>__init` is an
/// empty stub. Slicing the stub is its own way of asserting nothing, which
/// is the mistake this helper exists to avoid making twice.
fn entry_body(ir: &str) -> String {
let start = ir.find("define i32 @main()").expect("main must be emitted");
let end = ir[start..].find("\n}").expect("main must terminate") + start;
ir[start..end].to_string()
}

#[test]
fn a_nested_namespace_member_loads_the_target_namespace_global() {
let ir = emit_read("deep");
let body = entry_body(&ir);
assert!(
body.contains("load double, ptr @__perry_ns_ns2_ts"),
"the read must load the target module's namespace object:\n{body}"
);
// The target's init has to run first, or the namespace is read before
// it has been populated and every member comes back undefined.
assert!(
body.contains("call void @ns2_ts__init()"),
"the target's init must run before its namespace is loaded:\n{body}"
);
// The global lives in another module, so this one must declare it or
// LLVM refuses to parse the IR at all.
assert!(
ir.contains("@__perry_ns_ns2_ts = external"),
"the foreign namespace global must be declared, not just referenced:\n{ir}"
);
}

#[test]
fn an_ordinary_namespace_member_is_untouched() {
// The guard against over-reaching: a normal member still resolves the
// way it always did, through its origin module's symbol rather than a
// namespace object.
let body = entry_body(&emit_read("gamma"));
assert!(
!body.contains("load double, ptr @__perry_ns_ns3_ts"),
"a plain member must not be turned into a namespace load:\n{body}"
);
}
}
Loading
Loading