From 3bb2d0369c9c81517854227a2befd7ada5a4fa95 Mon Sep 17 00:00:00 2001 From: Stefan Zeiger Date: Thu, 6 Aug 2026 17:40:48 +0000 Subject: [PATCH] perf: add std.foldl object-merge fast path (avoid O(N^2) super-chain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composing an object inside `std.foldl` — e.g. `std.foldl(function(acc, x) acc { [key(x)]: x }, arr, {})` — builds a `super` chain of depth N. Key-union, field lookup, and materialization each walk that chain, so the fold is O(N^2) in time and transient memory. At large N this is a real blowup (one universe target needed ~12 GB and OOMed the 7 GB RBE cap). When the per-step object literal cannot observe the accumulator, the chain is semantically inert. `StaticOptimizer` now recognizes this shape (through `Builtin.specialize`) and rewrites the call to a fast path that gathers each step's own members into a single map layered over `init` as its only super, which is O(N) overall. `init` is kept intact, so its own super chain and assertions still resolve and fire exactly as under naive evaluation. The accumulator's key union is threaded through the fold in one shared, incrementally-grown map rather than rebuilt per step, so a callback that *reads* the accumulator (e.g. a `std.objectHas(acc, k)` dedup guard) resolves each lookup in O(1) instead of re-gathering the whole chain. Adds `FoldlObjectMergeTests` covering correctness, firing, and non-firing cases. --- sjsonnet/src/sjsonnet/Expr.scala | 6 + sjsonnet/src/sjsonnet/StaticOptimizer.scala | 5 + sjsonnet/src/sjsonnet/Val.scala | 14 +- .../src/sjsonnet/stdlib/ArrayModule.scala | 244 +++++++++++++++ .../src/sjsonnet/FoldlObjectMergeTests.scala | 289 ++++++++++++++++++ 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 sjsonnet/test/src/sjsonnet/FoldlObjectMergeTests.scala diff --git a/sjsonnet/src/sjsonnet/Expr.scala b/sjsonnet/src/sjsonnet/Expr.scala index b3feb5e2..3deea498 100644 --- a/sjsonnet/src/sjsonnet/Expr.scala +++ b/sjsonnet/src/sjsonnet/Expr.scala @@ -405,6 +405,12 @@ object Expr { var staticIdentityShape: Byte = 0 // When `staticIdentityShape == 2`, the absolute scope index of the captured `g`. var staticIdentityCapturedIdx: Int = -1 + + // Absolute ValScope index of this function's first parameter, assigned by StaticOptimizer + // (equal to `scope.size` at the definition site). Remains -1 until set. Lets a `Builtin`'s + // `specialize` recover the scope indices of a lambda's parameters (e.g. the `acc`/element + // params of a `std.foldl` callback) so it can pattern-match the body by index. + var paramBaseIdx: Int = -1 } final case class IfElse(var pos: Position, cond: Expr, `then`: Expr, `else`: Expr) extends Expr { final override private[sjsonnet] def tag = ExprTags.IfElse diff --git a/sjsonnet/src/sjsonnet/StaticOptimizer.scala b/sjsonnet/src/sjsonnet/StaticOptimizer.scala index f34b4881..c5779302 100644 --- a/sjsonnet/src/sjsonnet/StaticOptimizer.scala +++ b/sjsonnet/src/sjsonnet/StaticOptimizer.scala @@ -162,6 +162,11 @@ class StaticOptimizer( // Identity-equivalent function recognition. Cheap pattern match here keeps the runtime // fast path (`Val.Func.isEffectivelyIdentity`) at single-field cost. case f: Function => + // At this point `super.transform` has already processed (and exited) the function body, + // so `scope.size` is the absolute index that was assigned to the function's first + // parameter (see `identifyStaticIdentity`). Record it so builtin `specialize` hooks can + // resolve the parameters by index. + f.paramBaseIdx = scope.size identifyStaticIdentity(f) f diff --git a/sjsonnet/src/sjsonnet/Val.scala b/sjsonnet/src/sjsonnet/Val.scala index 039a441a..126cc339 100644 --- a/sjsonnet/src/sjsonnet/Val.scala +++ b/sjsonnet/src/sjsonnet/Val.scala @@ -2096,6 +2096,13 @@ object Val { value0 } + /** + * This object's own members (excluding any inherited from `super`), as a map. For objects + * backed by inline storage this lazily materializes the map (via [[getValue0]]); the returned + * map must not be mutated by callers. + */ + private[sjsonnet] def getMemberMap: util.LinkedHashMap[String, Obj.Member] = getValue0 + def triggerAllAsserts(brokenAssertionLogic: Boolean): Unit = { // Short-circuit: no asserts in this object or any super if (hasAnyAsserts && !asserting) { @@ -2301,7 +2308,12 @@ object Val { } } - private def getAllKeys = { + /** + * The key union of this object and its entire `super` chain, mapping each key to its + * hidden-ness (`true` if hidden, `false` if visible). Lazily computed and cached; the returned + * map is shared and must not be mutated by callers. + */ + private[sjsonnet] def getAllKeys: util.LinkedHashMap[String, java.lang.Boolean] = { if (allKeys == null) { val allKeys = new util.LinkedHashMap[String, java.lang.Boolean] gatherKeys(allKeys) diff --git a/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala b/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala index eea51c04..c7e94437 100644 --- a/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala @@ -1,6 +1,7 @@ package sjsonnet.stdlib import sjsonnet._ +import sjsonnet.Expr.Member.Visibility import sjsonnet.functions.AbstractFunctionModule import scala.collection.mutable @@ -821,6 +822,249 @@ object ArrayModule extends AbstractFunctionModule { } } + + override def specialize(args: Array[Expr], tailstrict: Boolean): (Val.Builtin, Array[Expr]) = { + if (args.length != 3) return null + args(0) match { + case f: Expr.Function if isObjectMergeFoldl(f) => (FoldlObjectMerge, args) + case _ => null + } + } + } + + // --------------------------------------------------------------------------------------------- + // std.foldl object-merge fast path. + // + // Recognizes the common (and, at scale, pathological) pattern of composing an object inside a + // foldl, e.g. `std.foldl(function(acc, x) acc { [key(x)]: x }, arr, {})`. Evaluated naively this + // builds a `super` chain of depth N: each step allocates an object whose `super` is the previous + // accumulator, and later key-union / lookup / materialization each walk that chain, giving + // O(N^2) time and transient memory (see the `getAllKeys` rebuild in `Val.Obj`). When the per-step + // object literal cannot observe the accumulator, that chain is semantically inert, so we instead + // gather every step's own members into a single map and return one object holding that map over + // `init` as its (depth-1) `super` — O(N) overall. `init` is kept intact, so its own `super` chain + // and assertions still resolve and fire exactly as they would under naive evaluation. + // + // Detection is fully static (in `Foldl.specialize`): the callback must be a 2-parameter literal + // whose body, after stripping `local`/`assert` wrappers and seeing through `if/else`, is a tree + // of leaves each of which is one of: + // - `acc` (a no-op step), + // - `acc { }` (ObjExtend), or + // - `acc + ` (BinaryOp `+`), + // where every "delta" object literal has: no `+:` fields, no method fields, no assertions, no + // `super` reference, and no reference to `acc` (transitively). Transitivity is covered by also + // rejecting any `local` binding (on the spine or inside a delta) whose right-hand side references + // `acc`, so `acc` can reach a delta only through a direct reference, which we reject. `acc` may + // still appear freely in `assert`/`if` conditions, since those are forced immediately rather than + // captured into the result. + // --------------------------------------------------------------------------------------------- + + /** + * Base class for the read-only reference scanners below. Delegates traversal to + * [[ExprTransform.rec]] (which is total over the AST) but intercepts array comprehensions: + * `rec`'s `Comp` case routes the `Array[CompSpec]` through `transformArr`, which fails at runtime + * (`ScopedExprTransform` overrides this case, so in the optimizer it is dead code). We only need + * to visit each child expression, so we recurse into the comprehension's parts directly. + */ + private abstract class ExprRefScanner extends ExprTransform { + var found = false + protected def hit(e: Expr): Boolean + final def transform(e: Expr): Expr = { + if (!found) { + if (hit(e)) found = true + else + e match { + case Expr.Comp(_, value, first, rest) => + transform(value) + transform(first) + var i = 0 + while (i < rest.length) { transform(rest(i)); i += 1 } + case _ => rec(e) + } + } + e + } + } + + /** Scans an expression subtree for a reference to a specific ValScope index. */ + private final class ContainsIdxScanner(idx: Int) extends ExprRefScanner { + protected def hit(e: Expr): Boolean = e match { + case id: Expr.ValidId => id.nameIdx == idx + case _ => false + } + } + + private def containsIdx(e: Expr, idx: Int): Boolean = { + val s = new ContainsIdxScanner(idx) + s.transform(e) + s.found + } + + /** Scans an expression subtree for any `super` reference. */ + private final class ContainsSuperScanner extends ExprRefScanner { + protected def hit(e: Expr): Boolean = e match { + case _: Expr.Super | _: Expr.SelectSuper | _: Expr.LookupSuper | _: Expr.InSuper => true + case _ => false + } + } + + private def containsSuper(e: Expr): Boolean = { + val s = new ContainsSuperScanner + s.transform(e) + s.found + } + + /** True if `e` is a direct reference to the accumulator parameter. */ + private def isAccRef(e: Expr, accIdx: Int): Boolean = e match { + case id: Expr.ValidId => id.nameIdx == accIdx + case _ => false + } + + /** + * True if `delta` (the ext of `acc { delta }` or the rhs of `acc + delta`) is an object literal + * that can neither observe nor capture the accumulator. See the header comment above. + */ + private def isSafeDelta(delta: Expr, accIdx: Int): Boolean = delta match { + // A constant-folded static object has only literal fields with fixed names and default + // visibility, so it references neither `acc` nor `super`. + case o: Val.Obj => o.getSuper == null + case ml: Expr.ObjBody.MemberList => + if (ml.asserts != null && ml.asserts.length > 0) return false + val fields = ml.fields + var i = 0 + while (i < fields.length) { + val f = fields(i) + if (f.plus) return false // `+:` implicitly reads `super` (i.e. `acc`) + if (f.args != null) return false // method field + i += 1 + } + !containsIdx(ml, accIdx) && !containsSuper(ml) + case _ => false // object comprehensions and non-object deltas are not handled + } + + /** + * Walks the "spine" of the callback body — `local`/`assert` wrappers and `if/else` branches — + * requiring that no spine `local` binding references `acc` and that every leaf is a bare `acc`, + * an `acc { lit }` (`ObjExtend`), or an `acc + lit` (`+`) whose `lit` is a safe delta. + */ + private def isLimitedFoldlBody(e: Expr, accIdx: Int): Boolean = e match { + case le: Expr.LocalExpr => + var i = 0 + while (i < le.bindings.length) { + if (containsIdx(le.bindings(i).rhs, accIdx)) return false + i += 1 + } + isLimitedFoldlBody(le.returned, accIdx) + case ae: Expr.AssertExpr => isLimitedFoldlBody(ae.returned, accIdx) + case ie: Expr.IfElse => + // A missing `else` yields null when the condition is false, which is not an object and would + // break the fold, so require both branches to be present and limited. + ie.`else` != null && + isLimitedFoldlBody(ie.`then`, accIdx) && + isLimitedFoldlBody(ie.`else`, accIdx) + case id: Expr.ValidId => id.nameIdx == accIdx // bare `acc` + case oe: Expr.ObjExtend => isAccRef(oe.base, accIdx) && isSafeDelta(oe.ext, accIdx) + case bo: Expr.BinaryOp if bo.op == Expr.BinaryOp.OP_+ => + isAccRef(bo.lhs, accIdx) && isSafeDelta(bo.rhs, accIdx) + case _ => false + } + + private def isObjectMergeFoldl(f: Expr.Function): Boolean = { + val params = f.params + if (params.names.length != 2) return false + val defs = params.defaultExprs + if (defs != null && (defs(0) != null || defs(1) != null)) return false + val base = f.paramBaseIdx + if (base < 0) return false + isLimitedFoldlBody(f.body, base) + } + + /** + * Runtime for the object-merge foldl fast path. Instead of building the depth-N `super` chain + * that naive evaluation would (`init { d0 } { d1 } ...`), it gathers every step's own members + * into a single map and returns one object holding that map over `init` as its (depth-1) super — + * O(N) instead of O(N^2). `init` is preserved as-is (super chain, assertions and all), so the + * fast path makes no assumptions about its shape. + * + * Correctness rests entirely on [[Foldl.specialize]]'s static analysis, which guarantees each + * step is `acc` (a no-op), `acc { lit }` or `acc + lit`, where `lit` cannot observe or capture + * the accumulator. The first form returns `accObj` unchanged; the other two return an object + * whose immediate super is `accObj`, so harvesting its own members yields exactly that step's + * `lit`. + * + * The accumulator's key union is threaded through the fold in one shared map (grown by + * [[foldlMergeKeys]]) rather than rebuilt each step, so a callback that *reads* the accumulator — + * e.g. a dedup guard `assert !std.objectHas(acc, k) ...` that `specialize` strips from the shape + * check but still executes at runtime — resolves each key lookup in O(1). A fresh wrapper per + * step keeps per-step value/key-name caches from going stale as the map grows. + */ + private def objectMergeFoldl( + func: Val.Func, + arr: Val.Arr, + init: Val.Obj, + ev: EvalScope, + pos: Position): Val = { + val direct = arr.directBackingArray + val len = if (direct == null) arr.length else direct.length + if (len == 0) return init + val combined = new java.util.LinkedHashMap[String, Val.Obj.Member]() + // Shared key union, seeded from `init` and grown as members are gathered. Rebuilding it per + // step (as a bare accumulator would when its callback calls `std.objectHas(acc, _)`) is the + // O(N^2) blowup this fast path exists to avoid. + val allKeys = new java.util.LinkedHashMap[String, java.lang.Boolean](init.getAllKeys) + val noOff = pos.noOffset + var i = 0 + while (i < len) { + // A fresh accumulator each step: the members gathered so far, layered over `init`, sharing + // the growing `combined` and `allKeys` maps. Fresh instances start with empty value and + // key-name caches, so those lazily-computed per-step views reflect the accumulator as of this + // step even though the shared maps keep growing. + val accObj = new Val.Obj(pos, combined, false, null, init, null, allKeys) + val elem = if (direct == null) arr.eval(i) else direct(i) + val r = func.apply2(accObj, elem, noOff)(ev, TailstrictModeDisabled) + if (r ne accObj) { + val delta = r.asObj.getMemberMap + combined.putAll(delta) + foldlMergeKeys(allKeys, delta) + } + i += 1 + } + new Val.Obj(pos, combined, false, null, init, null, allKeys) + } + + /** + * Grow a foldl accumulator's shared key-union map with one step's own members (`delta`), applying + * the same visibility-merge rules `Val.Obj` uses when gathering keys, so the shared map stays + * identical to a full re-gather over `init { d0 } { d1 } ...`. Keeps the fast path's per-step key + * union O(|delta|) rather than O(depth). + */ + private def foldlMergeKeys( + allKeys: java.util.LinkedHashMap[String, java.lang.Boolean], + delta: java.util.LinkedHashMap[String, Val.Obj.Member]): Unit = { + delta.forEach { (k, m) => + val vis = m.visibility + if (!allKeys.containsKey(k)) allKeys.put(k, vis == Visibility.Hidden) + else if (vis == Visibility.Hidden) allKeys.put(k, java.lang.Boolean.TRUE) + else if (vis == Visibility.Unhide) allKeys.put(k, java.lang.Boolean.FALSE) + } + } + + /** + * Specialized `std.foldl` emitted by [[Foldl.specialize]] for the statically recognized + * object-merge pattern. `arr` and `init` are arbitrary runtime values the pattern does not + * constrain (it only inspects the callback), so anything other than an array folded onto an + * object is left to the generic [[Foldl]], which also raises the correct error for bad inputs. + */ + private object FoldlObjectMerge extends Val.Builtin3("foldl", "func", "arr", "init") { + override def staticSafe: Boolean = false + + def evalRhs(_func: Eval, arr: Eval, init: Eval, ev: EvalScope, pos: Position): Val = { + val func = _func.value.asFunc + (arr.value, init.value) match { + case (a: Val.Arr, io: Val.Obj) => objectMergeFoldl(func, a, io, ev, pos) + case _ => Foldl.evalRhs(_func, arr, init, ev, pos) + } + } } /** diff --git a/sjsonnet/test/src/sjsonnet/FoldlObjectMergeTests.scala b/sjsonnet/test/src/sjsonnet/FoldlObjectMergeTests.scala new file mode 100644 index 00000000..4a707de2 --- /dev/null +++ b/sjsonnet/test/src/sjsonnet/FoldlObjectMergeTests.scala @@ -0,0 +1,289 @@ +package sjsonnet + +import utest._ +import TestUtils.{eval, evalErr} + +/** + * Tests for the `std.foldl` object-merge fast path (see `ArrayModule.FoldlObjectMerge`). + * + * Two things are checked: + * - Correctness: the optimized result must equal the standard object-composition semantics for + * the whole range of tricky cases (dedup, key overrides, non-empty init, `self` late-binding, + * `if/else` spines, asserts that read the accumulator, an `init` with its own super chain or + * assertions, ...). + * - Firing: the optimizer must actually emit the specialized builtin for the recognized patterns + * and must NOT emit it for patterns where in-place merging would be unsound (a reference to the + * accumulator inside a delta — direct or transitive, `super`/`+:`, an assert inside the delta, + * or a non-literal callback). Those still evaluate correctly via the generic fallback. + */ +object FoldlObjectMergeTests extends TestSuite { + + private val interp = new Interpreter( + Map(), + Map(), + DummyPath(), + Importer.empty, + parseCache = new DefaultParseCache, + Settings.default, + std = sjsonnet.stdlib.StdLibModule.Default.module + ) + + /** + * Records whether the optimized AST contains a call to the specialized object-merge foldl. + * Intercepts array comprehensions because `rec`'s `Comp` case throws at runtime (it is dead code + * in the real optimizer, which overrides it). + */ + private final class FoldlMergeScanner extends ExprTransform { + var found = false + def transform(e: Expr): Expr = { + e match { + case Expr.ApplyBuiltin3(_, func, _, _, _, _) + if func.getClass.getName.contains("FoldlObjectMerge") => + found = true + case Expr.Comp(_, value, first, rest) => + transform(value) + transform(first) + var i = 0 + while (i < rest.length) { transform(rest(i)); i += 1 } + case _ => rec(e) + } + e + } + } + + private def fires(code: String): Boolean = { + val expr = interp.resolver + .parse(DummyPath("(memory)"), StaticResolvedFile(code))(interp.evaluator) + .fold(e => throw new Exception(e.toString), _._1) + val s = new FoldlMergeScanner + s.transform(expr) + s.found + } + + def tests: Tests = Tests { + + // ------------------------------------------------------------------------- + // Correctness of the recognized (fast-path) patterns + // ------------------------------------------------------------------------- + test("correctness") { + test("basicMerge") { + eval("std.foldl(function(acc, t) acc { [t.k]: t.v }, " + + "[{k:'a',v:1},{k:'b',v:2}], {})") ==> ujson.Obj("a" -> 1, "b" -> 2) + } + test("lastWriteWins") { + eval("std.foldl(function(acc, t) acc { [t.k]: t.v }, " + + "[{k:'a',v:1},{k:'a',v:9},{k:'b',v:2}], {})") ==> ujson.Obj("a" -> 9, "b" -> 2) + } + test("nonEmptyInit") { + eval("std.foldl(function(acc, t) acc { [t]: true }, ['a','b'], {seed: 0})") ==> + ujson.Obj("seed" -> 0, "a" -> true, "b" -> true) + } + test("initKeyOverridden") { + eval("std.foldl(function(acc, t) acc { [t.k]: t.v }, [{k:'x',v:5}], {x: 0})") ==> + ujson.Obj("x" -> 5) + } + test("emptyArrayReturnsInit") { + eval("std.foldl(function(acc, t) acc { [t]: true }, [], {x: 1})") ==> ujson.Obj("x" -> 1) + } + test("singleElement") { + eval("std.foldl(function(acc, t) acc { [t]: t }, ['a'], {})") ==> ujson.Obj("a" -> "a") + } + test("plusOperatorForm") { + eval("std.foldl(function(acc, t) acc + { [t]: t }, ['x','y'], {})") ==> + ujson.Obj("x" -> "x", "y" -> "y") + } + test("localBoundToElement") { + // A `local` whose rhs depends on the element (not the accumulator) is fine. + eval("std.foldl(function(acc, t) local k = t.id; acc { [k]: t.val }, " + + "[{id:'a',val:1},{id:'b',val:2}], {})") ==> ujson.Obj("a" -> 1, "b" -> 2) + } + test("nestedObjectValue") { + eval("std.foldl(function(acc, t) acc { [t.k]: { nested: t.v } }, [{k:'a',v:1}], {})") ==> + ujson.Obj("a" -> ujson.Obj("nested" -> 1)) + } + test("deltaWithArrayComprehension") { + // A delta value containing an array comprehension (exercises the comprehension traversal + // in the static reference scan). + eval("std.foldl(function(acc, t) acc { [t.k]: [x * 2 for x in t.vs] }, " + + "[{k:'a',vs:[1,2]},{k:'b',vs:[3]}], {})") ==> + ujson.Obj("a" -> ujson.Arr(2, 4), "b" -> ujson.Arr(6)) + } + test("deltaWithObjectComprehension") { + eval("std.foldl(function(acc, t) acc { [t.k]: { [f]: true for f in t.fs } }, " + + "[{k:'a',fs:['x','y']}], {})") ==> + ujson.Obj("a" -> ujson.Obj("x" -> true, "y" -> true)) + } + test("ifElseSkipBranch") { + eval("std.foldl(function(acc, t) if t.skip then acc else acc { [t.k]: t.v }, " + + "[{k:'a',v:1,skip:false},{k:'b',v:2,skip:true},{k:'c',v:3,skip:false}], {})") ==> + ujson.Obj("a" -> 1, "c" -> 3) + } + test("selfLateBinding") { + // `self` in a delta must late-bind to the final merged object, exactly as the standard + // super-chain semantics require. Here `latest` is overwritten each step; the surviving one + // reads `self['b']` against the fully merged object. + eval("std.foldl(function(acc, t) acc { [t.k]: t.v, latest: self[t.k] }, " + + "[{k:'a',v:1},{k:'b',v:2}], {})") ==> + ujson.Obj("a" -> 1, "b" -> 2, "latest" -> 2) + } + test("dedupAssertReadsAccumulator") { + // The canonical dedup pattern: the assert reads the accumulator, then the delta adds a key. + eval("std.foldl(" + + "function(acc, t) assert !std.objectHas(acc, t.k) || acc[t.k] == t.v : 'dup'; " + + "acc { [t.k]: t.v }, [{k:'a',v:1},{k:'b',v:2},{k:'a',v:1}], {})") ==> + ujson.Obj("a" -> 1, "b" -> 2) + } + test("dedupAssertStillFires") { + // A genuine conflict must still raise the callback's assertion under the fast path. + val err = evalErr("std.foldl(" + + "function(acc, t) assert !std.objectHas(acc, t.k) || acc[t.k] == t.v : 'dup'; " + + "acc { [t.k]: t.v }, [{k:'a',v:1},{k:'a',v:2}], {})") + assert(err.contains("dup")) + } + test("assertReadsAccKeyCount") { + // The stripped assert reads the accumulator's *key set* every step (not just objectHas): + // `std.objectFields(acc)` hits the per-step key-name array. A fresh wrapper per step keeps + // that array correct as the shared key union grows -- reusing one accumulator would freeze + // the lazy `visibleKeyNames` at its first mid-fold read and make later steps observe stale + // keys. + eval("std.foldl(" + + "function(acc, t) assert std.length(std.objectFields(acc)) == t - 1 : 'len'; " + + "acc { [std.toString(t)]: t }, std.range(1, 5), {})") ==> + ujson.Obj("1" -> 1, "2" -> 2, "3" -> 3, "4" -> 4, "5" -> 5) + } + test("hiddenFieldDelta") { + // A hidden (`::`) delta field is folded into the shared key union with the right + // visibility: excluded from objectFields/materialization but present via objectFieldsAll. + eval("std.foldl(function(acc, t) acc { [t]:: t }, ['a','b'], {c: 1})") ==> ujson.Obj("c" -> 1) + eval("std.objectFieldsAll(" + + "std.foldl(function(acc, t) acc { [t]:: t }, ['a','b'], {c: 1}))") ==> + ujson.Arr("a", "b", "c") + } + test("initWithSuperChain") { + // `init` keeps its own `super` chain: it becomes the accumulator's super rather than being + // flattened, so all of its keys still show through. + eval("std.foldl(function(acc, t) acc { [t]: t }, ['x'], {a: 1} + {b: 2})") ==> + ujson.Obj("a" -> 1, "b" -> 2, "x" -> "x") + } + test("plusFormInitWithSuperChain") { + eval("std.foldl(function(acc, t) acc + { [t]: t }, ['x', 'y'], {base: 0} + {seed: 1})") ==> + ujson.Obj("base" -> 0, "seed" -> 1, "x" -> "x", "y" -> "y") + } + test("initWithPassingAssert") { + // `init`'s assertions still fire against the merged object and pass when satisfied. + eval("std.foldl(function(acc, t) acc { [t]: t }, ['x'], {assert self.a == 1, a: 1})") ==> + ujson.Obj("a" -> 1, "x" -> "x") + } + test("initWithFailingAssert") { + // An `init` assertion that fails must still raise, exactly as in naive evaluation. + val err = evalErr( + "std.foldl(function(acc, t) acc { [t]: t }, ['x'], {assert self.a == 2 : 'boom', a: 1})" + ) + assert(err.contains("boom")) + } + test("initAssertObservesMergedField") { + // `init`'s assert late-binds `self` to the fully merged object: a delta that overrides the + // asserted key is observed by the assert, exactly as under naive super-chain evaluation. + val err = evalErr( + "std.foldl(function(acc, t) acc { a: 2 }, ['x'], {assert self.a == 1 : 'changed', a: 1})" + ) + assert(err.contains("changed")) + } + test("nonObjectInitEmptyArray") { + // Non-object `init` is left to the generic foldl; an empty array returns `init` unchanged. + eval("std.foldl(function(acc, t) acc { [t]: t }, [], 5)") ==> ujson.Num(5) + } + } + + // ------------------------------------------------------------------------- + // The optimizer emits the specialized builtin for recognized patterns + // ------------------------------------------------------------------------- + test("fires") { + test("basicMerge")(assert(fires("std.foldl(function(acc, t) acc { [t.k]: t.v }, [], {})"))) + test("plusForm")(assert(fires("std.foldl(function(acc, t) acc + { [t]: t }, [], {})"))) + test("bareAccAndMerge")( + assert(fires("std.foldl(function(acc, t) if t then acc else acc { x: 1 }, [], {})")) + ) + test("localOnElement")( + assert(fires("std.foldl(function(acc, t) local k = t.id; acc { [k]: t }, [], {})")) + ) + test("assertReadsAcc")( + assert( + fires( + "std.foldl(function(acc, t) assert !std.objectHas(acc, t) : 'e'; acc { [t]: 1 }, [], {})" + ) + ) + ) + test("assertReadsAccKeys")( + assert( + fires( + "std.foldl(function(acc, t) assert std.length(std.objectFields(acc)) == 0; " + + "acc { [t]: 1 }, [], {})" + ) + ) + ) + test("hiddenFieldDelta")( + assert(fires("std.foldl(function(acc, t) acc { [t]:: t }, [], {})")) + ) + test("selfInDelta")( + assert(fires("std.foldl(function(acc, t) acc { [t]: self.x }, [], {x: 1})")) + ) + test("comprehensionOnElement")( + assert(fires("std.foldl(function(acc, t) acc { [t.k]: [x for x in t.vs] }, [], {})")) + ) + // Firing depends only on the callback shape, so it fires regardless of `init`'s shape. + test("initWithSuperChain")( + assert(fires("std.foldl(function(acc, t) acc { [t]: t }, [], {a: 1} + {b: 2})")) + ) + test("initWithAssert")( + assert(fires("std.foldl(function(acc, t) acc { [t]: t }, [], {assert self.a == 1, a: 1})")) + ) + } + + // ------------------------------------------------------------------------- + // The optimizer does NOT fire where in-place merging would be unsound; the + // generic fallback must still produce the correct result. + // ------------------------------------------------------------------------- + test("doesNotFire") { + test("accInDeltaValue") { + val code = "std.foldl(function(acc, t) " + + "acc { total: (if std.objectHas(acc,'total') then acc.total else 0) + t }, [1,2,3], {})" + assert(!fires(code)) + eval(code) ==> ujson.Obj("total" -> 6) + } + test("transitiveAccAlias") { + val code = "std.foldl(function(acc, t) local m = acc; acc { seen: m.start }, ['a'], " + + "{start: 5})" + assert(!fires(code)) + eval(code) ==> ujson.Obj("start" -> 5, "seen" -> 5) + } + test("plusMergeField") { + val code = "std.foldl(function(acc, t) acc { vals+: [t] }, [1,2], {vals: []})" + assert(!fires(code)) + eval(code) ==> ujson.Obj("vals" -> ujson.Arr(1, 2)) + } + test("explicitSuperRef") { + val code = "std.foldl(function(acc, t) acc { first: super.a }, ['x'], {a: 1})" + assert(!fires(code)) + eval(code) ==> ujson.Obj("a" -> 1, "first" -> 1) + } + test("assertInsideDelta") { + val code = "std.foldl(function(acc, t) acc { assert t > 0, [std.toString(t)]: t }, [1,2], {})" + assert(!fires(code)) + eval(code) ==> ujson.Obj("1" -> 1, "2" -> 2) + } + test("nonLiteralCallback") { + val code = "local f = function(acc, t) acc { [t]: true }; std.foldl(f, ['a'], {})" + assert(!fires(code)) + eval(code) ==> ujson.Obj("a" -> true) + } + test("comprehensionReadsAccumulator") { + // A comprehension inside a delta that iterates over the accumulator must be rejected. + val code = "std.foldl(function(acc, t) acc { [t]: [k for k in std.objectFields(acc)] }, " + + "['a','b'], {})" + assert(!fires(code)) + eval(code) ==> ujson.Obj("a" -> ujson.Arr(), "b" -> ujson.Arr("a")) + } + } + } +}