Skip to content

Commit 30b5903

Browse files
committed
fix: align format numeric edge cases with Python rules
Motivation: Jsonnet documents std.format and the % string operator as following Python-style formatting rules. sjsonnet diverged on several numeric edge cases: floating-point formats did not preserve the IEEE -0.0 sign bit, dynamic * width/precision accepted fractional values via truncation, and %g exponent selection was sensitive to floating-point log10 roundoff. Modification: Normalize dynamic * width and precision through a shared Python-style integer conversion path, including negative width and negative precision handling. Preserve -0.0 signs for floating-point conversions, keep integral conversions unchanged, and stabilize %g exponent calculation around powers of ten. Add focused regression coverage for negative zero, dynamic * width/precision, fractional * errors, and %g boundary values. Result: sjsonnet follows the official std.format Python-style contract for these numeric formatting edge cases. The PR is format-only and no longer includes unrelated manifestXmlJsonml changes. References: https://jsonnet.org/ref/stdlib.html#std-format
1 parent e686d89 commit 30b5903

8 files changed

Lines changed: 144 additions & 21 deletions

sjsonnet/src/sjsonnet/Format.scala

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package sjsonnet
22

33
/**
4-
* Minimal re-implementation of Python's `%` formatting logic, since Jsonnet's `%` formatter is
5-
* basically "do whatever python does", with a link to:
4+
* Minimal re-implementation of Python's `%` formatting logic. Jsonnet documents `std.format` and
5+
* the `%` operator as following Python formatting rules:
66
*
7-
* - https://docs.python.org/2/library/stdtypes.html#string-formatting
7+
* - https://jsonnet.org/ref/stdlib.html#std-format
8+
* - https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting
89
*
910
* Parses the formatted strings into a sequence of literal strings separated by `%` interpolations
1011
* modelled as structured [[Format.FormatSpec]]s, and use those to decide how to inteprolate the
@@ -93,12 +94,28 @@ object Format {
9394
conversion == 's' && !hasWidth && !hasPrecision &&
9495
(flags & SimpleStringDisqualifierFlags) == 0
9596

96-
def withStarWidth(newWidth: Int): FormatSpec =
97-
new FormatSpec((bits & ~(NumberMask << WidthShift)) | (encodeNumber(newWidth) << WidthShift))
97+
// Jsonnet documents std.format as Python-style formatting. Python treats a negative
98+
// dynamic width as left adjustment with the absolute width.
99+
def withStarWidth(newWidth: Int): FormatSpec = {
100+
val normalizedWidth =
101+
if (newWidth < 0) {
102+
if (newWidth == Int.MinValue)
103+
throw new Exception("Format width/precision is too large: " + newWidth)
104+
-newWidth
105+
} else newWidth
106+
val normalizedFlags = if (newWidth < 0) flags | LeftAdjustedFlag else flags
107+
new FormatSpec(
108+
(bits & ~(NumberMask << WidthShift) & ~(FlagsMask << FlagsShift)) |
109+
(normalizedFlags.toLong << FlagsShift) |
110+
(encodeNumber(normalizedWidth) << WidthShift)
111+
)
112+
}
98113

114+
// Python treats a negative dynamic precision as zero; %g later maps precision 0 to 1.
99115
def withStarPrecision(newPrecision: Int): FormatSpec =
100116
new FormatSpec(
101-
(bits & ~(NumberMask << PrecisionShift)) | (encodeNumber(newPrecision) << PrecisionShift)
117+
(bits & ~(NumberMask << PrecisionShift)) |
118+
(encodeNumber(math.max(newPrecision, 0)) << PrecisionShift)
102119
)
103120

104121
def withStarValues(newWidth: Int, newPrecision: Int): FormatSpec =
@@ -645,7 +662,7 @@ object Format {
645662
)
646663
}
647664
i += 1
648-
formatted = formatted.withStarWidth(width.asInt)
665+
formatted = withStarWidth(formatted, width, idx)
649666
valuesArr.value(i)
650667
case (false, true) =>
651668
val precision = valuesArr.value(i)
@@ -655,7 +672,7 @@ object Format {
655672
)
656673
}
657674
i += 1
658-
formatted = formatted.withStarPrecision(precision.asInt)
675+
formatted = withStarPrecision(formatted, precision, idx)
659676
valuesArr.value(i)
660677
case (true, true) =>
661678
val width = valuesArr.value(i)
@@ -672,7 +689,8 @@ object Format {
672689
)
673690
}
674691
i += 1
675-
formatted = formatted.withStarValues(width.asInt, precision.asInt)
692+
formatted =
693+
withStarPrecision(withStarWidth(formatted, width, idx), precision, idx)
676694
valuesArr.value(i)
677695
}
678696
} else {
@@ -984,14 +1002,47 @@ object Format {
9841002
case _ => false
9851003
}
9861004

1005+
private def withStarWidth(formatted: FormatSpec, width: Val, idx: Int): FormatSpec =
1006+
try formatted.withStarWidth(starInteger(width, "width", idx))
1007+
catch {
1008+
case e: Error => throw e
1009+
case e: Exception =>
1010+
Error.fail(e.getMessage)
1011+
}
1012+
1013+
private def withStarPrecision(formatted: FormatSpec, precision: Val, idx: Int): FormatSpec =
1014+
try formatted.withStarPrecision(starInteger(precision, "precision", idx))
1015+
catch {
1016+
case e: Error => throw e
1017+
case e: Exception =>
1018+
Error.fail(e.getMessage)
1019+
}
1020+
1021+
private def starInteger(value: Val, name: String, idx: Int): Int =
1022+
value match {
1023+
case n: Val.Num =>
1024+
val d = n.asDouble
1025+
// Jsonnet has one number type, so Python's "integer required for *" rule maps to
1026+
// finite whole-number values. Fractional star arguments must not be rounded.
1027+
if (!d.isWhole || !d.isValidInt)
1028+
Error.fail("* %s at position %d requires an integer".format(name, idx))
1029+
d.toInt
1030+
case _ =>
1031+
Error.fail("* %s at position %d requires an integer".format(name, idx))
1032+
}
1033+
1034+
// std.format follows Python's % formatting. Jsonnet numbers have IEEE double semantics, so
1035+
// floating-point formats preserve the sign bit of -0.0 even though -0.0 == 0.0.
1036+
private def isNegative(s: Double): Boolean = s < 0 || (s == 0.0 && 1.0 / s < 0)
1037+
9871038
private def formatInteger(formatted: FormatSpec, s: Double): String = {
9881039
formatIntegralRadix(formatted, s, 10, _ => "")
9891040
}
9901041

9911042
private def formatFloat(formatted: FormatSpec, s: Double): String = {
9921043
widen(
9931044
formatted,
994-
if (s < 0) "-" else "",
1045+
if (isNegative(s)) "-" else "",
9951046
"",
9961047
sjsonnet.DecimalFormat
9971048
.format(
@@ -1002,7 +1053,7 @@ object Format {
10021053
math.abs(s)
10031054
),
10041055
numeric = true,
1005-
signedConversion = s >= 0
1056+
signedConversion = !isNegative(s)
10061057
)
10071058

10081059
}
@@ -1055,10 +1106,10 @@ object Format {
10551106
if (s == 0) 0
10561107
else {
10571108
val abs = math.abs(s)
1058-
val rawExponent = math.floor(math.log10(abs)).toInt
1109+
val rawExponent = math.floor(math.log10(abs) + 1e-10).toInt
10591110
val scale = math.pow(10, rawExponent - precision + 1)
10601111
val rounded = Math.round(abs / scale) * scale
1061-
if (rounded == 0) 0 else math.floor(math.log10(rounded)).toInt
1112+
if (rounded == 0) 0 else math.floor(math.log10(rounded) + 1e-10).toInt
10621113
}
10631114
}
10641115

@@ -1069,7 +1120,7 @@ object Format {
10691120
if (exponent < -4 || exponent >= precision) {
10701121
widen(
10711122
formatted,
1072-
if (s < 0) "-" else "",
1123+
if (isNegative(s)) "-" else "",
10731124
"",
10741125
sjsonnet.DecimalFormat
10751126
.format(
@@ -1080,13 +1131,13 @@ object Format {
10801131
math.abs(s)
10811132
),
10821133
numeric = true,
1083-
signedConversion = s >= 0
1134+
signedConversion = !isNegative(s)
10841135
)
10851136
} else {
1086-
val fractionalPrecision = math.max(0, precision - exponent - 1)
1137+
val fractionalPrecision = math.max(0, precision - 1 - exponent)
10871138
widen(
10881139
formatted,
1089-
if (s < 0) "-" else "",
1140+
if (isNegative(s)) "-" else "",
10901141
"",
10911142
sjsonnet.DecimalFormat
10921143
.format(
@@ -1097,7 +1148,7 @@ object Format {
10971148
math.abs(s)
10981149
),
10991150
numeric = true,
1100-
signedConversion = s >= 0
1151+
signedConversion = !isNegative(s)
11011152
)
11021153
}
11031154

@@ -1106,7 +1157,7 @@ object Format {
11061157
private def formatExponent(formatted: FormatSpec, s: Double): String = {
11071158
widen(
11081159
formatted,
1109-
if (s < 0) "-" else "",
1160+
if (isNegative(s)) "-" else "",
11101161
"",
11111162
sjsonnet.DecimalFormat
11121163
.format(
@@ -1117,7 +1168,7 @@ object Format {
11171168
math.abs(s)
11181169
),
11191170
numeric = true,
1120-
signedConversion = s >= 0
1171+
signedConversion = !isNegative(s)
11211172
)
11221173
}
11231174

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"%.*g" % [2.7, 1.23456]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sjsonnet.Error: [std.format] * precision at position 0 requires an integer
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"%*g" % [3.7, 1]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sjsonnet.Error: [std.format] * width at position 0 requires an integer
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Tests for %g and %#g format fixes
2+
// Verifies floating-point precision fixes in roundedGenericExponent and formatGeneric
3+
4+
// Basic %g tests with various exponents
5+
std.assertEqual('%g' % 0.1, '0.1') &&
6+
std.assertEqual('%g' % 0.0001, '0.0001') &&
7+
std.assertEqual('%g' % 1.0, '1') &&
8+
std.assertEqual('%g' % 100.0, '100') &&
9+
std.assertEqual('%g' % 1234567.0, '1.23457e+06') &&
10+
11+
// %#g tests (alternate flag preserves trailing zeros)
12+
std.assertEqual('%#g' % 0.1, '0.100000') &&
13+
std.assertEqual('%#g' % 0.0001, '0.000100000') &&
14+
std.assertEqual('%#g' % 1.0, '1.00000') &&
15+
std.assertEqual('%#g' % 100.0, '100.000') &&
16+
std.assertEqual('%#g' % 1234567.0, '1.23457e+06') &&
17+
18+
// Precision-specific tests
19+
std.assertEqual('%.3g' % 0.0001, '0.0001') &&
20+
std.assertEqual('%.3g' % 123.456, '123') &&
21+
std.assertEqual('%.10g' % 0.1, '0.1') &&
22+
23+
// Zero handling
24+
std.assertEqual('%g' % 0.0, '0') &&
25+
std.assertEqual('%#g' % 0.0, '0.00000') &&
26+
27+
// Negative values
28+
std.assertEqual('%g' % -0.1, '-0.1') &&
29+
std.assertEqual('%#g' % -0.1, '-0.100000') &&
30+
31+
// Very small exponents
32+
std.assertEqual('%g' % 0.00001, '1e-05') &&
33+
std.assertEqual('%#g' % 0.00001, '1.00000e-05') &&
34+
35+
// Precision 0 (treated as precision 1)
36+
std.assertEqual('%.0g' % 0.0, '0') &&
37+
std.assertEqual('%.0g' % 1.0, '1') &&
38+
std.assertEqual('%.0g' % 0.1, '0.1') &&
39+
std.assertEqual('%#.0g' % 1.0, '1.') &&
40+
41+
// Star width/precision follows Python-style integer semantics
42+
std.assertEqual('%*g' % [10, 1.0], ' 1') &&
43+
std.assertEqual('%*g' % [10, 0.1], ' 0.1') &&
44+
std.assertEqual('%*g' % [4, 1.0], ' 1') &&
45+
std.assertEqual('%*g' % [-4, 1.0], '1 ') &&
46+
std.assertEqual('%.*g' % [3, 1.23456], '1.23') &&
47+
std.assertEqual('%.*g' % [-3, 1.23456], '1') &&
48+
std.assertEqual('%*.*g' % [6, 3, 1.23456], ' 1.23') &&
49+
std.assertEqual('%*.*g' % [-6, 3, 1.23456], '1.23 ') &&
50+
51+
// Regression tests: values close to powers of 10
52+
std.assertEqual('%g' % 99.9999, '99.9999') &&
53+
std.assertEqual('%g' % 9.99999, '9.99999') &&
54+
std.assertEqual('%g' % 999.999, '999.999') &&
55+
std.assertEqual('%g' % 0.999999, '0.999999') &&
56+
57+
true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
true

sjsonnet/test/resources/new_test_suite/format_zero_sign_flags.jsonnet

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,14 @@ std.assertEqual("%+E" % 0.0, "+0.000000E+00") &&
77
std.assertEqual("%+g" % 0.0, "+0") &&
88
std.assertEqual("%+G" % 0.0, "+0") &&
99
std.assertEqual("%+010f" % 0.0, "+00.000000") &&
10-
std.assertEqual("%+f" % (-0.0), "+0.000000")
10+
std.assertEqual("%+f" % (-0.0), "-0.000000") &&
11+
std.assertEqual("%f" % (-0.0), "-0.000000") &&
12+
std.assertEqual("%e" % (-0.0), "-0.000000e+00") &&
13+
std.assertEqual("%E" % (-0.0), "-0.000000E+00") &&
14+
std.assertEqual("%g" % (-0.0), "-0") &&
15+
std.assertEqual("%G" % (-0.0), "-0") &&
16+
std.assertEqual("% f" % (-0.0), "-0.000000") &&
17+
std.assertEqual("%010f" % (-0.0), "-00.000000") &&
18+
std.assertEqual("%d" % (-0.0), "0") &&
19+
std.assertEqual("%o" % (-0.0), "0") &&
20+
std.assertEqual("%x" % (-0.0), "0")

0 commit comments

Comments
 (0)