Skip to content

Commit b0ab267

Browse files
fzhinkinSpace Team
authored andcommitted
KT-81043 String.toBigDecimalOrNull should handle all supported strings
BigDecimal has its own format, different from the one supported for String.toFloat and String.toDouble. As a result, String.toBigDecimalOrNull was rejecting some valid inputs. This replaces the validation shared between toFloat and toDouble, with a one specific to String.toBigDecimalOrNull. ^KT-81043 Fixed Merge-request: KT-MR-23651 Merged-by: Filipp Zhinkin <[email protected]>
1 parent a09e51b commit b0ab267

File tree

4 files changed

+104
-18
lines changed

4 files changed

+104
-18
lines changed

libraries/stdlib/jvm/src/kotlin/text/StringNumberConversionsJVM.kt

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ public inline fun String.toBigDecimal(mathContext: java.math.MathContext): java.
250250
*/
251251
@SinceKotlin("1.2")
252252
public fun String.toBigDecimalOrNull(): java.math.BigDecimal? =
253-
screenFloatValue(this) { it.toBigDecimal() }
253+
screenBigDecimalValue(this) { it.toBigDecimal() }
254254

255255
/**
256256
* Parses the string as a [java.math.BigDecimal] number and returns the result
@@ -261,7 +261,7 @@ public fun String.toBigDecimalOrNull(): java.math.BigDecimal? =
261261
*/
262262
@SinceKotlin("1.2")
263263
public fun String.toBigDecimalOrNull(mathContext: java.math.MathContext): java.math.BigDecimal? =
264-
screenFloatValue(this) { it.toBigDecimal(mathContext) }
264+
screenBigDecimalValue(this) { it.toBigDecimal(mathContext) }
265265

266266
private inline fun <T> screenFloatValue(str: String, parse: (String) -> T): T? {
267267
return try {
@@ -274,6 +274,18 @@ private inline fun <T> screenFloatValue(str: String, parse: (String) -> T): T? {
274274
}
275275
}
276276

277+
private inline fun <T> screenBigDecimalValue(str: String, parse: (String) -> T): T? {
278+
return try {
279+
if (isValidBigDecimal(str)) {
280+
parse(str)
281+
} else {
282+
null
283+
}
284+
} catch (_: NumberFormatException) {
285+
null
286+
}
287+
}
288+
277289
private fun isValidFloat(s: String): Boolean {
278290
// A float can have one of two representations:
279291
//
@@ -396,6 +408,51 @@ private fun isValidFloat(s: String): Boolean {
396408
return false
397409
}
398410

411+
private fun isValidBigDecimal(s: String): Boolean {
412+
if (s.isEmpty()) return false
413+
// BigDecimal could be constructed from a string with a following format:
414+
// BigDecimal := Sign? Significand Exponent?
415+
// Sign := '-' | '+'
416+
// Significand := IntegerPart '.' FractionalPart?
417+
// | '.' FractionalPart
418+
// | IntegerPart
419+
// IntegerPart := Digits
420+
// FractionalPart := Digits
421+
// Exponent := ExponentIndicator SignedInteger
422+
// ExponentIndicator := 'e' | 'E'
423+
// SignedInteger := Sign? Digits
424+
// Digits := Digit+
425+
// Digits := Char, such as isDigit returns true
426+
427+
// consume optional sign
428+
val start = if (s[0] == '-' || s[0] == '+') 1 else 0
429+
// consume significand's integer part
430+
var index = s.skipWhile(start) { it.isDigit() }
431+
// if we ran out of characters, did we consume any digits?
432+
if (index == s.length) return index - start > 0
433+
// we found a dot, let's parse a fractional part
434+
if (s[index] == '.') {
435+
index++
436+
// fraction could be empty, but only if an integer part contained any digits
437+
if (index == s.length) return index - start > 1
438+
// fraction could contain only digits
439+
index = s.skipWhile(index) { it.isDigit() }
440+
}
441+
// nothing left, we're good
442+
if (index == s.length) return true
443+
// only exponent is expected here
444+
if (s[index] != 'e' && s[index] != 'E') return false
445+
index++ // consume exponent indicator
446+
if (index == s.length) return false
447+
// consume an optional sign
448+
if (s[index] == '+' || s[index] == '-') index++
449+
// there should be something after sign
450+
if (index == s.length) return false
451+
// and it could only contain digits
452+
index = s.skipWhile(index) { it.isDigit() }
453+
return index == s.length
454+
}
455+
399456
/**
400457
* Given a [start] and [endInclusive] index in a string, returns what possible float
401458
* named constant could be in that string. For instance, if there are 3 characters

libraries/stdlib/jvm/test/text/StringNumberConversionJVMTest.kt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
/*
2-
* Copyright 2010-2018 JetBrains s.r.o. and Kotlin Programming Language contributors.
2+
* Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
33
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
44
*/
55

66
package test.text
77

8-
import test.*
98
import kotlin.test.*
109

1110
class StringNumberConversionJVMTest {
@@ -77,17 +76,43 @@ class StringNumberConversionJVMTest {
7776
assertProduces("-77", bd("-77"))
7877
assertProduces("-77.0", bd("-77.0"))
7978
assertProduces("77.", bd("77"))
79+
assertProduces(".77", bd("0.77"))
8080
assertProduces("123456789012345678901234567890.123456789", bd("123456789012345678901234567890.123456789"))
8181
assertProduces("-1.77", bd("-1.77"))
8282
assertProduces("+.77", bd("0.77"))
8383
assertProduces("7.7e1", bd("77"))
84+
assertProduces("7.7E1", bd("77"))
85+
assertProduces("7.e1", bd("7E+1"))
86+
assertProduces("-7E+1", bd("-7E+1"))
87+
assertProduces(".7E1", bd("7"))
8488
assertProduces("+770e-1", bd("77.0"))
89+
assertProduces("৪೦໑.۵၅", bd("401.55"))
90+
assertProduces("-.1e-1", bd("-0.01"))
91+
assertProduces("-.1e+1", bd("-1"))
92+
assertProduces("+1", bd("1"))
93+
assertProduces("-1", bd("-1"))
8594

8695
assertFailsOrNull("7..7")
8796
assertFailsOrNull("\t-77 \n")
8897
assertFailsOrNull("007 not a number")
8998
assertFailsOrNull("")
9099
assertFailsOrNull(" ")
100+
assertFailsOrNull(" 3.14")
101+
assertFailsOrNull("3.14 ")
102+
assertFailsOrNull("0x77p1")
103+
assertFailsOrNull("++123")
104+
assertFailsOrNull("--123")
105+
assertFailsOrNull("+-123")
106+
assertFailsOrNull(".")
107+
assertFailsOrNull("-.e-")
108+
assertFailsOrNull("-.e-1")
109+
assertFailsOrNull("-.1e+1e")
110+
assertFailsOrNull("e")
111+
assertFailsOrNull("e1")
112+
assertFailsOrNull("e+1")
113+
assertFailsOrNull("\u058A1") // ֊1
114+
assertFailsOrNull("\u207A1") // ⁺1
115+
assertFailsOrNull("123.A10")
91116
}
92117

93118
var mc = java.math.MathContext(3, java.math.RoundingMode.UP)
@@ -101,4 +126,4 @@ class StringNumberConversionJVMTest {
101126
}
102127
}
103128

104-
}
129+
}

libraries/stdlib/src/kotlin/text/Strings.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,3 +1640,20 @@ public fun String.toBooleanStrictOrNull(): Boolean? = when (this) {
16401640
"false" -> false
16411641
else -> null
16421642
}
1643+
1644+
/**
1645+
* Scans the string (skips its characters) starting from [startIndex] until [predicate] return `false`,
1646+
* and returns the index of the first character rejected by the predicate, or the length of [this] string,
1647+
* whichever is reached first.
1648+
*
1649+
* This function is intended for internal use only and does not validate [startIndex] index value.
1650+
*
1651+
* @param startIndex the index to start scanning from
1652+
* @param predicate a test applied to each character of the scanned string prefix
1653+
* @return the index of a first character not conforming to the [predicate], or the length of this string, if all characters conform it.
1654+
*/
1655+
internal inline fun String.skipWhile(startIndex: Int, predicate: (Char) -> Boolean): Int {
1656+
var i = startIndex
1657+
while (i < length && predicate(this[i])) i++
1658+
return i
1659+
}

libraries/stdlib/src/kotlin/time/Duration.kt

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,19 +1436,6 @@ private inline fun handleError(throwException: Boolean, message: String = ""): D
14361436
*/
14371437
private inline fun Duration.onInvalid(block: () -> Duration?): Duration? = if (this == Duration.INVALID) block() else this
14381438

1439-
/**
1440-
* Skips characters in this string starting from the given index while they match the predicate.
1441-
*
1442-
* @param startIndex the index to start skipping from
1443-
* @param predicate condition to test each character
1444-
* @return the index of the first character that doesn't match the predicate, or string length
1445-
*/
1446-
private inline fun String.skipWhile(startIndex: Int, predicate: (Char) -> Boolean): Int {
1447-
var i = startIndex
1448-
while (i < length && predicate(this[i])) i++
1449-
return i
1450-
}
1451-
14521439
/**
14531440
* Parses a duration unit from its default format short name at the given position.
14541441
*

0 commit comments

Comments
 (0)