This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is pslua - a PureScript to Lua compiler backend. It takes PureScript CoreFn (the PureScript compiler's intermediate representation) and compiles it to Lua. The project supports dead code elimination (DCE), code inlining, FFI with Lua, and can emit either Lua modules or standalone applications.
The project uses Nix with flakes for reproducible builds and Cabal for Haskell development.
Versions are pinned by the flake (compiler-nix-name and the
purs-bin.* / spago-bin.* attrs in flake.nix); update them there, not
locally:
- GHC: 9.8.x (haskell.nix
ghc98) - PureScript:
purs0.15.16, from purescript-overlay pinned aspurs-bin.purs-0_15_16(explicit pin sonix flake updatenever silently bumps the compiler and churns goldens) - Spago: 1.0.x — the new PureScript spago, driven by
spago.yaml+spago.lockand the PureScript Registry (it dropped Dhall support), pinned asspago-bin.spago-1_0_4. The test project (test/ps) consumes the published purescript-lua package set viaworkspace.packageSet.url(see "Toolchain / package set" under Testing). The overlay's plainspagoattr also resolves to 1.x; the explicit pin keeps the version changed only by a deliberate flake edit.
# Enter development shell (provides all tools)
nix develop
# Or use direnv (if configured)
direnv allow# Build the project
cabal build
# Build specific component
cabal build exe:pslua
cabal build lib:pslua# Run all tests with detailed output
cabal test all --test-show-details=direct
# Run specific test suite
cabal test spec
# Run tests in watch mode (requires ghcid)
ghcid --command="cabal repl test:spec" --test=":main"The test suite includes:
- Unit tests: Property-based testing with Hedgehog
- Golden tests: Compiles PureScript test modules from
test/ps/src/Golden/*/Test.pursto Lua and compares against golden files - Evaluation tests: Runs generated Lua code and verifies output
- Luacheck tests: Validates generated Lua code syntax
- Differential tests: Anchors the Lua printer/parser pair to the reference
implementation (
Differential.SpecviaTest.Lua):luac -pmust accept everything the printer emits, and a semantic differential evaluates the printer's precedence/associativity against theluainterpreter itself
Golden tests require compiling PureScript sources first:
# Compile PureScript test sources (from test/ps directory)
cd test/ps
spago build
cd ../..The new spago manages codegen itself and rejects --codegen in --purs-args.
CoreFn is emitted because test/ps/spago.yaml declares a no-op backend
(cmd: "true"): with a backend configured spago compiles with --codegen corefn and the harness reads the resulting output/**/corefn.json. The
golden sources live under test/ps/src/ (new spago only globs src/ and
test/), with .lua FFI files co-located next to each .purs.
When a deliberate codegen/optimizer change legitimately moves the output, accept the new structural goldens in place:
# Rewrites mismatching golden.ir / golden.lua with the actual output and passes
PSLUA_GOLDEN_ACCEPT=1 cabal test all --test-show-details=directOnly the structural goldens (golden.ir, golden.lua) are auto-accepted. The
hand-verified eval/golden.txt oracle is never auto-accepted — if a change
alters runtime output, those tests still fail, which is the semantic safety net.
Review the resulting git diff, then run cabal test once more (no env var) to
confirm the accepted state is stable.
NB: ./scripts/golden_reset deletes all golden.* files — including
eval/golden.txt — and lets the harness recreate them from current output. That
silently overwrites the hand-verified oracle with whatever the code emits now
(even if buggy), so prefer PSLUA_GOLDEN_ACCEPT and do not run golden_reset
unless you intend to discard the oracles.
# Format Cabal/Nix/YAML/Dhall via treefmt
nix fmt
# Haskell is formatted separately with Fourmolu (NOT part of `nix fmt`)
fourmolu -i lib/ exe/ test/
# Run HLint
hlint lib/ exe/ test/
# Check Lua files
luacheck --quiet --std min test/ps/output/# Build and run
cabal run pslua -- --help
# Or after building (resolves the dist-newstyle path for the current GHC)
$(cabal list-bin pslua) --help
# Or via nix
nix run . -- --helpTypical usage:
pslua \
--foreign-path ./foreign \
--ps-output ./output \
--lua-output-file ./dist/Main.lua \
--entry Main.mainpslua is also a first-class Spago backend (workspace.backend.cmd: pslua in
spago.yaml). Spago compiles to CoreFn and then runs the backend command, so
spago build links the project to Lua via the configured --entry /
--lua-output-file. For spago run, Spago invokes the backend a second time
as pslua --run <Module>.<entry> (without the build-phase args); --run
compiles that entry point, writes it to a temp file, executes it with lua,
and forwards lua's exit code (Language.PureScript.Backend.Lua.Run.runChunk).
Foreign .lua files are resolved next to each module's source (recorded in the
CoreFn modulePath), so dependency FFI in .spago/** is found automatically;
--foreign-path is only a secondary fallback.
The compilation happens in several distinct phases:
PureScript Source → CoreFn → IR → Lua → Optimized Lua
-
CoreFn Reading (
Language.PureScript.CoreFn.*)- Reads PureScript compiler's CoreFn JSON output
- Parses module structure, imports, and expressions
-
IR Translation (
Language.PureScript.Backend.IR.*)- Converts CoreFn to an intermediate representation (IR)
- IR is simpler than CoreFn but still high-level
- Handles data declarations, bindings, and module structure
-
IR Optimization (
Language.PureScript.Backend.IR.Optimizer)- Performs optimizations on IR:
- Eta reduction/expansion
- Beta reduction
- Constant folding
- Case-of-case transformation
- Inliner (
IR.Inliner): Marks expressions for inlining - Dead Code Elimination (
IR.DCE): Removes unused bindings - Uncurrying (
IR.Uncurry): Splits curried bindings into n-ary workers plus curried wrappers and rewrites saturated call sites to direct worker calls
- Performs optimizations on IR:
-
Linking (
Language.PureScript.Backend.IR.Linker)- Creates an "UberModule" containing all reachable code
- Supports two modes:
LinkAsModule: Creates a Lua module (returns a table)LinkAsApplication: Creates a runnable Lua script (calls entry point)
-
Lua Code Generation (
Language.PureScript.Backend.Lua)- Converts optimized IR to Lua AST
- Handles foreign imports via FFI
- Manages name mangling and scope
-
Lua Optimization (
Language.PureScript.Backend.Lua.Optimizer)- Optimizes generated Lua code
-
Lua Printing (
Language.PureScript.Backend.Lua.Printer)- Pretty-prints Lua AST to text
CoreFn Layer (Language.PureScript.CoreFn.*):
CoreFn.Reader: Reads CoreFn JSON from diskCoreFn.FromJSON: JSON deserializationCoreFn.Expr,CoreFn.Module,CoreFn.Meta: CoreFn data typesCoreFn.Traversals: Traversal utilitiesCoreFn.Laziness: Lazy binding analysis
IR Layer (Language.PureScript.Backend.IR.*):
IR.Types: Core IR data types (RawExp,Module,Binding)IR.Names: Name types (Qualified,ModuleName, etc.)IR.Linker: Creates UberModule from multiple modulesIR.Optimizer: IR-level optimizationsIR.DCE: Dead code eliminationIR.Inliner: Inlining annotations and logicIR.Query: Queries over IR expressions
Lua Backend (Language.PureScript.Backend.Lua.*):
Lua.Types: Lua AST types (Chunk,Statement,Exp), annotated withCommentsLua.Name: Safe Lua identifier generationLua.Parser: Full Lua 5.1 parser producing the Lua AST (used for FFI files and runtime fixtures; preserves comments in annotation slots)Lua.Printer: Pretty-printing Lua codeLua.Optimizer: Lua-level optimizationsLua.Linker.Foreign: FFI support for Lua foreign modules (file resolution + foreign-module shape on top ofLua.Parser)Lua.Fixture: Runtime support code injected into outputLua.Key,Lua.Traversal: Table keys and AST traversal helpers
Main Entry (Language.PureScript.Backend):
compileModules: Top-level compilation function orchestrating the pipeline
De Bruijn Indices: The IR uses De Bruijn indices for variable references. A Ref contains:
- A qualified name
- An index indicating which binding of that name to reference
Groupings: Bindings are wrapped in Grouping:
Standalone: Non-recursive bindingRecursiveGroup: Mutually recursive bindings
AppOrModule: Determines compilation mode:
AsModule ModuleName: Generate a Lua moduleAsApplication ModuleName Ident: Generate an executable that calls the entry point
UberModule: A flattened representation of all modules after linking, containing:
- All reachable bindings
- Module exports
- Foreign imports
This project follows specific Haskell style conventions:
- Indentation: 2 spaces (enforced by Fourmolu)
- Line length: Max 80 characters
- Unicode: Always use unicode syntax (
∷instead of::,→instead of->) - Prelude: Uses Relude (not base Prelude)
- Imports: Explicit qualified imports preferred
- Extensions: Many enabled by default (see
pslua.cabalcommon stanza)
-
Fourmolu (
fourmolu.yaml):- 2-space indentation
- 80-character column limit
- Leading commas and function arrows
- Unicode always
- Multi-line Haddock style
-
HLint (
.hlint.yaml):- Configured with project-specific extensions
- Run with
--color --cpp-simple -XQuasiQuotes -XImportQualifiedPost
Use section-style comments to organize code:
--------------------------------------------------------------------------------
-- Section Title ---------------------------------------------------------------
code here...Both lines should be exactly 80 characters. Helper functions go at the bottom after a "Helper Functions" or "Utility Functions" section.
Golden tests are the primary integration testing mechanism:
- PureScript test files in
test/ps/src/Golden/*/Test.purs - Compiled to CoreFn with
spago build(the no-opbackendintest/ps/spago.yamlis what makes spago emit CoreFn — see "Testing PureScript Code") - Test suite reads CoreFn, compiles to IR, generates Lua
- Compares against golden files:
golden.ir- Intermediate representationgolden.lua- Generated Lua codeeval/golden.txt- Execution output (if module has amainfunction)
The golden harness re-implements the IR pipeline. compileCorefn in
test/Language/PureScript/Backend/Lua/Golden/Spec.hs calls
makeUberModule >>> optimizedUberModule directly — it does not go through
Backend.compileModules. Any new IR pipeline pass must live inside
optimizedUberModule (the shared function), or the golden tests will silently
bypass it.
To add a new golden test:
- Create
test/ps/src/Golden/NewTest/Test.purs - Run
cabal test- it will fail and createactual.*files - Review the actual files
- Rename
actual.*togolden.*if correct - Commit the golden files
For a runnable module (one with main) whose execution output you want
checked, also create eval/golden.txt with the expected output (plus
eval/.gitignore containing actual.txt) before running. The presence of
eval/golden.txt is what makes the harness link the module AsApplication and
run it through lua; without it the module is linked AsModule and only the
.ir/.lua goldens are generated (no execution check).
The project uses Hedgehog for property-based testing:
- Generators in
test/Language/PureScript/Backend/IR/Gen.hs - Tests in
test/Language/PureScript/Backend/IR/Spec.hsand similar
For a bug fix, work test-first (TDD): write a test that reproduces the bug and confirm it is red before touching the fix, then apply the fix and confirm it goes green. A fix written before its test cannot prove it actually catches the bug. Size the coverage to the bug (one focused case is often enough; add more when the bug spans several code paths).
- Make code changes in
lib/orexe/ - Format code:
fourmolu -i lib/ exe/ test/ - Run HLint:
hlint lib/ exe/ test/ - Run tests:
cabal test all --test-show-details=direct - If golden tests fail, inspect
actual.*files intest/ps/output/ - Update golden files if changes are correct
pslua follows the Package Versioning Policy (PVP), not SemVer: versions
are four components A.B.C.D where A.B is the major version. The same string
is used everywhere — the version: field in pslua.cabal, the git tag (no v
prefix, e.g. 0.3.0.0), the GitHub release, and the CHANGELOG.md section
header — and they must agree. A three-component version anywhere is a mistake to
fix, not a second convention. The changelog is managed with scriv (fragments
in changelog.d/, assembled by scriv collect --version <A.B.C.D> on release).
See docs/VERSIONING.md for the full policy, the meaning of each component, and
the release checklist.
nix flake update— refreshes haskell.nix (and with it the Hackage index pin), nixpkgs, and purescript-overlay. To bump GHC,purs, or spago, editcompiler-nix-name/purs-bin.*/spago-bin.*inflake.nix(the toolchain attrs are explicitly version-pinned, so a flake update alone never changes them).- PureScript dependencies live in
test/ps/spago.yaml, which consumes the published Lua package set viaworkspace.packageSet.url(apurescript-lua/purescript-lua-package-setspsc-*release). That set is the upstream registry baseline with the Lua forks (purescript-lua-*, which ship.luaFFI) overlaid as git entries, so it overrides the JavaScript-targeting upstreams — no inlinedextraPackages. To move the whole set, bump thepackageSet.urltag (the fork versions / dependency lists are carried centrally in the set, sourced from the set repo'ssrc/packages.json);spago.lockpins the resolved set (its URL plus an inlined snapshot) and the fork commits, and is committed. Keeppursat 0.15.16: no registry set is built for 0.15.16, so the set is built with 0.15.15 (a proven-compatible pairing). - After changing the
packageSet.urltag orpurs:cd test/ps && spago build, thencabal test all. Deletespago.lockfirst if you want spago to re-resolve the set. - Expected churn after updates:
test/ps/output/Golden*/corefn.jsonare committed; their"builtWith"stamp changes with thepursversion, andmodulePathreflects thesrc/source location.golden.irfiles embed.spago/p/<pkg>/<commit-hash>/...source paths (the new spago content-addressed layout), so a fork-tag bump that resolves to a new commit legitimately changes goldens.golden.luais path-free, so it only moves when codegen genuinely changes.
See also docs/QUIRKS.md for the catalogue of compiler/Lua-target quirks
(parser nesting limit, Char byte-string shapes, Lua 5.1 floor, …).
unitmust not benil: Lua tables cannot holdnilvalues, soArray Unitsilently collapses to an empty table if the prelude definesunit = nil. Requirespurescript-lua/purescript-lua-prelude≥ v7.2.0, whereunit = {}. If eval goldens for unit arrays start printing0, a package set downgraded the prelude — do not accept such goldens.- A generated-Lua change that only passes
luacheckis not verified: eval goldens (eval/golden.txt) are the semantic check.
- The IR and Lua types have
Showinstances - usepShowOptfor pretty debug output - Golden test failures show diffs between expected and actual
- Use
actual.*files alongsidegolden.*files to debug compilation issues - Check
test/ps/output/Golden.*/directories for generated IR and Lua - Lua evaluation errors are captured in the test output