Skip to content

fix: flatten Effect/ST do blocks (magic-do) to avoid Lua's nesting limit (#46)#105

Open
Unisay wants to merge 2 commits into
mainfrom
issue-46/magic-do-effect-st
Open

fix: flatten Effect/ST do blocks (magic-do) to avoid Lua's nesting limit (#46)#105
Unisay wants to merge 2 commits into
mainfrom
issue-46/magic-do-effect-st

Conversation

@Unisay

@Unisay Unisay commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #46.

Problem

A long straight-line do block desugars to a chain of bind/discard whose continuations nest lexically. Past ~200 levels Lua's parser rejects the chunk with chunk has too many syntax levels (LUAI_MAXCCALLS), so the file fails to load — before any code runs. The prelude test suites hit this in practice.

Approach

A new IR pass Language.PureScript.Backend.IR.MagicDo recognises bind/discard/pure specialised to the Effect or ST monad — whose runtime value is a nullary thunk — and rewrites the nested chain into a flat statement sequence:

function() local x = m1(); local _ = m2(); ...; return last() end

This mirrors the magic-do pass of the upstream JS backend and purs-backend-es, but is realised as a rewrite into existing Let/Abs constructs rather than a new IR node (which would ripple through every RawExp traversal, including the De Bruijn machinery behind #37/#56).

Key points:

  • Recognition normalises the application head — resolving module-local aliases, projecting fields out of literal dictionaries, and beta-reducing — until the Effect/ST bind instance is exposed. The optimizer inlines discard to discardUnit.discard = bind, so the chain head is rarely a bare Control.Bind.bind; normalisation sees through that.
  • Chunking. A single flat thunk would overflow Lua 5.1's other limit, LUAI_MAXVARS (200 locals/function). The statements are split into nested thunks of ≤150 locals, so both the nesting limit and the local-variable limit stay satisfied.
  • Placement. Runs as the final step of optimizedUberModule: after renameShadowedNames (locals are uniquely named, so moving binders into a Let needs no De Bruijn shifting) and after dead-code elimination (so the local _ = statements introduced for discard are not dropped as dead). Folding it into the single pipeline definition means both the compiler and the golden-test harness pick it up — no divergence.
  • Effect/ST only. Other monads keep their bind calls (their bind is not "run a thunk"). A long straight-line do in Maybe/Either/State/… can still hit the parser limit; the generic, monad-agnostic fallback is tracked in Generic fallback: flatten deeply-nested do-blocks for non-Effect/ST monads #104.

Testing

  • New Golden.LongDoBlock regression — a 300-statement Effect block that previously failed to load now evaluates and prints 1..300.
  • All existing eval goldens are unchanged (eval/golden.txt preserved, only golden.ir/golden.lua regenerated), confirming semantics are preserved — including Maybe (MaybeChain) and tailRecM (TailRecM2Shadow), which are deliberately not flattened.
  • Full suite green: 237 examples, 0 failures (unit + property + golden + eval + luacheck).

Docs

Adds docs/QUIRKS.md (user-facing Lua-target guide) and notes the residual non-Effect/ST limit there.

Unisay added 2 commits June 16, 2026 12:36
…mit (#46)

A long straight-line do block desugars to a chain of bind/discard whose
continuations nest lexically. Past ~200 levels Lua's parser rejects the chunk
("chunk has too many syntax levels", LUAI_MAXCCALLS), so the file fails to load
before any code runs.

Add an IR pass (IR.MagicDo) that recognises bind/discard/pure specialised to the
Effect or ST monad — whose value is a nullary thunk — and rewrites the chain
into a flat statement sequence (local x = m(); ...; return r()). Recognition
normalises the application head (resolving module-local aliases, projecting
fields out of literal dictionaries, beta-reducing) until the Effect/ST bind
instance is exposed, so it sees through the forms the optimizer leaves behind.

The flat statements are chunked into nested thunks of <=150 locals to stay under
Lua 5.1's other limit, LUAI_MAXVARS (200 locals per function).

The pass runs as the final step of optimizedUberModule: after
renameShadowedNames (so locals are uniquely named and no De Bruijn shifting is
needed) and after dead-code elimination (so the statements introduced for
discard are not dropped as dead). Other monads keep their bind calls; the
generic deeply-nested case is tracked in #104.

Adds the Golden.LongDoBlock regression (300-statement Effect block) and
regenerates the Effect/ST goldens. Eval goldens are unchanged, confirming
semantics are preserved.
Add docs/QUIRKS.md for users compiling PureScript to Lua: the Lua 5.1 target
floor, how PureScript values map onto Lua, how to write FFI (foreign-module
shape), the residual long-do-block limit for non-Effect/ST monads (#46/#104),
and stack-safety via MonadRec. Cross-reference it from CLAUDE.md's Known
Pitfalls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Long do blocks generate Lua that exceeds the parser nesting limit

1 participant