fix(hir): clear callee marker when lowering short-circuit/conditional operands - #5784
Conversation
… operands
Calling a native builtin reached through a short-circuit or conditional
expression — `(K || JSON.parse)(x)`, `(K ?? B.method)(x)`, `(c ? a : JSON.parse)(x)`,
`(0, JSON.parse)(x)` — threw `TypeError: value is not a function` when the
builtin branch was taken.
Root cause (perry-hir): lowering a call sets `ctx.lowering_call_callee` before
lowering the callee so a DIRECT intrinsic call (`JSON.parse(x)`, `Date.now()`)
takes the fast path — the member-tail reroute-undo collapses the receiver to a
bare `GlobalGet(0)` and codegen emits the intrinsic. But a binary/logical/
conditional/sequence expression in callee position is ITSELF the callee; its
operands/branches are values, not the immediate callee member. The marker
leaked through `lower_bin_expr` / `lower_cond` / `lower_seq` into those operands,
so a nested builtin-namespace member (`JSON.parse`) was collapsed to the
value-less intrinsic form `PropertyGet { GlobalGet(0), "parse" }` (the namespace
name dropped). That form has no value materialization and lowers to `undefined`,
so the short-circuit/conditional result threw when called. Stored-then-called
(`let g = K || JSON.parse; g(x)`) and direct calls were unaffected because the
marker was correctly false / true there.
Fix: save/clear/restore `lowering_call_callee` around lowering the operands in
`lower_bin_expr` (`||`/`??`/`&&`/all binary), `lower_cond` (ternary), and
`lower_seq` (comma). A nested *call* operand re-sets the flag for its own callee,
so direct intrinsic calls keep their fast path.
This is the runtime shape behind axios's `transformRequest` (`(K || JSON.parse)`)
and other `(x || nativeBuiltin)()` call sites.
Adds an integration test covering `||`/`??`/`&&`/ternary/comma callees with
native builtins, plus user-fn-via-`||`, stored-then-called, and direct-call
(must keep working), run under a wall-clock timeout.
📝 WalkthroughWalkthroughThree lowering functions ( Fix lowering_call_callee flag leak
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with 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.
Inline comments:
In `@crates/perry-hir/src/lower/expr_misc.rs`:
- Around line 42-50: The conditional lowering in lower_expr for
Expr::Conditional is eagerly evaluating cond.test, cond.cons, and cond.alt
before any error is handled, so it no longer short-circuits on the first
failure. Refactor this block to lower cond.test first, return immediately on
error, and only then lower cond.cons and cond.alt in sequence before restoring
ctx.lowering_call_callee. Use the lower_expr and Expr::Conditional symbols to
keep the change localized and preserve the existing lowering-state behavior.
In `@crates/perry-hir/src/lower/lower_expr/arm_bin.rs`:
- Around line 179-185: In lower_expr_bin, restore the original short-circuit
behavior so bin.right is not lowered after bin.left fails; lower the left side
first, immediately propagate its error with ?, and only then lower the right
side. Keep the lowering_call_callee save/restore around the same block in
arm_bin.rs, but make sure the state is reset before returning on error so
lower_expr does not continue mutating context after a failure.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 3cd365d1-070b-4cf6-ae5d-4fd969cdd8ea
📒 Files selected for processing (3)
crates/perry-hir/src/lower/expr_misc.rscrates/perry-hir/src/lower/lower_expr/arm_bin.rscrates/perry/tests/short_circuit_builtin_callee.rs
| let prev_call_callee = ctx.lowering_call_callee; | ||
| ctx.lowering_call_callee = false; | ||
| let condition = lower_expr(ctx, &cond.test); | ||
| let then_expr = lower_expr(ctx, &cond.cons); | ||
| let else_expr = lower_expr(ctx, &cond.alt); | ||
| ctx.lowering_call_callee = prev_call_callee; | ||
| Ok(Expr::Conditional { | ||
| condition, | ||
| then_expr, | ||
| else_expr, | ||
| condition: Box::new(condition?), | ||
| then_expr: Box::new(then_expr?), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep conditional lowering short-circuiting on the first error.
This eagerly lowers all three subexpressions before any ?, so cond.cons and cond.alt still run even if cond.test already failed. That changes the previous error path and can mutate lowering state after the first failure.
Proposed fix
let prev_call_callee = ctx.lowering_call_callee;
ctx.lowering_call_callee = false;
- let condition = lower_expr(ctx, &cond.test);
- let then_expr = lower_expr(ctx, &cond.cons);
- let else_expr = lower_expr(ctx, &cond.alt);
+ let lowered = (|| -> Result<(Box<Expr>, Box<Expr>, Box<Expr>)> {
+ Ok((
+ Box::new(lower_expr(ctx, &cond.test)?),
+ Box::new(lower_expr(ctx, &cond.cons)?),
+ Box::new(lower_expr(ctx, &cond.alt)?),
+ ))
+ })();
ctx.lowering_call_callee = prev_call_callee;
+ let (condition, then_expr, else_expr) = lowered?;
Ok(Expr::Conditional {
- condition: Box::new(condition?),
- then_expr: Box::new(then_expr?),
- else_expr: Box::new(else_expr?),
+ condition,
+ then_expr,
+ else_expr,
})📝 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.
| let prev_call_callee = ctx.lowering_call_callee; | |
| ctx.lowering_call_callee = false; | |
| let condition = lower_expr(ctx, &cond.test); | |
| let then_expr = lower_expr(ctx, &cond.cons); | |
| let else_expr = lower_expr(ctx, &cond.alt); | |
| ctx.lowering_call_callee = prev_call_callee; | |
| Ok(Expr::Conditional { | |
| condition, | |
| then_expr, | |
| else_expr, | |
| condition: Box::new(condition?), | |
| then_expr: Box::new(then_expr?), | |
| let prev_call_callee = ctx.lowering_call_callee; | |
| ctx.lowering_call_callee = false; | |
| let lowered = (|| -> Result<(Box<Expr>, Box<Expr>, Box<Expr>)> { | |
| Ok(( | |
| Box::new(lower_expr(ctx, &cond.test)?), | |
| Box::new(lower_expr(ctx, &cond.cons)?), | |
| Box::new(lower_expr(ctx, &cond.alt)?), | |
| )) | |
| })(); | |
| ctx.lowering_call_callee = prev_call_callee; | |
| let (condition, then_expr, else_expr) = lowered?; | |
| Ok(Expr::Conditional { | |
| condition, | |
| then_expr, |
🤖 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/expr_misc.rs` around lines 42 - 50, The
conditional lowering in lower_expr for Expr::Conditional is eagerly evaluating
cond.test, cond.cons, and cond.alt before any error is handled, so it no longer
short-circuits on the first failure. Refactor this block to lower cond.test
first, return immediately on error, and only then lower cond.cons and cond.alt
in sequence before restoring ctx.lowering_call_callee. Use the lower_expr and
Expr::Conditional symbols to keep the change localized and preserve the existing
lowering-state behavior.
| let prev_call_callee = ctx.lowering_call_callee; | ||
| ctx.lowering_call_callee = false; | ||
| let left = lower_expr(ctx, &bin.left); | ||
| let right = lower_expr(ctx, &bin.right); | ||
| ctx.lowering_call_callee = prev_call_callee; | ||
| let left = Box::new(left?); | ||
| let right = Box::new(right?); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve the original short-circuiting on lowering errors.
This now lowers bin.right even when bin.left has already failed. lower_expr is stateful, so doing extra lowering work after the first error can perturb context/diagnostics before the error is returned. Please restore the flag without losing the old ? behavior.
Proposed fix
let prev_call_callee = ctx.lowering_call_callee;
ctx.lowering_call_callee = false;
- let left = lower_expr(ctx, &bin.left);
- let right = lower_expr(ctx, &bin.right);
+ let lowered = (|| -> Result<(Box<Expr>, Box<Expr>)> {
+ Ok((
+ Box::new(lower_expr(ctx, &bin.left)?),
+ Box::new(lower_expr(ctx, &bin.right)?),
+ ))
+ })();
ctx.lowering_call_callee = prev_call_callee;
- let left = Box::new(left?);
- let right = Box::new(right?);
+ let (left, right) = lowered?;📝 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.
| let prev_call_callee = ctx.lowering_call_callee; | |
| ctx.lowering_call_callee = false; | |
| let left = lower_expr(ctx, &bin.left); | |
| let right = lower_expr(ctx, &bin.right); | |
| ctx.lowering_call_callee = prev_call_callee; | |
| let left = Box::new(left?); | |
| let right = Box::new(right?); | |
| let prev_call_callee = ctx.lowering_call_callee; | |
| ctx.lowering_call_callee = false; | |
| let lowered = (|| -> Result<(Box<Expr>, Box<Expr>)> { | |
| Ok(( | |
| Box::new(lower_expr(ctx, &bin.left)?), | |
| Box::new(lower_expr(ctx, &bin.right)?), | |
| )) | |
| })(); | |
| ctx.lowering_call_callee = prev_call_callee; | |
| let (left, right) = lowered?; |
🤖 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/lower_expr/arm_bin.rs` around lines 179 - 185, In
lower_expr_bin, restore the original short-circuit behavior so bin.right is not
lowered after bin.left fails; lower the left side first, immediately propagate
its error with ?, and only then lower the right side. Keep the
lowering_call_callee save/restore around the same block in arm_bin.rs, but make
sure the state is reset before returning on error so lower_expr does not
continue mutating context after a failure.
|
Lands a subcluster of #5591 (built-ins tail): native builtin reached through a short-circuit/conditional/comma callee was thrown as 'value is not a function'. |
Problem
Calling a native builtin reached through a short-circuit or conditional expression threw
TypeError: value is not a functionwhen the builtin branch was taken:A user function via
||, a builtin stored-then-called (let g = K || JSON.parse; g(x)), and a direct intrinsic call (JSON.parse(x)) all worked — only the inline short-circuit/conditional callee broke.Root cause (perry-hir)
Lowering a call sets
ctx.lowering_call_callee = truebefore lowering the callee, so a direct intrinsic call (JSON.parse(x),Date.now()) takes the fast path: the member-tail reroute-undo collapses the receiver to a bareGlobalGet(0)and codegen emits the intrinsic directly.But a binary/logical/conditional/sequence expression in callee position is itself the callee — its operands/branches are values, not the immediate callee member. The marker leaked through
lower_bin_expr/lower_cond/lower_seqinto those operands, so a nested builtin-namespace member (JSON.parse) was collapsed to the value-less intrinsic formPropertyGet { GlobalGet(0), "parse" }(the namespace name dropped). That form has no value materialization and lowers toundefined— so the short-circuit/conditional result threw when called.Verified via
--trace hir: the buggy||-callee form wasPropertyGet { GlobalGet(0), "parse" }; the working let-init form wasPropertyGet { PropertyGet { GlobalGet(0), "JSON" }, "parse" }. Routing the collapsed form by property name alone would be unsound (JSON.parseandDate.parseboth collapse to…, "parse"), so the fix is to not collapse it.Fix
Save/clear/restore
lowering_call_calleearound lowering the operands inlower_bin_expr(||/??/&&/all binary ops),lower_cond(ternary), andlower_seq(comma). A nested call operand re-sets the flag for its own callee, so direct intrinsic calls keep their fast path.This is the runtime shape behind axios's
transformRequest((K || JSON.parse)(data)) and other(x || nativeBuiltin)()sites.Test
crates/perry/tests/short_circuit_builtin_callee.rs(run under a wall-clock timeout): native builtin via||/??/&&/ ternary / comma callees, plus user-fn-via-||, stored-then-called, and directJSON.parse(x)(all must keep working).cargo test -p perry-hirand-p perry-codegengreen.Summary by CodeRabbit
Bug Fixes
JSON.parseandJSON.stringifyfrom being misread as non-callable values in these expression forms.Tests