From 9c5aa9df3905fe21237043ae008c276e03858d63 Mon Sep 17 00:00:00 2001 From: Rahmad Hidayat Date: Tue, 22 Sep 2026 23:32:19 +0700 Subject: [PATCH 1/3] fix: expand scientific notation in the Double constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin renders a Double in scientific notation once its magnitude reaches 1e7, or falls below 1e-3, so 29638000.0 prints as 2.9638E7. DECIMAL_REGEX has no exponent branch, so validateAndNormalizeDecimalLiteral rejected the literal the platform had just produced and Deci(Double) threw DeciParseException for every value from ten million up. 9999999.0 was accepted and 10000000.0 was not: the boundary sat exactly where Double.toString() changes format. Double.toDecimalLiteral() expands the exponent in place before the value reaches the String constructor, and all six actuals now use it. NaN and the infinities carry no exponent marker, pass through unchanged and are still rejected. The String constructor's accepted grammar is untouched — Deci("1e5") is rejected as before. Teaching DECIMAL_REGEX an exponent branch would have fixed this too, but it widens the input surface for every caller, which is a separate decision. Closes #6 --- CHANGELOG.md | 14 +++++ .../kotlin/org/kimplify/deci/Deci.kt | 3 +- .../kotlin/org/kimplify/deci/Deci.kt | 3 +- .../kotlin/org/kimplify/deci/Deci.kt | 5 ++ .../org/kimplify/deci/parser/DoubleLiteral.kt | 47 ++++++++++++++ .../deci/DeciDoubleConstructorTest.kt | 55 ++++++++++++++++ .../kimplify/deci/parser/DoubleLiteralTest.kt | 62 +++++++++++++++++++ .../jsMain/kotlin/org/kimplify/deci/Deci.kt | 3 +- .../jvmMain/kotlin/org/kimplify/deci/Deci.kt | 3 +- .../kotlin/org/kimplify/deci/Deci.kt | 3 +- 10 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 deci/src/commonMain/kotlin/org/kimplify/deci/parser/DoubleLiteral.kt create mode 100644 deci/src/commonTest/kotlin/org/kimplify/deci/DeciDoubleConstructorTest.kt create mode 100644 deci/src/commonTest/kotlin/org/kimplify/deci/parser/DoubleLiteralTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0322810..b882f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [Unreleased] + +### Fixed + +- **`Deci(Double)` no longer throws on ordinary magnitudes.** Kotlin renders a + `Double` in scientific notation once its magnitude reaches `1e7`, or falls + below `1e-3` — `29638000.0` prints as `2.9638E7`. That literal is outside the + grammar `DECIMAL_REGEX` accepts, so the `Double` constructor rejected it with + `DeciParseException`: every value from ten million up, which for a currency + amount is not an edge case. The constructor now expands the exponent before + validating, on all six targets. `NaN` and the infinities are still rejected, + and the `String` constructor's accepted grammar is unchanged — scientific + notation is expanded only on the `Double` path. + ## [0.3.0] - 2026-06-09 ### Changed diff --git a/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt index bfbf551..c02dd1d 100644 --- a/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.exception.DeciArithmeticException import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException +import org.kimplify.deci.parser.toDecimalLiteral import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral import java.math.BigDecimal import java.math.MathContext @@ -19,7 +20,7 @@ actual class Deci( actual constructor(value: Long) : this(value.toString()) actual constructor(value: Int) : this(value.toString()) - actual constructor(value: Double) : this(value.toString()) + actual constructor(value: Double) : this(value.toDecimalLiteral()) actual companion object { actual val ZERO = Deci("0") diff --git a/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt index 0dde1a0..f578a4f 100644 --- a/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException import org.kimplify.deci.parser.extractScale +import org.kimplify.deci.parser.toDecimalLiteral import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral import platform.Foundation.NSDecimalNumber import platform.Foundation.NSDecimalNumberHandler @@ -24,7 +25,7 @@ actual class Deci private constructor( actual constructor(value: Long) : this(value.toString()) actual constructor(value: Int) : this(value.toString()) - actual constructor(value: Double) : this(value.toString()) + actual constructor(value: Double) : this(value.toDecimalLiteral()) private fun roundWithHandler( value: NSDecimalNumber, diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt index f76ef53..9ed53fd 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt @@ -41,7 +41,12 @@ expect class Deci : Comparable { * `0.1 + 0.2` yields `0.30000000000000004` as a [Double]. Prefer the [String] * constructor whenever exact representation is required. * + * Scientific notation in that representation — which Kotlin uses for magnitudes at or above + * `1e7` and below `1e-3` — is expanded before parsing, so `29638000.0` and `0.0001` are + * accepted like any other finite value. + * * @param value the double value. + * @throws [org.kimplify.deci.exception.DeciParseException] if [value] is `NaN` or infinite. */ constructor(value: Double) diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/parser/DoubleLiteral.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/parser/DoubleLiteral.kt new file mode 100644 index 0000000..0d5d121 --- /dev/null +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/parser/DoubleLiteral.kt @@ -0,0 +1,47 @@ +package org.kimplify.deci.parser + +/** + * Renders a [Double] as a plain decimal literal, expanding scientific notation if + * [Double.toString] produced any. + * + * Kotlin switches to scientific notation for magnitudes at or above `1e7` and below `1e-3`, so + * `29638000.0` prints as `"2.9638E7"` and `0.0001` as `"1.0E-4"`. That form is not part of the + * decimal grammar [DECIMAL_REGEX] accepts, so feeding it straight to the [String] constructor + * rejects ordinary values. Expanding it first keeps the `Double` constructor total for every + * finite input. + * + * `NaN` and the infinities carry no exponent marker and are returned unchanged, so they still + * fail validation as before. + */ +internal fun Double.toDecimalLiteral(): String = toString().expandScientificNotation() + +/** + * Rewrites a decimal string in scientific notation as a plain one, preserving every significant + * digit: `"2.9638E7"` becomes `"29638000"` and `"1.0E-4"` becomes `"0.00010"`. + * + * Strings without an exponent marker, and those whose exponent is not an integer, are returned + * unchanged. + */ +internal fun String.expandScientificNotation(): String { + val exponentIndex = indexOfFirst { it == 'e' || it == 'E' } + if (exponentIndex < 0) return this + + val exponent = substring(exponentIndex + 1).toIntOrNull() ?: return this + val mantissa = substring(0, exponentIndex) + val isNegative = mantissa.startsWith('-') + val unsigned = mantissa.removePrefix("-").removePrefix("+") + + val digits = unsigned.filterNot { it == '.' } + val separatorIndex = unsigned.indexOf('.') + val integerLength = if (separatorIndex < 0) unsigned.length else separatorIndex + val pointPosition = integerLength + exponent + + val expanded = + when { + pointPosition <= 0 -> "0." + "0".repeat(-pointPosition) + digits + pointPosition >= digits.length -> digits + "0".repeat(pointPosition - digits.length) + else -> digits.substring(0, pointPosition) + "." + digits.substring(pointPosition) + } + + return if (isNegative) "-$expanded" else expanded +} diff --git a/deci/src/commonTest/kotlin/org/kimplify/deci/DeciDoubleConstructorTest.kt b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciDoubleConstructorTest.kt new file mode 100644 index 0000000..fcf72e6 --- /dev/null +++ b/deci/src/commonTest/kotlin/org/kimplify/deci/DeciDoubleConstructorTest.kt @@ -0,0 +1,55 @@ +package org.kimplify.deci + +import org.kimplify.deci.exception.DeciParseException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Kotlin prints a [Double] in scientific notation once its magnitude reaches `1e7`, or falls below + * `1e-3`. Those literals are outside the decimal grammar the [String] constructor validates + * against, so the [Double] constructor used to reject ordinary values — ten million of any currency + * is not an edge case. + */ +class DeciDoubleConstructorTest { + @Test + fun `accepts magnitudes at and above 1e7`() { + assertEquals(Deci("10000000"), Deci(1.0e7)) + assertEquals(Deci("29638000"), Deci(29_638_000.0)) + assertEquals(Deci("150000000"), Deci(1.5e8)) + assertEquals(Deci("1000000000000000000000"), Deci(1.0e21)) + } + + @Test + fun `accepts magnitudes below 1e-3`() { + assertEquals(Deci("0.0001"), Deci(1.0e-4)) + assertEquals(Deci("0.00012345"), Deci(0.00012345)) + } + + @Test + fun `keeps the sign`() { + assertEquals(Deci("-150000000"), Deci(-1.5e8)) + assertEquals(Deci("-0.0001"), Deci(-1.0e-4)) + } + + @Test + fun `still accepts the magnitudes that never needed expanding`() { + assertEquals(Deci("0"), Deci(0.0)) + assertEquals(Deci("9999999"), Deci(9_999_999.0)) + assertEquals(Deci("0.001"), Deci(0.001)) + assertEquals(Deci("-12.5"), Deci(-12.5)) + } + + @Test + fun `arithmetic on an expanded value is exact`() { + assertEquals(Deci("29638000"), Deci(21_170_000.0) + Deci(8_468_000.0)) + assertEquals(Deci("59276000"), Deci(29_638_000.0) * Deci(2)) + } + + @Test + fun `non-finite values are still rejected`() { + assertFailsWith { Deci(Double.NaN) } + assertFailsWith { Deci(Double.POSITIVE_INFINITY) } + assertFailsWith { Deci(Double.NEGATIVE_INFINITY) } + } +} diff --git a/deci/src/commonTest/kotlin/org/kimplify/deci/parser/DoubleLiteralTest.kt b/deci/src/commonTest/kotlin/org/kimplify/deci/parser/DoubleLiteralTest.kt new file mode 100644 index 0000000..6aae2ea --- /dev/null +++ b/deci/src/commonTest/kotlin/org/kimplify/deci/parser/DoubleLiteralTest.kt @@ -0,0 +1,62 @@ +package org.kimplify.deci.parser + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class DoubleLiteralTest { + @Test + fun `expand scientific notation various cases`() { + val cases = + listOf( + "123" to "123", + "1.5" to "1.5", + "-0.25" to "-0.25", + "2.9638E7" to "29638000", + "2.9638e7" to "29638000", + "1.0E7" to "10000000", + "1.2345E2" to "123.45", + "1E2" to "100", + "1.0E-4" to "0.00010", + "5.0E-1" to "0.50", + "-1.5E8" to "-150000000", + "-1.0E-4" to "-0.00010", + "1e+21" to "1000000000000000000000", + "NaN" to "NaN", + "Infinity" to "Infinity", + "-Infinity" to "-Infinity", + ) + + cases.forEach { (input, expected) -> + assertEquals(expected, input.expandScientificNotation(), "input: $input") + } + } + + @Test + fun `expand scientific notation leaves a non-integer exponent alone`() { + assertEquals("1.5E", "1.5E".expandScientificNotation()) + assertEquals("1.5Ex", "1.5Ex".expandScientificNotation()) + } + + @Test + fun `double literal never carries an exponent marker`() { + val values = + listOf( + 0.0, + 0.001, + 9_999_999.0, + 29_638_000.0, + -150_000_000.0, + 0.00012345, + 1.0e21, + 1.0e-10, + Double.MAX_VALUE, + Double.MIN_VALUE, + ) + + values.forEach { value -> + val literal = value.toDecimalLiteral() + assertFalse(literal.any { it == 'e' || it == 'E' }, "literal for $value: $literal") + } + } +} diff --git a/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt index cb8edcb..6f03786 100644 --- a/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException import org.kimplify.deci.parser.extractScale +import org.kimplify.deci.parser.toDecimalLiteral import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral @Serializable(with = DeciSerializer::class) @@ -18,7 +19,7 @@ actual class Deci private constructor( actual constructor(value: Long) : this(value.toString()) actual constructor(value: Int) : this(value.toString()) - actual constructor(value: Double) : this(value.toString()) + actual constructor(value: Double) : this(value.toDecimalLiteral()) actual companion object { actual val ZERO = Deci("0") diff --git a/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt index bfbf551..c02dd1d 100644 --- a/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.exception.DeciArithmeticException import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException +import org.kimplify.deci.parser.toDecimalLiteral import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral import java.math.BigDecimal import java.math.MathContext @@ -19,7 +20,7 @@ actual class Deci( actual constructor(value: Long) : this(value.toString()) actual constructor(value: Int) : this(value.toString()) - actual constructor(value: Double) : this(value.toString()) + actual constructor(value: Double) : this(value.toDecimalLiteral()) actual companion object { actual val ZERO = Deci("0") diff --git a/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt index cb8edcb..6f03786 100644 --- a/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import org.kimplify.deci.exception.DeciDivisionByZeroException import org.kimplify.deci.exception.DeciScaleException import org.kimplify.deci.parser.extractScale +import org.kimplify.deci.parser.toDecimalLiteral import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral @Serializable(with = DeciSerializer::class) @@ -18,7 +19,7 @@ actual class Deci private constructor( actual constructor(value: Long) : this(value.toString()) actual constructor(value: Int) : this(value.toString()) - actual constructor(value: Double) : this(value.toString()) + actual constructor(value: Double) : this(value.toDecimalLiteral()) actual companion object { actual val ZERO = Deci("0") From b177056a208564af9d315640506f9cffc660ec2e Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:41:51 +1000 Subject: [PATCH 2/3] test(ios): cover largest finite Double conversion --- .../org/kimplify/deci/DeciDoubleConstructorIosTest.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt diff --git a/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt b/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt new file mode 100644 index 0000000..df281e5 --- /dev/null +++ b/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt @@ -0,0 +1,11 @@ +package org.kimplify.deci + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeciDoubleConstructorIosTest { + @Test + fun `largest finite double does not become NaN`() { + assertEquals(Double.MAX_VALUE, Deci(Double.MAX_VALUE).toDouble()) + } +} From c7a55f8a2d2f9ec354bf6f13bbc9e970377398fd Mon Sep 17 00:00:00 2001 From: Merkost <56008710+Merkost@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:33:15 +1000 Subject: [PATCH 3/3] fix(apple): reject unrepresentable decimal inputs --- .../kotlin/org/kimplify/deci/Deci.kt | 14 ++++++++++++- .../kotlin/org/kimplify/deci/Deci.kt | 2 ++ .../kimplify/deci/exception/DeciException.kt | 3 +-- .../deci/DeciDoubleConstructorIosTest.kt | 21 +++++++++++++++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt index f578a4f..09ef0ea 100644 --- a/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,6 +4,7 @@ import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.serialization.Serializable import org.kimplify.deci.exception.DeciDivisionByZeroException +import org.kimplify.deci.exception.DeciOverflowException import org.kimplify.deci.exception.DeciScaleException import org.kimplify.deci.parser.extractScale import org.kimplify.deci.parser.toDecimalLiteral @@ -12,6 +13,17 @@ import platform.Foundation.NSDecimalNumber import platform.Foundation.NSDecimalNumberHandler import platform.Foundation.NSRoundingMode +private fun parseRepresentableDecimal(value: String): NSDecimalNumber { + val normalized = validateAndNormalizeDecimalLiteral(value) + val parsed = NSDecimalNumber(normalized) + if (parsed.doubleValue.isNaN() || + (normalized.any { it in '1'..'9' } && parsed.compare(NSDecimalNumber.zero) == 0L) + ) { + throw DeciOverflowException(value) + } + return parsed +} + @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) @Serializable(with = DeciSerializer::class) actual class Deci private constructor( @@ -19,7 +31,7 @@ actual class Deci private constructor( private val _scale: Int? = null, ) : Comparable { actual constructor(value: String) : this( - NSDecimalNumber(validateAndNormalizeDecimalLiteral(value)), + parseRepresentableDecimal(value), extractScale(validateAndNormalizeDecimalLiteral(value)), ) diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt index 9ed53fd..5453430 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt @@ -16,6 +16,7 @@ expect class Deci : Comparable { * * @param value a string representation of a decimal number (e.g. `"123.45"`, `"-0.001"`). * @throws [org.kimplify.deci.exception.DeciParseException] if [value] is not a valid decimal literal. + * @throws [org.kimplify.deci.exception.DeciOverflowException] on Apple platforms if [value] is outside the native decimal range. */ constructor(value: String) @@ -47,6 +48,7 @@ expect class Deci : Comparable { * * @param value the double value. * @throws [org.kimplify.deci.exception.DeciParseException] if [value] is `NaN` or infinite. + * @throws [org.kimplify.deci.exception.DeciOverflowException] on Apple platforms if [value] is outside the native decimal range. */ constructor(value: Double) diff --git a/deci/src/commonMain/kotlin/org/kimplify/deci/exception/DeciException.kt b/deci/src/commonMain/kotlin/org/kimplify/deci/exception/DeciException.kt index d8e5588..92c0e32 100644 --- a/deci/src/commonMain/kotlin/org/kimplify/deci/exception/DeciException.kt +++ b/deci/src/commonMain/kotlin/org/kimplify/deci/exception/DeciException.kt @@ -41,8 +41,7 @@ class DeciDivisionByZeroException( ) : DeciArithmeticException(message, cause) /** - * Thrown when a Deci value cannot be converted to a narrower type - * (e.g. Long) because it exceeds the target type's range. + * Thrown when a Deci value cannot be represented by the target type or platform backend. * * @property value String representation of the Deci that overflowed. */ diff --git a/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt b/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt index df281e5..a72b94c 100644 --- a/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt +++ b/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt @@ -1,11 +1,28 @@ package org.kimplify.deci +import org.kimplify.deci.exception.DeciOverflowException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class DeciDoubleConstructorIosTest { @Test - fun `largest finite double does not become NaN`() { - assertEquals(Double.MAX_VALUE, Deci(Double.MAX_VALUE).toDouble()) + fun `rejects a double above the Apple decimal range`() { + assertFailsWith { Deci(Double.MAX_VALUE) } + } + + @Test + fun `rejects a double below the Apple decimal range`() { + assertFailsWith { Deci(Double.MIN_VALUE) } + } + + @Test + fun `rejects a valid decimal above the Apple decimal range`() { + assertFailsWith { Deci("1" + "0".repeat(308)) } + } + + @Test + fun `accepts ordinary scientific notation on iOS`() { + assertEquals(Deci("29638000"), Deci(29_638_000.0)) } }