Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to decode numeric literals containing an exponent #2227

Merged
merged 19 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package kotlinx.serialization.json

import kotlinx.serialization.Serializable
import kotlinx.serialization.test.*
import kotlin.test.Test
import kotlin.test.assertEquals

class JsonExponentTest : JsonTestBase() {
@Serializable
data class SomeData(val count: Long)
@Serializable
data class SomeDataDouble(val count: Double)

@Test
fun testExponentDecodingPositive() = parametrizedTest {
val decoded = Json.decodeFromString<SomeData>("""{ "count": 23e11 }""", it)
assertEquals(2300000000000, decoded.count)
}

@Test
fun testExponentDecodingNegative() = parametrizedTest {
val decoded = Json.decodeFromString<SomeData>("""{ "count": -10E1 }""", it)
assertEquals(-100, decoded.count)
}

@Test
fun testExponentDecodingPositiveDouble() = parametrizedTest {
val decoded = Json.decodeFromString<SomeDataDouble>("""{ "count": 1.5E1 }""", it)
sandwwraith marked this conversation as resolved.
Show resolved Hide resolved
assertEquals(15.0, decoded.count)
}

@Test
fun testExponentDecodingNegativeDouble() = parametrizedTest {
val decoded = Json.decodeFromString<SomeDataDouble>("""{ "count": -1e-1 }""", it)
assertEquals(-0.1, decoded.count)
}

@Test
fun testExponentDecodingErrorTruncatedDecimal() = parametrizedTest {
assertFailsWithSerial("JsonDecodingException")
{ Json.decodeFromString<SomeData>("""{ "count": -1E-1 }""", it) }
}

@Test
fun testExponentDecodingErrorExponent() = parametrizedTest {
assertFailsWithSerial("JsonDecodingException")
{ Json.decodeFromString<SomeData>("""{ "count": 1e-1e-1 }""", it) }
}

@Test
fun testExponentDecodingErrorExponentDouble() = parametrizedTest {
assertFailsWithSerial("JsonDecodingException")
{ Json.decodeFromString<SomeDataDouble>("""{ "count": 1e-1e-1 }""", it) }
}

@Test
fun testExponentOverflowDouble() = parametrizedTest {
assertFailsWithSerial("JsonDecodingException")
{ Json.decodeFromString<SomeDataDouble>("""{ "count": 10000e10000 }""", it) }
}

@Test
fun testExponentUnderflowDouble() = parametrizedTest {
assertFailsWithSerial("JsonDecodingException")
{ Json.decodeFromString<SomeDataDouble>("""{ "count": -100e2222 }""", it) }
}

@Test
fun testExponentOverflow() = parametrizedTest {
assertFailsWithSerial("JsonDecodingException")
{ Json.decodeFromString<SomeData>("""{ "count": 10000e10000 }""", it) }
}

@Test
fun testExponentUnderflow() = parametrizedTest {
assertFailsWithSerial("JsonDecodingException")
{ Json.decodeFromString<SomeData>("""{ "count": -10000e10000 }""", it) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,23 +255,35 @@ public val JsonElement.jsonNull: JsonNull
* Returns content of the current element as int
* @throws NumberFormatException if current element is not a valid representation of number
*/
public val JsonPrimitive.int: Int get() = content.toInt()
public val JsonPrimitive.int: Int
get() {
val result = mapExceptions { StringJsonLexer(content).consumeNumericLiteral() }
if (result !in Int.MIN_VALUE..Int.MAX_VALUE) throw NumberFormatException("$content is not an Int")
return result.toInt()
}

/**
* Returns content of the current element as int or `null` if current element is not a valid representation of number
*/
public val JsonPrimitive.intOrNull: Int? get() = content.toIntOrNull()
public val JsonPrimitive.intOrNull: Int?
get() {
val result = mapExceptionsToNull { StringJsonLexer(content).consumeNumericLiteral() } ?: return null
if (result !in Int.MIN_VALUE..Int.MAX_VALUE) return null
return result.toInt()
}

/**
* Returns content of current element as long
* @throws NumberFormatException if current element is not a valid representation of number
*/
public val JsonPrimitive.long: Long get() = content.toLong()
public val JsonPrimitive.long: Long get() = mapExceptions { StringJsonLexer(content).consumeNumericLiteral() }

/**
* Returns content of current element as long or `null` if current element is not a valid representation of number
*/
public val JsonPrimitive.longOrNull: Long? get() = content.toLongOrNull()
public val JsonPrimitive.longOrNull: Long?
get() =
mapExceptionsToNull { StringJsonLexer(content).consumeNumericLiteral() }

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

private inline fun <T> mapExceptionsToNull(f: () -> T): T? {
return try {
f()
} catch (e: JsonDecodingException) {
null
}
}

private inline fun <T> mapExceptions(f: () -> T): T {
return try {
f()
} catch (e: JsonDecodingException) {
throw NumberFormatException(e.message)
}
}

@PublishedApi
internal fun unexpectedJson(key: String, expected: String): Nothing =
throw IllegalArgumentException("Element $key is not a $expected")
sandwwraith marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,18 @@ private object JsonLiteralSerializer : KSerializer<JsonLiteral> {
return encoder.encodeInline(value.coerceToInlineType).encodeString(value.content)
}

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

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

value.doubleOrNull?.let { return encoder.encodeDouble(it) }
value.booleanOrNull?.let { return encoder.encodeBoolean(it) }
value.content.toDoubleOrNull()?.let { return encoder.encodeDouble(it) }
sandwwraith marked this conversation as resolved.
Show resolved Hide resolved
value.content.toBooleanStrictOrNull()?.let { return encoder.encodeBoolean(it) }

encoder.encodeString(value.content)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,4 @@ internal fun String.toBooleanStrictOrNull(): Boolean? = when {
this.equals("true", ignoreCase = true) -> true
this.equals("false", ignoreCase = true) -> false
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import kotlinx.serialization.json.internal.CharMappings.CHAR_TO_TOKEN
import kotlinx.serialization.json.internal.CharMappings.ESCAPE_2_CHAR
import kotlin.js.*
import kotlin.jvm.*
import kotlin.math.*

internal const val lenientHint = "Use 'isLenient = true' in 'Json {}` builder to accept non-compliant JSON."
internal const val coerceInputValuesHint = "Use 'coerceInputValues = true' in 'Json {}` builder to coerce nulls to default values."
Expand Down Expand Up @@ -601,11 +602,32 @@ internal abstract class AbstractJsonLexer {
false
}
var accumulator = 0L
var exponentAccumulator = 0L
var isNegative = false
var isExponentPositive = false
var hasExponent = false
val start = current
var hasChars = true
while (hasChars) {
while (current != source.length) {
val ch: Char = source[current]
if ((ch == 'e' || ch == 'E') && !hasExponent) {
if (current == start) fail("Unexpected symbol $ch in numeric literal")
isExponentPositive = true
hasExponent = true
++current
continue
}
if (ch == '-' && hasExponent) {
if (current == start) fail("Unexpected symbol '-' in numeric literal")
isExponentPositive = false
++current
continue
}
if (ch == '+' && hasExponent) {
if (current == start) fail("Unexpected symbol '+' in numeric literal")
isExponentPositive = true
++current
continue
}
if (ch == '-') {
if (current != start) fail("Unexpected symbol '-' in numeric literal")
isNegative = true
Expand All @@ -615,12 +637,16 @@ internal abstract class AbstractJsonLexer {
val token = charToTokenClass(ch)
if (token != TC_OTHER) break
++current
hasChars = current != source.length
val digit = ch - '0'
if (digit !in 0..9) fail("Unexpected symbol '$ch' in numeric literal")
if (hasExponent) {
exponentAccumulator = exponentAccumulator * 10 + digit
continue
}
accumulator = accumulator * 10 - digit
if (accumulator > 0) fail("Numeric value overflow")
}
val hasChars = current != start
if (start == current || (isNegative && start == current - 1)) {
fail("Expected numeric literal")
}
Expand All @@ -630,6 +656,19 @@ internal abstract class AbstractJsonLexer {
++current
}
currentPosition = current

fun calculateExponent(exponentAccumulator: Long, isExponentPositive: Boolean): Double = when (isExponentPositive) {
false -> 10.0.pow(-exponentAccumulator.toDouble())
true -> 10.0.pow(exponentAccumulator.toDouble())
}

if (hasExponent) {
val doubleAccumulator = accumulator.toDouble() * calculateExponent(exponentAccumulator, isExponentPositive)
if (doubleAccumulator > Long.MAX_VALUE || doubleAccumulator < Long.MIN_VALUE) fail("Numeric value overflow")
if (floor(doubleAccumulator) != doubleAccumulator) fail("Can't convert $doubleAccumulator to Long")
accumulator = doubleAccumulator.toLong()
}

return when {
isNegative -> accumulator
accumulator != Long.MIN_VALUE -> -accumulator
Expand Down