diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 0b1d07352..bad890eaf 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -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, &[]); } @@ -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_` global from the resolved namespace /// entry list. Called at the end of `__perry_init_` (or `main` diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index f4289f7dd..9357d9781 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -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); } diff --git a/test-files/test_gap_class_static_blocks.ts b/test-files/test_gap_class_static_blocks.ts new file mode 100644 index 000000000..62a567c20 --- /dev/null +++ b/test-files/test_gap_class_static_blocks.ts @@ -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); diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index 1464dcf74..aa083f753 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -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",