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")) + } + } + } +}