From 6335d44e49076cbdb62f227ceaa5b6bcc7d16956 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Fri, 27 Feb 2026 21:42:24 +0800 Subject: [PATCH] feat: Add missing newlines and add flag --no-trailing-newline port https://github.com/google/go-jsonnet/pull/843 --- readme.md | 1 + sjsonnet/src-jvm-native/sjsonnet/Config.scala | 5 + .../sjsonnet/SjsonnetMainBase.scala | 33 +++- .../test/resources/db/multi_string.jsonnet | 4 + .../test/src-jvm/sjsonnet/MainTests.scala | 163 +++++++++++++++++- 5 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 sjsonnet/test/resources/db/multi_string.jsonnet diff --git a/readme.md b/readme.md index d1923a58..cfa46e4f 100644 --- a/readme.md +++ b/readme.md @@ -44,6 +44,7 @@ usage: sjsonnet [sjsonnet-options] script-file --tla-str-file = Provide top-level arguments variable as string from the file -y --yaml-stream Write output as a YAML stream of JSON documents + --no-trailing-newline Do not add a trailing newline to the output --yaml-debug Generate source line comments in the output YAML doc to make it easier to figure out where values come from. --yaml-out Write output as a YAML document diff --git a/sjsonnet/src-jvm-native/sjsonnet/Config.scala b/sjsonnet/src-jvm-native/sjsonnet/Config.scala index 8c71281b..db699a44 100644 --- a/sjsonnet/src-jvm-native/sjsonnet/Config.scala +++ b/sjsonnet/src-jvm-native/sjsonnet/Config.scala @@ -145,6 +145,11 @@ final case class Config( "Set maximum parser recursion depth to prevent stack overflow from deeply nested structures" ) maxParserRecursionDepth: Int = 1000, + @arg( + name = "no-trailing-newline", + doc = "Do not add a trailing newline to the output" + ) + noTrailingNewline: Flag = Flag(), @arg( name = "broken-assertion-logic", doc = diff --git a/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala b/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala index 3fb7ab2b..2769b334 100644 --- a/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala +++ b/sjsonnet/src-jvm-native/sjsonnet/SjsonnetMainBase.scala @@ -95,6 +95,11 @@ object SjsonnetMainBase { customDoc = doc, autoPrintHelpAndExit = None ) + _ <- { + if (config.noTrailingNewline.value && config.yamlStream.value) + Left("error: cannot use --no-trailing-newline with --yaml-stream") + else Right(()) + } file <- Right(config.file) outputStr <- mainConfigured( file, @@ -131,7 +136,12 @@ object SjsonnetMainBase { case Right((config, str)) => if (str.nonEmpty) { config.outputFile match { - case None => stdout.println(str) + case None => + // In multi mode, the file list on stdout always ends with a newline, + // matching go-jsonnet/C++ jsonnet behavior. --no-trailing-newline only + // affects the content written to the output files, not the file list. + if (config.multi.isDefined || !config.noTrailingNewline.value) stdout.println(str) + else stdout.print(str) case Some(f) => os.write.over(os.Path(f, wd), str) } } @@ -162,8 +172,18 @@ object SjsonnetMainBase { case e => e.toString } - private def writeFile(config: Config, f: os.Path, contents: String): Either[String, Unit] = - handleWriteFile(os.write.over(f, contents, createFolders = config.createDirs.value)) + private def writeFile( + config: Config, + f: os.Path, + contents: String, + trailingNewline: Boolean): Either[String, Unit] = + handleWriteFile( + os.write.over( + f, + if (trailingNewline) contents + "\n" else contents, + createFolders = config.createDirs.value + ) + ) private def writeToFile(config: Config, wd: os.Path)( materialize: Writer => Either[String, ?]): Either[String, String] = { @@ -196,7 +216,7 @@ object SjsonnetMainBase { writeToFile(config, wd) { writer => val renderer = rendererForConfig(writer, config, getCurrentPosition) val res = interp.interpret0(jsonnetCode, OsPath(path), renderer) - if (config.yamlOut.value) writer.write('\n') + if (config.yamlOut.value && !config.noTrailingNewline.value) writer.write('\n') res } } @@ -301,6 +321,7 @@ object SjsonnetMainBase { (config.multi, config.yamlStream.value) match { case (Some(multiPath), _) => + val trailingNewline = !config.noTrailingNewline.value interp.interpret(jsonnetCode, OsPath(path)).flatMap { case obj: ujson.Obj => val renderedFiles: Seq[Either[String, os.FilePath]] = @@ -313,7 +334,7 @@ object SjsonnetMainBase { Right(writer.toString) } relPath = (os.FilePath(multiPath) / os.RelPath(f)).asInstanceOf[os.FilePath] - _ <- writeFile(config, relPath.resolveFrom(wd), rendered) + _ <- writeFile(config, relPath.resolveFrom(wd), rendered, trailingNewline) } yield relPath } @@ -333,7 +354,7 @@ object SjsonnetMainBase { ) } case (None, true) => - // YAML stream + // YAML stream (--no-trailing-newline is already rejected above for yaml-stream) interp.interpret(jsonnetCode, OsPath(path)).flatMap { case arr: ujson.Arr => diff --git a/sjsonnet/test/resources/db/multi_string.jsonnet b/sjsonnet/test/resources/db/multi_string.jsonnet new file mode 100644 index 00000000..be2d938e --- /dev/null +++ b/sjsonnet/test/resources/db/multi_string.jsonnet @@ -0,0 +1,4 @@ +{ + "hello.txt": "hello world", + "bar.txt": "bar" +} diff --git a/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala b/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala index bdfbce7b..f492ee69 100644 --- a/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala +++ b/sjsonnet/test/src-jvm/sjsonnet/MainTests.scala @@ -114,10 +114,10 @@ object MainTests extends TestSuite { assert((res, out, err) == ((0, expectedOut, ""))) val helloDestStr = os.read(multiDest / "hello") - assert(helloDestStr == "1") + assert(helloDestStr == "1\n") val worldDestStr = os.read(multiDest / "world") - assert(worldDestStr == expectedWorldDestStr) + assert(worldDestStr == expectedWorldDestStr + "\n") } test("multiOutputFile") { @@ -132,10 +132,10 @@ object MainTests extends TestSuite { assert(destStr == expectedOut) val helloDestStr = os.read(multiDest / "hello") - assert(helloDestStr == "1") + assert(helloDestStr == "1\n") val worldDestStr = os.read(multiDest / "world") - assert(worldDestStr == expectedWorldDestStr) + assert(worldDestStr == expectedWorldDestStr + "\n") } test("multiYamlOut") { @@ -146,16 +146,167 @@ object MainTests extends TestSuite { assert((res, out, err) == ((0, expectedOut, ""))) val helloDestStr = os.read(multiDest / "hello") - assert(helloDestStr == "1") + assert(helloDestStr == "1\n") val worldDestStr = os.read(multiDest / "world") assert( worldDestStr == """- 2 |- three - |- true""".stripMargin + |- true + |""".stripMargin ) } + + // -- Default trailing newline behavior (with newline) -- + + test("execString") { + val source = """"hello"""" + val (res, out, err) = runMain(source, "--exec", "--string") + assert((res, out, err) == ((0, "hello\n", ""))) + } + + test("multiStringOutput") { + val source = testSuiteRoot / "db" / "multi_string.jsonnet" + val multiDest = os.temp.dir() + val (res, out, err) = runMain(source, "--multi", multiDest, "--string") + assert(res == 0) + assert(err.isEmpty) + + val helloDestStr = os.read(multiDest / "hello.txt") + assert(helloDestStr == "hello world\n") + + val barDestStr = os.read(multiDest / "bar.txt") + assert(barDestStr == "bar\n") + } + + // -- No trailing newline behavior -- + + test("noTrailingNewline") { + // Simple scalar output — default has trailing newline + val (resDefault, outDefault, _) = runMain("42", "--exec") + assert((resDefault, outDefault) == ((0, "42\n"))) + + // Simple scalar output — no trailing newline + val (res1, out1, err1) = runMain("42", "--exec", "--no-trailing-newline") + assert((res1, out1, err1) == ((0, "42", ""))) + + // Object output — default has trailing newline + val (resObj, outObj, _) = runMain("""{"a": 1, "b": 2}""", "--exec") + val expectedJsonWithNewline = + """{ + | "a": 1, + | "b": 2 + |} + |""".stripMargin + assert((resObj, outObj) == ((0, expectedJsonWithNewline))) + + // Object output — no trailing newline + val (res2, out2, err2) = + runMain("""{"a": 1, "b": 2}""", "--exec", "--no-trailing-newline") + val expectedJson = + """{ + | "a": 1, + | "b": 2 + |}""".stripMargin + assert((res2, out2, err2) == ((0, expectedJson, ""))) + + // String output — default has trailing newline + val (resStr, outStr, _) = runMain(""""hello"""", "--exec", "--string") + assert((resStr, outStr) == ((0, "hello\n"))) + + // String output — no trailing newline + val (res3, out3, err3) = + runMain(""""hello"""", "--exec", "--string", "--no-trailing-newline") + assert((res3, out3, err3) == ((0, "hello", ""))) + } + + test("noTrailingNewlineMulti") { + // Default multi — files have trailing newline + val source = testSuiteRoot / "db" / "multi.jsonnet" + val multiDestDefault = os.temp.dir() + val (resDefault, _, _) = runMain(source, "--multi", multiDestDefault) + assert(resDefault == 0) + assert(os.read(multiDestDefault / "hello") == "1\n") + assert(os.read(multiDestDefault / "world") == expectedWorldDestStr + "\n") + + // No trailing newline multi — files have no trailing newline, + // but the file list on stdout still ends with \n (matching go-jsonnet behavior) + val multiDest = os.temp.dir() + val (res, out, err) = + runMain(source, "--multi", multiDest, "--no-trailing-newline") + val expectedOut = s"$multiDest/hello\n$multiDest/world\n" + assert((res, out, err) == ((0, expectedOut, ""))) + assert(os.read(multiDest / "hello") == "1") + assert(os.read(multiDest / "world") == expectedWorldDestStr) + } + + test("noTrailingNewlineMultiString") { + val source = testSuiteRoot / "db" / "multi_string.jsonnet" + + // Default multi+string — files have trailing newline + val multiDestDefault = os.temp.dir() + val (resDefault, _, _) = runMain(source, "--multi", multiDestDefault, "--string") + assert(resDefault == 0) + assert(os.read(multiDestDefault / "hello.txt") == "hello world\n") + assert(os.read(multiDestDefault / "bar.txt") == "bar\n") + + // No trailing newline multi+string — files have no trailing newline, + // but the file list on stdout still ends with \n (matching go-jsonnet behavior) + val multiDest = os.temp.dir() + val (res, out, err) = + runMain(source, "--multi", multiDest, "--string", "--no-trailing-newline") + val expectedOut = s"$multiDest/bar.txt\n$multiDest/hello.txt\n" + assert((res, out, err) == ((0, expectedOut, ""))) + assert(os.read(multiDest / "hello.txt") == "hello world") + assert(os.read(multiDest / "bar.txt") == "bar") + } + + test("noTrailingNewlineOutputFile") { + // Default output-file — file has content without trailing newline (file mode) + val source = "42" + val destDefault = os.temp() + val (resDefault, _, _) = runMain(source, "--exec", "--output-file", destDefault) + assert(resDefault == 0) + val defaultContent = os.read(destDefault) + assert(defaultContent == "42") + + // No trailing newline output-file — same behavior + val dest = os.temp() + val (res, out, err) = + runMain(source, "--exec", "--no-trailing-newline", "--output-file", dest) + assert((res, out, err) == ((0, "", ""))) + assert(os.read(dest) == "42") + } + + test("noTrailingNewlineYamlStreamError") { + val source = testSuiteRoot / "db" / "stream.jsonnet" + val (res, out, err) = + runMain(source, "--yaml-stream", "--no-trailing-newline") + assert(res == 1) + assert(out.isEmpty) + assert(err.contains("cannot use --no-trailing-newline with --yaml-stream")) + } + + test("noTrailingNewlineYamlOut") { + // Default yaml-out — has trailing newline + val source = "local x = [1]; local y = [2]; x + y" + val (resDefault, outDefault, _) = runMain(source, "--exec", "--yaml-out") + val expectedYamlWithNewline = + """- 1 + |- 2 + | + |""".stripMargin + assert((resDefault, outDefault) == ((0, expectedYamlWithNewline))) + + // No trailing newline yaml-out — no trailing newline + val (res, out, err) = + runMain(source, "--exec", "--yaml-out", "--no-trailing-newline") + val expectedYaml = + """- 1 + |- 2""".stripMargin + assert((res, out, err) == ((0, expectedYaml, ""))) + } } def runMain(args: os.Shellable*): (Int, String, String) = {