Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
17 changes: 15 additions & 2 deletions deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,40 @@ 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(
private val internal: NSDecimalNumber,
private val _scale: Int? = null,
) : Comparable<Deci> {
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,
Expand Down
7 changes: 7 additions & 0 deletions deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ expect class Deci : Comparable<Deci> {
*
* @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)

Expand All @@ -41,7 +42,13 @@ expect class Deci : Comparable<Deci> {
* `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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<DeciParseException> { Deci(Double.NaN) }
assertFailsWith<DeciParseException> { Deci(Double.POSITIVE_INFINITY) }
assertFailsWith<DeciParseException> { Deci(Double.NEGATIVE_INFINITY) }
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
@@ -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<DeciOverflowException> { Deci(Double.MAX_VALUE) }
}

@Test
fun `rejects a double below the Apple decimal range`() {
assertFailsWith<DeciOverflowException> { Deci(Double.MIN_VALUE) }
}

@Test
fun `rejects a valid decimal above the Apple decimal range`() {
assertFailsWith<DeciOverflowException> { Deci("1" + "0".repeat(308)) }
}

@Test
fun `accepts ordinary scientific notation on iOS`() {
assertEquals(Deci("29638000"), Deci(29_638_000.0))
}
}
3 changes: 2 additions & 1 deletion deci/src/jsMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion deci/src/jvmMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion deci/src/wasmJsMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down