Skip to content

Unnest: don't hoist sub-expressions out of inner LAMBDA/LET scope #278

Description

@jimmytacks

Problem

/Unnest parses an inner LAMBDA(...) as an ordinary function call and descends into its body, hoisting nested sub-expressions up into the top-level LET. Any step that references a LAMBDA parameter then escapes the scope where that parameter is bound, producing a broken formula.

Example

=ROUND(MIN(BYROW(HSTACK(IFS(SEQUENCE(6), "Vienna"), PERMUTATIONS(XLOOKUP(G141:I141,t[Concat], t[City]),3)), LAMBDA(r, SUM(PAIROP(r, LAMBDA(a,b, SQRT(SUMSQ(PAIROP(XV(VSTACK(a,b), t[[City]:[Y-Coordinates]], {2,3}),,1)))*100),,1)))))+45,)

/Unnest pulls VSTACK(a, b) (and other inner nodes) out into its own top-level step, but a and b are only bound inside LAMBDA(a, b, …). Hoisted above the lambda they are unbound, so the synthesised LET does not evaluate.

The same bug class applies to a nested LET(...) used as a sub-expression (e.g. =SUM(LET(x, A1, x+1))) — LET and LAMBDA are the two name-binding constructs and both introduce scope the hoister must respect. (Top-level LET is already refused, so only nested LETs reach this path.)

Fix (Option 1 — opaque LAMBDA/LET)

Treat a LAMBDA(...) (and a nested LET(...)) as opaque in the decomposition engine: never descend into its body and never extract it as a step — it stays inline in its parent's RHS exactly as written. Everything outside lambdas decomposes as before.

For the example above, the outer nest (ROUND / MIN / BYROW / HSTACK / IFS / SEQUENCE / PERMUTATIONS / XLOOKUP, and +45) still decomposes; the two lambdas stay intact.

This is the minimal correctness fix — right now /Unnest silently emits broken output whenever a lambda is present. Step-level decomposition inside lambdas (via a nested LET in the lambda body) is tracked separately as a future enhancement.

Acceptance criteria

  • The decomposition engine never extracts a step from inside a LAMBDA(...) body, and never emits a LAMBDA(...) node as a step — it stays inline in its parent.
  • A nested LET(...) sub-expression is treated the same way (opaque; not descended into).
  • A formula containing an inner lambda decomposes its non-lambda structure and round-trips cleanly through LetParser; no step references a lambda parameter.
  • Unit tests cover: inner-lambda formula (lambda kept intact, outer nodes still stepped), a closure that references an enclosing param (still inline), and a nested-LET sub-expression.

Engine-level change (originally #271 territory), independent of the dialog (#272).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions