fix: #740 — Effect TaggedError class-extends-factory(captured) pattern#783
Merged
Conversation
Root cause: a class declared inside a factory function (`class Inner {
_tag = tag }` where `tag` is the factory's param) participates in two
broken seams:
- lower_class_decl synthesizes the capture machinery (__perry_cap_<id>
ctor params + matching fields + field-init rewrite), but
lower_class_from_ast (used for class EXPRESSIONS like
`class extends Error { ... }`) did not, so anon-class-expr
captures were silently dropped.
- When the factory function returns the class (`return Inner` or
`return O.BaseEffectError`), the inliner replaces the call with
`ClassRef(...)` and discards the parameter bindings — so even the
named-class path lost the captured value at the call site.
Fix has two parts:
1. crates/perry-hir/src/lower_decl.rs — extract the Issue #212 capture
machinery into a shared `synthesize_class_captures` helper and call
it from BOTH `lower_class_decl` and `lower_class_from_ast`, so
anon class expressions get the same __perry_cap_<outer_id> ctor
params + field rewriting as named declarations. Also collect captures
from field initializers / computed keys (not just method bodies)
inside the helper.
2. crates/perry-transform/src/inline.rs — add a pre-pass
`specialize_captured_class_factories` that, for each
`Let { init: Call(FuncRef(f), args) }` where `f` is a single-
expression factory that returns a captured class (three body shapes
supported: direct `Return ClassRef(C)`; `Let X = ClassRef(C); ...; Return LocalGet(X)`;
`Let O = New(__AnonShape, [ClassRef(C),...]); ...; Return O.<field>`):
- Clone C → C__inline_<n> with each __perry_cap_<outer_id> ctor
param's id substituted by the corresponding call arg in
method/getter/setter/field-init/computed-key bodies, then drop
the cap ctor param, the cap field, and the ctor-body assignment.
- If the cloned ctor ends up with no params and an empty body, drop
the ctor entirely so lower_new's parent-walk continues to the
first real ancestor ctor (otherwise the empty middle-class ctor
would short-circuit BaseError's `this.issue = opts.issue`).
- Replace the Call site with `ClassRef(C__inline_<n>)`.
- When the rewrite lands inside `RegisterClassParentDynamic`'s
parent_expr, hoist the resolved `extends_name` (and `extends`
class-id) onto the child class statically so codegen's field-
initializer-recursive walk and parent-ctor inlining find the
specialized class.
3. crates/perry-codegen/src/lower_call.rs — bind ctor params BEFORE the
`apply_field_initializers_recursive` call (saving/restoring locals
around the whole block), so field initializers rewritten to read
`LocalGet(__perry_cap_<n>)` see the actual ctor param value instead
of falling through to the 0.0 soft-fallback.
4. crates/perry-hir/src/lower.rs + destructuring.rs + lower/expr_new.rs —
register let-binding aliases at HIR-lowering time
(`const Cls = Inner` style direct-ClassRef bindings) and follow
LocalGet alias chains, so `Expr::New { class_name: <alias> }` can
resolve through to the underlying class's captures. (Useful for
intra-module rebinds that never went through a factory.)
5. crates/perry-hir/src/analysis.rs — make `remap_local_ids_in_expr`
pub(crate) so the capture-machinery extraction can reach it from
`lower_decl`.
Standalone repro from issue text matches node byte-for-byte:
[3] ParseError defined
[4] pe._tag: ParseError
[5] pe.message: parse error: {"kind":"bad","detail":42}
Three smaller probes (factory returning class via direct return / local-
binding / object-literal-wrapping) all match node.
Gap suite: 33/35 pass — no regression from main (same two pre-existing
failures: test_gap_console_methods, test_gap_regexp_advanced).
Targeted cargo test suites (perry-hir, perry-runtime, perry-codegen,
perry-transform) all pass.
Resolves the failing `lint` check on PR #783 (cargo fmt --all --check). Pure rustfmt reflow — no token or logic changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
class ParseError extends TaggedError("ParseError")<...>in Effect'sParseResult.ts__initthrewTypeError: value is not a functionat module init. Two broken seams when a class is declared inside a factory function with captured values:lower_class_from_ast(anonymous class expressions used byclass extends Error<{}> { ... }) silently skipped the__perry_cap_<id>capture-synthesis machinery thatlower_class_declran for named declarations.makeFactory("MyTag")withClassRef("Inner")while discarding the param bindings, so the captured value never reached thenew Cls()site.RegisterClassParentDynamic; even with captures wired, codegen's static parent-ctor walk couldn't find the specialized parent.Fix:
synthesize_class_captureshelper inperry-hir/src/lower_decl.rsand call it from bothlower_class_declandlower_class_from_astso anon class expressions get the same__perry_cap_*field/param synthesis. (Also covers field initializers like_tag = tagand computed-key expressions, which previously read garbage at runtime.)specialize_captured_class_factoriespre-pass inperry-transform/src/inline.rsthat clones the captured class per call site with captures baked in as constants; recurses intoRegisterClassParentDynamic.parent_exprand hoists the resolved parent onto the child's staticextends_name; drops empty synthesized middle ctors so the real ancestor ctor runs.perry-codegen/src/lower_call.rs: bind ctor params beforeapply_field_initializers_recursiveso field-init expressions see the captured value at construction time.lower.rs,destructuring.rs,lower/expr_new.rs,analysis.rs(let-binding class-alias chain at HIR-lower time + helper visibility bump).Refs #321 (Effect end-to-end blocker post-#711).
Test plan
ParseError extends TaggedError("ParseError")<...>) matchesnode --experimental-strip-typesbyte-for-byte:cargo test --release -p perry-hir --lib— 41/41 passcargo build --release -p perry-hir -p perry-transform -p perry-codegen -p perrygreentest_gap_console_methods,test_gap_regexp_advanced)bun add effect@3.21.2 && perry compile test_bare.ts -o /tmp/out && /tmp/out) — not validated in this PR; the standalone repro from the issue is the validation gate. Worth verifying before closing Tracking: Effect framework end-to-end compat (post-#309 / #310) #321.