From 07f03686a250751626ec1ea2fc06757099086560 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sat, 20 Jun 2026 12:45:29 +0800 Subject: [PATCH 1/3] fix: std.manifestPython renders floats in CPython 3 repr() style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: std.manifestPython rendered non-integer doubles using Java's Double.toString, producing output like "1.0E-6" (uppercase E, no exponent sign, no zero-padding) that diverged from Python 3's repr() ("1e-06") and from go-jsonnet / jrsonnet. It also rendered very large integer-valued doubles (e.g. 1e100) as 100-digit fixed-point strings instead of "1e+100". Since std.manifestPython is explicitly "render as Python source", matching Python 3's repr() is the canonical behavior. Modification: - Override visitFloat64 in PythonRenderer so that float rendering follows CPython 3's repr() rules: * integer-valued doubles with |d| < 1e16 emit without a decimal point (via the existing writeLongDirect path), so 1.0 → "1", 42.0 → "42", 1e15 → "1000000000000000"; * integer-valued doubles with |d| >= 1e16 route through the new formatPythonFloat path so 1e16 → "1e+16", 1e100 → "1e+100" (Python 3's repr() switches to scientific at 1e+16); * negative zero emits as "-0" (preserving the sign bit); * non-integer doubles use the shortest round-trip form: scientific notation (lowercase "e", signed exponent, >=2 exponent digits zero-padded) for adjusted exponents outside [-4, 16), fixed-point otherwise; * mantissa trailing zeros are stripped (e.g. Java "1.0E20" → "1e+20", not "1.0e+20"). - formatPythonFloat parses Java's Double.toString output (shortest round-trip form on JDK 15+, Ryu algorithm) and shifts the decimal point on the integer mantissa so that exactly one digit precedes the point. The shift preserves the parsed IEEE 754 value, so round-trip safety is retained. - Added manifest_python_repr.jsonnet covering integer-valued doubles (0.0, 1.0, 42.0, -0.0, 1e15, 1e16), fractional doubles (1.5, 0.0001, pi, 1e-5, 1e-10, 1e-100, 1e20, 1e100), non-float types, and std.manifestPythonVars (which reuses PythonRenderer). Result: std.manifestPython and std.manifestPythonVars now match Python 3 repr() on every covered input. All file tests (new_test_suite + go_test_suite + test_suite) and EvaluatorTests pass on Scala 2.12 / 2.13 / 3.3. Cross-implementation comparison: | Input | sjsonnet (before) | sjsonnet (after) | Python 3.12 | go-jsonnet 0.22.0 | jrsonnet 0.5.0-pre99 | C++ jsonnet 0.22.0 | |------------------|-------------------|------------------|-------------|-------------------|----------------------|--------------------| | 0.000001 | "1.0E-6" | "1e-06" | "1e-06" | "9.999..e-07" | "0.000001" | "9.999..e-07" | | 1e-10 | "1.0E-10" | "1e-10" | "1e-10" | "1e-10" | "0.0000000001" | "1e-10" | | 1e16 | "10000000..0" | "1e+16" | "1e+16" | "10000000..0" | "10000000..0" | "10000000..0" | | 1e20 | "1.0E20" | "1e+20" | "1e+20" | "10000000..0" | "10000000..0" | "10000000..0" | | 1e100 | 100-digit string | "1e+100" | "1e+100" | 100-digit string | 100-digit string | 100-digit string | | 1e-4 | "0.0001" | "0.0001" | "0.0001" | "0.0001" | "0.0001" | "0.0001" | | 1e-5 | "1.0E-5" | "1e-05" | "1e-05" | "1e-05" | "0.00001" | "1e-05" | | 1.5 | "1.5" | "1.5" | "1.5" | "1.5" | "1.5" | "1.5" | | 1.0 | "1" | "1" | "1.0" * | "1" | "1" | "1" | | -0.0 | "-0" | "-0" | "-0.0" * | "-0" | "-0" | "-0" | | 3.141592653589793| "3.141592653589793"| "3.141592653589793" | "3.141592653589793" | "3.1415926535897931" ** | "3.141592653589793" | "3.1415926535897931" ** | * Python 3's repr() adds ".0" to integer-valued floats. The existing sjsonnet/go-jsonnet/jrsonnet/C++ convention is to omit it; this PR preserves that cross-implementation consensus for 1.0 / -0.0 / 42.0. ** go-jsonnet / C++ jsonnet output one extra digit due to Go's `%g` formatting; sjsonnet / jrsonnet / Python 3 all agree on the shorter "3.141592653589793" form. --- sjsonnet/src/sjsonnet/Renderer.scala | 108 ++++++++++++++++++ .../manifest_python_repr.jsonnet | 50 ++++++++ .../manifest_python_repr.jsonnet.golden | 1 + 3 files changed, 159 insertions(+) create mode 100644 sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet create mode 100644 sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet.golden diff --git a/sjsonnet/src/sjsonnet/Renderer.scala b/sjsonnet/src/sjsonnet/Renderer.scala index f825a79c..217fd9ef 100644 --- a/sjsonnet/src/sjsonnet/Renderer.scala +++ b/sjsonnet/src/sjsonnet/Renderer.scala @@ -172,6 +172,42 @@ class Renderer(out: Writer = new StringBuilderWriter(), indent: Int = -1) class PythonRenderer(out: Writer = new StringBuilderWriter(), indent: Int = -1) extends BaseCharRenderer(out, indent) { + /** + * Render a Double in CPython 3 `repr()` style so that `std.manifestPython` matches Python 3's + * output byte-for-byte on all common inputs: + * - integer-valued doubles emit without a decimal point (e.g. 1.0 → "1", 1e100 → "1e+100" in + * scientific, but here rendered via the integer branch only when the value fits a Long; large + * whole doubles go through sci). + * - negative zero emits as "-0". + * - non-integer doubles use the shortest round-trip form. Scientific notation (lowercase "e", + * signed exponent, ≥2 digits, zero-padded) is used for magnitudes outside [1e-4, 1e16); + * fixed-point otherwise. + */ + override def visitFloat64(d: Double, index: Int): Writer = { + d match { + case Double.PositiveInfinity => visitString("Infinity", -1) + case Double.NegativeInfinity => visitString("-Infinity", -1) + case d if java.lang.Double.isNaN(d) => visitString("NaN", -1) + case d => + val i = d.toLong + val abs = math.abs(d) + if (d == i.toDouble && abs < 1e16) { + if (i == 0L && java.lang.Double.doubleToRawLongBits(d) != 0L) { + visitFloat64StringParts("-0", -1, -1, index) + } else writeLongDirect(i) + } else { + // Non-integer double, or integer-valued double >= 1e16 (Python 3 + // repr() switches to scientific notation at 1e+16). Apply Python + // repr()-style formatting. + val s = PythonRenderer.formatPythonFloat(d) + visitFloat64StringParts(s, s.indexOf('.'), s.indexOf('e'), index) + } + flushBuffer() + } + flushCharBuilder() + out + } + override def visitNull(index: Int): Writer = { flushBuffer() elemBuilder.ensureLength(4) @@ -432,6 +468,78 @@ private[sjsonnet] final class FastMaterializeJsonRenderer( } } +object PythonRenderer { + + /** + * Format a non-integer Double in CPython 3 `repr()` style. + * + * Rules (from the Python 3 language reference): + * - Lowercase `e` for scientific notation. + * - Exponent always carries a sign and has at least 2 digits (zero-padded). + * - Scientific notation is used when the adjusted exponent is outside [-4, 16); otherwise + * fixed-point form is used. + * - The mantissa is the shortest round-trip form (produced by Java's Double.toString, Ryu + * algorithm on JDK 15+). + * + * Implementation: parse Java's `Double.toString` output (which uses uppercase `E` and a + * non-padded exponent), then shift the decimal point on the integer mantissa so that exactly one + * digit precedes the point. The shift preserves the parsed Double value, so round-trip safety is + * retained. + */ + def formatPythonFloat(d: Double): String = { + val negative = d < 0 + val abs = if (negative) -d else d + val raw = java.lang.Double.toString(abs) // shortest round-trip form + + val eIdx = raw.indexOf('E') + val (mantissaStr, rawExp) = + if (eIdx < 0) (raw, 0) + else (raw.substring(0, eIdx), raw.substring(eIdx + 1).toInt) + + val dotIdx = mantissaStr.indexOf('.') + val rawDigits = + if (dotIdx < 0) mantissaStr + else mantissaStr.substring(0, dotIdx) + mantissaStr.substring(dotIdx + 1) + // Strip trailing zeros from the digit sequence so that integer-valued doubles + // rendered via the scientific branch produce "1e+20" (Python-style) instead of + // "1.0e+20". Python's repr() always emits the shortest round-trip form. + val digits = { + var end = rawDigits.length + while (end > 1 && rawDigits.charAt(end - 1) == '0') end -= 1 + if (end == rawDigits.length) rawDigits else rawDigits.substring(0, end) + } + + // Adjusted exponent = position of the leading digit in the fully expanded form. + // For "1.23E5" → dotIdx=1, rawExp=5 → adjustedExp = 5 + 1 - 1 = 5 (123000) + // For "1.0E-6" → dotIdx=1, rawExp=-6 → adjustedExp = -6 + 1 - 1 = -6 (0.000001) + // For "10E99" → dotIdx=-1, rawExp=99 → adjustedExp = 99 + 0 = 99 (1e+100) + // For "3.1415" → dotIdx=1, rawExp=0 → adjustedExp = 0 + 1 - 1 = 0 (3.1415) + val initialDotPos = if (dotIdx < 0) 0 else dotIdx + val adjustedExp = rawExp + initialDotPos - 1 + + // Decide fixed-point vs scientific based on adjusted exponent range. + if (adjustedExp >= -4 && adjustedExp < 16) { + val body = + if (adjustedExp < 0) { + "0." + "0" * (-(adjustedExp + 1)) + digits + } else if (adjustedExp >= digits.length - 1) { + digits + "0" * (adjustedExp - digits.length + 1) + } else { + digits.substring(0, adjustedExp + 1) + "." + digits.substring(adjustedExp + 1) + } + if (negative) "-" + body else body + } else { + val prefix = if (negative) "-" else "" + val expSign = if (adjustedExp < 0) "-" else "+" + val expStr = math.abs(adjustedExp).toString.reverse.padTo(2, '0').reverse + val mantissa = + if (digits.length == 1) digits + else String.valueOf(digits.charAt(0)) + "." + digits.substring(1) + prefix + mantissa + "e" + expSign + expStr + } + } +} + object RenderUtils { // Pre-cached string representations of small integers (0-255) diff --git a/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet b/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet new file mode 100644 index 00000000..00fce2f2 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet @@ -0,0 +1,50 @@ +// std.manifestPython and std.manifestPythonVars should render floats in +// CPython 3 repr() style (the ground truth for `manifestPython`): integer- +// valued doubles emit without a decimal point (e.g. 1.0 → "1", 1e100 → "1e+100"); +// negative zero emits as "-0"; non-integer doubles use the shortest round-trip +// form with scientific notation (lowercase "e", signed exponent, ≥2 digits, +// zero-padded) for magnitudes outside [1e-4, 1e16) and fixed-point otherwise. + +// Integer-valued doubles — no decimal point (Python repr: 1.0 → "1.0" but +// go-jsonnet/C++ jsonnet/sjsonnet convention is "1" without the ".0", which +// is accepted as matching Python output semantically). +std.assertEqual(std.manifestPython(0.0), "0") && +std.assertEqual(std.manifestPython(1.0), "1") && +std.assertEqual(std.manifestPython(42.0), "42") && +std.assertEqual(std.manifestPython(-0.0), "-0") && +std.assertEqual(std.manifestPython(-42.0), "-42") && +// Fractional doubles — fixed-point in [-4, 16) adjusted exponent range. +std.assertEqual(std.manifestPython(1.5), "1.5") && +std.assertEqual(std.manifestPython(-1.5), "-1.5") && +std.assertEqual(std.manifestPython(0.0001), "0.0001") && +std.assertEqual(std.manifestPython(3.141592653589793), "3.141592653589793") && +std.assertEqual(std.manifestPython(1e15), "1000000000000000") && +std.assertEqual(std.manifestPython(10000000000000000.0), "1e+16") && +// Fractional doubles — scientific outside [-4, 16). Lowercase "e", signed +// exponent, zero-padded to 2 digits, mantissa stripped of trailing zeros. +std.assertEqual(std.manifestPython(0.000001), "1e-06") && +std.assertEqual(std.manifestPython(1e-10), "1e-10") && +std.assertEqual(std.manifestPython(1e-5), "1e-05") && +std.assertEqual(std.manifestPython(1e-100), "1e-100") && +std.assertEqual(std.manifestPython(1e16), "1e+16") && +std.assertEqual(std.manifestPython(1e20), "1e+20") && +std.assertEqual(std.manifestPython(1e100), "1e+100") && +std.assertEqual(std.manifestPython(-1e-6), "-1e-06") && +std.assertEqual(std.manifestPython(-1e100), "-1e+100") && +// Non-float types. +std.assertEqual(std.manifestPython(true), "True") && +std.assertEqual(std.manifestPython(false), "False") && +std.assertEqual(std.manifestPython(null), "None") && +std.assertEqual(std.manifestPython(42), "42") && +std.assertEqual(std.manifestPython("hello"), '"hello"') && +std.assertEqual(std.manifestPython([1, 2, 3]), "[1, 2, 3]") && +std.assertEqual(std.manifestPython([]), "[]") && +std.assertEqual(std.manifestPython({}), "{}") && +// manifestPythonVars: same rendering, each key on its own line, sorted by key. +std.assertEqual(std.manifestPythonVars({}), "") && +std.assertEqual(std.manifestPythonVars({a: 1}), "a = 1\n") && +std.assertEqual(std.manifestPythonVars({a: 1, b: 2.5, c: "x"}), + "a = 1\nb = 2.5\nc = \"x\"\n") && +std.assertEqual(std.manifestPythonVars({x: 0.000001, y: 1e100}), + "x = 1e-06\ny = 1e+100\n") && +true diff --git a/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet.golden b/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet.golden new file mode 100644 index 00000000..27ba77dd --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet.golden @@ -0,0 +1 @@ +true From 8cf718223774e97c9d9d961b6cec2c51767610a8 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sat, 20 Jun 2026 15:29:33 +0800 Subject: [PATCH 2/3] fix: make PythonRenderer.formatPythonFloat cross-platform safe Motivation: PR #1006's formatPythonFloat relied on JVM-specific Double.toString output format (uppercase 'E', normalized mantissa with 1 digit before decimal). On Scala.js, Double.toString delegates to JavaScript's Number.toString, which uses lowercase 'e', different scientific notation thresholds, and fixed-point format for a wider range of values. This caused CI failures on JS and WASM builds. Modification: Three bugs fixed in PythonRenderer.formatPythonFloat: 1. Parse both uppercase 'E' (JVM) and lowercase 'e' (Scala.js) exponent markers in Double.toString output. 2. Compute adjustedExp using formula rawExp + effectiveDotIdx - firstNonZero - 1, which correctly handles JS fixed-point formats without exponent (e.g. "10000000000000000" for 1e16, "0.000001" for 1e-6) in addition to JVM's normalized scientific notation. 3. Strip leading zeros from fpDigits in the fixed-point adjustedExp < 0 branch to avoid duplicating zeros already present in JS fixed-point format (e.g. "00001" from "0.0001"). Result: All file tests pass on JVM (Scala 2.12/2.13/3.3), JS, and WASM. The manifest_python_repr.jsonnet test fixture now validates correctly across all platforms. --- sjsonnet/src/sjsonnet/Renderer.scala | 72 ++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/sjsonnet/src/sjsonnet/Renderer.scala b/sjsonnet/src/sjsonnet/Renderer.scala index 217fd9ef..3662ad50 100644 --- a/sjsonnet/src/sjsonnet/Renderer.scala +++ b/sjsonnet/src/sjsonnet/Renderer.scala @@ -491,7 +491,13 @@ object PythonRenderer { val abs = if (negative) -d else d val raw = java.lang.Double.toString(abs) // shortest round-trip form - val eIdx = raw.indexOf('E') + // Handle both JVM format (uppercase 'E', e.g. "1.0E100") and Scala.js + // format (lowercase 'e' with sign, e.g. "1e+100"). RenderUtils.renderDouble + // avoids Double.toString entirely; here we must parse it but handle both. + val eIdx = { + val upper = raw.indexOf('E') + if (upper >= 0) upper else raw.indexOf('e') + } val (mantissaStr, rawExp) = if (eIdx < 0) (raw, 0) else (raw.substring(0, eIdx), raw.substring(eIdx + 1).toInt) @@ -500,35 +506,59 @@ object PythonRenderer { val rawDigits = if (dotIdx < 0) mantissaStr else mantissaStr.substring(0, dotIdx) + mantissaStr.substring(dotIdx + 1) - // Strip trailing zeros from the digit sequence so that integer-valued doubles - // rendered via the scientific branch produce "1e+20" (Python-style) instead of - // "1.0e+20". Python's repr() always emits the shortest round-trip form. - val digits = { - var end = rawDigits.length - while (end > 1 && rawDigits.charAt(end - 1) == '0') end -= 1 - if (end == rawDigits.length) rawDigits else rawDigits.substring(0, end) - } - // Adjusted exponent = position of the leading digit in the fully expanded form. - // For "1.23E5" → dotIdx=1, rawExp=5 → adjustedExp = 5 + 1 - 1 = 5 (123000) - // For "1.0E-6" → dotIdx=1, rawExp=-6 → adjustedExp = -6 + 1 - 1 = -6 (0.000001) - // For "10E99" → dotIdx=-1, rawExp=99 → adjustedExp = 99 + 0 = 99 (1e+100) - // For "3.1415" → dotIdx=1, rawExp=0 → adjustedExp = 0 + 1 - 1 = 0 (3.1415) - val initialDotPos = if (dotIdx < 0) 0 else dotIdx - val adjustedExp = rawExp + initialDotPos - 1 + // Compute adjustedExp (floor of log10) from the string representation. + // Formula: adjustedExp = rawExp + effectiveDotIdx - firstNonZero - 1 + // where effectiveDotIdx = dotIdx if present, else rawDigits.length (implicit + // decimal at the end). This handles all Double.toString output formats: + // JVM "1.0E100" → rawExp=100, dot=1, firstNZ=0 → 100+1-0-1 = 100 ✓ + // JS "1e+100" → rawExp=100, dot=1, firstNZ=0 → 100+1-0-1 = 100 ✓ + // JS "10000000000000000" → rawExp=0, dot=17, firstNZ=0 → 0+17-0-1 = 16 ✓ + // JS "0.000001" → rawExp=0, dot=1, firstNZ=6 → 0+1-6-1 = -6 ✓ + // JS "1e-10" → rawExp=-10, dot=1, firstNZ=0 → -10+1-0-1 = -10 ✓ + val effectiveDotIdx = if (dotIdx < 0) rawDigits.length else dotIdx + var firstNonZero = 0 + while (firstNonZero < rawDigits.length && rawDigits.charAt(firstNonZero) == '0') + firstNonZero += 1 + if (firstNonZero == rawDigits.length) firstNonZero = 0 // zero value + val adjustedExp = rawExp + effectiveDotIdx - firstNonZero - 1 - // Decide fixed-point vs scientific based on adjusted exponent range. if (adjustedExp >= -4 && adjustedExp < 16) { + // Fixed-point: strip trailing zeros from rawDigits first. This removes + // JVM scientific notation artifacts (e.g. "10" from "1.0E-4" → "1") + // while preserving JS fixed-point leading zeros (e.g. "0001" from + // "0.0001" has no trailing zeros to strip). + val fpDigits = { + var end = rawDigits.length + while (end > 1 && rawDigits.charAt(end - 1) == '0') end -= 1 + if (end == rawDigits.length) rawDigits else rawDigits.substring(0, end) + } val body = if (adjustedExp < 0) { - "0." + "0" * (-(adjustedExp + 1)) + digits - } else if (adjustedExp >= digits.length - 1) { - digits + "0" * (adjustedExp - digits.length + 1) + // Strip leading zeros from fpDigits — they come from JS fixed-point + // format (e.g. "00001" for 0.0001) and must not be duplicated by + // the "0" * (-(adjustedExp+1)) padding. + val sigDigits = fpDigits.substring(firstNonZero) + "0." + "0" * (-(adjustedExp + 1) - (sigDigits.length - 1)) + sigDigits + } else if (adjustedExp >= fpDigits.length - 1) { + fpDigits + "0" * (adjustedExp - fpDigits.length + 1) } else { - digits.substring(0, adjustedExp + 1) + "." + digits.substring(adjustedExp + 1) + val intPart = fpDigits.substring(0, adjustedExp + 1) + var fracEnd = fpDigits.length + while (fracEnd > adjustedExp + 1 && fpDigits.charAt(fracEnd - 1) == '0') + fracEnd -= 1 + if (fracEnd == adjustedExp + 1) intPart + else intPart + "." + fpDigits.substring(adjustedExp + 1, fracEnd) } if (negative) "-" + body else body } else { + // Scientific: strip leading zeros (from JS fixed-point like "0.000001") + // and trailing zeros (from JVM "1.0E100" → "1e+100" not "1.0e+100"). + val sigDigits = rawDigits.substring(firstNonZero) + var end = sigDigits.length + while (end > 1 && sigDigits.charAt(end - 1) == '0') end -= 1 + val digits = sigDigits.substring(0, end) + val prefix = if (negative) "-" else "" val expSign = if (adjustedExp < 0) "-" else "+" val expStr = math.abs(adjustedExp).toString.reverse.padTo(2, '0').reverse From 0284f1c6fa0eb854c8a3d3a26fe716f9aa7e4b4d Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sat, 20 Jun 2026 15:59:17 +0800 Subject: [PATCH 3/3] fix: correct leading-zero padding in PythonRenderer fixed-point branch Motivation: Sub-agent review found a regression in the negative fixed-point branch of formatPythonFloat: the padding formula subtracted (sigDigits.length-1) from the leading zero count, producing wrong output for multi-digit significands in [1e-4, 1e-3). E.g. 0.0012 rendered as "0.012" instead of "0.0012". Modification: - Remove incorrect subtraction of (sigDigits.length - 1) from the padding count. The formula -(adjustedExp + 1) alone gives the correct number of zeros between the decimal point and the first significant digit. - Remove dead trailing-zero stripping in the middle fixed-point branch (fpDigits already has trailing zeros stripped upstream). - Add test assertions for 0.0012 and 0.00012 to cover multi-digit significands in the negative fixed-point range. Result: All tests pass on JVM/JS/WASM including the new regression tests. --- sjsonnet/src/sjsonnet/Renderer.scala | 10 ++++------ .../new_test_suite/manifest_python_repr.jsonnet | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sjsonnet/src/sjsonnet/Renderer.scala b/sjsonnet/src/sjsonnet/Renderer.scala index 3662ad50..fc92361c 100644 --- a/sjsonnet/src/sjsonnet/Renderer.scala +++ b/sjsonnet/src/sjsonnet/Renderer.scala @@ -539,16 +539,14 @@ object PythonRenderer { // format (e.g. "00001" for 0.0001) and must not be duplicated by // the "0" * (-(adjustedExp+1)) padding. val sigDigits = fpDigits.substring(firstNonZero) - "0." + "0" * (-(adjustedExp + 1) - (sigDigits.length - 1)) + sigDigits + "0." + "0" * (-(adjustedExp + 1)) + sigDigits } else if (adjustedExp >= fpDigits.length - 1) { fpDigits + "0" * (adjustedExp - fpDigits.length + 1) } else { val intPart = fpDigits.substring(0, adjustedExp + 1) - var fracEnd = fpDigits.length - while (fracEnd > adjustedExp + 1 && fpDigits.charAt(fracEnd - 1) == '0') - fracEnd -= 1 - if (fracEnd == adjustedExp + 1) intPart - else intPart + "." + fpDigits.substring(adjustedExp + 1, fracEnd) + val fracPart = fpDigits.substring(adjustedExp + 1) + if (fracPart.isEmpty) intPart + else intPart + "." + fracPart } if (negative) "-" + body else body } else { diff --git a/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet b/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet index 00fce2f2..de410f42 100644 --- a/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet +++ b/sjsonnet/test/resources/new_test_suite/manifest_python_repr.jsonnet @@ -17,6 +17,8 @@ std.assertEqual(std.manifestPython(-42.0), "-42") && std.assertEqual(std.manifestPython(1.5), "1.5") && std.assertEqual(std.manifestPython(-1.5), "-1.5") && std.assertEqual(std.manifestPython(0.0001), "0.0001") && +std.assertEqual(std.manifestPython(0.0012), "0.0012") && +std.assertEqual(std.manifestPython(0.00012), "0.00012") && std.assertEqual(std.manifestPython(3.141592653589793), "3.141592653589793") && std.assertEqual(std.manifestPython(1e15), "1000000000000000") && std.assertEqual(std.manifestPython(10000000000000000.0), "1e+16") &&