From ae0293ac0ca7c91d603f7529112186f170179e18 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Fri, 3 Jul 2026 07:18:46 +0800 Subject: [PATCH] fix: convert format parser exceptions to Jsonnet errors Motivation: Jsonnet documents std.format as Python-style percent formatting and documents the % operator as shorthand for std.format. Malformed percent-format specs are user input errors: Python raises ValueError, and other Jsonnet implementations report runtime format errors. sjsonnet's scanner/lowering path threw plain JVM exceptions for cases such as "%z", a trailing "%", or an unterminated named label, which surfaced as "Internal Error" with Java stack traces. Modification: Introduce parseFormatOrFail to wrap both cached format-string parsing and legacy lowered-format conversion. Preserve existing sjsonnet Error values, but map NonFatal parser/lowering exceptions to Error.fail at the format expression position. Add regression coverage for std.format invalid conversions, truncated format specs, unterminated named labels, the % shorthand path, and representative successful formats. Result: Malformed format strings now produce user-facing sjsonnet errors without leaking Java stack traces. The % shorthand continues to use the same formatting implementation while keeping its existing operator stack shape. Verified with __.checkFormat, sjsonnet.jvm[3.3.8].test, and sjsonnet.jvm[3.3.8].assembly. References: - https://jsonnet.org/ref/stdlib.html#std-format - https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting --- sjsonnet/src/sjsonnet/Format.scala | 16 ++++++++++++++-- .../error.format_invalid_conversion.jsonnet | 1 + ...rror.format_invalid_conversion.jsonnet.golden | 2 ++ ...or.format_operator_invalid_conversion.jsonnet | 1 + ...at_operator_invalid_conversion.jsonnet.golden | 1 + .../error.format_truncated.jsonnet | 1 + .../error.format_truncated.jsonnet.golden | 2 ++ .../error.format_unterminated_label.jsonnet | 1 + ...rror.format_unterminated_label.jsonnet.golden | 1 + .../new_test_suite/format_error_messages.jsonnet | 5 +++++ .../format_error_messages.jsonnet.golden | 1 + 11 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet.golden create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet.golden create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet.golden create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet create mode 100644 sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet.golden create mode 100644 sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet create mode 100644 sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet.golden diff --git a/sjsonnet/src/sjsonnet/Format.scala b/sjsonnet/src/sjsonnet/Format.scala index 2f88e1a52..6bb7becaf 100644 --- a/sjsonnet/src/sjsonnet/Format.scala +++ b/sjsonnet/src/sjsonnet/Format.scala @@ -1,5 +1,7 @@ package sjsonnet +import scala.util.control.NonFatal + /** * Minimal re-implementation of Python's `%` formatting logic, since Jsonnet's `%` formatter is * basically "do whatever python does", with a link to: @@ -273,7 +275,7 @@ object Format { } def format(s: String, values0: Val, pos: Position)(implicit evaluator: EvalScope): Val.Str = { - val parsed = parseFormatCached(s, evaluator.formatCache) + val parsed = parseFormatOrFail(pos)(parseFormatCached(s, evaluator.formatCache)) format(parsed, values0, pos) } @@ -558,9 +560,19 @@ object Format { def format(leading: String, chunks: scala.Seq[(FormatSpec, String)], values0: Val, pos: Position)( implicit evaluator: EvalScope): Val.Str = { - format(lowerParsedFormat((leading, chunks)), values0, pos) + val parsed = parseFormatOrFail(pos)(lowerParsedFormat((leading, chunks))) + format(parsed, values0, pos) } + private def parseFormatOrFail(pos: Position)(parse: => RuntimeFormat)(implicit + evaluator: EvalErrorScope): RuntimeFormat = + try parse + catch { + case e: Error => throw e + case NonFatal(e) => + Error.fail(e.getMessage, pos) + } + private def appendLeading(output: java.lang.StringBuilder, parsed: RuntimeFormat): Unit = { val source = parsed.source if (source != null) output.append(source, parsed.leadingStart, parsed.leadingEnd) diff --git a/sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet b/sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet new file mode 100644 index 000000000..ca3eeda20 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet @@ -0,0 +1 @@ +std.format("%z", [1]) diff --git a/sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet.golden b/sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet.golden new file mode 100644 index 000000000..41651169d --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_invalid_conversion.jsonnet.golden @@ -0,0 +1,2 @@ +sjsonnet.Error: [std.format] Unrecognized conversion type: z + at [].(error.format_invalid_conversion.jsonnet:1:11) diff --git a/sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet b/sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet new file mode 100644 index 000000000..359470419 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet @@ -0,0 +1 @@ +"%z" % [1] diff --git a/sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet.golden b/sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet.golden new file mode 100644 index 000000000..b5c12b48c --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_operator_invalid_conversion.jsonnet.golden @@ -0,0 +1 @@ +sjsonnet.Error: Unrecognized conversion type: z diff --git a/sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet b/sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet new file mode 100644 index 000000000..9564c8a65 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet @@ -0,0 +1 @@ +std.format("hello %", []) diff --git a/sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet.golden b/sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet.golden new file mode 100644 index 000000000..e9f434e3e --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_truncated.jsonnet.golden @@ -0,0 +1,2 @@ +sjsonnet.Error: [std.format] Truncated format code at end of string + at [].(error.format_truncated.jsonnet:1:11) diff --git a/sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet b/sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet new file mode 100644 index 000000000..1e95db241 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet @@ -0,0 +1 @@ +std.format("%(key", { key: "value" }) diff --git a/sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet.golden b/sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet.golden new file mode 100644 index 000000000..7e86c2c23 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/error.format_unterminated_label.jsonnet.golden @@ -0,0 +1 @@ +sjsonnet.Error: [std.format] Unterminated ( in format spec diff --git a/sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet b/sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet new file mode 100644 index 000000000..943f00771 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet @@ -0,0 +1,5 @@ +std.assertEqual(std.format("100%% done %s", ["yes"]), "100% done yes") && +std.assertEqual(std.format("%*s", [10, "hello"]), " hello") && +std.assertEqual(std.format("%.*f", [3, 3.14159]), "3.142") && +std.assertEqual(std.format("%#x", [255]), "0xff") && +true diff --git a/sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet.golden b/sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet.golden new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/format_error_messages.jsonnet.golden @@ -0,0 +1 @@ +true