From 5c86dafe13db7fc8401cf7bca20b02f8273dc7a1 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Mon, 27 Apr 2026 03:01:32 +0800 Subject: [PATCH] fix: Align stdlib 1:1 edge semantics Motivation: Official Jsonnet std.jsonnet defines several edge-case stdlib behaviors that sjsonnet native implementations diverged from. The audit should keep hot paths native while making observable semantics match Jsonnet 0.22.0 where the behavior is clear and low risk. Modification: Add official-style comparison helpers, fix minArray/maxArray default sentinels and comparison errors, align mapWithIndex/flatten/removeAt/repeat/string escaping/isEmpty/resolvePath behavior, and preserve explicit keyF=false semantics in set helpers. Add directional compatibility coverage for the audited cases and update the existing isEmpty golden. Result: The new direction tests, full JVM test suite, format check, and whitespace check pass against this branch. --- sjsonnet/src/sjsonnet/Util.scala | 33 ++++++ .../src/sjsonnet/stdlib/ArrayModule.scala | 91 +++++++++------ sjsonnet/src/sjsonnet/stdlib/SetModule.scala | 32 ++--- .../src/sjsonnet/stdlib/StringModule.scala | 53 +++++++-- sjsonnet/src/sjsonnet/stdlib/TypeModule.scala | 27 +++++ .../builtinIsEmpty2.jsonnet.golden | 3 +- .../src/sjsonnet/Std0150FunctionsTests.scala | 2 +- .../StdLibOfficialCompatibilityTests.scala | 110 ++++++++++++++++++ 8 files changed, 288 insertions(+), 63 deletions(-) create mode 100644 sjsonnet/test/src/sjsonnet/StdLibOfficialCompatibilityTests.scala diff --git a/sjsonnet/src/sjsonnet/Util.scala b/sjsonnet/src/sjsonnet/Util.scala index f55c8e0cc..d79c8e71e 100644 --- a/sjsonnet/src/sjsonnet/Util.scala +++ b/sjsonnet/src/sjsonnet/Util.scala @@ -173,6 +173,39 @@ object Util { override def compare(x: String, y: String): Int = compareStringsByCodepoint(x, y) } + def compareJsonnetStd(v1: Val, v2: Val, ev: EvalScope): Int = { + val t1 = v1.prettyName + val t2 = v2.prettyName + if (t1 != t2) { + Error.fail("Comparison requires matching types. Got " + t1 + " and " + t2) + } + + v1 match { + case arr1: Val.Arr => + compareJsonnetStdArrays(arr1, v2.asInstanceOf[Val.Arr], ev) + case _: Val.Func | _: Val.Obj | _: Val.Bool => + Error.fail("Values of type " + t1 + " are not comparable.") + case _: Val.Null => + Error.fail("binary operator < does not operate on null.") + case _ => + val cmp = ev.compare(v1, v2) + if (cmp < 0) -1 else if (cmp > 0) 1 else 0 + } + } + + def compareJsonnetStdArrays(arr1: Val.Arr, arr2: Val.Arr, ev: EvalScope): Int = { + val len1 = arr1.length + val len2 = arr2.length + val minLen = math.min(len1, len2) + var i = 0 + while (i < minLen) { + val cmp = compareJsonnetStd(arr1.eval(i).value, arr2.eval(i).value, ev) + if (cmp != 0) return cmp + i += 1 + } + java.lang.Integer.compare(len1, len2) + } + val isWindows: Boolean = { // This is normally non-null on the JVM, but it might be null in ScalaJS hence the Option: Option(System.getProperty("os.name")).exists(_.toLowerCase.startsWith("windows")) diff --git a/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala b/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala index 51780b1bc..726ade9ac 100644 --- a/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala @@ -8,34 +8,43 @@ import scala.collection.mutable object ArrayModule extends AbstractFunctionModule { def name = "array" + private val DefaultKeyF = Val.Null(dummyPos) + private val DefaultOnEmpty = Val.Null(dummyPos) + + @inline private def isDefaultKeyF(v: Val): Boolean = v.asInstanceOf[AnyRef] eq DefaultKeyF + @inline private def isDefaultOnEmpty(v: Val): Boolean = + v.asInstanceOf[AnyRef] eq DefaultOnEmpty + + private def applyArrayKey(keyF: Val, elem: Val, pos: Position, ev: EvalScope): Val = + if (isDefaultKeyF(keyF)) elem + else keyF.asFunc.apply1(elem, pos.noOffset)(ev, TailstrictModeDisabled) + private object MinArray extends Val.Builtin( "minArray", Array("arr", "keyF", "onEmpty"), - Array(null, Val.False(dummyPos), Val.False(dummyPos)) + Array(null, DefaultKeyF, DefaultOnEmpty) ) { override def evalRhs(args: Array[? <: Eval], ev: EvalScope, pos: Position): Val = { val arr = args(0).value.asArr val keyF = args(1).value - val onEmpty = args(2) + val onEmpty = args(2).value if (arr.length == 0) { - if (onEmpty.value.isInstanceOf[Val.False]) { + if (isDefaultOnEmpty(onEmpty)) { Error.fail("Expected at least one element in array. Got none") } else { - onEmpty.value + onEmpty } - } else if (keyF.isInstanceOf[Val.False]) { - arr.asStrictArray.min(ev) } else { val strict = arr.asStrictArray - val func = keyF.asInstanceOf[Val.Func] var bestIdx = 0 - var bestVal = func.apply1(strict(0), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled) + var bestKey = applyArrayKey(keyF, strict(0), pos, ev) + Util.compareJsonnetStd(bestKey, bestKey, ev) var i = 1 while (i < strict.length) { - val v = func.apply1(strict(i), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled) - if (ev.compare(v, bestVal) < 0) { - bestVal = v + val v = applyArrayKey(keyF, strict(i), pos, ev) + if (Util.compareJsonnetStd(v, bestKey, ev) < 0) { + bestKey = v bestIdx = i } i += 1 @@ -49,30 +58,28 @@ object ArrayModule extends AbstractFunctionModule { extends Val.Builtin( "maxArray", Array("arr", "keyF", "onEmpty"), - Array(null, Val.False(dummyPos), Val.False(dummyPos)) + Array(null, DefaultKeyF, DefaultOnEmpty) ) { override def evalRhs(args: Array[? <: Eval], ev: EvalScope, pos: Position): Val = { val arr = args(0).value.asArr val keyF = args(1).value - val onEmpty = args(2) + val onEmpty = args(2).value if (arr.length == 0) { - if (onEmpty.value.isInstanceOf[Val.False]) { + if (isDefaultOnEmpty(onEmpty)) { Error.fail("Expected at least one element in array. Got none") } else { - onEmpty.value + onEmpty } - } else if (keyF.isInstanceOf[Val.False]) { - arr.asStrictArray.max(ev) } else { val strict = arr.asStrictArray - val func = keyF.asInstanceOf[Val.Func] var bestIdx = 0 - var bestVal = func.apply1(strict(0), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled) + var bestKey = applyArrayKey(keyF, strict(0), pos, ev) + Util.compareJsonnetStd(bestKey, bestKey, ev) var i = 1 while (i < strict.length) { - val v = func.apply1(strict(i), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled) - if (ev.compare(v, bestVal) > 0) { - bestVal = v + val v = applyArrayKey(keyF, strict(i), pos, ev) + if (Util.compareJsonnetStd(v, bestKey, ev) > 0) { + bestKey = v bestIdx = i } i += 1 @@ -205,7 +212,10 @@ object ArrayModule extends AbstractFunctionModule { private object MapWithIndex extends Val.Builtin2("mapWithIndex", "func", "arr") { def evalRhs(_func: Eval, _arr: Eval, ev: EvalScope, pos: Position): Val = { val func = _func.value.asFunc - val arr = _arr.value.asArr.asLazyArray + val arr = _arr.value match { + case Val.Str(_, str) => stringChars(pos, str).asLazyArray + case v => v.asArr.asLazyArray + } val a = new Array[Eval](arr.length) val noOff = pos.noOffset var i = 0 @@ -243,9 +253,9 @@ object ArrayModule extends AbstractFunctionModule { out.sizeHint(arr.length * 4) // Rough size hint for (x <- arr) { x.value match { - case Val.Null(_) => // do nothing - case v: Val.Arr => out ++= v.asLazyArray - case x => Error.fail("Cannot call flattenArrays on " + x) + case v: Val.Arr => out ++= v.asLazyArray + case x => + Error.fail("binary operator + requires matching types, got array and " + x.prettyName) } } Val.Arr(pos, out.result()) @@ -254,7 +264,11 @@ object ArrayModule extends AbstractFunctionModule { private object FlattenDeepArrays extends Val.Builtin1("flattenDeepArray", "value") { def evalRhs(value: Eval, ev: EvalScope, pos: Position): Val = { - val lazyArray = value.value.asArr.asLazyArray + val value0 = value.value + if (!value0.isInstanceOf[Val.Arr]) { + return Val.Arr(pos, Array[Eval](value0)) + } + val lazyArray = value0.asInstanceOf[Val.Arr].asLazyArray val out = new mutable.ArrayBuilder.ofRef[Eval] out.sizeHint(lazyArray.length) val q = new java.util.ArrayDeque[Eval](lazyArray.length) @@ -653,6 +667,9 @@ object ArrayModule extends AbstractFunctionModule { Val.Arr(pos, b.result()) }, builtin("repeat", "what", "count") { (pos, ev, what: Val, count: Int) => + if (count < 0) { + Error.fail("makeArray requires size >= 0, got " + count) + } val res: Val = what match { case Val.Str(_, str) => val builder = new StringBuilder(str.length * count) @@ -726,14 +743,20 @@ object ArrayModule extends AbstractFunctionModule { ) } }, - builtin("removeAt", "arr", "idx") { (_, _, arr: Val.Arr, idx: Int) => - if (!(0 <= idx && idx < arr.length)) { - Error.fail("index out of bounds: 0 <= " + idx + " < " + arr.length) + builtin("removeAt", "arr", "idx") { (_, _, arr: Val.Arr, idx: Val) => + val removeIdx = idx match { + case n: Val.Num => + val d = n.asDouble + if (d.isWhole && d >= 0 && d < arr.length) d.toInt else -1 + case _ => -1 + } + if (removeIdx == -1) arr + else { + Val.Arr( + arr.pos, + arr.asLazyArray.slice(0, removeIdx) ++ arr.asLazyArray.slice(removeIdx + 1, arr.length) + ) } - Val.Arr( - arr.pos, - arr.asLazyArray.slice(0, idx) ++ arr.asLazyArray.slice(idx + 1, arr.length) - ) }, builtin("sum", "arr") { (_, _, arr: Val.Arr) => val a = arr.asLazyArray diff --git a/sjsonnet/src/sjsonnet/stdlib/SetModule.scala b/sjsonnet/src/sjsonnet/stdlib/SetModule.scala index e78b9fe7f..bbba12dd5 100644 --- a/sjsonnet/src/sjsonnet/stdlib/SetModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/SetModule.scala @@ -9,18 +9,19 @@ import scala.collection.Searching.* object SetModule extends AbstractFunctionModule { def name = "set" - private object Set_ extends Val.Builtin2("set", "arr", "keyF", Array(null, Val.False(dummyPos))) { + private val DefaultKeyF = Val.Null(dummyPos) + + @inline private def isDefaultKeyF(v: Val): Boolean = v.asInstanceOf[AnyRef] eq DefaultKeyF + + private object Set_ extends Val.Builtin2("set", "arr", "keyF", Array(null, DefaultKeyF)) { def evalRhs(arr: Eval, keyF: Eval, ev: EvalScope, pos: Position): Val = { uniqArr(pos, ev, sortArr(pos, ev, arr.value, keyF.value), keyF.value) } } private def applyKeyFunc(elem: Val, keyF: Val, pos: Position, ev: EvalScope): Val = { - keyF match { - case keyFFunc: Val.Func => - keyFFunc.apply1(elem, pos.noOffset)(ev, TailstrictModeDisabled).value - case _ => elem - } + if (isDefaultKeyF(keyF)) elem + else keyF.asFunc.apply1(elem, pos.noOffset)(ev, TailstrictModeDisabled).value } private def toArrOrString(arg: Val, pos: Position, ev: EvalScope) = { @@ -46,6 +47,7 @@ object SetModule extends AbstractFunctionModule { keyF: Val, arr: mutable.IndexedSeq[? <: Eval], toFind: Val): Boolean = { + if (arr.isEmpty) return false val appliedX = applyKeyFunc(toFind, keyF, pos, ev) arr .search(appliedX)((toFind: Eval, value: Eval) => { @@ -70,8 +72,8 @@ object SetModule extends AbstractFunctionModule { while (i < arrValue.length) { val v = arrValue(i) val vKey = - if (keyF.isInstanceOf[Val.False]) v.value - else keyF.asInstanceOf[Val.Func].apply1(v, pos.noOffset)(ev, TailstrictModeDisabled) + if (isDefaultKeyF(keyF)) v.value + else keyF.asFunc.apply1(v, pos.noOffset)(ev, TailstrictModeDisabled) if (lastAddedKey == null || !ev.equal(vKey, lastAddedKey)) { out.+=(v) lastAddedKey = vKey @@ -88,7 +90,7 @@ object SetModule extends AbstractFunctionModule { arr } else { val keyFFunc = - if (keyF == null || keyF.isInstanceOf[Val.False]) null else keyF.asInstanceOf[Val.Func] + if (keyF == null || isDefaultKeyF(keyF)) null else keyF.asFunc Val.Arr( pos, if (keyFFunc != null) { @@ -211,13 +213,13 @@ object SetModule extends AbstractFunctionModule { (pos, ev, indexable: Val, index: Option[Int], _end: Option[Int], _step: Option[Int]) => Util.slice(pos, ev, indexable, index, _end, _step) }, - builtinWithDefaults("uniq", "arr" -> null, "keyF" -> Val.False(dummyPos)) { (args, pos, ev) => + builtinWithDefaults("uniq", "arr" -> null, "keyF" -> DefaultKeyF) { (args, pos, ev) => uniqArr(pos, ev, args(0), args(1)) }, - builtinWithDefaults("sort", "arr" -> null, "keyF" -> Val.False(dummyPos)) { (args, pos, ev) => + builtinWithDefaults("sort", "arr" -> null, "keyF" -> DefaultKeyF) { (args, pos, ev) => sortArr(pos, ev, args(0), args(1)) }, - builtinWithDefaults("setUnion", "a" -> null, "b" -> null, "keyF" -> Val.False(dummyPos)) { + builtinWithDefaults("setUnion", "a" -> null, "b" -> null, "keyF" -> DefaultKeyF) { (args, pos, ev) => val keyF = args(2) validateSet(ev, pos, keyF, args(0)) @@ -274,7 +276,7 @@ object SetModule extends AbstractFunctionModule { Val.Arr(pos, out.result()) } }, - builtinWithDefaults("setInter", "a" -> null, "b" -> null, "keyF" -> Val.False(dummyPos)) { + builtinWithDefaults("setInter", "a" -> null, "b" -> null, "keyF" -> DefaultKeyF) { (args, pos, ev) => val keyF = args(2) validateSet(ev, pos, keyF, args(0)) @@ -314,7 +316,7 @@ object SetModule extends AbstractFunctionModule { Val.Arr(pos, out.result()) }, - builtinWithDefaults("setDiff", "a" -> null, "b" -> null, "keyF" -> Val.False(dummyPos)) { + builtinWithDefaults("setDiff", "a" -> null, "b" -> null, "keyF" -> DefaultKeyF) { (args, pos, ev) => val keyF = args(2) validateSet(ev, pos, keyF, args(0)) @@ -362,7 +364,7 @@ object SetModule extends AbstractFunctionModule { Val.Arr(pos, out.result()) }, - builtinWithDefaults("setMember", "x" -> null, "arr" -> null, "keyF" -> Val.False(dummyPos)) { + builtinWithDefaults("setMember", "x" -> null, "arr" -> null, "keyF" -> DefaultKeyF) { (args, pos, ev) => val keyF = args(2) validateSet(ev, pos, keyF, args(1)) diff --git a/sjsonnet/src/sjsonnet/stdlib/StringModule.scala b/sjsonnet/src/sjsonnet/stdlib/StringModule.scala index f4a7c9762..c87d3b54c 100644 --- a/sjsonnet/src/sjsonnet/stdlib/StringModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/StringModule.scala @@ -431,6 +431,12 @@ object StringModule extends AbstractFunctionModule { } } + private def stdToString(v: Val)(implicit ev: EvalScope): String = + v match { + case Val.Str(_, s) => s + case other => Materializer.stringify(other)(ev) + } + private def stringChars(pos: Position, str: String): Val.Arr = { val chars = new Array[Eval](str.codePointCount(0, str.length)) var charIndex = 0 @@ -460,6 +466,23 @@ object StringModule extends AbstractFunctionModule { builtin(Split), builtin(SplitLimit), builtin(SplitLimitR), + builtin("resolvePath", "f", "r") { (_, _, f: String, r: String) => + val parts = f.split("/", -1) + val prefixCount = parts.length - 1 + if (prefixCount <= 0) r + else { + val out = new java.lang.StringBuilder(f.length + r.length) + var i = 0 + while (i < prefixCount) { + if (i > 0) out.append('/') + out.append(parts(i)) + i += 1 + } + out.append('/') + out.append(r) + out.toString + } + }, builtin(StringChars), builtin(ParseInt), builtin(ParseOctal), @@ -493,8 +516,15 @@ object StringModule extends AbstractFunctionModule { } } }, - builtin("isEmpty", "str") { (_, _, str: String) => - str.isEmpty + builtin("isEmpty", "str") { (_, _, value: Val) => + value match { + case Val.Str(_, s) => s.isEmpty + case a: Val.Arr => a.length == 0 + case o: Val.Obj => o.visibleKeyNames.isEmpty + case f: Val.Func => f.params.names.isEmpty + case x => + Error.fail("length operates on strings, objects, and arrays, got " + x.prettyName) + } }, builtin("trim", "str") { (_, _, str: String) => StripUtils.unspecializedStrip(str, whiteSpaces, true, true) @@ -511,16 +541,17 @@ object StringModule extends AbstractFunctionModule { out.toString } }, - builtin("escapeStringPython", "str") { (pos, ev, str: String) => + builtin("escapeStringPython", "str") { (pos, ev, str: Val) => val out = new java.io.StringWriter() - BaseRenderer.escape(out, str, unicode = true) + BaseRenderer.escape(out, stdToString(str)(ev), unicode = true) out.toString }, - builtin("escapeStringXML", "str") { (_, _, str: String) => + builtin("escapeStringXML", "str") { (_, ev, str: Val) => + val string = stdToString(str)(ev) val out = new java.io.StringWriter() var i = 0 - while (i < str.length) { - str.charAt(i) match { + while (i < string.length) { + string.charAt(i) match { case '<' => out.write("<") case '>' => out.write(">") case '&' => out.write("&") @@ -532,11 +563,11 @@ object StringModule extends AbstractFunctionModule { } out.toString }, - builtin("escapeStringBash", "str_") { (pos, ev, str: String) => - "'" + str.replace("'", """'"'"'""") + "'" + builtin("escapeStringBash", "str_") { (pos, ev, str: Val) => + "'" + stdToString(str)(ev).replace("'", """'"'"'""") + "'" }, - builtin("escapeStringDollars", "str_") { (pos, ev, str: String) => - str.replace("$", "$$") + builtin("escapeStringDollars", "str_") { (pos, ev, str: Val) => + stdToString(str)(ev).replace("$", "$$") } ) } diff --git a/sjsonnet/src/sjsonnet/stdlib/TypeModule.scala b/sjsonnet/src/sjsonnet/stdlib/TypeModule.scala index 9da70bbc5..eac2cf219 100644 --- a/sjsonnet/src/sjsonnet/stdlib/TypeModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/TypeModule.scala @@ -60,6 +60,19 @@ object TypeModule extends AbstractFunctionModule { } } + private object Compare extends Val.Builtin2("__compare", "v1", "v2") { + def evalRhs(v1: Eval, v2: Eval, ev: EvalScope, pos: Position): Val = + Val.cachedNum(pos, Util.compareJsonnetStd(v1.value, v2.value, ev).toDouble) + } + + private object CompareArray extends Val.Builtin2("__compare_array", "arr1", "arr2") { + def evalRhs(arr1: Eval, arr2: Eval, ev: EvalScope, pos: Position): Val = + Val.cachedNum( + pos, + Util.compareJsonnetStdArrays(arr1.value.asArr, arr2.value.asArr, ev).toDouble + ) + } + val functions: Seq[(String, Val.Func)] = Seq( builtin(AssertEqual), builtin(IsString), @@ -94,6 +107,20 @@ object TypeModule extends AbstractFunctionModule { ) } } + }, + builtin(Compare), + builtin(CompareArray), + builtin("__array_less", "arr1", "arr2") { (_, ev, arr1: Val.Arr, arr2: Val.Arr) => + Util.compareJsonnetStdArrays(arr1, arr2, ev) == -1 + }, + builtin("__array_greater", "arr1", "arr2") { (_, ev, arr1: Val.Arr, arr2: Val.Arr) => + Util.compareJsonnetStdArrays(arr1, arr2, ev) == 1 + }, + builtin("__array_less_or_equal", "arr1", "arr2") { (_, ev, arr1: Val.Arr, arr2: Val.Arr) => + Util.compareJsonnetStdArrays(arr1, arr2, ev) <= 0 + }, + builtin("__array_greater_or_equal", "arr1", "arr2") { (_, ev, arr1: Val.Arr, arr2: Val.Arr) => + Util.compareJsonnetStdArrays(arr1, arr2, ev) >= 0 } ) } diff --git a/sjsonnet/test/resources/go_test_suite/builtinIsEmpty2.jsonnet.golden b/sjsonnet/test/resources/go_test_suite/builtinIsEmpty2.jsonnet.golden index 7208b6c5a..f38106a09 100644 --- a/sjsonnet/test/resources/go_test_suite/builtinIsEmpty2.jsonnet.golden +++ b/sjsonnet/test/resources/go_test_suite/builtinIsEmpty2.jsonnet.golden @@ -1,3 +1,2 @@ -sjsonnet.Error: [std.isEmpty] Wrong parameter type: expected String, got number +sjsonnet.Error: [std.isEmpty] length operates on strings, objects, and arrays, got number at [].(builtinIsEmpty2.jsonnet:1:12) - diff --git a/sjsonnet/test/src/sjsonnet/Std0150FunctionsTests.scala b/sjsonnet/test/src/sjsonnet/Std0150FunctionsTests.scala index ab906b7db..27804cca9 100644 --- a/sjsonnet/test/src/sjsonnet/Std0150FunctionsTests.scala +++ b/sjsonnet/test/src/sjsonnet/Std0150FunctionsTests.scala @@ -197,7 +197,7 @@ object Std0150FunctionsTests extends TestSuite { assert( evalErr("""std.isEmpty(10)""") .startsWith( - "sjsonnet.Error: [std.isEmpty] Wrong parameter type: expected String, got number" + "sjsonnet.Error: [std.isEmpty] length operates on strings, objects, and arrays, got number" ) ) } diff --git a/sjsonnet/test/src/sjsonnet/StdLibOfficialCompatibilityTests.scala b/sjsonnet/test/src/sjsonnet/StdLibOfficialCompatibilityTests.scala new file mode 100644 index 000000000..12fb7b2ea --- /dev/null +++ b/sjsonnet/test/src/sjsonnet/StdLibOfficialCompatibilityTests.scala @@ -0,0 +1,110 @@ +package sjsonnet + +import utest._ +import TestUtils.{eval, evalErr} + +object StdLibOfficialCompatibilityTests extends TestSuite { + def tests: Tests = Tests { + test("mapWithIndex accepts strings") { + eval("""std.mapWithIndex(function(i, x) i + std.codepoint(x), "ab")""") ==> + ujson.Arr(97, 99) + } + + test("flatten arrays follows std.jsonnet fold semantics") { + eval("""std.flattenArrays([[1], [], [2]])""") ==> ujson.Arr(1, 2) + assert(evalErr("""std.flattenArrays([[1], null, [2]])""").contains("array and null")) + } + + test("flattenDeepArray wraps scalar values") { + eval("""std.flattenDeepArray(1)""") ==> ujson.Arr(1) + eval("""std.flattenDeepArray(null)""") ==> ujson.Arr(ujson.Null) + eval("""std.flattenDeepArray([1, [2, [3]]])""") ==> ujson.Arr(1, 2, 3) + } + + test("repeat reports official negative count error") { + assert(evalErr("""std.repeat("a", -1)""").contains("makeArray requires size >= 0, got -1")) + } + + test("removeAt filters by exact index equality") { + eval("""std.removeAt([1, 2, 3], 1)""") ==> ujson.Arr(1, 3) + eval("""std.removeAt([1, 2, 3], 1.5)""") ==> ujson.Arr(1, 2, 3) + eval("""std.removeAt([1, 2, 3], -1)""") ==> ujson.Arr(1, 2, 3) + eval("""std.removeAt([1, 2, 3], 9)""") ==> ujson.Arr(1, 2, 3) + eval("""std.removeAt([1, 2, 3], "1")""") ==> ujson.Arr(1, 2, 3) + } + + test("isEmpty delegates to std.length") { + eval("""std.isEmpty("")""") ==> ujson.True + eval("""std.isEmpty([])""") ==> ujson.True + eval("""std.isEmpty({})""") ==> ujson.True + eval("""std.isEmpty(function() 1)""") ==> ujson.True + eval("""std.isEmpty(function(a, b) a)""") ==> ujson.False + assert(evalErr("""std.isEmpty(10)""").contains("length operates on strings")) + } + + test("escape string helpers stringify non-string inputs") { + eval("""std.escapeStringPython(10)""") ==> ujson.Str("\"10\"") + eval("""std.escapeStringBash(10)""") ==> ujson.Str("'10'") + eval("""std.escapeStringDollars("$a")""") ==> ujson.Str("$$a") + eval("""std.escapeStringDollars(10)""") ==> ujson.Str("10") + eval("""std.escapeStringXML(10)""") ==> ujson.Str("10") + } + + test("resolvePath is available") { + eval("""std.resolvePath("a/b/c.jsonnet", "d.jsonnet")""") ==> ujson.Str("a/b/d.jsonnet") + eval("""std.resolvePath("c.jsonnet", "d.jsonnet")""") ==> ujson.Str("d.jsonnet") + eval("""std.resolvePath("/c.jsonnet", "d.jsonnet")""") ==> ujson.Str("/d.jsonnet") + } + + test("hidden comparison helpers match std.jsonnet") { + eval( + """[ + | std.__compare(1, 2), + | std.__compare("b", "a"), + | std.__compare([1], [1, 2]), + | std.__array_less([1], [2]), + | std.__array_greater_or_equal([1, 2], [1]), + |] + |""".stripMargin + ) ==> ujson.Arr(-1, 1, -1, true, true) + assert(evalErr("""std.__compare(false, false)""").contains("boolean")) + assert(evalErr("""std.__compare(null, null)""").contains("null")) + } + + test("minArray and maxArray distinguish explicit false from defaults") { + eval("""[std.minArray([], onEmpty=false), std.maxArray([], onEmpty=false)]""") ==> + ujson.Arr(false, false) + assert(evalErr("""std.minArray([1], keyF=false)""").contains("boolean")) + assert(evalErr("""std.maxArray([1], keyF=false)""").contains("boolean")) + assert(evalErr("""std.minArray([true])""").contains("boolean")) + assert(evalErr("""std.maxArray([null])""").contains("null")) + } + + test("set keyF defaults do not collide with explicit false") { + eval( + """{ + | sortOne: std.sort([1], keyF=false), + | uniqOne: std.uniq([1], keyF=false), + | setOne: std.set([1], keyF=false), + | setUnionEmpty: std.setUnion([], [1], keyF=false), + | setInterEmpty: std.setInter([], [1], keyF=false), + | setDiffEmpty: std.setDiff([], [1], keyF=false), + | setMemberEmpty: std.setMember(1, [], keyF=false), + |} + |""".stripMargin + ) ==> ujson.Obj( + "sortOne" -> ujson.Arr(1), + "uniqOne" -> ujson.Arr(1), + "setOne" -> ujson.Arr(1), + "setUnionEmpty" -> ujson.Arr(1), + "setInterEmpty" -> ujson.Arr(), + "setDiffEmpty" -> ujson.Arr(), + "setMemberEmpty" -> ujson.False + ) + assert(evalErr("""std.sort([2, 1], keyF=false)""").contains("boolean")) + assert(evalErr("""std.uniq([1, 1], keyF=false)""").contains("boolean")) + assert(evalErr("""std.setUnion([1], [2], keyF=false)""").contains("boolean")) + assert(evalErr("""std.setMember(1, [1], keyF=false)""").contains("boolean")) + } + } +}