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
41 changes: 38 additions & 3 deletions crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,15 +561,33 @@ pub(super) fn init_static_fields_late(
}
}
// Static blocks — emitted as synthetic static methods with the
// name prefix `__perry_static_init_`. Call them in registration
// order for each class, after that class's static fields are
// initialized, so they can reference those fields.
// name prefix `__perry_static_init_`. HIR lowering injects an inline
// `StaticMethodCall` for each one at the class-decl source position
// (right after that class's static-field-init stmts), so blocks
// normally run from `hir.init`. This loop is a fallback for any
// class whose static_methods include a block not yet hooked via
// init (e.g. class expressions that bypass the stmt-decl path);
// calling it here keeps the legacy behavior of "always run, just
// late" for those.
for c in &hir.classes {
for sm in &c.static_methods {
if !sm.name.starts_with("__perry_static_init_") {
continue;
}
let key = (c.name.clone(), sm.name.clone());
// Skip if the init stream already invokes this block. The
// typical class-decl path emits a `StaticMethodCall` for
// each block; if we find one referencing this (class,
// method) pair, the user-init lowering above has already
// run it and a duplicate call here would double-fire any
// observable side effects.
if hir
.init
.iter()
.any(|s| init_calls_static_block(s, &c.name, &sm.name))
{
continue;
}
if let Some(llvm_name) = ctx.methods.get(&key).cloned() {
ctx.block().call(DOUBLE, &llvm_name, &[]);
}
Expand All @@ -578,6 +596,23 @@ pub(super) fn init_static_fields_late(
Ok(())
}

/// Returns true if `stmt` is a top-level `Expr(StaticMethodCall)`
/// invoking the (`class_name`, `method_name`) pair — the shape HIR
/// lowering emits at the class-decl position for each
/// `__perry_static_init_*` synthetic method.
fn init_calls_static_block(stmt: &perry_hir::Stmt, class_name: &str, method_name: &str) -> bool {
if let perry_hir::Stmt::Expr(perry_hir::Expr::StaticMethodCall {
class_name: c,
method_name: m,
..
}) = stmt
{
c == class_name && m == method_name
} else {
false
}
}

/// Issue #100: emit the IR that populates this module's
/// `@__perry_ns_<module_prefix>` global from the resolved namespace
/// entry list. Called at the end of `__perry_init_<prefix>` (or `main`
Expand Down
21 changes: 21 additions & 0 deletions crates/perry-hir/src/lower/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,27 @@ pub(crate) fn lower_stmt(
}
}
}
// Static blocks — `class { static { ... } }`. Per ES
// spec, these run as part of class evaluation in
// source order, right AFTER the class's static-field
// initializers. HIR lifts each block to a synthetic
// static method `__perry_static_init_N`; emit an
// inline `StaticMethodCall` here at the class-decl
// position so each block fires at the right point.
// The codegen-side fallback in `init_static_fields_late`
// is kept for class expressions that bypass this
// declaration path; it skips blocks already invoked
// via this inline call. Closes the `test_gap_class_advanced`
// "static block initialized" diff.
for sm in &class.static_methods {
if sm.name.starts_with("__perry_static_init_") {
module.init.push(Stmt::Expr(Expr::StaticMethodCall {
class_name: class.name.clone(),
method_name: sm.name.clone(),
args: Vec::new(),
}));
}
}
append_legacy_decorator_init_for_class(ctx, &mut module.init, &class);
push_class_dedup(module, class);
}
Expand Down
64 changes: 64 additions & 0 deletions test-files/test_gap_class_static_blocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Class static blocks run at the source-order position of the
// `class { ... }` declaration, right after the class's static-field
// initializers. Perry hoists classes to a separate `module.classes`
// list, so the lowering injects an inline `StaticMethodCall` for each
// synthetic `__perry_static_init_N` synthetic method at the class-decl
// position in `module.init` — matching the spec's "static initializers
// run during class evaluation" ordering.
//
// Pre-fix the codegen called all static blocks at the *end* of module
// init (after every console.log), so a `class A { static { A.flag =
// true; } } console.log(A.flag);` printed the uninitialized default
// (`0`/`false`) instead of `true`. Refs the `static block initialized:
// true` line of `test_gap_class_advanced.ts`.

// (1) Static block initializing a declared-only static field.
class WithStaticBlock {
static initialized: boolean;
static {
WithStaticBlock.initialized = true;
}
}
console.log("(1) initialized:", WithStaticBlock.initialized);

// (2) Static block runs AFTER same-class static-field initializers, in
// source order. `static n = 0` runs first, then `static { A.n = 10
// }` overrides it; the user-init read should see `10`.
class A {
static n = 0;
static {
A.n = 10;
}
}
console.log("(2) A.n:", A.n);

// (3) Multiple static blocks in one class run in source order. The
// second block can read the first's writes.
class D {
static x = 0;
static y = 0;
static {
D.x = 1;
}
static {
D.y = D.x + 1;
}
}
console.log("(3) D.x:", D.x, "D.y:", D.y);

// (4) Cross-class block reads see the earlier class's already-run
// static block result. Classes are evaluated in source order, so
// by the time C's block runs, A and B have completed their own.
class B {
static m = 0;
static {
B.m = 20;
}
}
class C {
static both = 0;
static {
C.both = A.n + B.m;
}
}
console.log("(4) C.both:", C.both);
6 changes: 0 additions & 6 deletions test-parity/known_failures.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,6 @@
"category": "bug-open",
"reason": "Bisected 2026-05-28 for #1635: single residual sub-case is `typeof === \"string\"` narrowing on a `string | number[]` union — else-branch leaves the array remainder mis-typed as string, so `.join` lowers as a string method and throws. Tracked under #2277."
},
"test_gap_class_advanced": {
"issue": "2278",
"added": "2026-05-17",
"category": "bug-open",
"reason": "Bisected 2026-05-28 for #1635: single residual sub-case is a static class block assigning `true` to a declared `static initialized: boolean` field — read-back returns `0` instead of `true`. Tracked under #2278."
},
"test_gap_regexp_advanced": {
"issue": null,
"added": "2026-05-15",
Expand Down