Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5d63137
Remove stripTrailingZeros from JVM and Android constructors
Merkost Mar 26, 2026
3e48941
Add design spec for removing toPlainString and unifying toString
Merkost Mar 27, 2026
78cbe68
Add implementation plan for removing toPlainString
Merkost Mar 27, 2026
d0bc282
Fix plan: JS/wasmJs toString must also avoid scientific notation
Merkost Mar 27, 2026
f9947f8
Task 1: Remove toPlainString expect declaration; update toString KDoc…
Merkost Mar 27, 2026
b6386a2
Task 2: JVM actual — delegate toString to BigDecimal.toPlainString(),…
Merkost Mar 27, 2026
03f088b
Task 3: Android actual — delegate toString to BigDecimal.toPlainStrin…
Merkost Mar 27, 2026
4aa2d72
Task 4: JS actual — unify toString to always use toFixed(), remove to…
Merkost Mar 27, 2026
acb93b4
Task 5: wasmJs actual — unify toString to always use toFixed(), remov…
Merkost Mar 27, 2026
abad93e
Task 6: Apple actual — remove toPlainString(); toString already handl…
Merkost Mar 27, 2026
c1d78e3
Migrate all internal toPlainString calls to toString
Merkost Mar 27, 2026
c43462c
Update all tests: toPlainString -> toString, fix trailing zeros asser…
Merkost Mar 27, 2026
1764a10
Sample app: toPlainString -> toString
Merkost Mar 27, 2026
be5f246
Fix ktlint violations, update division test for preserved trailing zeros
Merkost Mar 27, 2026
6c9e270
Fix Android API dump and update CHANGELOG for breaking change
Merkost Mar 27, 2026
7259ae6
Clean up CHANGELOG: 0.2.0 was never published to Maven
Merkost Mar 27, 2026
d5349bd
Fix tests for cross-platform consistency
Merkost Mar 27, 2026
54836e2
Track scale from construction and division on JS/wasmJs/Apple
Merkost Mar 27, 2026
0fdffee
Add tests for small values in toString and toScientificNotation methods
Merkost Mar 27, 2026
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
40 changes: 8 additions & 32 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,19 @@
- **Sample app restructured** into a 5-tab navigation app (Core, Scale & Context,
Financial, Format & Stats, Validation) showcasing all library features.

## [0.2.0] - 2026-03-20
- **`toString()` now preserves trailing zeros** — `Deci("1.50").toString()` returns
`"1.50"`, and `Deci("1.2300").toString()` returns `"1.2300"`. The JVM/Android
constructor no longer strips trailing zeros.

### Breaking Changes
- **`toString()` never uses scientific notation** — guaranteed across all platforms
(JVM, Android, JS, wasmJs, Apple). For example, `Deci("100000000000000000000").toString()`
always returns `"100000000000000000000"`, never `"1E+20"`.

- **`toString()` may now return scientific notation** for very large or very small values.
On JVM/Android this delegates to `BigDecimal.toString()`, on JS/WASM to `decimal.js`'s `toString()`.
Previously, `toString()` always returned a plain decimal string.
## [0.2.0] - 2026-03-20

### Added

- **`toPlainString()`** — returns the string representation without scientific notation,
preserving the scale (e.g. `"1.50"` stays `"1.50"`). This is the behaviour that
`toString()` had in 0.1.x.

### Migration Guide

Replace `toString()` with `toPlainString()` wherever you need a plain decimal string:

```kotlin
// Before (0.1.x)
val text = myDeci.toString() // always "100000000000000000000"

// After (0.2.0)
val text = myDeci.toPlainString() // always "100000000000000000000"
val debug = myDeci.toString() // may return "1E+20" on JVM
```

Common patterns to update:

| Old pattern | New pattern |
|---|---|
| `deci.toString()` (for display/storage) | `deci.toPlainString()` |
| `Deci(someDeci.toString())` | `Deci(someDeci.toPlainString())` |
| `"Amount: $deci"` (if plain format needed) | `"Amount: ${deci.toPlainString()}"` |

**Serialization is unaffected** — the built-in `DeciSerializer` has been updated internally
to use `toPlainString()`, so JSON output remains a plain decimal string.
- Comprehensive sample app with tabbed navigation

## [0.1.1]

Expand Down
1 change: 0 additions & 1 deletion deci/api/android/deci.api
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ public final class org/kimplify/deci/Deci : java/lang/Comparable {
public final fun setScale (ILorg/kimplify/deci/RoundingMode;)Lorg/kimplify/deci/Deci;
public final fun times (Lorg/kimplify/deci/Deci;)Lorg/kimplify/deci/Deci;
public final fun toDouble ()D
public final fun toPlainString ()Ljava/lang/String;
public fun toString ()Ljava/lang/String;
public final fun unaryMinus ()Lorg/kimplify/deci/Deci;
}
Expand Down
1 change: 0 additions & 1 deletion deci/api/jvm/deci.api
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ public final class org/kimplify/deci/Deci : java/lang/Comparable {
public final fun setScale (ILorg/kimplify/deci/RoundingMode;)Lorg/kimplify/deci/Deci;
public final fun times (Lorg/kimplify/deci/Deci;)Lorg/kimplify/deci/Deci;
public final fun toDouble ()D
public final fun toPlainString ()Ljava/lang/String;
public fun toString ()Ljava/lang/String;
public final fun unaryMinus ()Lorg/kimplify/deci/Deci;
}
Expand Down
6 changes: 2 additions & 4 deletions deci/src/androidMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ actual class Deci(
private val internal: BigDecimal,
) : Comparable<Deci> {
actual constructor(value: String) : this(
BigDecimal(validateAndNormalizeDecimalLiteral(value)).stripTrailingZeros(),
BigDecimal(validateAndNormalizeDecimalLiteral(value)),
)

actual constructor(value: Long) : this(value.toString())
Expand Down Expand Up @@ -103,9 +103,7 @@ actual class Deci(
return Deci(internal.setScale(scale, convert(roundingMode)))
}

actual override fun toString(): String = internal.toString()

actual fun toPlainString(): String = internal.toPlainString()
actual override fun toString(): String = internal.toPlainString()

actual fun toDouble(): Double = internal.toDouble()

Expand Down
14 changes: 3 additions & 11 deletions deci/src/appleMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
import org.kimplify.deci.config.DeciConfiguration
import org.kimplify.deci.exception.DeciDivisionByZeroException
import org.kimplify.deci.exception.DeciScaleException
import org.kimplify.deci.parser.extractScale
import org.kimplify.deci.parser.validateAndNormalizeDecimalLiteral
import platform.Foundation.NSDecimalNumber
import platform.Foundation.NSDecimalNumberHandler
Expand All @@ -19,6 +20,7 @@ actual class Deci private constructor(
) : Comparable<Deci> {
actual constructor(value: String) : this(
NSDecimalNumber(validateAndNormalizeDecimalLiteral(value)),
extractScale(validateAndNormalizeDecimalLiteral(value)),
)

actual constructor(value: Long) : this(value.toString())
Expand Down Expand Up @@ -119,7 +121,7 @@ actual class Deci private constructor(
raiseOnUnderflow = false,
raiseOnDivideByZero = false,
)
return Deci(raw.decimalNumberByRoundingAccordingToBehavior(handler).stringValue)
return Deci(raw.decimalNumberByRoundingAccordingToBehavior(handler), policy.fractionalDigits)
}

actual fun divide(
Expand Down Expand Up @@ -171,16 +173,6 @@ actual class Deci private constructor(
return if (currentScale >= scale) str else str + "0".repeat(scale - currentScale)
}

actual fun toPlainString(): String {
val str = internal.stringValue
val scale = _scale ?: return str
if (scale == 0) return str.split(".")[0]
val parts = str.split(".")
val intPart = parts[0]
val fracPart = if (parts.size > 1) parts[1] else ""
return "$intPart.${fracPart.padEnd(scale, '0')}"
}

actual fun toDouble(): Double = internal.doubleValue

actual fun isZero(): Boolean = internal.compare(NSDecimalNumber.zero) == 0L
Expand Down
14 changes: 2 additions & 12 deletions deci/src/commonMain/kotlin/org/kimplify/deci/Deci.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,23 +129,13 @@ expect class Deci : Comparable<Deci> {
roundingMode: RoundingMode,
): Deci

/**
* Returns the string representation of this [Deci].
*
* For very large or very small values, the result may use scientific notation
* (e.g. `"1E+20"`) depending on the platform. Use [toPlainString] when a
* plain decimal string is required.
*/
override fun toString(): String

/**
* Returns the string representation of this [Deci] without scientific notation,
* preserving the scale (e.g. `"1.50"` stays `"1.50"`).
*
* Unlike [toString], this method never uses exponential notation regardless
* of the value's magnitude.
* This method never uses exponential notation regardless of the value's magnitude.
*/
fun toPlainString(): String
override fun toString(): String

/**
* Converts this [Deci] to a [Double].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.kimplify.deci.exception.DeciSerializationException
* For example, `Deci("1.50")` is serialized as the JSON string `"1.50"`, not the
* JSON number `1.5`.
*
* Serialization uses [Deci.toString], which never produces scientific notation.
* Deserialization parses the decoded string via the [Deci] string constructor.
*
* @throws [org.kimplify.deci.exception.DeciSerializationException] if the decoded string
Expand All @@ -27,7 +28,7 @@ object DeciSerializer : KSerializer<Deci> {
encoder: Encoder,
value: Deci,
) {
encoder.encodeString(value.toPlainString())
encoder.encodeString(value.toString())
}

override fun deserialize(decoder: Decoder): Deci {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fun Deci.toLong(): Long = toLongExact()
*/
fun Deci.toLongOrNull(): Long? {
val truncated = this.setScale(0, RoundingMode.DOWN)
val str = truncated.toPlainString()
val str = truncated.toString()
return str.toLongOrNull()
}

Expand All @@ -53,7 +53,7 @@ fun Deci.toLongExact(): Long =
* @return the scale (number of fractional digits), or `0` if there is no decimal separator.
*/
fun Deci.scale(): Int {
val text = toPlainString()
val text = toString()
val separatorIndex = text.indexOf('.')
if (separatorIndex < 0) return 0
return text.length - separatorIndex - 1
Expand All @@ -65,7 +65,7 @@ fun Deci.scale(): Int {
* @return the total number of digit characters in the canonical string representation.
*/
fun Deci.precision(): Int {
val text = toPlainString()
val text = toString()
return text.count { it.isDigit() }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fun Deci.formatCurrency(
* @return Formatted string with thousands separators
*/
fun Deci.formatWithThousandsSeparator(separator: String = ","): String {
val str = this.toPlainString()
val str = this.toString()
val parts = str.split(".")
val integerPart = parts[0]
val decimalPart = if (parts.size > 1) ".${parts[1]}" else ""
Expand Down Expand Up @@ -66,7 +66,7 @@ fun Deci.formatAsPercentage(
): String {
val percentage = this * DeciConstants.HUNDRED
val rounded = percentage.setScale(scale, RoundingMode.HALF_UP)
return "${rounded.toPlainString()}$symbol"
return "$rounded$symbol"
}

/**
Expand All @@ -88,7 +88,7 @@ fun Deci.formatAsPercentage(
fun Deci.toScientificNotation(precision: Int = 6): String {
if (this.isZero()) return "0.0E+0"

val str = this.abs().toPlainString()
val str = this.abs().toString()
val allDigits = str.filter { it.isDigit() }
val firstNonZeroIdx = allDigits.indexOfFirst { it != '0' }
if (firstNonZeroIdx == -1) return "0.0E+0"
Expand Down Expand Up @@ -138,9 +138,9 @@ fun Deci.toScientificNotation(precision: Int = 6): String {
*/
fun Deci.format(pattern: String): String =
when (pattern) {
"0.00" -> this.setScale(2, RoundingMode.HALF_UP).toPlainString()
"0.00" -> this.setScale(2, RoundingMode.HALF_UP).toString()
"#,##0.00" -> this.setScale(2, RoundingMode.HALF_UP).formatWithThousandsSeparator()
"0.0000" -> this.setScale(4, RoundingMode.HALF_UP).toPlainString()
"0.0000" -> this.setScale(4, RoundingMode.HALF_UP).toString()
"#,##0" -> this.setScale(0, RoundingMode.HALF_UP).formatWithThousandsSeparator()
else -> throw DeciFormatException(pattern = pattern)
}
Expand All @@ -156,7 +156,7 @@ fun Deci.toWords(): String {

val isNegative = this.isNegative()
val abs = this.abs()
val parts = abs.toPlainString().split(".")
val parts = abs.toString().split(".")
val integerPart = parts[0].toLongOrNull() ?: return "number too large"

val fractionalPart =
Expand Down Expand Up @@ -254,7 +254,7 @@ fun Deci.pad(
padChar: Char = ' ',
padLeft: Boolean = true,
): String {
val str = this.toPlainString()
val str = this.toString()
return if (padLeft) {
str.padStart(width, padChar)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ fun Deci.roundToSignificantDigits(digits: Int): Deci {
if (this.isZero()) return Deci.ZERO

val absValue = this.abs()
val str = absValue.toPlainString()
val str = absValue.toString()

val firstSigDigitIndex = str.indexOfFirst { it.isDigit() && it != '0' }
if (firstSigDigitIndex == -1) return Deci.ZERO
Expand Down
14 changes: 14 additions & 0 deletions deci/src/commonMain/kotlin/org/kimplify/deci/parser/StringUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,17 @@ private fun normalizeWithDecimal(

return "$safeInteger.$decimalPart"
}

/**
* Extracts the scale (number of digits after the decimal point) from a normalized decimal string.
*
* Examples:
* "1.2300" -> 4
* "123" -> 0
* "-2.5" -> 1
* "0.5" -> 1
*/
internal fun extractScale(normalizedValue: String): Int {
val dotIndex = normalizedValue.indexOf('.')
return if (dotIndex < 0) 0 else normalizedValue.length - dotIndex - 1
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ fun Deci.clamp(
* @return True if this is a whole number
*/
fun Deci.isWhole(): Boolean {
val str = this.toPlainString()
val str = this.toString()
return !str.contains('.') || str.endsWith(".0") || str.substringAfter('.').all { it == '0' }
}

Expand Down Expand Up @@ -115,7 +115,7 @@ fun Deci.safeDivide(
fun Deci.hasValidDecimalPlaces(maxDecimalPlaces: Int): Boolean {
require(maxDecimalPlaces >= 0) { "Max decimal places must be non-negative: $maxDecimalPlaces" }

val str = this.toPlainString()
val str = this.toString()
val decimalIndex = str.indexOf('.')

return if (decimalIndex == -1) {
Expand Down Expand Up @@ -234,7 +234,7 @@ fun Deci.validateForForm(
if (this < min) {
return ValidationResult(
false,
"Value must be at least ${min.toPlainString()}",
"Value must be at least $min",
)
}
}
Expand All @@ -243,7 +243,7 @@ fun Deci.validateForForm(
if (this > max) {
return ValidationResult(
false,
"Value must be at most ${max.toPlainString()}",
"Value must be at most $max",
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ class DeciPropertyTest {
fun `compareTo zero implies equality`() =
runTest {
checkAll(config, arbDeci()) { a ->
val copy = Deci(a.toPlainString())
val copy = Deci(a.toString())
assertTrue(a.compareTo(copy) == 0, "a=$a not equal to copy")
assertEquals(a, copy)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class DeciSerializationTest {
val encoded = json.encodeToString(Deci.serializer(), d)
val restored = json.decodeFromString(Deci.serializer(), encoded)
assertEquals(d, restored, "Round-trip failed for $s")
assertEquals(s, restored.toPlainString(), "String representation changed for $s")
assertEquals(s, restored.toString(), "String representation changed for $s")
}
}

Expand Down
25 changes: 14 additions & 11 deletions deci/src/commonTest/kotlin/org/kimplify/deci/DeciTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.kimplify.deci.exception.DeciParseException
import org.kimplify.deci.exception.DeciScaleException
import org.kimplify.deci.extension.precision
import org.kimplify.deci.extension.scale
import org.kimplify.deci.formatting.toScientificNotation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
Expand Down Expand Up @@ -63,7 +64,7 @@ class DeciTest {
@Test
fun `div should divide with default scale`() {
val result = Deci("5") / Deci("2")
assertEquals("2.5", result.toString())
assertEquals("2.50000000000000000000", result.toString())
Comment thread
Merkost marked this conversation as resolved.
}

@Test
Expand Down Expand Up @@ -255,10 +256,10 @@ class DeciTest {
assertEquals(Deci("10"), Deci.TEN)
}

@Test fun `toDouble and toPlainString roundtrip for simple values`() {
@Test fun `toDouble and toString roundtrip for simple values`() {
listOf("0", "1.5", "-2.75").forEach { s ->
val d = Deci(s)
assertEquals(s, d.toPlainString())
assertEquals(s, d.toString())
assertEquals(s.toDouble(), d.toDouble())
}
}
Expand All @@ -282,18 +283,20 @@ class DeciTest {
assertEquals(Deci.ZERO, Deci("-0"))
}

@Test fun `trailing zeros are stripped by constructor`() {
assertEquals("1.23", Deci("1.2300").toPlainString())
@Test fun `trailing zeros are preserved by constructor`() {
assertEquals("1.2300", Deci("1.2300").toString())
Comment thread
Merkost marked this conversation as resolved.
}

@Test fun `toPlainString never uses scientific notation`() {
assertEquals("100000000000000000000", Deci("100000000000000000000").toPlainString())
assertEquals("0.000000001", Deci("0.000000001").toPlainString())
@Test fun `toString never uses scientific notation`() {
assertEquals("100000000000000000000", Deci("100000000000000000000").toString())
assertEquals("0.000000001", Deci("0.000000001").toString())
assertEquals("0.00000000000001", Deci("0.00000000000001").toString())
}

@Test fun `toString may use scientific notation for extreme values`() {
val large = Deci("100000000000000000000")
assertEquals(large, Deci(large.toPlainString()))
@Test fun `toString and toScientificNotation for very small values`() {
val small = Deci("0.00000000000001")
assertEquals("0.00000000000001", small.toString())
assertEquals("1E-14", small.toScientificNotation())
}

@Test fun `invalid string formats throw`() {
Expand Down
Loading
Loading