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
15 changes: 14 additions & 1 deletion crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// (mirrors the `Expr::ExternFuncRef` arm at the
// bottom of this function): emit the INT32-tagged
// class-id NaN-box that `Expr::ClassRef` produces.
if let Some(&cid) = ctx.class_ids.get(property) {
// #1758: a renamed export (`export { Number$ as Number }`)
// is keyed in `class_ids` under the ORIGIN name (`Number$`),
// but `property` here is the EXPORTED alias (`Number`). Try
// the alias first (direct exports), then the origin name via
// `import_function_origin_names` — otherwise `ns.Number`
// misses the class ref and falls back to the global
// `Number`, dropping all inherited statics (effect's
// `S.Number.ast`).
let class_cid = ctx.class_ids.get(property).copied().or_else(|| {
ctx.import_function_origin_names
.get(property)
.and_then(|origin| ctx.class_ids.get(origin).copied())
});
if let Some(cid) = class_cid {
let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF);
return Ok(double_literal(f64::from_bits(bits)));
}
Expand Down
26 changes: 25 additions & 1 deletion crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,8 +748,32 @@ pub fn run_with_parse_cache(
}
// Named exports (export { foo, bar as baz })
for export in &hir_module.exports {
if let perry_hir::Export::Named { exported, .. } = export {
if let perry_hir::Export::Named { local, exported } = export {
exports.insert(exported.clone(), path_str.clone());
// #1758: a LOCAL renamed export of a CLASS
// (`export { Number$ as Number }`, no `from`) must record the
// origin (local) name so importers resolve `ns.Number` to the
// defining class `Number$`. The re-export propagation loop below
// only records origin names for cross-module
// `export { X as Y } from "src"`. Without this, the
// namespace-member class value-read (property_get.rs) looks up
// `class_ids["Number"]` (the export alias) — a miss — and
// `S.Number` falls back to the global `Number`, losing all
// inherited statics (effect's `S.Number.ast` → undefined →
// Schema decode crash). Scoped to classes: renamed var/func
// exports route through wrapper-symbol emission that keys on the
// export name, and feeding the origin name there breaks linking.
if local != exported
&& hir_module
.classes
.iter()
.any(|c| c.name == *local && c.is_exported)
{
all_module_export_origin_names
.entry(path_str.clone())
.or_insert_with(BTreeMap::new)
.insert(exported.clone(), local.clone());
}
}
// ReExport is handled in the propagation loop below (avoids borrow issues)
}
Expand Down
19 changes: 19 additions & 0 deletions test-files/_helpers/renamed_class_export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Helper for test_gap_renamed_class_export_namespace.ts (#1758).
// Mirrors effect's `class Number$ extends make(numberKeyword) {}` +
// `export { Number$ as Number }` shape: a class whose static `ast` is
// INHERITED from a class-expression parent, re-exported under a renamed
// (and global-colliding) name.

export function make(a: any) {
return class SchemaClass {
static ast = a;
};
}

class Number$ extends make({ _tag: "NumberKeyword" }) {}
class Widget$ extends make({ _tag: "WidgetKeyword" }) {}
export class DirectCls extends make({ _tag: "DirectKeyword" }) {}

// Renamed exports. `Number` deliberately collides with the JS global
// `Number` — pre-fix, `M.Number` resolved to the global constructor.
export { Number$ as Number, Widget$ as Widget };
31 changes: 31 additions & 0 deletions test-files/test_gap_renamed_class_export_namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// epic #1785 / #1758: namespace-member access of a RENAMED class export.
//
// `export { Number$ as Number }` (a local renamed export) read via
// `import * as M from "..."; M.Number` resolved `class_ids["Number"]` (the
// export ALIAS) — a miss, since the class is named `Number$` — so `M.Number`
// fell back to the JS global `Number` (or undefined), and `M.Number.ast`
// (an inherited static field) read undefined.
//
// This blocked effect Schema DECODE: `S.Number = class Number$ extends
// make(numberKeyword) {}` re-exported as `Number`; `S.decodeUnknownSync(S.Number)`
// read `S.Number.ast` === undefined → `_tag` of undefined in ParseResult.
//
// Fix: record the origin (local) name for local renamed CLASS exports
// (compile.rs) and fall back to it when resolving a namespace-member class
// ref (property_get.rs).
//
// Compared byte-for-byte against `node --experimental-strip-types`.

import * as M from "./_helpers/renamed_class_export.ts";

// (1) renamed export whose alias collides with the global `Number`.
console.log("(1) M.Number.ast._tag:", (M as any).Number.ast?._tag);

// (2) renamed export, no global collision.
console.log("(2) M.Widget.ast._tag:", (M as any).Widget.ast?._tag);

// (3) direct export (regression guard).
console.log("(3) M.DirectCls.ast._tag:", (M as any).DirectCls.ast?._tag);

// (4) the global `Number` is still itself (not shadowed by the import).
console.log("(4) global Number(42):", Number("42"));