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
108 changes: 106 additions & 2 deletions crates/perry-hir/src/lower/module_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1924,6 +1924,62 @@ pub(crate) fn lower_module_decl(
/// with a static method `create`. Exported namespace variables are lowered as module-level
/// locals (not static fields) and accessed via compile-time namespace resolution.
/// Private namespace members (non-exported) are lowered as module-level variables.
/// #5130: the simple-ident name of a (non-dotted) nested namespace, if it has a
/// body. `namespace A.B {}` (dotted form) and bodiless `declare` modules return
/// `None`.
fn nested_namespace_name(ts_module: &ast::TsModuleDecl) -> Option<String> {
if ts_module.body.is_none() {
return None;
}
match &ts_module.id {
ast::TsModuleName::Ident(ident) => Some(ident.sym.to_string()),
ast::TsModuleName::Str(_) => None,
}
}

/// #5130: lower a namespace nested inside another (`namespace Outer { export
/// namespace Inner { ... } }`). The inner namespace becomes its own synthetic
/// class registered under the qualified name `Outer.Inner`, and the outer
/// namespace gains a static field `Inner` holding a `ClassRef` to it — so
/// `Outer.Inner` resolves to the inner namespace object and `Outer.Inner.member`
/// reads its statics (a runtime property/method access on a class-ref resolves
/// static fields/methods). Nesting recurses to any depth.
fn lower_nested_namespace(
ctx: &mut LoweringContext,
module: &mut Module,
outer_ns_name: &str,
ts_module: &ast::TsModuleDecl,
ns_static_fields: &mut Vec<crate::ir::ClassField>,
) -> Result<()> {
let Some(inner_name) = nested_namespace_name(ts_module) else {
return Ok(());
};
let Some(body) = &ts_module.body else {
return Ok(());
};
let qualified = format!("{outer_ns_name}.{inner_name}");
let class = lower_namespace_as_class(ctx, module, &qualified, body, true)?;
push_class_dedup(module, class);

// Surface the inner namespace as a static field of the outer one, set to a
// ClassRef to the inner class. Mirrors the const-member wiring above.
ns_static_fields.push(crate::ir::ClassField {
name: inner_name.clone(),
key_expr: None,
ty: Type::Any,
init: None,
is_private: false,
is_readonly: true,
decorators: Vec::new(),
});
module.init.push(Stmt::Expr(Expr::StaticFieldSet {
class_name: outer_ns_name.to_string(),
field_name: inner_name,
value: Box::new(Expr::ClassRef(qualified)),
}));
Ok(())
}

pub(crate) fn lower_namespace_as_class(
ctx: &mut LoweringContext,
module: &mut Module,
Expand Down Expand Up @@ -1971,6 +2027,13 @@ pub(crate) fn lower_namespace_as_class(

let mut static_methods = Vec::new();
let mut static_method_names = Vec::new();
// #5130: nested namespace names (`namespace G { export namespace Nested {} }`).
// Each is surfaced as a static field on the outer namespace class holding a
// `ClassRef` to the (recursively lowered) inner namespace class, so
// `G.Nested` resolves to the inner namespace and `G.Nested.value` /
// `G.Nested.f()` read its statics. Registered as static fields up-front so
// `has_static_field` routes `G.Nested` to `StaticFieldGet`.
let mut nested_ns_names: Vec<String> = Vec::new();
// Namespace `export const` members surfaced as static fields so `Ns.member`
// resolves CROSS-MODULE (the per-module `namespace_vars` local is invisible
// to importers; only namespace FUNCTIONS — lowered as static methods —
Expand Down Expand Up @@ -2012,9 +2075,25 @@ pub(crate) fn lower_namespace_as_class(
}
}
}
// #5130: nested `export namespace Inner { ... }`.
ast::Decl::TsModule(ts_module) => {
if !ts_module.declare {
if let Some(name) = nested_namespace_name(ts_module) {
nested_ns_names.push(name);
}
}
}
_ => {}
}
}
// #5130: nested non-exported `namespace Inner { ... }`.
ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::TsModule(ts_module))) => {
if !ts_module.declare {
if let Some(name) = nested_namespace_name(ts_module) {
nested_ns_names.push(name);
}
}
}
// Pre-register non-exported functions (hoisted like JS)
ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Fn(fn_decl))) => {
if fn_decl.function.body.is_some() {
Expand Down Expand Up @@ -2049,8 +2128,14 @@ pub(crate) fn lower_namespace_as_class(
}
}

// Register class and statics early so method bodies can reference them
ctx.register_class_statics(ns_name.to_string(), Vec::new(), static_method_names.clone());
// Register class and statics early so method bodies can reference them.
// Nested namespace names are registered as static fields so `Outer.Inner`
// resolves via `has_static_field` → `StaticFieldGet` (#5130).
ctx.register_class_statics(
ns_name.to_string(),
nested_ns_names.clone(),
static_method_names.clone(),
);

// Set current namespace so internal function calls resolve as StaticMethodCall
let prev_namespace = ctx.current_namespace.take();
Expand All @@ -2059,6 +2144,15 @@ pub(crate) fn lower_namespace_as_class(
// Second pass: lower all items
for item in items {
match item {
// #5130: nested non-exported `namespace Inner { ... }` — surface as a
// static field of the outer namespace (same as the exported form)
// rather than letting `lower_stmt` register it as a top-level
// namespace with an unqualified name.
ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::TsModule(ts_module)))
if !ts_module.declare && nested_namespace_name(ts_module).is_some() =>
{
lower_nested_namespace(ctx, module, ns_name, ts_module, &mut ns_static_fields)?;
}
// Non-exported items → module-level variables/functions
ast::ModuleItem::Stmt(stmt) => {
lower_stmt(ctx, module, stmt)?;
Expand Down Expand Up @@ -2186,6 +2280,16 @@ pub(crate) fn lower_namespace_as_class(
let class = lower_class_decl(ctx, class_decl, is_exported)?;
push_class_dedup(module, class);
}
// #5130: nested `export namespace Inner { ... }`.
ast::Decl::TsModule(ts_module) => {
lower_nested_namespace(
ctx,
module,
ns_name,
ts_module,
&mut ns_static_fields,
)?;
Comment on lines +2284 to +2291

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing declare guard emits runtime namespace artifacts for type-only declarations.

At Line 2284, exported nested namespaces are always lowered. Unlike the non-exported arm (Line 2152), this path does not skip ts_module.declare, so export declare namespace ... can incorrectly produce runtime class/static-field emission.

Proposed fix
-                    ast::Decl::TsModule(ts_module) => {
-                        lower_nested_namespace(
-                            ctx,
-                            module,
-                            ns_name,
-                            ts_module,
-                            &mut ns_static_fields,
-                        )?;
-                    }
+                    ast::Decl::TsModule(ts_module) => {
+                        if !ts_module.declare {
+                            lower_nested_namespace(
+                                ctx,
+                                module,
+                                ns_name,
+                                ts_module,
+                                &mut ns_static_fields,
+                            )?;
+                        }
+                    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ast::Decl::TsModule(ts_module) => {
lower_nested_namespace(
ctx,
module,
ns_name,
ts_module,
&mut ns_static_fields,
)?;
ast::Decl::TsModule(ts_module) => {
if !ts_module.declare {
lower_nested_namespace(
ctx,
module,
ns_name,
ts_module,
&mut ns_static_fields,
)?;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower/module_decl.rs` around lines 2284 - 2291, The
exported nested namespace handling path (around line 2284 where
lower_nested_namespace is called) is missing a guard for the declare modifier
that exists in the non-exported arm at line 2152. Add a check to skip the
lower_nested_namespace call when ts_module.declare is true, ensuring that export
declare namespace declarations are not lowered to runtime artifacts. This guard
should match the logic in the non-exported arm to prevent type-only namespace
declarations from generating class or static field emissions.

}
_ => {}
}
}
Expand Down
106 changes: 106 additions & 0 deletions crates/perry/tests/issue_5130_nested_namespace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Regression test for #5130: a nested `namespace` was not emitted — accessing
//! a member of an inner namespace yielded `undefined` (and then threw on the
//! property access). Top-level namespaces worked.
//!
//! Root cause: `lower_namespace_as_class` dropped nested `TsModule` items (the
//! `ExportDecl`/`Stmt` match arms fell through to `_ => {}`), and the dotted
//! `TsNamespaceDecl` body returned an empty class.
//!
//! Fix: nested namespaces are lowered recursively as their own synthetic class
//! registered under a qualified `Outer.Inner` name, and the outer namespace
//! gains a static field `Inner` holding a `ClassRef` to it. `Outer.Inner` then
//! resolves to the inner namespace and `Outer.Inner.member` reads its statics
//! (a runtime property/method access on a class-ref resolves static
//! fields/methods). Works to any nesting depth.

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn compile_and_run(dir: &std::path::Path, source: &str) -> String {
let entry = dir.join("main.ts");
let output = dir.join("main_bin");
std::fs::write(&entry, source).expect("write entry");

let compile = Command::new(perry_bin())
.current_dir(dir)
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

let run = Command::new(&output)
.current_dir(dir)
.output()
.expect("run compiled binary");
assert!(
run.status.success(),
"compiled binary failed (pre-fix: nested namespace undefined)\nstatus: {:?}\n\
stdout:\n{}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).into_owned()
}

#[test]
fn nested_namespace_members_resolve() {
let dir = tempfile::tempdir().expect("tempdir");
let stdout = compile_and_run(
dir.path(),
r#"
namespace G {
export const PI = 3.14;
export function area(r: number) { return PI * r * r; }
export namespace Nested {
export const value = 42;
export function f() { return value + 1; }
}
}
console.log(G.area(2));
console.log(G.Nested.value);
console.log(G.Nested.f());
"#,
);
assert_eq!(stdout, "12.56\n42\n43\n");
}

#[test]
fn deeply_nested_namespaces_and_cross_level_refs() {
let dir = tempfile::tempdir().expect("tempdir");
let stdout = compile_and_run(
dir.path(),
r#"
namespace Outer {
export const base = 100;
export namespace Mid {
export const m = 10;
export function getM() { return m; }
export namespace Inner {
export const deep = 1;
export function sum() { return deep + m + base; } // reads all enclosing scopes
}
}
}
console.log(Outer.Mid.m);
console.log(Outer.Mid.getM());
console.log(Outer.Mid.Inner.deep);
console.log(Outer.Mid.Inner.sum());
const M = Outer.Mid; // aliasing a nested namespace to a value
console.log(M.m, M.Inner.deep);
"#,
);
assert_eq!(stdout, "10\n10\n1\n111\n10 1\n");
}