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

- The IR's unary application node `App` is now the singleton case of an n-ary
`AppN ann f (args)`, where the argument list is one Lua call passing every
argument. `App` remains as a bidirectional pattern synonym, so the existing
unary rewrite rules are unchanged and currying stays expressed by nesting.
The Lua backend emits a single n-ary call for a multi-argument `AppN`, and a
new linter invariant (`WellApplied`) rejects applying a literal lambda to
more arguments than it binds. This is the representational groundwork for
lifting the uncurried `*.Uncurried` wrappers to direct calls; on its own it
leaves generated code unchanged (#179).
1 change: 1 addition & 0 deletions lib/Language/PureScript/Backend/IR/FlattenDeepBinds.hs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import Language.PureScript.Backend.IR.Types
, refLocal
, rewriteExpTopDownM
, substituteCopyM
, pattern App
)

-- | 'flattenDeepBindsM' with a private supply, for standalone use.
Expand Down
31 changes: 31 additions & 0 deletions lib/Language/PureScript/Backend/IR/Linter.hs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module Language.PureScript.Backend.IR.Linter
, Site (..)
, lintWellScoped
, lintUniqueBinders
, lintWellApplied
, unboundLocals
) where

Expand Down Expand Up @@ -53,6 +54,13 @@ data Violation
(see 'discardName').
-}
RefToDiscard Site
| {- | A literal lambda applied to more than one argument in a single
call ('WellApplied'). A lambda compiles to a one-parameter Lua
function (Note [n-ary application]), so every argument past the first
would be silently dropped. The 'Natural' is the offending call's
argument count.
-}
OverApplied Site Natural
deriving stock (Eq, Show)

-- | The top-level entry of the module a violation was found in.
Expand Down Expand Up @@ -83,6 +91,18 @@ lintUniqueBinders = overSites \site e →
(DuplicateBinder site <$> duplicateBinders e)
<> [RefToDiscard site | hasRefToDiscard e]

{- | Check the @WellApplied@ invariant: no literal lambda is applied to
more than one argument in a single call. A lambda compiles to a
one-parameter Lua function, so a multi-argument 'AppN' onto a lambda head
drops every argument past the first (Note [n-ary application]). A
well-formed multi-argument call always has a non-lambda head — a reference
to an n-ary foreign function. An empty result means the module holds the
invariant.
-}
lintWellApplied ∷ UberModule → [Violation]
lintWellApplied = overSites \site e →
OverApplied site <$> overApplications e

{- | Run a per-site check over every top-level binding, foreign binding,
and export of the module.
-}
Expand Down Expand Up @@ -170,3 +190,14 @@ hasRefToDiscard e =
[ nm == discardName
| Ref _ (Local nm) ← toListOf (cosmosOf subexpressions) e
]

{- | The argument count of every 'AppN' that applies a literal lambda to
more than one argument. Each such node is a miscompile: the lambda is a
one-parameter Lua function, so its surplus arguments are dropped.
-}
overApplications ∷ Exp → [Natural]
overApplications e =
[ fromIntegral (length args)
| AppN _ (Abs {}) args ← toListOf (cosmosOf subexpressions) e
, length args > (1 ∷ Int)
]
1 change: 1 addition & 0 deletions lib/Language/PureScript/Backend/IR/MagicDo.hs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import Language.PureScript.Backend.IR.Types
, noAnn
, rewriteExpTopDownM
, substituteMoveM
, pattern App
)

-- | Flatten Effect/ST @do@ blocks in every binding and export of the module.
Expand Down
1 change: 1 addition & 0 deletions lib/Language/PureScript/Backend/IR/Optimizer.hs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import Language.PureScript.Backend.IR.Types
, substituteCopyM
, substituteMoveM
, thenRewrite
, pattern App
)
import Language.PureScript.Backend.IR.Uniquify (uniquifyNames)

Expand Down
9 changes: 7 additions & 2 deletions lib/Language/PureScript/Backend/IR/Pass.hs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import Language.PureScript.Backend.IR.Linker (UberModule)
import Language.PureScript.Backend.IR.Linter
( Violation
, lintUniqueBinders
, lintWellApplied
, lintWellScoped
)
import Language.PureScript.Backend.IR.Supply (SupplyM)
Expand All @@ -66,12 +67,15 @@ import Language.PureScript.Backend.IR.Types (WasRewritten (..))

* 'WellScoped' — every local reference resolves to an enclosing binder;
* 'UniqueBinders' — within one top-level site no local binder name is
bound twice (the discard binder @_@ exempt).
bound twice (the discard binder @_@ exempt);
* 'WellApplied' — no literal lambda is applied to more than one argument
in a single call (Note [n-ary application]). Ensured by any pass that
introduces multi-argument 'AppN' nodes.

'UniqueBinders' is the global-uniqueness condition (GUC): a local
reference resolves to its binder unambiguously by name.
-}
data Invariant = WellScoped | UniqueBinders
data Invariant = WellScoped | UniqueBinders | WellApplied
deriving stock (Eq, Ord, Show)

data Pass = Pass
Expand Down Expand Up @@ -216,6 +220,7 @@ runStepsChecked steps uber0 = runExceptT (foldlM (flip runStep) uber0 steps)
( \case
WellScoped → lintWellScoped u
UniqueBinders → lintUniqueBinders u
WellApplied → lintWellApplied u
)
(Set.toList invariants)

Expand Down
47 changes: 40 additions & 7 deletions lib/Language/PureScript/Backend/IR/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,40 @@ data RawExp ann
| ObjectProp ann (RawExp ann) PropName
| ObjectUpdate ann (RawExp ann) (NonEmpty (PropName, RawExp ann))
| Abs ann (Parameter ann) (RawExp ann)
| App ann (RawExp ann) (RawExp ann)
| AppN ann (RawExp ann) (NonEmpty (RawExp ann))
| Ref ann (Qualified Name)
| Let ann (NonEmpty (Grouping (ann, Name, RawExp ann))) (RawExp ann)
| IfThenElse ann (RawExp ann) (RawExp ann) (RawExp ann)
| Exception ann Text
| ForeignImport ann ModuleName FilePath [(ann, Name)]

{- Note [n-ary application]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
'AppN f (a₁ :| [a₂, …, aₙ])' is a single Lua call @f(a₁, a₂, …, aₙ)@. The
argument list is not a flattened application spine: 'AppN f [a, b]' (one
call, @f(a, b)@) and 'AppN (AppN f [a]) [b]' (two calls, @f(a)(b)@) denote
different programs, because Lua silently drops surplus arguments and fills
missing ones with nil. Currying therefore stays expressed by nesting,
exactly as CoreFn produces it.

Translation and every existing rewrite rule build and match the unary
singleton through the 'App' pattern synonym below, so they are oblivious
to genuinely n-ary calls. A multi-argument node is introduced only by a
pass that can prove the callee consumes every argument (lifting the
uncurried @*.Uncurried@ wrappers to direct calls). The linter's
'Language.PureScript.Backend.IR.Linter.OverApplied' invariant rejects the
one ill-formed shape such a pass must never emit: a literal lambda applied
to more arguments than it binds.
-}

{- | The unary application @f a@ — the singleton 'AppN'. As a constructor
it builds the one-argument call; as a pattern it matches exactly the
one-argument calls, leaving genuinely n-ary nodes to fall through to a
later alternative. Every unary rule keeps using it unchanged.
-}
pattern App ∷ ann → RawExp ann → RawExp ann → RawExp ann
pattern App ann f a = AppN ann f (a :| [])

{- Note [Sequential scoping of Let bindings]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A local variable is referenced by name ('Ref _ (Local name)') and
Expand Down Expand Up @@ -202,7 +229,7 @@ getAnn = \case
ObjectProp ann _ _ → ann
ObjectUpdate ann _ _ → ann
Abs ann _ _ → ann
App ann _ _ → ann
AppN ann _ _ → ann
Ref ann _ → ann
Let ann _ _ → ann
IfThenElse ann _ _ _ → ann
Expand Down Expand Up @@ -232,7 +259,7 @@ setAnn ann = \case
ObjectProp _ e prop → ObjectProp ann e prop
ObjectUpdate _ e patches → ObjectUpdate ann e patches
Abs _ param body → Abs ann param body
App _ f argApp ann f arg
AppN _ f argsAppN ann f args
Ref _ qname → Ref ann qname
Let _ binds body → Let ann binds body
IfThenElse _ cond th el → IfThenElse ann cond th el
Expand Down Expand Up @@ -311,6 +338,9 @@ lets = Let noAnn
application ∷ Exp → Exp → Exp
application = App noAnn

applicationN ∷ Exp → NonEmpty Exp → Exp
applicationN = AppN noAnn

paramNamed ∷ Name → Parameter Ann
paramNamed = ParamNamed noAnn

Expand Down Expand Up @@ -417,8 +447,8 @@ subexpressions go = \case
ObjectProp ann <$> go a <*> pure prp
ObjectUpdate ann a ps →
ObjectUpdate ann <$> go a <*> traverse (traverse go) ps
App ann a b
App ann <$> go a <*> go b
AppN ann f args
AppN ann <$> go f <*> traverse go args
Abs ann arg a →
Abs ann arg <$> go a
Let ann bs body →
Expand Down Expand Up @@ -619,8 +649,11 @@ alphaEq = go 0 Map.empty Map.empty
(Let annL bindsL bodyL, Let annR bindsR bodyR) →
annL == annR
&& goLet lvl scopeL scopeR (toList bindsL) (toList bindsR) bodyL bodyR
(App annL fL aL, App annR fR aR) →
annL == annR && go lvl scopeL scopeR fL fR && go lvl scopeL scopeR aL aR
(AppN annL fL argsL, AppN annR fR argsR) →
annL == annR
&& length argsL == length argsR
&& go lvl scopeL scopeR fL fR
&& and (zipWith (go lvl scopeL scopeR) (toList argsL) (toList argsR))
(LiteralArray annL asL, LiteralArray annR asR) →
annL == annR
&& length asL == length asR
Expand Down
13 changes: 7 additions & 6 deletions lib/Language/PureScript/Backend/Lua.hs
Original file line number Diff line number Diff line change
Expand Up @@ -222,14 +222,15 @@ fromIR foreigns topLevelNames modname ir = case ir of
pure . Right $ case body of
Left chunk → Lua.functionDef luaParams chunk
Right e → Lua.functionDef luaParams [Lua.return e]
IR.App _ann expr arg → do
e ← goExp expr
Right . Lua.functionCall e <$> case arg of
IR.AppN _ann fn args → do
e ← goExp fn
Right . Lua.functionCall e <$> case args of
-- See Note [Nullary functions and Prim.undefined]. PS sometimes inserts
-- a synthetic unused argument "Prim.undefined", which is elided here.
IR.Ref _ann (IR.Imported (IR.ModuleName "Prim") (IR.Name "undefined")) →
-- a synthetic unused argument "Prim.undefined", which is elided here so
-- a nullary function is emitted as f() rather than f(nil).
IR.Ref _ann (IR.Imported (IR.ModuleName "Prim") (IR.Name "undefined")) :| [] →
pure []
_ → (: []) <$> goExp arg
_ → traverse goExp (toList args)
IR.Ref _ann qualifiedName →
case qualifiedName of
IR.Local name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import Language.PureScript.Backend.IR.Types
, refImported
, refLocal
, subexpressions
, pattern App
)
import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)
import Test.Hspec.Hedgehog.Extended (prop)
Expand Down
41 changes: 41 additions & 0 deletions test/Language/PureScript/Backend/IR/Linter/Spec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Language.PureScript.Backend.IR.Linter
( Site (..)
, Violation (..)
, lintUniqueBinders
, lintWellApplied
, lintWellScoped
, unboundLocals
)
Expand All @@ -21,6 +22,7 @@ import Language.PureScript.Backend.IR.Types
, Grouping (..)
, abstraction
, application
, applicationN
, lets
, literalInt
, noAnn
Expand Down Expand Up @@ -139,3 +141,42 @@ spec = describe "IR Linter" do
(application (refLocal discardName) (refLocal discardName))
)
`shouldBe` [RefToDiscard (InBinding itQName)]

describe "WellApplied" do
let x = Name "x"
lam = abstraction (paramNamed x) (refLocal x)
a = literalInt 1
b = literalInt 2
c = literalInt 3

it "accepts a single-argument application of a lambda" do
lintWellApplied (inBinding (application lam a)) `shouldBe` []

it "accepts a multi-argument call with a non-lambda head" do
lintWellApplied
(inBinding (applicationN (refLocal (Name "f")) (a :| [b, c])))
`shouldBe` []

it "flags a lambda applied to more than one argument in one call" do
lintWellApplied (inBinding (applicationN lam (a :| [b])))
`shouldBe` [OverApplied (InBinding itQName) 2]

it "flags a curried lambda applied to several arguments in one call" do
-- \x → \y → x compiles to nested one-parameter Lua functions, so a
-- single call passing two arguments to the outer one drops the
-- second: currying must stay expressed by nesting, not a flat list.
let y = Name "y"
curried =
abstraction
(paramNamed x)
(abstraction (paramNamed y) (refLocal x))
lintWellApplied (inBinding (applicationN curried (a :| [b])))
`shouldBe` [OverApplied (InBinding itQName) 2]

it "descends into call arguments to find nested over-applications" do
-- A well-formed outer call whose second argument is itself an
-- over-application: every argument must be visited.
let nested = applicationN lam (a :| [b])
outer = applicationN (refLocal (Name "g")) (a :| [nested])
lintWellApplied (inBinding outer)
`shouldBe` [OverApplied (InBinding itQName) 2]
17 changes: 17 additions & 0 deletions test/Language/PureScript/Backend/IR/Types/Spec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Language.PureScript.Backend.IR.Types
, abstraction
, alphaEq
, application
, applicationN
, countFreeRef
, countFreeRefs
, eq
Expand Down Expand Up @@ -132,6 +133,22 @@ spec = describe "Types" do
test "distinguishes free references by name" do
alphaEq (refLocal x) (refLocal y) === False

-- A flat argument list is one Lua call, not an application spine, so
-- alphaEq must compare arity and pair arguments up positionally.
test "distinguishes n-ary calls by argument count" do
let f = refLocal (Name "f")
alphaEq
(applicationN f (refLocal x :| [refLocal y]))
(application f (refLocal x))
=== False

test "identifies n-ary calls with matching head and arguments" do
let f = refLocal (Name "f")
alphaEq
(applicationN f (refLocal x :| [refLocal y]))
(applicationN f (refLocal x :| [refLocal y]))
=== True

-- See Note [Sequential scoping of Let bindings]: a Standalone RHS
-- does not see its own binder, so both references below are free
-- occurrences of the same enclosing x.
Expand Down
16 changes: 16 additions & 0 deletions test/Language/PureScript/Backend/Lua/Spec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ spec = describe "Lua.fromUberModule" do
rendered ← compileExportedExpr (ctorExpr IR.SumType)
rendered `shouldSatisfy` Text.isInfixOf "[\"$ctor\"]"

it "emits one n-ary Lua call for a multi-argument AppN" do
rendered ← compileExportedExpr naryCall
-- A single call passing every argument, not a curried f(a)(b)(c) spine.
rendered `shouldSatisfy` Text.isInfixOf "f(a, b, c)"
rendered `shouldSatisfy` (not . Text.isInfixOf "f(a)(b)(c)")

compileExportedExpr ∷ IR.Exp → IO Text
compileExportedExpr expr = do
foreignPath ← Tagged <$> getCurrentDir
Expand Down Expand Up @@ -91,6 +97,16 @@ absWithIfBody =
(IR.LiteralInt IR.noAnn 0)
)

-- @f(a, b, c)@: one call, three arguments, head is a plain reference.
naryCall ∷ IR.Exp
naryCall =
IR.AppN
IR.noAnn
(localRef "f")
(localRef "a" :| [localRef "b", localRef "c"])
where
localRef = IR.Ref IR.noAnn . IR.Local . IR.Name

ctorExpr ∷ IR.AlgebraicType → IR.Exp
ctorExpr algebraicTy =
IR.ctor
Expand Down
Loading