Skip to content

Commit dec7e3e

Browse files
authored
feat: Add JSONNET_PATH support for cli (#616)
refs: #608 @tmeijn would you like to test this locally?
1 parent e29b862 commit dec7e3e

5 files changed

Lines changed: 275 additions & 21 deletions

File tree

sjsonnet/src-jvm-native/sjsonnet/Config.scala

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,21 +164,55 @@ final case class Config(
164164
) {
165165

166166
/**
167-
* Returns the sequence of jpaths specified on the command line, ordered according to the flags.
167+
* Returns the sequence of jpaths, combining command-line flags and the JSONNET_PATH environment
168+
* variable.
168169
*
169-
* Historically, sjsonnet evaluated jpaths in left-to-right order, which is also the order of
170-
* evaluation in the core. However, in gojsonnet, the arguments are prioritized right to left, and
171-
* the reverse-jpaths-priority flag was introduced for possible consistency across the two
170+
* JSONNET_PATH directories always have lower priority than --jpath flags. Within JSONNET_PATH,
171+
* the left-most entry has the highest priority, matching the behavior of the C++ and Go
172172
* implementations.
173173
*
174+
* The --reverse-jpaths-priority flag only affects the ordering of --jpath flags (reversing them
175+
* so that the rightmost wins, matching go-jsonnet behavior). JSONNET_PATH entries are always
176+
* appended after the (possibly reversed) --jpath flags.
177+
*
178+
* For example, `JSONNET_PATH=a:b sjsonnet -J c -J d` results in search order: c, d, a, b (default
179+
* mode). With --reverse-jpaths-priority, the order becomes: d, c, a, b.
180+
*
174181
* See [[https://jsonnet-libs.github.io/jsonnet-training-course/lesson2.html#jsonnet_path]] for
175182
* details.
176183
*/
177-
def getOrderedJpaths: Seq[String] = {
178-
if (reverseJpathsPriority.value) {
179-
jpaths.reverse
180-
} else {
181-
jpaths
184+
def getOrderedJpaths: Seq[String] = getOrderedJpaths(jsonnetPathEnv = None)
185+
186+
/**
187+
* Returns the sequence of jpaths, combining command-line flags and the JSONNET_PATH environment
188+
* variable.
189+
*
190+
* @param jsonnetPathEnv
191+
* If Some(value), use the given value instead of reading from the JSONNET_PATH environment
192+
* variable. If None, read from System.getenv("JSONNET_PATH").
193+
*/
194+
def getOrderedJpaths(jsonnetPathEnv: Option[String]): Seq[String] = {
195+
val envValue = jsonnetPathEnv.getOrElse(System.getenv("JSONNET_PATH"))
196+
val envPaths = Config.jsonnetPathEntries(envValue)
197+
val orderedJpaths = if (reverseJpathsPriority.value) jpaths.reverse else jpaths
198+
orderedJpaths ++ envPaths
199+
}
200+
}
201+
202+
object Config {
203+
204+
/**
205+
* Parses the JSONNET_PATH value into a sequence of directory paths. Entries are kept in their
206+
* original order so that, with sjsonnet's default left-to-right search, the left-most entry in
207+
* the environment variable has the highest priority among the JSONNET_PATH entries, matching the
208+
* behavior of the C++ and Go implementations.
209+
*
210+
* The separator is colon on Unix and semicolon on Windows ({@code java.io.File.pathSeparator}).
211+
*/
212+
private[sjsonnet] def jsonnetPathEntries(envValue: String): Seq[String] = {
213+
if (envValue == null || envValue.isEmpty) Nil
214+
else {
215+
envValue.split(java.io.File.pathSeparator).filter(_.nonEmpty).toSeq
182216
}
183217
}
184218
}

sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,33 @@ object SjsonnetMainBase {
6464
}
6565
}
6666

67+
/**
68+
* Java-compatible overload that omits `jsonnetPathEnv` (defaults to `None`). Keeps source
69+
* compatibility for callers that were compiled against the pre-JSONNET_PATH signature.
70+
*/
71+
def main0(
72+
args: Array[String],
73+
parseCache: ParseCache,
74+
stdin: InputStream,
75+
stdout: PrintStream,
76+
stderr: PrintStream,
77+
wd: os.Path,
78+
allowedInputs: Option[Set[os.Path]],
79+
importer: Option[Importer],
80+
std: Val.Obj): Int =
81+
main0(
82+
args,
83+
parseCache,
84+
stdin,
85+
stdout,
86+
stderr,
87+
wd,
88+
allowedInputs,
89+
importer,
90+
std,
91+
jsonnetPathEnv = None
92+
)
93+
6794
def main0(
6895
args: Array[String],
6996
parseCache: ParseCache,
@@ -73,7 +100,8 @@ object SjsonnetMainBase {
73100
wd: os.Path,
74101
allowedInputs: Option[Set[os.Path]] = None,
75102
importer: Option[Importer] = None,
76-
std: Val.Obj = sjsonnet.stdlib.StdLibModule.Default.module): Int = {
103+
std: Val.Obj = sjsonnet.stdlib.StdLibModule.Default.module,
104+
jsonnetPathEnv: Option[String] = None): Int = {
77105

78106
var hasWarnings = false
79107
def warn(isTrace: Boolean, msg: String): Unit = {
@@ -87,14 +115,27 @@ object SjsonnetMainBase {
87115
val parser = mainargs.ParserForClass[Config]
88116
val name = s"Sjsonnet ${sjsonnet.Version.version}"
89117
val doc = "usage: sjsonnet [sjsonnet-options] script-file"
118+
val envVarsDoc =
119+
"""
120+
|Environment variables:
121+
| JSONNET_PATH is a colon (semicolon on Windows) separated list of directories
122+
| added in reverse order before the paths specified by --jpath (i.e. left-most
123+
| wins). E.g. these are equivalent:
124+
| JSONNET_PATH=a:b sjsonnet -J c -J d
125+
| JSONNET_PATH=d:c:a:b sjsonnet
126+
| sjsonnet -J b -J a -J c -J d""".stripMargin
127+
90128
val result = for {
91-
config <- parser.constructEither(
92-
args.toIndexedSeq,
93-
allowRepeats = true,
94-
customName = name,
95-
customDoc = doc,
96-
autoPrintHelpAndExit = None
97-
)
129+
config <- parser
130+
.constructEither(
131+
args.toIndexedSeq,
132+
allowRepeats = true,
133+
customName = name,
134+
customDoc = doc,
135+
autoPrintHelpAndExit = None
136+
)
137+
.left
138+
.map(_ + envVarsDoc)
98139
_ <- {
99140
if (config.noTrailingNewline.value && config.yamlStream.value)
100141
Left("error: cannot use --no-trailing-newline with --yaml-stream")
@@ -115,7 +156,7 @@ object SjsonnetMainBase {
115156
wd,
116157
importer.getOrElse {
117158
new SimpleImporter(
118-
config.getOrderedJpaths.map(p => OsPath(os.Path(p, wd))),
159+
config.getOrderedJpaths(jsonnetPathEnv).map(p => OsPath(os.Path(p, wd))),
119160
allowedInputs,
120161
debugImporter = config.debugImporter.value
121162
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package sjsonnet
2+
3+
import utest.*
4+
5+
object ConfigTests extends TestSuite {
6+
private val sep = java.io.File.pathSeparator
7+
8+
val tests: Tests = Tests {
9+
test("jsonnetPathEntries") {
10+
test("null") {
11+
assert(Config.jsonnetPathEntries(null) == Nil)
12+
}
13+
test("empty") {
14+
assert(Config.jsonnetPathEntries("") == Nil)
15+
}
16+
test("single") {
17+
// Single path should be returned as-is
18+
assert(Config.jsonnetPathEntries("/foo") == Seq("/foo"))
19+
}
20+
test("multiple") {
21+
// JSONNET_PATH=a<sep>b should produce Seq("a", "b") (original order, left-most wins)
22+
val result = Config.jsonnetPathEntries(s"/a${sep}/b${sep}/c")
23+
assert(result == Seq("/a", "/b", "/c"))
24+
}
25+
test("emptyEntries") {
26+
// Empty entries between separators should be filtered out
27+
val result = Config.jsonnetPathEntries(s"/a$sep$sep/b")
28+
assert(result == Seq("/a", "/b"))
29+
}
30+
test("trailingSeparator") {
31+
val result = Config.jsonnetPathEntries(s"/a$sep/b$sep")
32+
assert(result == Seq("/a", "/b"))
33+
}
34+
}
35+
36+
test("getOrderedJpaths") {
37+
test("jpathsOnlyDefaultOrder") {
38+
// Without JSONNET_PATH env set, getOrderedJpaths should return jpaths in original order
39+
val config = Config(jpaths = List("/x", "/y", "/z"), file = "test.jsonnet")
40+
val result = config.getOrderedJpaths(jsonnetPathEnv = Some(""))
41+
assert(result == Seq("/x", "/y", "/z"))
42+
}
43+
test("jpathsOnlyReversed") {
44+
import mainargs.Flag
45+
val config = Config(
46+
jpaths = List("/x", "/y", "/z"),
47+
reverseJpathsPriority = Flag(true),
48+
file = "test.jsonnet"
49+
)
50+
val result = config.getOrderedJpaths(jsonnetPathEnv = Some(""))
51+
assert(result == Seq("/z", "/y", "/x"))
52+
}
53+
test("envPathsAppendedAfterJpaths") {
54+
val config = Config(jpaths = List("/c", "/d"), file = "test.jsonnet")
55+
val result = config.getOrderedJpaths(jsonnetPathEnv = Some(s"/a$sep/b"))
56+
// -J paths first, then JSONNET_PATH entries in original order
57+
assert(result == Seq("/c", "/d", "/a", "/b"))
58+
}
59+
test("envPathsWithReverse") {
60+
import mainargs.Flag
61+
val config = Config(
62+
jpaths = List("/c", "/d"),
63+
reverseJpathsPriority = Flag(true),
64+
file = "test.jsonnet"
65+
)
66+
val result = config.getOrderedJpaths(jsonnetPathEnv = Some(s"/a$sep/b"))
67+
// reversed -J paths first, then JSONNET_PATH entries in original order
68+
assert(result == Seq("/d", "/c", "/a", "/b"))
69+
}
70+
test("envPathsOnlyNoJpaths") {
71+
val config = Config(file = "test.jsonnet")
72+
val result = config.getOrderedJpaths(jsonnetPathEnv = Some(s"/a$sep/b"))
73+
assert(result == Seq("/a", "/b"))
74+
}
75+
test("noArgsDefaultFallback") {
76+
// Use the injected overload with an empty env string so the test is
77+
// deterministic even when the real JSONNET_PATH env var is set.
78+
val config = Config(jpaths = List("/x"), file = "test.jsonnet")
79+
val result = config.getOrderedJpaths(jsonnetPathEnv = Some(""))
80+
assert(result == Seq("/x"))
81+
}
82+
}
83+
}
84+
}

sjsonnet/test/src-jvm/sjsonnet/Example.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ public void example(){
1313
os.package$.MODULE$.pwd(),
1414
scala.None$.empty(),
1515
scala.None$.empty(),
16-
new sjsonnet.stdlib.StdLibModule(Map$.MODULE$.empty(), Map$.MODULE$.empty()).module()
16+
new sjsonnet.stdlib.StdLibModule(Map$.MODULE$.empty(), Map$.MODULE$.empty()).module(),
17+
scala.None$.empty()
1718
);
1819
}
1920
}

sjsonnet/test/src-jvm/sjsonnet/MainTests.scala

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,102 @@ object MainTests extends TestSuite {
307307
|- 2""".stripMargin
308308
assert((res, out, err) == ((0, expectedYaml, "")))
309309
}
310+
311+
test("jsonnetPath") {
312+
// Create temp directories with library files to test JSONNET_PATH resolution
313+
val libDir = os.temp.dir()
314+
os.write(libDir / "mylib.libsonnet", """{ x: 42 }""")
315+
316+
val mainFile = os.temp(suffix = ".jsonnet")
317+
os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""")
318+
319+
// Without JSONNET_PATH or -J, the import should fail
320+
val (res1, _, err1) = runMain(mainFile)
321+
assert(res1 == 1)
322+
assert(err1.contains("Couldn't import"))
323+
324+
// With -J pointing to the lib directory, the import should succeed
325+
val (res2, out2, _) = runMain("-J", libDir, mainFile)
326+
assert(res2 == 0)
327+
assert(out2.trim == "42")
328+
329+
// With JSONNET_PATH pointing to the lib directory, the import should succeed
330+
val (res3, out3, _) =
331+
runMainWithEnv(jsonnetPathEnv = libDir.toString, mainFile)
332+
assert(res3 == 0)
333+
assert(out3.trim == "42")
334+
}
335+
336+
test("jsonnetPathMultipleDirs") {
337+
// Test that JSONNET_PATH=a:b results in left-most winning (a has priority over b)
338+
val libDirA = os.temp.dir()
339+
val libDirB = os.temp.dir()
340+
341+
// Both dirs have mylib.libsonnet but with different values
342+
os.write(libDirA / "mylib.libsonnet", """{ x: "from_a" }""")
343+
os.write(libDirB / "mylib.libsonnet", """{ x: "from_b" }""")
344+
345+
val mainFile = os.temp(suffix = ".jsonnet")
346+
os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""")
347+
348+
// JSONNET_PATH=a:b → left-most (a) wins
349+
val sep = java.io.File.pathSeparator
350+
val (res, out, _) =
351+
runMainWithEnv(jsonnetPathEnv = s"$libDirA$sep$libDirB", mainFile)
352+
assert(res == 0)
353+
assert(out.trim == "\"from_a\"")
354+
}
355+
356+
test("jsonnetPathJpathPriority") {
357+
// -J flags should take priority over JSONNET_PATH
358+
val libDirEnv = os.temp.dir()
359+
val libDirJ = os.temp.dir()
360+
361+
os.write(libDirEnv / "mylib.libsonnet", """{ x: "from_env" }""")
362+
os.write(libDirJ / "mylib.libsonnet", """{ x: "from_jflag" }""")
363+
364+
val mainFile = os.temp(suffix = ".jsonnet")
365+
os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""")
366+
367+
// -J flag should win over JSONNET_PATH
368+
val (res, out, _) =
369+
runMainWithEnv(jsonnetPathEnv = libDirEnv.toString, "-J", libDirJ, mainFile)
370+
assert(res == 0)
371+
assert(out.trim == "\"from_jflag\"")
372+
}
373+
374+
test("jsonnetPathReverseJpathsPriority") {
375+
// With --reverse-jpaths-priority, rightmost -J wins, but -J still beats JSONNET_PATH
376+
val libDirEnv = os.temp.dir()
377+
val libDirC = os.temp.dir()
378+
val libDirD = os.temp.dir()
379+
380+
os.write(libDirEnv / "mylib.libsonnet", """{ x: "from_env" }""")
381+
os.write(libDirC / "mylib.libsonnet", """{ x: "from_c" }""")
382+
os.write(libDirD / "mylib.libsonnet", """{ x: "from_d" }""")
383+
384+
val mainFile = os.temp(suffix = ".jsonnet")
385+
os.write.over(mainFile, """local lib = import 'mylib.libsonnet'; lib.x""")
386+
387+
// With --reverse-jpaths-priority, -J d (rightmost) should win over -J c and JSONNET_PATH
388+
val (res, out, _) = runMainWithEnv(
389+
jsonnetPathEnv = libDirEnv.toString,
390+
"--reverse-jpaths-priority",
391+
"-J",
392+
libDirC,
393+
"-J",
394+
libDirD,
395+
mainFile
396+
)
397+
assert(res == 0)
398+
assert(out.trim == "\"from_d\"")
399+
}
310400
}
311401

312-
def runMain(args: os.Shellable*): (Int, String, String) = {
402+
def runMain(args: os.Shellable*): (Int, String, String) =
403+
runMainWithEnv(jsonnetPathEnv = "", args*)
404+
405+
def runMainWithEnv(jsonnetPathEnv: String, args: os.Shellable*): (Int, String, String) = {
313406
val err = new ByteArrayOutputStream()
314407
val perr = new PrintStream(err, true, "UTF-8")
315408
val out = new ByteArrayOutputStream()
@@ -321,7 +414,8 @@ object MainTests extends TestSuite {
321414
pout,
322415
perr,
323416
workspaceRoot,
324-
None
417+
None,
418+
jsonnetPathEnv = Some(jsonnetPathEnv)
325419
)
326420
(res, new String(out.toByteArray, "UTF-8"), new String(err.toByteArray, "UTF-8"))
327421
}

0 commit comments

Comments
 (0)