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
16 changes: 16 additions & 0 deletions changelog.d/20260711_123511_unisay_lift_uncurried_mk_wrappers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
### Changed

- The `mk` half of the `*.Uncurried` FFI wrappers now lifts to n-ary
definitions (#227), completing the pair started by the `run` half (#198).
The foreign lifter (#178) learned the two missing shapes: a multi-parameter
function literal becomes a single n-ary `AbsN` (varargs and duplicate
parameter names decline), and a nullary call becomes an application to the
`EffectRunArg` marker — the shape magic-do emits, erased back to `()` at
code generation. `mkFn2`…`mkFn10`, `mkSTFn1`…`mkSTFn10`, and
`mkEffectFn1`…`mkEffectFn10` join the allowlist as inline-always IR, so a
definition like `add3 = mkFn3 \a b c -> a + b + c` beta-reduces to the
n-ary literal itself — `function(a, b, c) return a + b + c end`, zero
closures per call, where the opaque wrapper re-curried every call through
two. With both halves lifted, a saturated `runFn3 add3 1 2 3` site calls
that definition directly as `add3(1, 2, 3)`, and the `mk` accessors drop
out of the emitted FFI tables just like the `run` ones.
121 changes: 88 additions & 33 deletions lib/Language/PureScript/Backend/Lua/ForeignLift.hs
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,33 @@ shed at code generation ('Language.PureScript.Backend.Lua.fromIR'),
turning @runSTFn2(pushImpl)(x)(arr)()@ from four calls and three closures
into one @pushImpl(x, arr)@.

The @mk@ half lifts too (issue #227): @mkFn3@ becomes
@\\fn -> AbsN [a, b, c] (fn(a)(b)(c))@ — the inner multi-parameter
literal is one n-ary 'AbsN' — and the effectful variants run the
re-curried call by applying it to the 'EffectRunArg' marker
(@mkEffectFn2@'s trailing @fn(a)(b)()@). Inlined at a definition site
like @add3 = mkFn3 \\a b c -> …@, beta reduction leaves the n-ary
literal itself: zero closures per call, and the @mk@ accessors drop out
of the emitted FFI tables just like the @run@ ones.

= What lifts

The translatable subset, mirroring the shapes the prelude forks actually
use:

* curried single-parameter function literals → nested 'Abs';
* function literals → one n-ary 'AbsN' binding every parameter at a
single call (a curried chain is nested unary 'Abs'); varargs and
duplicate parameter names decline (Lua binds the body's reference
to the /last/ same-named parameter — an 'AbsN' would miscompile
it);
* a zero-parameter function literal (the @*.Uncurried@ effect thunk,
@function() … end@) → a unary 'Abs' with an unused parameter;
* a saturated call @fn(a, b, …)@ of one or more arguments → the n-ary
'AppN' node (issue #198); a nullary @fn()@ has no 'AppN' and does not
lift, and a literal-lambda head called at any arity other than its
own declines (Note [n-ary application]);
* a call @fn(a, b, …)@ → the n-ary 'AppN' node (issue #198); a
nullary @fn()@ — the trailing effect run of the @mk{ST,Effect}FnN@
wrappers — becomes an application to the 'EffectRunArg' marker
(issue #227); a literal-lambda head called at any arity other than
its own declines (Note [n-ary application]), the marker counting
as one argument;
* @return@ / @if … then … else@ trees (an @elseif@ is a nested @if@ in
the else branch) → 'IfThenElse', provided every branch returns a
value (a branch that falls through to @nil@ does not lift);
Expand All @@ -56,8 +71,8 @@ use:
the @refEq@ aliases resolve.

Everything else — loops, mutation, varargs, table constructors,
multi-parameter function literals, string/char literals — leaves the
export opaque (correct for e.g. @foldlArray@).
string/char literals — leaves the export opaque (correct for e.g.
@foldlArray@).
-}
module Language.PureScript.Backend.Lua.ForeignLift
( liftForeigns
Expand Down Expand Up @@ -86,18 +101,21 @@ import Language.PureScript.Backend.IR.Types
, PrimOp (..)
, RawExp (AbsN, ForeignImport, ObjectProp)
, abstraction
, abstractionN
, applicationN
, eq
, ifThenElse
, literalBool
, literalFloat
, literalInt
, noAnn
, paramNamed
, paramUnused
, primBinOp
, primNot
, refLocal
, setAnn
, pattern EffectRunArg
)
import Language.PureScript.Backend.Lua.Key qualified as Key
import Language.PureScript.Backend.Lua.Linker.Foreign (Source (..))
Expand All @@ -121,10 +139,24 @@ import Prelude hiding (show)
-- Allowlist -------------------------------------------------------------------

{- | The foreign exports lifted into the IR: the arithmetic, comparison,
boolean, and concatenation core of the prelude (issue #178), plus the
@run@ half of the @*.Uncurried@ wrappers (issue #198). Membership is a
hard contract (see the module header): a listed export that fails to lift
is a compile error. A broader allowlist is follow-up work (issue #187).
boolean, and concatenation core of the prelude (issue #178), plus both
halves of the @*.Uncurried@ wrappers — @run@ (issue #198) and @mk@
(issue #227). Membership is a hard contract (see the module header): a
listed export that fails to lift is a compile error. A broader allowlist
is follow-up work (issue #187).

A warning to that follow-up: do /not/ list the Effect\/ST core —
@Effect.bindE@\/@pureE@, @Control.Monad.ST.Internal.bind_@\/@pure_@ —
even though its thunk-shaped bodies are technically liftable now that
nullary calls translate. Magic-do
('Language.PureScript.Backend.IR.MagicDo') recognises bind chains by
/name/, resolving dictionaries to the @Effect.bindEffect@ and
@Control.Monad.ST.Internal.bindST@ instances; a lifted core would be
inlined away during the optimizer fixpoint that runs first, blinding
magic-do — no flat @do@ chunks, and no chunked statement sequences
keeping the output under Lua's local-variable limits (issue #19).
Lifting the core behind a marker magic-do understands is tracked as
issue #228.
-}
allowlist ∷ Set QName
allowlist =
Expand Down Expand Up @@ -152,19 +184,29 @@ allowlist =
]
)
, ("Data.Semigroup", ["concatString"])
, -- The @run@ half of the uncurried FFI wrappers (issue #198). Their
-- @mk@ counterparts need an n-ary 'AbsN' (issue #227) and stay opaque;
-- @runFn0@ is a nullary call with no 'AppN', @runFn1@ is PureScript
-- @id@ with no foreign — both absent below.
("Data.Function.Uncurried", runWrappers "runFn" [2 .. 10])
, ("Control.Monad.ST.Uncurried", runWrappers "runSTFn" [1 .. 10])
, ("Effect.Uncurried", runWrappers "runEffectFn" [1 .. 10])
, -- Both halves of the uncurried FFI wrappers: @run@ (issue #198)
-- and @mk@ (issue #227). @runFn0@/@mkFn0@ are absent by policy,
-- not liftability: their bodies force a /pure/ @Fn0@ with a
-- nullary call, which must not be marked an effect run.
-- @runFn1@/@mkFn1@ are PureScript @id@ with no foreign.

( "Data.Function.Uncurried"
, wrappers "runFn" [2 .. 10] <> wrappers "mkFn" [2 .. 10]
)
,
( "Control.Monad.ST.Uncurried"
, wrappers "runSTFn" [1 .. 10] <> wrappers "mkSTFn" [1 .. 10]
)
,
( "Effect.Uncurried"
, wrappers "runEffectFn" [1 .. 10] <> wrappers "mkEffectFn" [1 .. 10]
)
]

-- @[prefix<n> | n <- arities]@, e.g. @runWrappers "runFn" [2, 3]@ is
-- @[prefix<n> | n <- arities]@, e.g. @wrappers "runFn" [2, 3]@ is
-- @["runFn2", "runFn3"]@.
runWrappers ∷ Text → [Int] → [Text]
runWrappers prefix arities = [prefix <> toText (show n) | n ← arities]
wrappers ∷ Text → [Int] → [Text]
wrappers prefix arities = [prefix <> toText (show n) | n ← arities]

--------------------------------------------------------------------------------
-- Orchestration ---------------------------------------------------------------
Expand Down Expand Up @@ -299,32 +341,45 @@ liftLuaExp env bound = \case
a' ← liftLuaExp env bound a
b' ← liftLuaExp env bound b
liftBinOp op a' b'
-- Only single-parameter (curried) function literals lift: a Lua
-- function binds every parameter at one call, so translating a
-- multi-parameter one to nested 'Abs' would misapply it (it would
-- fail the WellApplied invariant). The prelude FFI is curried anyway.
Function [(_ann, ParamNamed param)] body →
abstraction (paramNamed (irName param))
<$> liftBlock env (Set.insert param bound) body
-- A function literal binds every parameter at one call, so it lifts
-- to a single n-ary 'AbsN' (issue #227) — nested unary 'Abs' would
-- misapply when curried (it would fail the WellApplied invariant).
-- Varargs decline (the length check fails on the filtered-out
-- 'ParamVararg'), and so do duplicate parameter names: Lua binds the
-- body's reference to the /last/ same-named parameter, which an
-- 'AbsN' would silently miscompile.
Function params body
| Just names ← nonEmpty [name | (_ann, ParamNamed name) ← params]
, length names == length params
, let paramSet = Set.fromList (toList names)
, Set.size paramSet == length names →
abstractionN (paramNamed . irName <$> names)
<$> liftBlock env (Set.union bound paramSet) body
-- A zero-parameter function literal is the effect thunk of the
-- @run{ST,Effect}FnN@ wrappers: @function() return fn(a, b) end@. It
-- lifts to a unary 'Abs' with an unused parameter — the shape
-- magic-do runs in statement position and codegen then sheds, so a
-- saturated site fuses the thunk away entirely (issue #198).
Function [] body →
abstraction paramUnused <$> liftBlock env bound body
-- A saturated call @fn(a, b, …)@ — the body of the @runFnN@ wrappers —
-- lifts to the n-ary 'AppN' node (issue #198). A nullary call @fn()@
-- has no 'AppN' representation, so 'nonEmpty' declines it (e.g.
-- @runFn0 = \\fn -> fn()@ stays opaque).
-- A call @fn(a, b, …)@ — the body of the @runFnN@ wrappers — lifts to
-- the n-ary 'AppN' node (issue #198). A nullary call @fn()@ — the
-- trailing effect run of the @mk{ST,Effect}FnN@ wrappers — lifts as
-- an application to the 'EffectRunArg' marker (issue #227): the shape
-- magic-do emits, which the Lua backend erases back to an empty
-- argument list.
FunctionCall (_ann, fn) args → do
fn' ← liftLuaExp env bound fn
args' ← nonEmpty args >>= traverse (\(_ann', a) → liftLuaExp env bound a)
args' ← case nonEmpty args of
Nothing → Just (EffectRunArg noAnn :| [])
Just ne → traverse (\(_ann', a) → liftLuaExp env bound a) ne
-- A literal-lambda head (an inlined header local, or a parenthesized
-- function literal) must be called at exactly its own arity: any
-- other count would build an ill-formed 'AppN' (Note [n-ary
-- application]). Splitting the spine instead would change the
-- program — Lua drops surplus arguments — so a mismatch declines.
-- The marker counts as one argument: a nullary call passes a thunk
-- head and declines any wider literal.
case fn' of
AbsN _ params _ | length params /= length args' → Nothing
_ → Just (applicationN fn' args')
Expand Down
121 changes: 110 additions & 11 deletions test/Language/PureScript/Backend/Lua/ForeignLift/Spec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import Language.PureScript.Backend.IR.Names (Name (..))
import Language.PureScript.Backend.IR.Types
( PrimOp (..)
, abstraction
, abstractionN
, application
, applicationN
, eq
, ifThenElse
, noAnn
, paramNamed
, paramUnused
, primBinOp
, primNot
, refLocal
, pattern EffectRunArg
)
import Language.PureScript.Backend.Lua.ForeignLift (liftExport)
import Language.PureScript.Backend.Lua.Linker.Foreign
Expand Down Expand Up @@ -95,6 +99,18 @@ spec = describe "Foreign lift (#178)" do
primBinOp PrimConcat (refLocal (Name "s1")) (refLocal (Name "s2"))
)

it "lifts a multi-parameter function literal to one n-ary AbsN (#227)" do
-- A Lua function binds every parameter at one call, so the literal
-- becomes a single n-ary 'AbsN' — nested unary 'Abs' would misapply
-- when curried (Note [n-ary application]).
liftExport
(source "return { f = function(x, y) return x + y end }")
(Name "f")
`shouldBe` Just
( abstractionN (paramNamed (Name "x") :| [paramNamed (Name "y")]) $
primBinOp PrimAdd (refLocal (Name "x")) (refLocal (Name "y"))
)

describe "lifts the *.Uncurried run wrappers (#198)" do
it "lifts runFn3 to a saturated n-ary call" do
-- The pure wrapper: `\fn a b c -> fn(a, b, c)`. The body is a single
Expand Down Expand Up @@ -156,26 +172,99 @@ spec = describe "Foreign lift (#178)" do
applicationN (refLocal fn) (refLocal a :| [])
)

it "declines the mk* wrappers (their inner function is n-ary, #227)" do
describe "lifts the *.Uncurried mk wrappers (#227)" do
it "lifts mkFn2 to an n-ary literal over a re-curried call" do
-- `mkFn2 = \fn -> function(a, b) return fn(a)(b) end`: the inner
-- multi-parameter function needs an n-ary AbsN (issue #227), so the
-- wrapper stays an opaque foreign, not on this allowlist.
-- multi-parameter literal becomes a single n-ary 'AbsN'; its body
-- re-curries the wrapped function, so the call chain lifts as
-- nested unary applications. Marked inline-always downstream, a
-- `mkFn2 \a b -> …` definition beta-reduces to the two-parameter
-- literal itself — zero closures per call.
let src =
"return { mkFn2 = function(fn) "
<> "return function(a, b) return fn(a)(b) end end }"
liftExport (source src) (Name "mkFn2") `shouldSatisfy` isNothing
fn = Name "fn"
a = Name "a"
b = Name "b"
liftExport (source src) (Name "mkFn2")
`shouldBe` Just
( abstraction (paramNamed fn) $
abstractionN (paramNamed a :| [paramNamed b]) $
applicationN
(applicationN (refLocal fn) (refLocal a :| []))
(refLocal b :| [])
)

describe "declines everything outside the subset" do
it "declines a multi-parameter function (would misapply when curried)" do
liftExport (source "return { f = function(x, y) return x + y end }") (Name "f")
`shouldSatisfy` isNothing
it "lifts mkEffectFn2 (effect run of the re-curried call)" do
-- `mkEffectFn2 = \fn -> function(a, b) return fn(a)(b)() end`: the
-- trailing nullary call runs the effect the wrapped function
-- returns, and lifts as an application to the 'EffectRunArg'
-- marker — the shape magic-do emits, erased to an empty argument
-- list at code generation.
let src =
"return { mkEffectFn2 = function(fn) "
<> "return function(a, b) return fn(a)(b)() end end }"
fn = Name "fn"
a = Name "a"
b = Name "b"
liftExport (source src) (Name "mkEffectFn2")
`shouldBe` Just
( abstraction (paramNamed fn) $
abstractionN (paramNamed a :| [paramNamed b]) $
application
( applicationN
(applicationN (refLocal fn) (refLocal a :| []))
(refLocal b :| [])
)
(EffectRunArg noAnn)
)

it "declines a nullary call (AppN cannot express a zero-argument call)" do
-- `runFn0 = \fn -> fn()`. An n-ary AppN needs at least one argument;
-- the zero-argument call falls outside the subset and stays opaque.
it "lifts mkSTFn1 (unary inner literal, the nullary-call half alone)" do
-- The arity-1 wrappers keep a unary inner literal, so this
-- exercises only the nullary-call half of #227: `fn(a)()` becomes
-- the effect-run application under a plain unary 'Abs'.
let src =
"return { mkSTFn1 = function(fn) "
<> "return function(a) return fn(a)() end end }"
fn = Name "fn"
a = Name "a"
liftExport (source src) (Name "mkSTFn1")
`shouldBe` Just
( abstraction (paramNamed fn) $
abstraction (paramNamed a) $
application
(applicationN (refLocal fn) (refLocal a :| []))
(EffectRunArg noAnn)
)

it "lifts a nullary call to an effect-run application" do
-- `runFn0 = \fn -> fn()`. The zero-argument call lifts as an
-- application to the 'EffectRunArg' marker. `runFn0`/`mkFn0`
-- nonetheless stay off the allowlist by policy, not liftability:
-- forcing a pure `Fn0` is not an effect run and must not be
-- marked as one.
liftExport
(source "return { runFn0 = function(fn) return fn() end }")
(Name "runFn0")
`shouldBe` Just
( abstraction (paramNamed (Name "fn")) $
application (refLocal (Name "fn")) (EffectRunArg noAnn)
)

describe "declines everything outside the subset" do
it "declines a vararg function literal" do
liftExport
(source "return { f = function(a, ...) return a end }")
(Name "f")
`shouldSatisfy` isNothing

it "declines duplicate parameter names" do
-- Lua's `function(a, a)` binds the body's `a` to the *last*
-- parameter; an 'AbsN' with two same-named parameters would
-- silently miscompile that reference, so the literal declines.
liftExport
(source "return { f = function(a, a) return a end }")
(Name "f")
`shouldSatisfy` isNothing

it "declines a literal-lambda call at a mismatched arity" do
Expand All @@ -189,6 +278,16 @@ spec = describe "Foreign lift (#178)" do
<> "return { f = function(a) return k(a, a) end }"
liftExport (source src) (Name "f") `shouldSatisfy` isNothing

it "declines an under-applied multi-parameter literal head" do
-- The mirror image: `local k = function(a, b) return a end` now
-- inlines to a two-parameter 'AbsN' (#227), and calling it with
-- one argument would again build an ill-formed 'AppN' — Lua pads
-- the missing parameter with nil, so the mismatch declines.
let src =
"local k = function(a, b) return a end\n"
<> "return { f = function(x) return k(x) end }"
liftExport (source src) (Name "f") `shouldSatisfy` isNothing

it "declines a body with a table index" do
liftExport (source "return { f = function(xs) return xs[1] end }") (Name "f")
`shouldSatisfy` isNothing
Expand Down
2 changes: 1 addition & 1 deletion test/ps/output/Golden.UncurriedLift.Test/corefn.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions test/ps/output/Golden.UncurriedLift.Test/eval/golden.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ hello
world
6
20
48
103
203
42
Loading