pslua is a PureScript-to-Lua compiler backend. It reads PureScript CoreFn
(the compiler's intermediate representation) and emits Lua 5.1 code. This
document gives a Copilot cloud agent the context needed to work effectively in
this repository.
All builds and tests run inside nix develop. The system GHC is not used.
nix develop --command cabal build
nix develop --command cabal test all --test-show-details=directThe flake is pinned:
- GHC 9.8.x (
compiler-nix-name = "ghc98"inflake.nix) - PureScript 0.15.16 (
purs-bin.purs-0_15_16) - Spago 1.0.4 (
spago-bin.spago-1_0_4) - Lua 5.1 (
lua51Packages.lua,lua51Packages.luacheck)
A Cachix binary cache (purescript-lua) is available so nix develop is fast
after the first run. The CI workflow (ci.yaml) shows the exact invocations.
lib/ — Haskell library (the compiler)
exe/ — pslua executable (CLI in Cli.hs, entry in Main.hs)
test/ — Haskell test suite
test/ps/ — PureScript golden-test project (spago.yaml, src/, output/)
docs/ — GOLDEN_TESTING.md, QUIRKS.md, VERSIONING.md
scripts/ — golden_reset, prepare_release
changelog.d/ — scriv changelog fragments
Inside lib/Language/PureScript/:
CoreFn.* — reads and parses CoreFn JSON from purs
Backend/IR.* — intermediate representation, DCE, inliner, linker, optimizer
Backend/Lua.* — Lua AST, codegen, printer, Lua-level optimizer, FFI linker
Backend.hs — top-level compileModules orchestrator
# build
nix develop --command cabal build
# run all tests
nix develop --command cabal test all --test-show-details=direct
# run only the spec suite
nix develop --command cabal test spec
# format (Cabal/Nix/YAML via treefmt — does NOT touch Haskell)
nix develop --command nix fmt
# format Haskell (Fourmolu)
nix develop --command fourmolu -i lib/ exe/ test/
# lint Haskell
nix develop --command hlint lib/ exe/ test/
# run the compiled binary
nix develop --command cabal run pslua -- --help-
Indentation: 2 spaces (enforced by Fourmolu; see
fourmolu.yaml) -
Line length: 80 characters maximum
-
Unicode syntax: always use
∷not::,→not->,⇒not=> -
Prelude: Relude (not base Prelude) — configured via cabal mixins in
pslua.cabal; import basePreludeis hidden. Never importPreludefrom base directly. -
Imports: qualified imports preferred;
ImportQualifiedPoststyle (import Foo qualified as F) -
Section headers: use exactly 80-character comment lines:
-------------------------------------------------------------------------------- -- Section Name ----------------------------------------------------------------
-
All GHC extensions listed in
pslua.cabal'sdefault-extensionsstanza are available everywhere without pragma. -
GHC is set to
-Werrorfor several warnings (-Werror=missing-fields,-Werror=incomplete-patterns, etc.) — fix all warnings, they are errors.
PureScript source
↓ (spago build with no-op backend)
CoreFn JSON (test/ps/output/*/corefn.json)
↓ CoreFn.readModuleRecursively
CoreFn modules
↓ IR.mkModule
IR modules
↓ Linker.makeUberModule
UberModule
↓ optimizedUberModule ← DCE, inliner, magic-do, flatten, rename
Optimized UberModule
↓ Lua.fromUberModule
Lua Chunk
↓ optimizeChunk
↓ exceedsNestingLimit (rejects if > 180 deep; throws Lua.NestingTooDeep)
Final Lua output
compileModules in lib/Language/PureScript/Backend.hs is the top-level
entry point that runs the full pipeline.
Critical: optimizedUberModule is the shared IR optimization function.
The golden test harness (Golden/Spec.hs) calls it directly — it does not
go through compileModules. Any new IR pass must be added inside
optimizedUberModule, otherwise it is silently skipped by golden tests.
Use Hedgehog (property-based). Generators are in
test/Language/PureScript/Backend/IR/Gen.hs and
test/Language/PureScript/Backend/Lua/Gen.hs.
Golden tests are the primary integration tests. They:
- Compile PureScript sources in
test/ps/with spago →corefn.json - Run the IR pipeline → compare
golden.ir - Generate Lua → compare
golden.lua - (If
eval/golden.txtexists) Execute withlua, compare stdout
Important files per module in test/ps/output/Golden.<Name>.Test/:
A module is compiled AsApplication (entry main called) when
eval/golden.txt exists; otherwise AsModule. The oracle must be created by
hand the first time.
Accepting structural goldens after a codegen change:
PSLUA_GOLDEN_ACCEPT=1 nix develop --command cabal test specThis rewrites golden.ir and golden.lua but never touches
eval/golden.txt. Review git diff before committing.
Full diff on mismatch:
PSLUA_GOLDEN_FULL_DIFF=1 nix develop --command cabal test specAdding a new golden test:
- Create
test/ps/src/Golden/<Name>/Test.purs(moduleGolden.<Name>.Test) cd test/ps && spago build && cd ../..- For a runnable test, create
test/ps/output/Golden.<Name>.Test/eval/golden.txt(expected stdout) andeval/.gitignore(containingactual.txt) by hand - Run
cabal test spec—golden.ir/golden.luaare created on first pass - Review and commit
Test.purs,corefn.json,golden.ir,golden.lua,eval/golden.txt,eval/.gitignore
Compiling PureScript sources (required before golden tests):
cd test/ps && spago build && cd ../..The spago.yaml uses a no-op backend: cmd: "true" so spago emits
corefn.json without invoking pslua.
- Write a failing test that reproduces the bug (confirm it is red)
- Apply the fix
- Confirm the test goes green
Never write the fix before the test — a test written after cannot prove it actually catches the bug.
Version format: A.B.C.D (four components)
A.B— major (breaking change)C— minor (backwards-compatible addition)D— patch (bug fix, internal refactor)
Three-component versions are wrong. The same string must appear in:
pslua.cabal(version:field)- git tag (no
vprefix, e.g.0.3.0.0) - GitHub release
CHANGELOG.mdsection header
Changelog fragments go in changelog.d/. Collect with:
scriv collect --version <A.B.C.D>pslua targets Lua 5.1 only. FFI must be compatible with 5.1.
Key PureScript → Lua mappings:
Unit must be {}, never nil — Lua tables silently drop nil values,
which collapses Array Unit to an empty table.
Lua 5.1 limits to keep in mind:
- Max ~200 local variables per function
- Max ~60 upvalues per closure
- Parser nesting depth limit ~200 levels in Lua;
psluarejects conservatively at depth 180 with theNestingTooDeeperror
Foreign .lua files have a header of local helpers followed by a single
returned table. Each exported value must be wrapped in parentheses:
local function helper(x) return x + 1 end
return {
foo = (function(a) return helper(a) end),
bar = (function(a) return function(b) return a + b end end),
}Do not put -- comments between table fields. The FFI parser rejects them.
- Golden pipeline bypass: New IR passes wired only into
compileModulesare invisible to golden tests. Always put them inoptimizedUberModule. eval/golden.txtoracle: Never regenerate it from current output unless you have re-verified the expected behaviour by hand.scripts/golden_reset: Deletes allgolden.*files includingeval/golden.txt. PreferPSLUA_GOLDEN_ACCEPT=1for codegen churn.- Relude:
fromList,toList,show, etc. come from Relude. If something is missing, check Relude docs before importing from base. -Werrorwarnings: GHC treats several warning classes as errors. Fix all warnings before expecting a clean build.spago buildbefore golden tests:corefn.jsonfiles must be up to date. Runcd test/ps && spago buildafter touching any.pursintest/ps/src/.- Lua 5.1
unit: Requirespurescript-lua-prelude≥ v7.2.0 whereunit = {}. If eval goldens print0for unit arrays, the prelude was downgraded — do not accept such goldens.
