diff --git a/sjsonnet/src-jvm-native/sjsonnet/Config.scala b/sjsonnet/src-jvm-native/sjsonnet/Config.scala index db699a44..f06f6dbb 100644 --- a/sjsonnet/src-jvm-native/sjsonnet/Config.scala +++ b/sjsonnet/src-jvm-native/sjsonnet/Config.scala @@ -164,21 +164,55 @@ final case class Config( ) { /** - * Returns the sequence of jpaths specified on the command line, ordered according to the flags. + * Returns the sequence of jpaths, combining command-line flags and the JSONNET_PATH environment + * variable. * - * Historically, sjsonnet evaluated jpaths in left-to-right order, which is also the order of - * evaluation in the core. However, in gojsonnet, the arguments are prioritized right to left, and - * the reverse-jpaths-priority flag was introduced for possible consistency across the two + * JSONNET_PATH directories always have lower priority than --jpath flags. Within JSONNET_PATH, + * the left-most entry has the highest priority, matching the behavior of the C++ and Go * implementations. * + * The --reverse-jpaths-priority flag only affects the ordering of --jpath flags (reversing them + * so that the rightmost wins, matching go-jsonnet behavior). JSONNET_PATH entries are always + * appended after the (possibly reversed) --jpath flags. + * + * For example, `JSONNET_PATH=a:b sjsonnet -J c -J d` results in search order: c, d, a, b (default + * mode). With --reverse-jpaths-priority, the order becomes: d, c, a, b. + * * See [[https://jsonnet-libs.github.io/jsonnet-training-course/lesson2.html#jsonnet_path]] for * details. */ - def getOrderedJpaths: Seq[String] = { - if (reverseJpathsPriority.value) { - jpaths.reverse - } else { - jpaths + def getOrderedJpaths: Seq[String] = getOrderedJpaths(jsonnetPathEnv = None) + + /** + * Returns the sequence of jpaths, combining command-line flags and the JSONNET_PATH environment + * variable. + * + * @param jsonnetPathEnv + * If Some(value), use the given value instead of reading from the JSONNET_PATH environment + * variable. If None, read from System.getenv("JSONNET_PATH"). + */ + def getOrderedJpaths(jsonnetPathEnv: Option[String]): Seq[String] = { + val envValue = jsonnetPathEnv.getOrElse(System.getenv("JSONNET_PATH")) + val envPaths = Config.jsonnetPathEntries(envValue) + val orderedJpaths = if (reverseJpathsPriority.value) jpaths.reverse else jpaths + orderedJpaths ++ envPaths + } +} + +object Config { + + /** + * Parses the JSONNET_PATH value into a sequence of directory paths. Entries are kept in their + * original order so that, with sjsonnet's default left-to-right search, the left-most entry in + * the environment variable has the highest priority among the JSONNET_PATH entries, matching the + * behavior of the C++ and Go implementations. + * + * The separator is colon on Unix and semicolon on Windows ({@code java.io.File.pathSeparator}). + */ + private[sjsonnet] def jsonnetPathEntries(envValue: String): Seq[String] = { + if (envValue == null || envValue.isEmpty) Nil + else { + envValue.split(java.io.File.pathSeparator).filter(_.nonEmpty).toSeq } } } diff --git a/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala b/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala index 2769b334..9b667dc6 100644 --- a/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala +++ b/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala @@ -64,6 +64,33 @@ object SjsonnetMainBase { } } + /** + * Java-compatible overload that omits `jsonnetPathEnv` (defaults to `None`). Keeps source + * compatibility for callers that were compiled against the pre-JSONNET_PATH signature. + */ + def main0( + args: Array[String], + parseCache: ParseCache, + stdin: InputStream, + stdout: PrintStream, + stderr: PrintStream, + wd: os.Path, + allowedInputs: Option[Set[os.Path]], + importer: Option[Importer], + std: Val.Obj): Int = + main0( + args, + parseCache, + stdin, + stdout, + stderr, + wd, + allowedInputs, + importer, + std, + jsonnetPathEnv = None + ) + def main0( args: Array[String], parseCache: ParseCache, @@ -73,7 +100,8 @@ object SjsonnetMainBase { wd: os.Path, allowedInputs: Option[Set[os.Path]] = None, importer: Option[Importer] = None, - std: Val.Obj = sjsonnet.stdlib.StdLibModule.Default.module): Int = { + std: Val.Obj = sjsonnet.stdlib.StdLibModule.Default.module, + jsonnetPathEnv: Option[String] = None): Int = { var hasWarnings = false def warn(isTrace: Boolean, msg: String): Unit = { @@ -87,14 +115,27 @@ object SjsonnetMainBase { val parser = mainargs.ParserForClass[Config] val name = s"Sjsonnet ${sjsonnet.Version.version}" val doc = "usage: sjsonnet [sjsonnet-options] script-file" + val envVarsDoc = + """ + |Environment variables: + | JSONNET_PATH is a colon (semicolon on Windows) separated list of directories + | added in reverse order before the paths specified by --jpath (i.e. left-most + | wins). E.g. these are equivalent: + | JSONNET_PATH=a:b sjsonnet -J c -J d + | JSONNET_PATH=d:c:a:b sjsonnet + | sjsonnet -J b -J a -J c -J d""".stripMargin + val result = for { - config <- parser.constructEither( - args.toIndexedSeq, - allowRepeats = true, - customName = name, - customDoc = doc, - autoPrintHelpAndExit = None - ) + config <- parser + .constructEither( + args.toIndexedSeq, + allowRepeats = true, + customName = name, + customDoc = doc, + autoPrintHelpAndExit = None + ) + .left + .map(_ + envVarsDoc) _ <- { if (config.noTrailingNewline.value && config.yamlStream.value) Left("error: cannot use --no-trailing-newline with --yaml-stream") @@ -115,7 +156,7 @@ object SjsonnetMainBase { wd, importer.getOrElse { new SimpleImporter( - config.getOrderedJpaths.map(p => OsPath(os.Path(p, wd))), + config.getOrderedJpaths(jsonnetPathEnv).map(p => OsPath(os.Path(p, wd))), allowedInputs, debugImporter = config.debugImporter.value ) diff --git a/sjsonnet/test/src-jvm-native/sjsonnet/ConfigTests.scala b/sjsonnet/test/src-jvm-native/sjsonnet/ConfigTests.scala new file mode 100644 index 00000000..50ffcb6d --- /dev/null +++ b/sjsonnet/test/src-jvm-native/sjsonnet/ConfigTests.scala @@ -0,0 +1,84 @@ +package sjsonnet + +import utest.* + +object ConfigTests extends TestSuite { + private val sep = java.io.File.pathSeparator + + val tests: Tests = Tests { + test("jsonnetPathEntries") { + test("null") { + assert(Config.jsonnetPathEntries(null) == Nil) + } + test("empty") { + assert(Config.jsonnetPathEntries("") == Nil) + } + test("single") { + // Single path should be returned as-is + assert(Config.jsonnetPathEntries("/foo") == Seq("/foo")) + } + test("multiple") { + // JSONNET_PATH=ab should produce Seq("a", "b") (original order, left-most wins) + val result = Config.jsonnetPathEntries(s"/a${sep}/b${sep}/c") + assert(result == Seq("/a", "/b", "/c")) + } + test("emptyEntries") { + // Empty entries between separators should be filtered out + val result = Config.jsonnetPathEntries(s"/a$sep$sep/b") + assert(result == Seq("/a", "/b")) + } + test("trailingSeparator") { + val result = Config.jsonnetPathEntries(s"/a$sep/b$sep") + assert(result == Seq("/a", "/b")) + } + } + + test("getOrderedJpaths") { + test("jpathsOnlyDefaultOrder") { + // Without JSONNET_PATH env set, getOrderedJpaths should return jpaths in original order + val config = Config(jpaths = List("/x", "/y", "/z"), file = "test.jsonnet") + val result = config.getOrderedJpaths(jsonnetPathEnv = Some("")) + assert(result == Seq("/x", "/y", "/z")) + } + test("jpathsOnlyReversed") { + import mainargs.Flag + val config = Config( + jpaths = List("/x", "/y", "/z"), + reverseJpathsPriority = Flag(true), + file = "test.jsonnet" + ) + val result = config.getOrderedJpaths(jsonnetPathEnv = Some("")) + assert(result == Seq("/z", "/y", "/x")) + } + test("envPathsAppendedAfterJpaths") { + val config = Config(jpaths = List("/c", "/d"), file = "test.jsonnet") + val result = config.getOrderedJpaths(jsonnetPathEnv = Some(s"/a$sep/b")) + // -J paths first, then JSONNET_PATH entries in original order + assert(result == Seq("/c", "/d", "/a", "/b")) + } + test("envPathsWithReverse") { + import mainargs.Flag + val config = Config( + jpaths = List("/c", "/d"), + reverseJpathsPriority = Flag(true), + file = "test.jsonnet" + ) + val result = config.getOrderedJpaths(jsonnetPathEnv = Some(s"/a$sep/b")) + // reversed -J paths first, then JSONNET_PATH entries in original order + assert(result == Seq("/d", "/c", "/a", "/b")) + } + test("envPathsOnlyNoJpaths") { + val config = Config(file = "test.jsonnet") + val result = config.getOrderedJpaths(jsonnetPathEnv = Some(s"/a$sep/b")) + assert(result == Seq("/a", "/b")) + } + test("noArgsDefaultFallback") { + // Use the injected overload with an empty env string so the test is + // deterministic even when the real JSONNET_PATH env var is set. + val config = Config(jpaths = List("/x"), file = "test.jsonnet") + val result = config.getOrderedJpaths(jsonnetPathEnv = Some("")) + assert(result == Seq("/x")) + } + } + } +} diff --git a/sjsonnet/test/src-jvm/sjsonnet/Example.java b/sjsonnet/test/src-jvm/sjsonnet/Example.java index 041b2383..6b21e8b3 100644 --- a/sjsonnet/test/src-jvm/sjsonnet/Example.java +++ b/sjsonnet/test/src-jvm/sjsonnet/Example.java @@ -13,7 +13,8 @@ public void example(){ os.package$.MODULE$.pwd(), scala.None$.empty(), scala.None$.empty(), - new sjsonnet.stdlib.StdLibModule(Map$.MODULE$.empty(), Map$.MODULE$.empty()).module() + new sjsonnet.stdlib.StdLibModule(Map$.MODULE$.empty(), Map$.MODULE$.empty()).module(), + scala.None$.empty() ); } } diff --git a/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala b/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala index f492ee69..4fb52a9d 100644 --- a/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala +++ b/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala @@ -307,9 +307,102 @@ object MainTests extends TestSuite { |- 2""".stripMargin assert((res, out, err) == ((0, expectedYaml, ""))) } + + test("jsonnetPath") { + // Create temp directories with library files to test JSONNET_PATH resolution + val libDir = os.temp.dir() + os.write(libDir / "mylib.libsonnet", """{ x: 42 }""") + + val mainFile = os.temp(suffix = ".jsonnet") + os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""") + + // Without JSONNET_PATH or -J, the import should fail + val (res1, _, err1) = runMain(mainFile) + assert(res1 == 1) + assert(err1.contains("Couldn't import")) + + // With -J pointing to the lib directory, the import should succeed + val (res2, out2, _) = runMain("-J", libDir, mainFile) + assert(res2 == 0) + assert(out2.trim == "42") + + // With JSONNET_PATH pointing to the lib directory, the import should succeed + val (res3, out3, _) = + runMainWithEnv(jsonnetPathEnv = libDir.toString, mainFile) + assert(res3 == 0) + assert(out3.trim == "42") + } + + test("jsonnetPathMultipleDirs") { + // Test that JSONNET_PATH=a:b results in left-most winning (a has priority over b) + val libDirA = os.temp.dir() + val libDirB = os.temp.dir() + + // Both dirs have mylib.libsonnet but with different values + os.write(libDirA / "mylib.libsonnet", """{ x: "from_a" }""") + os.write(libDirB / "mylib.libsonnet", """{ x: "from_b" }""") + + val mainFile = os.temp(suffix = ".jsonnet") + os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""") + + // JSONNET_PATH=a:b → left-most (a) wins + val sep = java.io.File.pathSeparator + val (res, out, _) = + runMainWithEnv(jsonnetPathEnv = s"$libDirA$sep$libDirB", mainFile) + assert(res == 0) + assert(out.trim == "\"from_a\"") + } + + test("jsonnetPathJpathPriority") { + // -J flags should take priority over JSONNET_PATH + val libDirEnv = os.temp.dir() + val libDirJ = os.temp.dir() + + os.write(libDirEnv / "mylib.libsonnet", """{ x: "from_env" }""") + os.write(libDirJ / "mylib.libsonnet", """{ x: "from_jflag" }""") + + val mainFile = os.temp(suffix = ".jsonnet") + os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""") + + // -J flag should win over JSONNET_PATH + val (res, out, _) = + runMainWithEnv(jsonnetPathEnv = libDirEnv.toString, "-J", libDirJ, mainFile) + assert(res == 0) + assert(out.trim == "\"from_jflag\"") + } + + test("jsonnetPathReverseJpathsPriority") { + // With --reverse-jpaths-priority, rightmost -J wins, but -J still beats JSONNET_PATH + val libDirEnv = os.temp.dir() + val libDirC = os.temp.dir() + val libDirD = os.temp.dir() + + os.write(libDirEnv / "mylib.libsonnet", """{ x: "from_env" }""") + os.write(libDirC / "mylib.libsonnet", """{ x: "from_c" }""") + os.write(libDirD / "mylib.libsonnet", """{ x: "from_d" }""") + + val mainFile = os.temp(suffix = ".jsonnet") + os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""") + + // With --reverse-jpaths-priority, -J d (rightmost) should win over -J c and JSONNET_PATH + val (res, out, _) = runMainWithEnv( + jsonnetPathEnv = libDirEnv.toString, + "--reverse-jpaths-priority", + "-J", + libDirC, + "-J", + libDirD, + mainFile + ) + assert(res == 0) + assert(out.trim == "\"from_d\"") + } } - def runMain(args: os.Shellable*): (Int, String, String) = { + def runMain(args: os.Shellable*): (Int, String, String) = + runMainWithEnv(jsonnetPathEnv = "", args*) + + def runMainWithEnv(jsonnetPathEnv: String, args: os.Shellable*): (Int, String, String) = { val err = new ByteArrayOutputStream() val perr = new PrintStream(err, true, "UTF-8") val out = new ByteArrayOutputStream() @@ -321,7 +414,8 @@ object MainTests extends TestSuite { pout, perr, workspaceRoot, - None + None, + jsonnetPathEnv = Some(jsonnetPathEnv) ) (res, new String(out.toByteArray, "UTF-8"), new String(err.toByteArray, "UTF-8")) }