Issue 1727: Tail-Loop Rewrite for Multi-Parameter-Group Functions
Status: proposed
Date: 2026-02-20
Issue: https://github.com/johnynek/bosatsu/issues/1727
Problem statement
Issue #1727 reports that recursive defs with multiple parameter groups often miss tail-rec loop lowering.
Current source shape:
def eq_List(fn: (a, a) -> Bool)(a: List[a], b: List[a]) -> Bool:
recur (a, b):
case ([ah, *at], [bh, *bt]) if fn(ah, bh): eq_List(fn)(at, bt)
case ([], []): True
case _: False
Target efficiency should match the explicit helper form:
def eq_List(fn: (a, a) -> Bool)(a: List[a], b: List[a]) -> Bool:
def loop(fn: (a, a) -> Bool, a: List[a], b: List[a]) -> Bool:
recur (a, b):
case ([ah, *at], [bh, *bt]) if fn(ah, bh): loop(fn, at, bt)
case ([], []): True
case _: False
loop(fn, a, b)
Current behavior summary
SourceConverter.toLambdaExprlowers parameter groups into nested lambdas.- Recursive calls become chained application nodes like
App(App(Local(self), g1Args), g2Args). - Existing tail-call loop lowering is oriented around direct self-call shapes and may miss these chained grouped calls.
Where this rewrite should live
This rewrite should live in TypedExprNormalization as a pre-pass immediately before tail-call-to-loop lowering.
- It belongs in normalization (not parser/source conversion), because normalization already sees typed
TypedExprshapes and performs alpha-safe rewrites. - It should run right before whichever hook currently lowers self tail calls to
Loop/Recur. - If loop-lowering internals moved (for example, after PR #1737), keep this as a normalization-stage pre-pass and wire it at the new call site.
Proposed rewrite
High-level transformation
For a recursive binding with grouped lambdas, create an internal helper that takes all group parameters in one argument list, rewrite recursive self-calls to that helper, then run existing loop lowering.
General shape:
def f(g1)(g2)...(gn):
body with recursive calls f(a1)(a2)...(an)
becomes (conceptually):
def f(g1)(g2)...(gn):
def loop(g1, g2, ..., gn):
body with recursive calls loop(a1, a2, ..., an)
loop(g1, g2, ..., gn)
This handles any number of parameter groups (n >= 2) in one pass.
Explicit detection in TypedExpr
Detection is AST-driven and type-checked, not string-based.
- Identify candidate recursive binding
Let(fnName, rhs, in, rec = Recursive, tag). - Collect lambda groups from
rhsby unwrappingGeneric/Annotation, walking nestedAnnotatedLambda, and allowing non-recursiveLetwrappers between groups when they do not shadow already-collected group names. Abort if fewer than two groups are recovered. - Traverse only the terminal group body and classify each occurrence of
fnName: flatten chainedAppinto(head, argGroups); requireheadisLocal(fnName, _, _)(after stripping wrappers); require group-count and per-group arity match; require full application to result type (no partialf(g1)values); require tail position eligibility. - If any recursive occurrence fails these checks, abort rewrite for this binding.
This is robust to shapes like:
foo = fn -> (
x = 2
(x, y) -> (
... foo(fn)(x, y)
)
)
because the detection allows a non-recursive Let wrapper between groups and still requires fully-applied grouped self-calls at the terminal body.
Prefix arguments may change
We do not require prefix groups to be invariant. Recursive calls like f(nextFn)(x1, x2) are valid rewrite targets.
Reason:
- The helper threads all group arguments as explicit loop state.
- Recursive updates to any group become ordinary
Recurstate updates. - This is semantically direct and avoids imposing an unnecessary restriction.
Why not recurse through an outer-function reference?
Keeping recursion as “call outer function from inside helper” is possible but weaker:
- It reintroduces higher-order application in the recursive path.
- It can block existing direct
Recurlowering and may keep extra closure/application overhead. - Rewriting directly to helper self-calls aligns with the current loop-lowering pipeline.
Integration point
In normalization, at the recursive-let rewrite site:
- normalize RHS first.
- run
rewriteMultiGroupTailRec. - then run existing tail-call loop lowering.
- then run existing closure/capture rewrites.
This preserves current pass ordering while exposing the loopable shape earlier.
Sketch of helper API and invariants
private def rewriteMultiGroupTailRec[A](
fnName: Bindable,
expr: TypedExpr[A],
tag: A
): Option[TypedExpr[A]]
Invariants:
Nonemeans “no safe rewrite”.Some(expr1)means type-preserving rewrite:expr1.getType.sameAs(expr.getType).Some(expr1)preserves semantics up to alpha-renaming and existing normalization equalities.
Correctness notes
- External function signature and partial-application behavior are unchanged.
- Only internal recursive target shape changes.
- Match/guard ordering and evaluation order are preserved.
- Alpha-safety is maintained via existing fresh-name/unshadow discipline.
Tests to add
In core/src/test/scala/dev/bosatsu/TypedExprTest.scala:
- Issue-1727 regression (
eq_List) rewrites to helper-recursive shape and then loop form. - Partial-application behavior remains unchanged (
eq_List(eq_Int)style). - Changed-prefix recursion rewrites correctly (
f(g1)(x)recursing asf(g1Next)(xNext)). Let-between-groups shape is supported.- Escaping self-reference still blocks rewrite.
- Idempotence: second normalization pass is stable.
Non-goals
- No syntax changes.
- No backend-specific rewrite.
- No recursion-rule changes in
DefRecursionCheck.