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..09ef0ea 100644 --- a/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt +++ b/deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt @@ -4,13 +4,26 @@ 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 import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral 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( @@ -18,13 +31,13 @@ actual class Deci private constructor( private val _scale: Int? = null, ) : Comparable { actual constructor(value: String) : this( - NSDecimalNumber(validateAndNormalizeDecimalLiteral(value)), + parseRepresentableDecimal(value), extractScale(validateAndNormalizeDecimalLiteral(value)), ) 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..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) @@ -41,7 +42,13 @@ 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. + * @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/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/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt b/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt new file mode 100644 index 0000000..a72b94c --- /dev/null +++ b/deci/src/iosTest/kotlin/org/kimplify/deci/DeciDoubleConstructorIosTest.kt @@ -0,0 +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 `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)) + } +} 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")