Problem
An Effect/ST statement in pslua is represented as a thunk that magic-do runs by applying it to the EffectRunArg marker. When the foreign lifter has translated a runEffectFnN/runSTFnN wrapper, that application reduces to a literal thunk applied to the marker, and code generation recognises the pattern and drops the redundant build-and-force — emitting fn(a, …) instead of (function() return fn(a, …) end)(). This is the effect-side payoff of lifting the uncurried wrappers.
The recogniser requires the thunk's body to be a call, IR.AppN, verbatim from Language.PureScript.Backend.Lua:
IR.AppN
_ann
(IR.AbsN _ (IR.ParamUnused _ :| []) body@IR.AppN {})
(IR.EffectRunArg _ :| []) →
Right <$> goExp body
Its own comment justifies the restriction by what the body must lower to, not by its being a call:
-- The body is required to be an 'IR.AppN', which
-- always lowers to an expression — so this never inlines a 'Let' (magic-do
-- keeps such chunk boundaries thunked to bound locals per Lua function).
The stated hazard is a Let body, which lowers to statements rather than an expression and so cannot be spliced into an expression position. But a call is not the only body that lowers to an expression: a projection (ObjectProp), an index read (ArrayIndex), a length read (PrimLen) and the primops all do too. The guard is therefore narrower than the reason for it, and every such body pays a closure allocation the recogniser exists to remove.
This is not hypothetical: it is what currently blocks lifting Data.Array.ST.lengthImpl, whose Lua body is function(xs) return #xs end — byte-identical to Data.Array.length, which #247 does lift. Because lengthImpl is typed STFn1 (STArray h a) h Int, its call is an effect statement, so a lifted length body keeps the thunk:
local before = (function() return #(arr) end)() -- lengthImpl allowlisted
local before = Data_Array_ST_lengthImpl(arr) -- as shipped
The lifted form is worse than the opaque foreign call it replaced — a closure allocation on top of the same call — which is why #247 shipped with lengthImpl off the lift allowlist even though its body translates.
Approach
Widen the guard from "the body is a call" to "the body lowers to an expression". The cheap-read and primop nodes qualify; Let, LetValues and anything else the Lua backend lowers to a statement chunk do not, and must keep the thunk for the reason the existing comment gives. Since the backend already distinguishes the two — goExp returns Left for a chunk and Right for an expression — the discrimination can be taken from that result rather than duplicated as a syntactic whitelist, which keeps the two in step if a node changes category.
Whether the widened shed is safe for a body reading mutable state needs one check before lengthImpl is allowlisted on top of it. The shed happens at code generation, after every IR rewrite, so no IR-level rule can reorder the exposed read; but the Lua-level optimizer (Language.PureScript.Backend.Lua.Optimizer) runs afterwards, and its licence to move or share a #arr read across a pushImpl statement has to be established rather than assumed. See Note [PrimLen reads immutable values] for the invariant at stake.
Verification / Measurement
The existing Golden.LengthLift module already contains the shape: lengthsAroundPush brackets a push with two Data.Array.ST.length reads. Its Lua golden currently pins both at a direct Data_Array_ST_lengthImpl(arr) and its eval/golden.txt oracle at [3, 4], so widening the shed and then allowlisting lengthImpl should move the golden to a bare local before = #(arr) with the execution output unchanged — the oracle is what proves the two reads still see different lengths.
A golden for a non-length cheap read under an effect thunk (a record field or array index behind an EffectFn1) would cover the widened guard independently of the lengthImpl follow-up.
Prerequisites / Relations
Independent, and the prerequisite for the third export #247 set out to lift. Extends the code-generation half of the wrapper lift from #198.
Problem
An Effect/ST statement in
psluais represented as a thunk that magic-do runs by applying it to theEffectRunArgmarker. When the foreign lifter has translated arunEffectFnN/runSTFnNwrapper, that application reduces to a literal thunk applied to the marker, and code generation recognises the pattern and drops the redundant build-and-force — emittingfn(a, …)instead of(function() return fn(a, …) end)(). This is the effect-side payoff of lifting the uncurried wrappers.The recogniser requires the thunk's body to be a call,
IR.AppN, verbatim fromLanguage.PureScript.Backend.Lua:Its own comment justifies the restriction by what the body must lower to, not by its being a call:
The stated hazard is a
Letbody, which lowers to statements rather than an expression and so cannot be spliced into an expression position. But a call is not the only body that lowers to an expression: a projection (ObjectProp), an index read (ArrayIndex), a length read (PrimLen) and the primops all do too. The guard is therefore narrower than the reason for it, and every such body pays a closure allocation the recogniser exists to remove.This is not hypothetical: it is what currently blocks lifting
Data.Array.ST.lengthImpl, whose Lua body isfunction(xs) return #xs end— byte-identical toData.Array.length, which #247 does lift. BecauselengthImplis typedSTFn1 (STArray h a) h Int, its call is an effect statement, so a lifted length body keeps the thunk:The lifted form is worse than the opaque foreign call it replaced — a closure allocation on top of the same call — which is why #247 shipped with
lengthImploff the lift allowlist even though its body translates.Approach
Widen the guard from "the body is a call" to "the body lowers to an expression". The cheap-read and primop nodes qualify;
Let,LetValuesand anything else the Lua backend lowers to a statement chunk do not, and must keep the thunk for the reason the existing comment gives. Since the backend already distinguishes the two —goExpreturnsLeftfor a chunk andRightfor an expression — the discrimination can be taken from that result rather than duplicated as a syntactic whitelist, which keeps the two in step if a node changes category.Whether the widened shed is safe for a body reading mutable state needs one check before
lengthImplis allowlisted on top of it. The shed happens at code generation, after every IR rewrite, so no IR-level rule can reorder the exposed read; but the Lua-level optimizer (Language.PureScript.Backend.Lua.Optimizer) runs afterwards, and its licence to move or share a#arrread across apushImplstatement has to be established rather than assumed. SeeNote [PrimLen reads immutable values]for the invariant at stake.Verification / Measurement
The existing
Golden.LengthLiftmodule already contains the shape:lengthsAroundPushbrackets apushwith twoData.Array.ST.lengthreads. Its Lua golden currently pins both at a directData_Array_ST_lengthImpl(arr)and itseval/golden.txtoracle at[3, 4], so widening the shed and then allowlistinglengthImplshould move the golden to a barelocal before = #(arr)with the execution output unchanged — the oracle is what proves the two reads still see different lengths.A golden for a non-length cheap read under an effect thunk (a record field or array index behind an
EffectFn1) would cover the widened guard independently of thelengthImplfollow-up.Prerequisites / Relations
Independent, and the prerequisite for the third export #247 set out to lift. Extends the code-generation half of the wrapper lift from #198.