Skip to content

Commit f76d073

Browse files
authored
Add support to decode numeric literals containing an exponent (#2227)
Fixes #2078
1 parent f833852 commit f76d073

File tree

5 files changed

+159
-11
lines changed

5 files changed

+159
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package kotlinx.serialization.json
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.test.*
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
8+
class JsonExponentTest : JsonTestBase() {
9+
@Serializable
10+
data class SomeData(val count: Long)
11+
@Serializable
12+
data class SomeDataDouble(val count: Double)
13+
14+
@Test
15+
fun testExponentDecodingPositive() = parametrizedTest {
16+
val decoded = Json.decodeFromString<SomeData>("""{ "count": 23e11 }""", it)
17+
assertEquals(2300000000000, decoded.count)
18+
}
19+
20+
@Test
21+
fun testExponentDecodingNegative() = parametrizedTest {
22+
val decoded = Json.decodeFromString<SomeData>("""{ "count": -10E1 }""", it)
23+
assertEquals(-100, decoded.count)
24+
}
25+
26+
@Test
27+
fun testExponentDecodingPositiveDouble() = parametrizedTest {
28+
val decoded = Json.decodeFromString<SomeDataDouble>("""{ "count": 1.5E1 }""", it)
29+
assertEquals(15.0, decoded.count)
30+
}
31+
32+
@Test
33+
fun testExponentDecodingNegativeDouble() = parametrizedTest {
34+
val decoded = Json.decodeFromString<SomeDataDouble>("""{ "count": -1e-1 }""", it)
35+
assertEquals(-0.1, decoded.count)
36+
}
37+
38+
@Test
39+
fun testExponentDecodingErrorTruncatedDecimal() = parametrizedTest {
40+
assertFailsWithSerial("JsonDecodingException")
41+
{ Json.decodeFromString<SomeData>("""{ "count": -1E-1 }""", it) }
42+
}
43+
44+
@Test
45+
fun testExponentDecodingErrorExponent() = parametrizedTest {
46+
assertFailsWithSerial("JsonDecodingException")
47+
{ Json.decodeFromString<SomeData>("""{ "count": 1e-1e-1 }""", it) }
48+
}
49+
50+
@Test
51+
fun testExponentDecodingErrorExponentDouble() = parametrizedTest {
52+
assertFailsWithSerial("JsonDecodingException")
53+
{ Json.decodeFromString<SomeDataDouble>("""{ "count": 1e-1e-1 }""", it) }
54+
}
55+
56+
@Test
57+
fun testExponentOverflowDouble() = parametrizedTest {
58+
assertFailsWithSerial("JsonDecodingException")
59+
{ Json.decodeFromString<SomeDataDouble>("""{ "count": 10000e10000 }""", it) }
60+
}
61+
62+
@Test
63+
fun testExponentUnderflowDouble() = parametrizedTest {
64+
assertFailsWithSerial("JsonDecodingException")
65+
{ Json.decodeFromString<SomeDataDouble>("""{ "count": -100e2222 }""", it) }
66+
}
67+
68+
@Test
69+
fun testExponentOverflow() = parametrizedTest {
70+
assertFailsWithSerial("JsonDecodingException")
71+
{ Json.decodeFromString<SomeData>("""{ "count": 10000e10000 }""", it) }
72+
}
73+
74+
@Test
75+
fun testExponentUnderflow() = parametrizedTest {
76+
assertFailsWithSerial("JsonDecodingException")
77+
{ Json.decodeFromString<SomeData>("""{ "count": -10000e10000 }""", it) }
78+
}
79+
}

formats/json/commonMain/src/kotlinx/serialization/json/JsonElement.kt

+32-4
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,35 @@ public val JsonElement.jsonNull: JsonNull
255255
* Returns content of the current element as int
256256
* @throws NumberFormatException if current element is not a valid representation of number
257257
*/
258-
public val JsonPrimitive.int: Int get() = content.toInt()
258+
public val JsonPrimitive.int: Int
259+
get() {
260+
val result = mapExceptions { StringJsonLexer(content).consumeNumericLiteral() }
261+
if (result !in Int.MIN_VALUE..Int.MAX_VALUE) throw NumberFormatException("$content is not an Int")
262+
return result.toInt()
263+
}
259264

260265
/**
261266
* Returns content of the current element as int or `null` if current element is not a valid representation of number
262267
*/
263-
public val JsonPrimitive.intOrNull: Int? get() = content.toIntOrNull()
268+
public val JsonPrimitive.intOrNull: Int?
269+
get() {
270+
val result = mapExceptionsToNull { StringJsonLexer(content).consumeNumericLiteral() } ?: return null
271+
if (result !in Int.MIN_VALUE..Int.MAX_VALUE) return null
272+
return result.toInt()
273+
}
264274

265275
/**
266276
* Returns content of current element as long
267277
* @throws NumberFormatException if current element is not a valid representation of number
268278
*/
269-
public val JsonPrimitive.long: Long get() = content.toLong()
279+
public val JsonPrimitive.long: Long get() = mapExceptions { StringJsonLexer(content).consumeNumericLiteral() }
270280

271281
/**
272282
* Returns content of current element as long or `null` if current element is not a valid representation of number
273283
*/
274-
public val JsonPrimitive.longOrNull: Long? get() = content.toLongOrNull()
284+
public val JsonPrimitive.longOrNull: Long?
285+
get() =
286+
mapExceptionsToNull { StringJsonLexer(content).consumeNumericLiteral() }
275287

276288
/**
277289
* Returns content of current element as double
@@ -315,6 +327,22 @@ public val JsonPrimitive.contentOrNull: String? get() = if (this is JsonNull) nu
315327
private fun JsonElement.error(element: String): Nothing =
316328
throw IllegalArgumentException("Element ${this::class} is not a $element")
317329

330+
private inline fun <T> mapExceptionsToNull(f: () -> T): T? {
331+
return try {
332+
f()
333+
} catch (e: JsonDecodingException) {
334+
null
335+
}
336+
}
337+
338+
private inline fun <T> mapExceptions(f: () -> T): T {
339+
return try {
340+
f()
341+
} catch (e: JsonDecodingException) {
342+
throw NumberFormatException(e.message)
343+
}
344+
}
345+
318346
@PublishedApi
319347
internal fun unexpectedJson(key: String, expected: String): Nothing =
320348
throw IllegalArgumentException("Element $key is not a $expected")

formats/json/commonMain/src/kotlinx/serialization/json/JsonElementSerializers.kt

+5-3
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,18 @@ private object JsonLiteralSerializer : KSerializer<JsonLiteral> {
117117
return encoder.encodeInline(value.coerceToInlineType).encodeString(value.content)
118118
}
119119

120-
value.longOrNull?.let { return encoder.encodeLong(it) }
120+
// use .content instead of .longOrNull as latter can process exponential notation,
121+
// and it should be delegated to double when encoding.
122+
value.content.toLongOrNull()?.let { return encoder.encodeLong(it) }
121123

122124
// most unsigned values fit to .longOrNull, but not ULong
123125
value.content.toULongOrNull()?.let {
124126
encoder.encodeInline(ULong.serializer().descriptor).encodeLong(it.toLong())
125127
return
126128
}
127129

128-
value.doubleOrNull?.let { return encoder.encodeDouble(it) }
129-
value.booleanOrNull?.let { return encoder.encodeBoolean(it) }
130+
value.content.toDoubleOrNull()?.let { return encoder.encodeDouble(it) }
131+
value.content.toBooleanStrictOrNull()?.let { return encoder.encodeBoolean(it) }
130132

131133
encoder.encodeString(value.content)
132134
}

formats/json/commonMain/src/kotlinx/serialization/json/internal/StringOps.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@ internal fun String.toBooleanStrictOrNull(): Boolean? = when {
7070
this.equals("true", ignoreCase = true) -> true
7171
this.equals("false", ignoreCase = true) -> false
7272
else -> null
73-
}
73+
}

formats/json/commonMain/src/kotlinx/serialization/json/internal/lexer/AbstractJsonLexer.kt

+42-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlinx.serialization.json.internal.CharMappings.CHAR_TO_TOKEN
88
import kotlinx.serialization.json.internal.CharMappings.ESCAPE_2_CHAR
99
import kotlin.js.*
1010
import kotlin.jvm.*
11+
import kotlin.math.*
1112

1213
internal const val lenientHint = "Use 'isLenient = true' in 'Json {}` builder to accept non-compliant JSON."
1314
internal const val coerceInputValuesHint = "Use 'coerceInputValues = true' in 'Json {}` builder to coerce nulls to default values."
@@ -601,11 +602,32 @@ internal abstract class AbstractJsonLexer {
601602
false
602603
}
603604
var accumulator = 0L
605+
var exponentAccumulator = 0L
604606
var isNegative = false
607+
var isExponentPositive = false
608+
var hasExponent = false
605609
val start = current
606-
var hasChars = true
607-
while (hasChars) {
610+
while (current != source.length) {
608611
val ch: Char = source[current]
612+
if ((ch == 'e' || ch == 'E') && !hasExponent) {
613+
if (current == start) fail("Unexpected symbol $ch in numeric literal")
614+
isExponentPositive = true
615+
hasExponent = true
616+
++current
617+
continue
618+
}
619+
if (ch == '-' && hasExponent) {
620+
if (current == start) fail("Unexpected symbol '-' in numeric literal")
621+
isExponentPositive = false
622+
++current
623+
continue
624+
}
625+
if (ch == '+' && hasExponent) {
626+
if (current == start) fail("Unexpected symbol '+' in numeric literal")
627+
isExponentPositive = true
628+
++current
629+
continue
630+
}
609631
if (ch == '-') {
610632
if (current != start) fail("Unexpected symbol '-' in numeric literal")
611633
isNegative = true
@@ -615,12 +637,16 @@ internal abstract class AbstractJsonLexer {
615637
val token = charToTokenClass(ch)
616638
if (token != TC_OTHER) break
617639
++current
618-
hasChars = current != source.length
619640
val digit = ch - '0'
620641
if (digit !in 0..9) fail("Unexpected symbol '$ch' in numeric literal")
642+
if (hasExponent) {
643+
exponentAccumulator = exponentAccumulator * 10 + digit
644+
continue
645+
}
621646
accumulator = accumulator * 10 - digit
622647
if (accumulator > 0) fail("Numeric value overflow")
623648
}
649+
val hasChars = current != start
624650
if (start == current || (isNegative && start == current - 1)) {
625651
fail("Expected numeric literal")
626652
}
@@ -630,6 +656,19 @@ internal abstract class AbstractJsonLexer {
630656
++current
631657
}
632658
currentPosition = current
659+
660+
fun calculateExponent(exponentAccumulator: Long, isExponentPositive: Boolean): Double = when (isExponentPositive) {
661+
false -> 10.0.pow(-exponentAccumulator.toDouble())
662+
true -> 10.0.pow(exponentAccumulator.toDouble())
663+
}
664+
665+
if (hasExponent) {
666+
val doubleAccumulator = accumulator.toDouble() * calculateExponent(exponentAccumulator, isExponentPositive)
667+
if (doubleAccumulator > Long.MAX_VALUE || doubleAccumulator < Long.MIN_VALUE) fail("Numeric value overflow")
668+
if (floor(doubleAccumulator) != doubleAccumulator) fail("Can't convert $doubleAccumulator to Long")
669+
accumulator = doubleAccumulator.toLong()
670+
}
671+
633672
return when {
634673
isNegative -> accumulator
635674
accumulator != Long.MIN_VALUE -> -accumulator

0 commit comments

Comments
 (0)