From 40239fd927bd9addacc8668d495ee2da667610a2 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Mon, 11 May 2026 16:52:39 +0800 Subject: [PATCH 1/2] perf: fast-path strict json imports Motivation: Kube-prometheus imports large strict JSON files; parsing them through the full Jsonnet parser creates avoidable AST and materialization work. Modification: Add a strict .json import fast path shared by CachedResolver and Preloader, trim visitor work, build race-free inline-array objects for imported JSON, and reuse the no-offset position for imported literals. Result: Strict JSON imports keep Jsonnet fallback semantics for non-strict inputs while reducing parse and manifestation overhead for large imported JSON data. --- sjsonnet/src/sjsonnet/Importer.scala | 189 ++++++++++++++++-- sjsonnet/src/sjsonnet/Preloader.scala | 55 +++-- .../sjsonnet/JsonImportFastPathJvmTests.scala | 111 ++++++++++ .../sjsonnet/JsonImportFastPathTests.scala | 109 ++++++++++ .../test/src/sjsonnet/PreloaderTests.scala | 33 +++ 5 files changed, 458 insertions(+), 39 deletions(-) create mode 100644 sjsonnet/test/src-jvm/sjsonnet/JsonImportFastPathJvmTests.scala create mode 100644 sjsonnet/test/src/sjsonnet/JsonImportFastPathTests.scala diff --git a/sjsonnet/src/sjsonnet/Importer.scala b/sjsonnet/src/sjsonnet/Importer.scala index 3d1e4df19..01ae87f8d 100644 --- a/sjsonnet/src/sjsonnet/Importer.scala +++ b/sjsonnet/src/sjsonnet/Importer.scala @@ -4,6 +4,7 @@ import fastparse.{IndexedParserInput, Parsed, ParserInput} import java.io.{BufferedInputStream, File, FileInputStream, RandomAccessFile} import java.nio.charset.StandardCharsets +import java.util import scala.collection.mutable /** Resolve and read imported files */ @@ -229,23 +230,14 @@ class CachedResolver( val parsed: Either[Error, (Expr, FileScope)] = content.preParsedAst match { case Some(pre) => Right(pre) case None => - try { - fastparse.parse( - content.getParserInput(), - parser(path).document(_) - ) match { - case f @ Parsed.Failure(_, _, _) => - val traced = f.trace() - val pos = new Position(new FileScope(path), traced.index) - Left(new ParseError(traced.msg).addFrame(pos)) - case Parsed.Success(r, _) => Right(r) - } - } catch { - case e: ParseError if e.offset >= 0 => - val pos = new Position(new FileScope(path), e.offset) - Left(new ParseError(e.getMessage).addFrame(pos)) - case e: ParseError => - Left(e) + CachedResolver.parseJsonImport( + path, + content, + internedStrings, + settings + ) match { + case Some(parsedJson) => Right(parsedJson) + case None => parseJsonnet(path, content) } } parsed.flatMap { case (e, fs) => process(e, fs) } @@ -253,6 +245,28 @@ class CachedResolver( ) } + private def parseJsonnet(path: Path, content: ResolvedFile)(implicit + ev: EvalErrorScope): Either[Error, (Expr, FileScope)] = { + try { + fastparse.parse( + content.getParserInput(), + parser(path).document(_) + ) match { + case f @ Parsed.Failure(_, _, _) => + val traced = f.trace() + val pos = new Position(new FileScope(path), traced.index) + Left(new ParseError(traced.msg).addFrame(pos)) + case Parsed.Success(r, _) => Right(r) + } + } catch { + case e: ParseError if e.offset >= 0 => + val pos = new Position(new FileScope(path), e.offset) + Left(new ParseError(e.getMessage).addFrame(pos)) + case e: ParseError => + Left(e) + } + } + def process(expr: Expr, fs: FileScope): Either[Error, (Expr, FileScope)] = Right((expr, fs)) /** @@ -268,3 +282,144 @@ class CachedResolver( new Parser(path, internedStrings, internedStaticFieldSets, settings) } } + +object CachedResolver { + private final class DuplicateJsonKey extends RuntimeException(null, null, false, false) + private final class InvalidJsonNumber extends RuntimeException(null, null, false, false) + private final class JsonParseDepthExceeded extends RuntimeException(null, null, false, false) + + private[sjsonnet] def parseJsonImport( + path: Path, + content: ResolvedFile, + internedStrings: mutable.HashMap[String, String], + settings: Settings): Option[(Expr, FileScope)] = { + if (!path.last.endsWith(".json")) return None + val fileScope = new FileScope(path) + try { + val visitor = + new JsonImportVisitor(fileScope, internedStrings, settings) + Some((ujson.StringParser.transform(content.readString(), visitor), fileScope)) + } catch { + case _: ujson.ParsingFailedException | _: DuplicateJsonKey | _: InvalidJsonNumber | + _: JsonParseDepthExceeded | _: NumberFormatException => + None + } + } + + private final class JsonImportVisitor( + fileScope: FileScope, + internedStrings: mutable.HashMap[String, String], + settings: Settings) + extends ujson.JsVisitor[Val, Val] { self => + private val jsonPos = fileScope.noOffsetPos + + override def visitJsonableObject(length: Int, index: Int): upickle.core.ObjVisitor[Val, Val] = + visitObject(length, index) + + def visitArray(length: Int, index: Int): upickle.core.ArrVisitor[Val, Val] = { + enterContainer() + val startPos = pos(index) + new upickle.core.ArrVisitor[Val, Val] { + private val values = new mutable.ArrayBuilder.ofRef[Eval] + if (length >= 0) values.sizeHint(length) + def subVisitor: upickle.core.Visitor[?, ?] = self + def visitValue(v: Val, index: Int): Unit = values += v + def visitEnd(index: Int): Val = { + leaveContainer() + Val.Arr(startPos, values.result()) + } + } + } + + def visitObject(length: Int, index: Int): upickle.core.ObjVisitor[Val, Val] = { + enterContainer() + val startPos = pos(index) + new upickle.core.ObjVisitor[Val, Val] { + private val seen = new util.HashSet[String]() + private val keys = new mutable.ArrayBuilder.ofRef[String] + private val members = new mutable.ArrayBuilder.ofRef[Val.Obj.Member] + if (length >= 0) keys.sizeHint(length) + if (length >= 0) members.sizeHint(length) + private var key: String = _ + def subVisitor: upickle.core.Visitor[?, ?] = self + def visitKey(index: Int): upickle.core.StringVisitor.type = upickle.core.StringVisitor + def visitKeyValue(s: Any): Unit = key = intern(s.toString) + def visitValue(v: Val, index: Int): Unit = { + if (!seen.add(key)) throw new DuplicateJsonKey + keys += key + // Imported JSON literals can be shared through ParseCache/Preloader across evaluators. + // Keep their inline object members immutable by disabling Val.Obj's lazy field cache. + members += new Val.Obj.ConstMember( + false, + Expr.Member.Visibility.Normal, + v, + cached2 = false + ) + } + def visitEnd(index: Int): Val = { + val keyArray = keys.result() + val memberArray = members.result() + leaveContainer() + val obj = new Val.Obj( + startPos, + null, + static = false, + null, + null, + null, + null, + null, + null, + null, + keyArray, + memberArray + ) + obj._skipFieldCache = true + obj + } + } + } + + def visitNull(index: Int): Val = Val.Null(pos(index)) + def visitFalse(index: Int): Val = Val.False(pos(index)) + def visitTrue(index: Int): Val = Val.True(pos(index)) + + def visitFloat64StringParts(s: CharSequence, decIndex: Int, expIndex: Int, index: Int): Val = + Val.Num( + pos(index), + parseNumber(s) + ) + + def visitString(s: CharSequence, index: Int): Val = { + val str = s match { + case str: String => str + case _ => s.toString + } + val unique = intern(str) + Val.Str(pos(index), unique) + } + + private def pos(index: Int): Position = jsonPos + + private def intern(s: String): String = + if (s.length > 1024) s else internedStrings.getOrElseUpdate(s, s) + + private def parseNumber(s: CharSequence): Double = { + val value = s.toString.toDouble + if (!java.lang.Double.isFinite(value)) throw new InvalidJsonNumber + value + } + + private var containerDepth = 0 + + private def enterContainer(): Unit = { + containerDepth += 1 + if (containerDepth > settings.maxParserRecursionDepth) { + throw new JsonParseDepthExceeded + } + } + + private def leaveContainer(): Unit = + containerDepth -= 1 + } +} diff --git a/sjsonnet/src/sjsonnet/Preloader.scala b/sjsonnet/src/sjsonnet/Preloader.scala index e2e2291a5..2c5a483d1 100644 --- a/sjsonnet/src/sjsonnet/Preloader.scala +++ b/sjsonnet/src/sjsonnet/Preloader.scala @@ -130,29 +130,40 @@ class Preloader(parentImporter: Importer, settings: Settings = Settings.default) } private def discover(path: Path, content: ResolvedFile): Either[Error, Unit] = { - val parser = new Parser(path, internedStrings, internedFieldSets, settings) - try { - fastparse.parse(content.getParserInput(), parser.document(_)) match { - case f: Parsed.Failure => - val traced = f.trace() - Left(new ParseError(s"$path: ${traced.msg}", offset = traced.index)) - case Parsed.Success((expr, fs), _) => - // Stash the parsed AST on the cache entry so the Interpreter doesn't re-run fastparse. - // The optimizer still runs once at evaluation time on cache hit. - cache.put((path, false), PreParsedResolvedFile(content, expr, fs)) - // Match the synchronous evaluator's docBase: resolve relative to the importing file's - // parent directory, not the file path itself. See Importer.resolveAndReadOrFail, which - // calls resolve(pos.fileScope.currentFile.parent(), ...). - val docBase = path.parent() - ImportFinder.collect(expr).foreach { found => - parentImporter.resolve(docBase, found.value).foreach { resolved => - record(resolved, found.kind) - } + CachedResolver.parseJsonImport( + path, + content, + internedStrings, + settings + ) match { + case Some((expr, fs)) => + cache.put((path, false), PreParsedResolvedFile(content, expr, fs)) + Right(()) + case None => + val parser = new Parser(path, internedStrings, internedFieldSets, settings) + try { + fastparse.parse(content.getParserInput(), parser.document(_)) match { + case f: Parsed.Failure => + val traced = f.trace() + Left(new ParseError(s"$path: ${traced.msg}", offset = traced.index)) + case Parsed.Success((expr, fs), _) => + // Stash the parsed AST on the cache entry so the Interpreter doesn't re-run fastparse. + // The optimizer still runs once at evaluation time on cache hit. + cache.put((path, false), PreParsedResolvedFile(content, expr, fs)) + // Match the synchronous evaluator's docBase: resolve relative to the importing file's + // parent directory, not the file path itself. See Importer.resolveAndReadOrFail, which + // calls resolve(pos.fileScope.currentFile.parent(), ...). + val docBase = path.parent() + ImportFinder.collect(expr).foreach { found => + parentImporter.resolve(docBase, found.value).foreach { resolved => + record(resolved, found.kind) + } + } + Right(()) } - Right(()) - } - } catch { - case e: ParseError => Left(e) + } catch { + case e: ParseError => Left(e) + } } } diff --git a/sjsonnet/test/src-jvm/sjsonnet/JsonImportFastPathJvmTests.scala b/sjsonnet/test/src-jvm/sjsonnet/JsonImportFastPathJvmTests.scala new file mode 100644 index 000000000..77b592676 --- /dev/null +++ b/sjsonnet/test/src-jvm/sjsonnet/JsonImportFastPathJvmTests.scala @@ -0,0 +1,111 @@ +package sjsonnet + +import utest._ + +import java.util.concurrent.{ConcurrentHashMap, CountDownLatch} +import java.util.concurrent.atomic.AtomicReference + +object JsonImportFastPathJvmTests extends TestSuite { + private class ConcurrentParseCache extends ParseCache { + private val cache = + new ConcurrentHashMap[(Path, String), Either[Error, (Expr, FileScope)]]() + + def getOrElseUpdate( + key: (Path, String), + defaultValue: => Either[Error, (Expr, FileScope)]): Either[Error, (Expr, FileScope)] = { + val existing = cache.get(key) + if (existing != null) existing + else { + val computed = defaultValue + val previous = cache.putIfAbsent(key, computed) + if (previous == null) computed else previous + } + } + } + + def tests: Tests = Tests { + test("strict json imports can be shared by concurrent interpreters") { + val files = Map( + "data.json" -> + """{"a":{"b":[1,2,3]},"arr":[{"x":"one"},{"x":"two"}],"z":true}""" + ) + val importer = new Importer { + def resolve(docBase: Path, importName: String): Option[Path] = + if (files.contains(importName)) Some(DummyPath(importName)) else None + def read(path: Path, binaryData: Boolean): Option[ResolvedFile] = + path match { + case DummyPath(name) => files.get(name).map(StaticResolvedFile.apply) + case _ => None + } + } + val parseCache = new ConcurrentParseCache + val code = + """local d = import "data.json"; + |{whole: d, pick: d.arr[1].x, fields: std.objectFields(d), singleFields: std.objectFields(d.a)} + |""".stripMargin + val path = DummyPath("root", "main.jsonnet") + val expected = Right( + ujson.Obj( + "whole" -> ujson.Obj( + "a" -> ujson.Obj("b" -> ujson.Arr(1, 2, 3)), + "arr" -> ujson.Arr(ujson.Obj("x" -> "one"), ujson.Obj("x" -> "two")), + "z" -> true + ), + "pick" -> "two", + "fields" -> ujson.Arr("a", "arr", "z"), + "singleFields" -> ujson.Arr("b") + ) + ) + + val warm = new Interpreter( + Map.empty, + Map.empty, + DummyPath("root"), + importer, + parseCache = parseCache + ).interpret(code, path) + assert(warm == expected) + + val threads = new Array[Thread](8) + val start = new CountDownLatch(1) + val done = new CountDownLatch(threads.length) + val failure = new AtomicReference[Throwable]() + + var i = 0 + while (i < threads.length) { + threads(i) = new Thread(new Runnable { + def run(): Unit = { + try { + start.await() + var j = 0 + while (j < 50) { + val result = new Interpreter( + Map.empty, + Map.empty, + DummyPath("root"), + importer, + parseCache = parseCache + ).interpret(code, path) + if (result != expected) { + throw new java.lang.AssertionError(s"unexpected result: $result") + } + j += 1 + } + } catch { + case t: Throwable => failure.compareAndSet(null, t) + } finally { + done.countDown() + } + } + }) + threads(i).start() + i += 1 + } + + start.countDown() + done.await() + val thrown = failure.get() + if (thrown != null) throw thrown + } + } +} diff --git a/sjsonnet/test/src/sjsonnet/JsonImportFastPathTests.scala b/sjsonnet/test/src/sjsonnet/JsonImportFastPathTests.scala new file mode 100644 index 000000000..ccc0a17cf --- /dev/null +++ b/sjsonnet/test/src/sjsonnet/JsonImportFastPathTests.scala @@ -0,0 +1,109 @@ +package sjsonnet + +import utest._ + +object JsonImportFastPathTests extends TestSuite { + private def interpreter(files: Map[String, String], settings: Settings): Interpreter = + new Interpreter( + Map.empty, + Map.empty, + DummyPath("root"), + new Importer { + def resolve(docBase: Path, importName: String): Option[Path] = + if (files.contains(importName)) Some(DummyPath(importName)) else None + def read(path: Path, binaryData: Boolean): Option[ResolvedFile] = + path match { + case DummyPath(name) => files.get(name).map(StaticResolvedFile.apply) + case _ => None + } + }, + parseCache = new DefaultParseCache, + settings = settings + ) + + private def eval( + files: Map[String, String], + code: String, + settings: Settings = Settings.default): Either[String, ujson.Value] = + interpreter(files, settings).interpret(code, DummyPath("root", "main.jsonnet")) + + def tests: Tests = Tests { + test("strict json imports produce normal Jsonnet values") { + val files = Map( + "data.json" -> + """{"a":[1,true,null],"b":{"c":"d"},"n":9.007199254740992E15}""" + ) + + eval(files, """import "data.json"""") ==> + Right(ujson.Obj("a" -> ujson.Arr(1, true, ujson.Null), "b" -> ujson.Obj("c" -> "d"), "n" -> 9.007199254740992E15)) + } + + test("invalid json can still fall back to Jsonnet syntax") { + val files = Map( + "loose.json" -> + """// Valid Jsonnet, invalid strict JSON. + |{ + | a: 1, + |} + |""".stripMargin + ) + + eval(files, """import "loose.json"""") ==> Right(ujson.Obj("a" -> 1)) + } + + test("duplicate json object keys keep Jsonnet duplicate-field error semantics") { + val files = Map("dupe.json" -> """{"a":1,"a":2}""") + + val result = eval(files, """import "dupe.json"""") + assert(result.isLeft) + result match { + case Left(error) => assert(error.contains("duplicate") || error.contains("Duplicate")) + case Right(_) => assert(false) + } + } + + test("large integer json numbers keep Jsonnet double semantics") { + val files = Map("large-int.json" -> """{"n":18446744073709551615}""") + + eval(files, """import "large-int.json"""") ==> + Right(ujson.Obj("n" -> 1.8446744073709552E19)) + } + + test("non-finite json numbers keep Jsonnet parser errors") { + val files = Map("overflow.json" -> """{"n":1e10000}""") + + val result = eval(files, """import "overflow.json"""") + assert(result.isLeft) + result match { + case Left(error) => assert(error.contains("finite number required")) + case Right(_) => assert(false) + } + } + + test("incomplete json falls back to normal parse errors") { + val files = Map("truncated.json" -> """{"a":[1""") + + val result = eval(files, """import "truncated.json"""") + assert(result.isLeft) + result match { + case Left(error) => assert(!error.contains("Internal Error")) + case Right(_) => assert(false) + } + } + + test("deep json imports keep parser recursion guard") { + val files = Map("deep.json" -> """[[[]]]""") + + val result = eval( + files, + """import "deep.json"""", + Settings.default.copy(maxParserRecursionDepth = 1) + ) + assert(result.isLeft) + result match { + case Left(error) => assert(error.contains("maximum recursion depth")) + case Right(_) => assert(false) + } + } + } +} diff --git a/sjsonnet/test/src/sjsonnet/PreloaderTests.scala b/sjsonnet/test/src/sjsonnet/PreloaderTests.scala index 209bce6b4..7d3732c80 100644 --- a/sjsonnet/test/src/sjsonnet/PreloaderTests.scala +++ b/sjsonnet/test/src/sjsonnet/PreloaderTests.scala @@ -168,6 +168,39 @@ object PreloaderTests extends TestSuite { assert(parseCount(DummyPath("lib.libsonnet")) == 1) } + test("preloader uses json import fast path") { + val dataPath = DummyPath("data.json") + class JsonOnlyResolvedFile(content: String) extends ResolvedFile { + def getParserInput(): fastparse.ParserInput = + throw new RuntimeException("strict JSON should not be parsed with fastparse") + def readString(): String = content + def contentHash(): String = content + def readRawBytes(): Array[Byte] = + content.getBytes(java.nio.charset.StandardCharsets.UTF_8) + } + val importer = new Importer { + def resolve(docBase: Path, importName: String): Option[Path] = + if (importName == "data.json") Some(dataPath) else None + def read(path: Path, binaryData: Boolean): Option[ResolvedFile] = + throw new RuntimeException(s"unexpected read: $path") + } + val preloader = new Preloader(importer) + + val out = preloader.add(dataPath, new JsonOnlyResolvedFile("""{"a":1}"""), ImportKind.Code) + assert(out == Right(())) + assert(preloader.loaded((dataPath, false)).preParsedAst.isDefined) + + val interp = new Interpreter( + Map.empty[String, String], + Map.empty[String, String], + DummyPath(), + preloader.importer, + parseCache = new DefaultParseCache + ) + val result = interp.interpret("""import "data.json"""", DummyPath("main.jsonnet")) + assert(result == Right(ujson.Obj("a" -> 1))) + } + test("resolves imports relative to the importing file's parent directory") { // Resolver records what docBase it was called with, and only resolves names against the // expected `dir/` parent — proving the preloader passes parent(), not the file path itself. From ada928d6579dedc114038ce0c23927c26e5b208f Mon Sep 17 00:00:00 2001 From: He-Pin Date: Mon, 11 May 2026 19:54:56 +0800 Subject: [PATCH 2/2] fix: separate text and binary import cache entries Motivation: Preloaded importstr and importbin entries for the same path were separated in Preloader, but CachedImporter still keyed reads only by Path. Interpreter evaluation could then reuse a text resolved file for binary reads or the reverse. Modification: Key CachedImporter entries by both Path and binaryData, update the top-level source cache insertion to use the text key, and add interpreter-level regression coverage for both import orders. Result: Preloaded text and binary imports for the same path remain independent through normal Interpreter evaluation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sjsonnet/src/sjsonnet/Importer.scala | 20 ++++++---- sjsonnet/src/sjsonnet/Interpreter.scala | 2 +- .../test/src/sjsonnet/PreloaderTests.scala | 40 +++++++++++++++---- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/sjsonnet/src/sjsonnet/Importer.scala b/sjsonnet/src/sjsonnet/Importer.scala index 01ae87f8d..ca823389d 100644 --- a/sjsonnet/src/sjsonnet/Importer.scala +++ b/sjsonnet/src/sjsonnet/Importer.scala @@ -198,17 +198,21 @@ final case class StaticBinaryResolvedFile(content: Array[Byte]) extends Resolved } class CachedImporter(parent: Importer) extends Importer { - val cache: mutable.HashMap[Path, ResolvedFile] = mutable.HashMap.empty[Path, ResolvedFile] + val cache: mutable.HashMap[(Path, Boolean), ResolvedFile] = + mutable.HashMap.empty[(Path, Boolean), ResolvedFile] def resolve(docBase: Path, importName: String): Option[Path] = parent.resolve(docBase, importName) - def read(path: Path, binaryData: Boolean): Option[ResolvedFile] = cache.get(path) match { - case s @ Some(x) => - if (x == null) None else s - case None => - val x = parent.read(path, binaryData) - cache.put(path, x.orNull) - x + def read(path: Path, binaryData: Boolean): Option[ResolvedFile] = { + val key = (path, binaryData) + cache.get(key) match { + case s @ Some(x) => + if (x == null) None else s + case None => + val x = parent.read(path, binaryData) + cache.put(key, x.orNull) + x + } } } diff --git a/sjsonnet/src/sjsonnet/Interpreter.scala b/sjsonnet/src/sjsonnet/Interpreter.scala index 9d1c461ff..d40adf2e3 100644 --- a/sjsonnet/src/sjsonnet/Interpreter.scala +++ b/sjsonnet/src/sjsonnet/Interpreter.scala @@ -219,7 +219,7 @@ class Interpreter( private def evaluateImpl(txt: String, path: Path): Either[Error, Val] = { val resolvedImport = StaticResolvedFile(txt) - resolver.cache(path) = resolvedImport + resolver.cache((path, false)) = resolvedImport resolver.parse(path, resolvedImport)(evaluator) flatMap { case (expr, _) => lastTopLevelPos = expr.pos handleException { diff --git a/sjsonnet/test/src/sjsonnet/PreloaderTests.scala b/sjsonnet/test/src/sjsonnet/PreloaderTests.scala index 7d3732c80..9d3bc985e 100644 --- a/sjsonnet/test/src/sjsonnet/PreloaderTests.scala +++ b/sjsonnet/test/src/sjsonnet/PreloaderTests.scala @@ -125,14 +125,14 @@ object PreloaderTests extends TestSuite { parseCount(path) = parseCount(path) + 1 fastparse.IndexedParserInput(content) } - def readString(): String = content - def contentHash(): String = content - def readRawBytes(): Array[Byte] = + def readString(): String = content + def contentHash(): String = content + def readRawBytes(): Array[Byte] = content.getBytes(java.nio.charset.StandardCharsets.UTF_8) } val files = Map( "lib.libsonnet" -> "{ x: 1 }", - "entry" -> "(import 'lib.libsonnet').x" + "entry" -> "(import 'lib.libsonnet').x" ) val importer = new Importer { def resolve(docBase: Path, importName: String): Option[Path] = @@ -173,8 +173,8 @@ object PreloaderTests extends TestSuite { class JsonOnlyResolvedFile(content: String) extends ResolvedFile { def getParserInput(): fastparse.ParserInput = throw new RuntimeException("strict JSON should not be parsed with fastparse") - def readString(): String = content - def contentHash(): String = content + def readString(): String = content + def contentHash(): String = content def readRawBytes(): Array[Byte] = content.getBytes(java.nio.charset.StandardCharsets.UTF_8) } @@ -205,7 +205,8 @@ object PreloaderTests extends TestSuite { // Resolver records what docBase it was called with, and only resolves names against the // expected `dir/` parent — proving the preloader passes parent(), not the file path itself. val seenDocBases = mutable.ArrayBuffer.empty[String] - val files = Map("dir/a.libsonnet" -> "import 'b.libsonnet'", "dir/b.libsonnet" -> "{ ok: true }") + val files = + Map("dir/a.libsonnet" -> "import 'b.libsonnet'", "dir/b.libsonnet" -> "{ ok: true }") val importer = new Importer { def resolve(docBase: Path, importName: String): Option[Path] = { seenDocBases += docBase.asInstanceOf[DummyPath].segments.mkString("/") @@ -273,6 +274,31 @@ object PreloaderTests extends TestSuite { assert(sameBin.exists(_.readRawBytes().sameElements(Array[Byte](1, 2, 3)))) } + test("interpreter keeps preloaded importstr and importbin for the same path separate") { + val fs = new FakeFs( + Map("same" -> "the-text"), + binFiles = Map("same" -> Array[Byte](1, 2, 3)) + ) + val entryPath = DummyPath("entry") + val forward = "[importstr 'same', importbin 'same']" + val preloader = runPreload(fs, entryPath, forward) + val interp = new Interpreter( + Map.empty[String, String], + Map.empty[String, String], + DummyPath(), + preloader.importer, + parseCache = new DefaultParseCache + ) + + assert( + interp.interpret(forward, entryPath) == Right(ujson.Arr("the-text", ujson.Arr(1, 2, 3))) + ) + assert( + interp.interpret("[importbin 'same', importstr 'same']", entryPath) == + Right(ujson.Arr(ujson.Arr(1, 2, 3), "the-text")) + ) + } + test("parse error in entry is reported") { val fs = new FakeFs(Map.empty) val preloader = new Preloader(fs.importer)