Skip to content

Commit 45eaea8

Browse files
committed
fix: align format edge cases with Python rules
Motivation: Jsonnet documents std.format and the % string operator as following Python-style formatting rules. sjsonnet diverged on several edge cases: floating-point formats did not preserve the IEEE -0.0 sign bit, dynamic * width/precision accepted fractional values via truncation, negative dynamic width/precision did not consistently follow Python behavior, %s ignored precision, 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, apply %s precision by Jsonnet codepoint, and stabilize %g exponent calculation around powers of ten. Add focused regression coverage for negative zero, dynamic * width/precision, fractional * errors, %s precision, and %g boundary values. Result: sjsonnet follows the official std.format Python-style contract for these formatting edge cases. The PR is format-only and no longer includes unrelated manifestXmlJsonml changes. References: https://jsonnet.org/ref/stdlib.html#std-format https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting
1 parent e686d89 commit 45eaea8

10 files changed

Lines changed: 203 additions & 28 deletions

sjsonnet/src/sjsonnet/Format.scala

Lines changed: 109 additions & 27 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 =
@@ -241,6 +258,19 @@ object Format {
241258
def widenRaw(formatted: FormatSpec, txt: String): String =
242259
if (!formatted.hasWidth) txt // fast path: no width/padding needed
243260
else widen(formatted, "", "", txt, numeric = false, signedConversion = false)
261+
262+
private def formatString(formatted: FormatSpec, txt: String): String = {
263+
val truncated =
264+
if (!formatted.hasPrecision) txt
265+
else {
266+
val precision = formatted.precisionValue
267+
if (precision == 0) ""
268+
else if (txt.codePointCount(0, txt.length) <= precision) txt
269+
else txt.substring(0, txt.offsetByCodePoints(0, precision))
270+
}
271+
widenRaw(formatted, truncated)
272+
}
273+
244274
def widen(
245275
formatted: FormatSpec,
246276
lhs: String,
@@ -645,7 +675,7 @@ object Format {
645675
)
646676
}
647677
i += 1
648-
formatted = formatted.withStarWidth(width.asInt)
678+
formatted = withStarWidth(formatted, width, idx)
649679
valuesArr.value(i)
650680
case (false, true) =>
651681
val precision = valuesArr.value(i)
@@ -655,7 +685,7 @@ object Format {
655685
)
656686
}
657687
i += 1
658-
formatted = formatted.withStarPrecision(precision.asInt)
688+
formatted = withStarPrecision(formatted, precision, idx)
659689
valuesArr.value(i)
660690
case (true, true) =>
661691
val width = valuesArr.value(i)
@@ -672,7 +702,8 @@ object Format {
672702
)
673703
}
674704
i += 1
675-
formatted = formatted.withStarValues(width.asInt, precision.asInt)
705+
formatted =
706+
withStarPrecision(withStarWidth(formatted, width, idx), precision, idx)
676707
valuesArr.value(i)
677708
}
678709
} else {
@@ -706,7 +737,8 @@ object Format {
706737
pos
707738
)
708739
}
709-
widenRaw(formatted, vs.str)
740+
if (formatted.conversion == 's') formatString(formatted, vs.str)
741+
else widenRaw(formatted, vs.str)
710742
case vn: Val.Num =>
711743
val s = vn.asDouble
712744
formatted.conversion match {
@@ -728,7 +760,7 @@ object Format {
728760
val c = if (codePoint >= 0xd800 && codePoint <= 0xdfff) 0xfffd else codePoint
729761
widenRaw(formatted, Character.toString(c))
730762
case 's' =>
731-
widenRaw(formatted, RenderUtils.renderDouble(s))
763+
formatString(formatted, RenderUtils.renderDouble(s))
732764
case _ =>
733765
Error.fail(
734766
"unsupported format conversion at position %d, got number".format(i)
@@ -752,7 +784,7 @@ object Format {
752784
)
753785
case _: Val.Null =>
754786
formatted.conversion match {
755-
case 's' => widenRaw(formatted, "null")
787+
case 's' => formatString(formatted, "null")
756788
case 'c' => Error.fail("%c expected number or string, got null")
757789
case _ =>
758790
Error.fail(
@@ -766,7 +798,7 @@ object Format {
766798
case r: Val.Obj => Materializer.apply0(r, new Renderer(indent = -1))
767799
case _ => Materializer(rawVal)
768800
}
769-
widenRaw(formatted, value.toString)
801+
formatString(formatted, value.toString)
770802
}
771803
i += 1
772804
formattedValue
@@ -807,7 +839,7 @@ object Format {
807839
case 'g' => formatGeneric(formatted, numericValue).toLowerCase
808840
case 'G' => formatGeneric(formatted, numericValue)
809841
case 'c' => Error.fail("%c expected number or string, got boolean")
810-
case 's' => widenRaw(formatted, textValue)
842+
case 's' => formatString(formatted, textValue)
811843
case _ =>
812844
Error.fail(
813845
"expected number or string at position %d, got boolean".format(index)
@@ -816,7 +848,7 @@ object Format {
816848

817849
private def formatStrictBoolean(formatted: FormatSpec, index: Int, value: String): String =
818850
formatted.conversion match {
819-
case 's' => widenRaw(formatted, value)
851+
case 's' => formatString(formatted, value)
820852
case 'c' => Error.fail("%c expected number or string, got boolean")
821853
case _ =>
822854
Error.fail(
@@ -984,14 +1016,47 @@ object Format {
9841016
case _ => false
9851017
}
9861018

1019+
private def withStarWidth(formatted: FormatSpec, width: Val, idx: Int): FormatSpec =
1020+
try formatted.withStarWidth(starInteger(width, "width", idx))
1021+
catch {
1022+
case e: Error => throw e
1023+
case e: Exception =>
1024+
Error.fail(e.getMessage)
1025+
}
1026+
1027+
private def withStarPrecision(formatted: FormatSpec, precision: Val, idx: Int): FormatSpec =
1028+
try formatted.withStarPrecision(starInteger(precision, "precision", idx))
1029+
catch {
1030+
case e: Error => throw e
1031+
case e: Exception =>
1032+
Error.fail(e.getMessage)
1033+
}
1034+
1035+
private def starInteger(value: Val, name: String, idx: Int): Int =
1036+
value match {
1037+
case n: Val.Num =>
1038+
val d = n.asDouble
1039+
// Jsonnet has one number type, so Python's "integer required for *" rule maps to
1040+
// finite whole-number values. Fractional star arguments must not be rounded.
1041+
if (!d.isWhole || !d.isValidInt)
1042+
Error.fail("* %s at position %d requires an integer".format(name, idx))
1043+
d.toInt
1044+
case _ =>
1045+
Error.fail("* %s at position %d requires an integer".format(name, idx))
1046+
}
1047+
1048+
// std.format follows Python's % formatting. Jsonnet numbers have IEEE double semantics, so
1049+
// floating-point formats preserve the sign bit of -0.0 even though -0.0 == 0.0.
1050+
private def isNegative(s: Double): Boolean = s < 0 || (s == 0.0 && 1.0 / s < 0)
1051+
9871052
private def formatInteger(formatted: FormatSpec, s: Double): String = {
9881053
formatIntegralRadix(formatted, s, 10, _ => "")
9891054
}
9901055

9911056
private def formatFloat(formatted: FormatSpec, s: Double): String = {
9921057
widen(
9931058
formatted,
994-
if (s < 0) "-" else "",
1059+
if (isNegative(s)) "-" else "",
9951060
"",
9961061
sjsonnet.DecimalFormat
9971062
.format(
@@ -1002,7 +1067,7 @@ object Format {
10021067
math.abs(s)
10031068
),
10041069
numeric = true,
1005-
signedConversion = s >= 0
1070+
signedConversion = !isNegative(s)
10061071
)
10071072

10081073
}
@@ -1051,14 +1116,31 @@ object Format {
10511116
}
10521117
}
10531118

1119+
private def decimalExponent(value: Double): Int = {
1120+
if (value == 0 || value.isNaN || value.isInfinity) 0
1121+
else {
1122+
val exact = new java.math.BigDecimal(value)
1123+
var exponent = math.floor(math.log10(value)).toInt
1124+
while (exact.compareTo(java.math.BigDecimal.ONE.scaleByPowerOfTen(exponent + 1)) >= 0) {
1125+
exponent += 1
1126+
}
1127+
while (exact.compareTo(java.math.BigDecimal.ONE.scaleByPowerOfTen(exponent)) < 0) {
1128+
exponent -= 1
1129+
}
1130+
exponent
1131+
}
1132+
}
1133+
10541134
private def roundedGenericExponent(s: Double, precision: Int): Int = {
10551135
if (s == 0) 0
10561136
else {
10571137
val abs = math.abs(s)
1058-
val rawExponent = math.floor(math.log10(abs)).toInt
1138+
val rawExponent = decimalExponent(abs)
10591139
val scale = math.pow(10, rawExponent - precision + 1)
1060-
val rounded = Math.round(abs / scale) * scale
1061-
if (rounded == 0) 0 else math.floor(math.log10(rounded)).toInt
1140+
val roundedDigits = Math.round(abs / scale)
1141+
if (roundedDigits == 0) 0
1142+
else if (roundedDigits.toDouble >= math.pow(10, precision)) rawExponent + 1
1143+
else rawExponent
10621144
}
10631145
}
10641146

@@ -1069,7 +1151,7 @@ object Format {
10691151
if (exponent < -4 || exponent >= precision) {
10701152
widen(
10711153
formatted,
1072-
if (s < 0) "-" else "",
1154+
if (isNegative(s)) "-" else "",
10731155
"",
10741156
sjsonnet.DecimalFormat
10751157
.format(
@@ -1080,13 +1162,13 @@ object Format {
10801162
math.abs(s)
10811163
),
10821164
numeric = true,
1083-
signedConversion = s >= 0
1165+
signedConversion = !isNegative(s)
10841166
)
10851167
} else {
1086-
val fractionalPrecision = math.max(0, precision - exponent - 1)
1168+
val fractionalPrecision = math.max(0, precision - 1 - exponent)
10871169
widen(
10881170
formatted,
1089-
if (s < 0) "-" else "",
1171+
if (isNegative(s)) "-" else "",
10901172
"",
10911173
sjsonnet.DecimalFormat
10921174
.format(
@@ -1097,7 +1179,7 @@ object Format {
10971179
math.abs(s)
10981180
),
10991181
numeric = true,
1100-
signedConversion = s >= 0
1182+
signedConversion = !isNegative(s)
11011183
)
11021184
}
11031185

@@ -1106,7 +1188,7 @@ object Format {
11061188
private def formatExponent(formatted: FormatSpec, s: Double): String = {
11071189
widen(
11081190
formatted,
1109-
if (s < 0) "-" else "",
1191+
if (isNegative(s)) "-" else "",
11101192
"",
11111193
sjsonnet.DecimalFormat
11121194
.format(
@@ -1117,7 +1199,7 @@ object Format {
11171199
math.abs(s)
11181200
),
11191201
numeric = true,
1120-
signedConversion = s >= 0
1202+
signedConversion = !isNegative(s)
11211203
)
11221204
}
11231205

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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
std.assertEqual('%.12g' % 9.99999999876, '9.99999999876') &&
57+
std.assertEqual('%.12g' % 99.9999999876, '99.9999999876') &&
58+
std.assertEqual('%.12g' % 0.999999999876, '0.999999999876') &&
59+
60+
true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
true
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Python-style %s precision limits the formatted string by codepoint.
2+
3+
std.assertEqual("%.3s" % "abcdef", "abc") &&
4+
std.assertEqual("%.0s" % "abcdef", "") &&
5+
std.assertEqual("%5.3s" % "abcdef", " abc") &&
6+
std.assertEqual("%-5.3s" % "abcdef", "abc ") &&
7+
std.assertEqual("%.*s" % [3, "abcdef"], "abc") &&
8+
std.assertEqual("%.*s" % [0, "abcdef"], "") &&
9+
std.assertEqual("%.*s" % [-3, "abcdef"], "") &&
10+
std.assertEqual("%*.*s" % [6, 3, "abcdef"], " abc") &&
11+
std.assertEqual("%-*.*s" % [6, 3, "abcdef"], "abc ") &&
12+
std.assertEqual("%.1s" % "😀x", "😀") &&
13+
std.assertEqual("%.*s" % [1, "éx"], "é") &&
14+
std.assertEqual("%.2s" % 123.456, "12") &&
15+
std.assertEqual("%.2s" % true, "tr") &&
16+
std.assertEqual("%.2s" % null, "nu") &&
17+
true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
true

0 commit comments

Comments
 (0)