Skip to content

Commit 2cd6566

Browse files
committed
Make JVM and JS implementations conform to exception contract
1 parent 614c6ea commit 2cd6566

File tree

17 files changed

+464
-176
lines changed

17 files changed

+464
-176
lines changed

core/commonMain/src/DateTimePeriod.kt

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,18 @@ fun Duration.toDateTimePeriod(): DateTimePeriod = toComponents { hours, minutes,
110110
}
111111

112112
operator fun DateTimePeriod.plus(other: DateTimePeriod): DateTimePeriod = DateTimePeriod(
113-
this.years + other.years,
114-
this.months + other.months,
115-
this.days + other.days,
116-
this.hours + other.hours,
117-
this.minutes + other.minutes,
118-
this.seconds + other.seconds,
119-
this.nanoseconds + other.nanoseconds
113+
safeAdd(this.years, other.years),
114+
safeAdd(this.months, other.months),
115+
safeAdd(this.days, other.days),
116+
safeAdd(this.hours, other.hours),
117+
safeAdd(this.minutes, other.minutes),
118+
safeAdd(this.seconds, other.seconds),
119+
safeAdd(this.nanoseconds, other.nanoseconds)
120120
)
121121

122122
operator fun DatePeriod.plus(other: DatePeriod): DatePeriod = DatePeriod(
123-
this.years + other.years,
124-
this.months + other.months,
125-
this.days + other.days
123+
safeAdd(this.years, other.years),
124+
safeAdd(this.months, other.months),
125+
safeAdd(this.days, other.days)
126126
)
127127

core/commonMain/src/DateTimeUnit.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ sealed class DateTimeUnit {
5050
}
5151
}
5252

53-
override fun times(scalar: Int): TimeBased = TimeBased(nanoseconds * scalar) // TODO: prevent overflow
53+
override fun times(scalar: Int): TimeBased = TimeBased(safeMultiply(nanoseconds, scalar.toLong()))
5454

5555
@ExperimentalTime
5656
val duration: Duration = nanoseconds.nanoseconds
@@ -70,7 +70,7 @@ sealed class DateTimeUnit {
7070
require(days > 0) { "Unit duration must be positive, but was $days days." }
7171
}
7272

73-
override fun times(scalar: Int): DayBased = DayBased(days * scalar)
73+
override fun times(scalar: Int): DayBased = DayBased(safeMultiply(days, scalar))
7474

7575
internal override val calendarUnit: CalendarUnit get() = CalendarUnit.DAY
7676
internal override val calendarScale: Long get() = days.toLong()
@@ -90,7 +90,7 @@ sealed class DateTimeUnit {
9090
require(months > 0) { "Unit duration must be positive, but was $months months." }
9191
}
9292

93-
override fun times(scalar: Int): MonthBased = MonthBased(months * scalar)
93+
override fun times(scalar: Int): MonthBased = MonthBased(safeMultiply(months, scalar))
9494

9595
internal override val calendarUnit: CalendarUnit get() = CalendarUnit.MONTH
9696
internal override val calendarScale: Long get() = months.toLong()

core/commonMain/src/Instant.kt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,6 @@ public fun Instant.monthsUntil(other: Instant, zone: TimeZone): Int =
124124
public fun Instant.yearsUntil(other: Instant, zone: TimeZone): Int =
125125
until(other, DateTimeUnit.YEAR, zone).clampToInt()
126126

127-
// TODO: move to internal utils
128-
internal fun Long.clampToInt(): Int =
129-
if (this > Int.MAX_VALUE) Int.MAX_VALUE else if (this < Int.MIN_VALUE) Int.MIN_VALUE else toInt()
130-
131127
public fun Instant.minus(other: Instant, zone: TimeZone): DateTimePeriod = other.periodUntil(this, zone)
132128

133129

@@ -137,18 +133,25 @@ public fun Instant.minus(other: Instant, zone: TimeZone): DateTimePeriod = other
137133
public fun Instant.plus(unit: DateTimeUnit, zone: TimeZone): Instant =
138134
plus(unit.calendarScale, unit.calendarUnit, zone)
139135

140-
// TODO: safeMultiply
141136
/**
142137
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
143138
*/
144139
public fun Instant.plus(value: Int, unit: DateTimeUnit, zone: TimeZone): Instant =
145-
plus(value * unit.calendarScale, unit.calendarUnit, zone)
140+
try {
141+
plus(safeMultiply(value.toLong(), unit.calendarScale), unit.calendarUnit, zone)
142+
} catch (e: ArithmeticException) {
143+
throw DateTimeArithmeticException(e)
144+
}
146145

147146
/**
148147
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
149148
*/
150149
public fun Instant.plus(value: Long, unit: DateTimeUnit, zone: TimeZone): Instant =
151-
plus(value * unit.calendarScale, unit.calendarUnit, zone)
150+
try {
151+
plus(safeMultiply(value, unit.calendarScale), unit.calendarUnit, zone)
152+
} catch (e: ArithmeticException) {
153+
throw DateTimeArithmeticException(e)
154+
}
152155

153156

154157
public fun Instant.minus(other: Instant, unit: DateTimeUnit, zone: TimeZone): Long = other.until(this, unit, zone)

core/commonMain/src/LocalDate.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,19 @@ public expect fun LocalDate.yearsUntil(other: LocalDate): Int
6969
public fun LocalDate.plus(unit: DateTimeUnit.DateBased): LocalDate =
7070
plus(unit.calendarScale, unit.calendarUnit)
7171
public fun LocalDate.plus(value: Int, unit: DateTimeUnit.DateBased): LocalDate =
72-
plus(value * unit.calendarScale, unit.calendarUnit)
72+
try {
73+
plus(safeMultiply(value.toLong(), unit.calendarScale), unit.calendarUnit)
74+
} catch (e: Exception) {
75+
if (e !is ArithmeticException) throw e
76+
throw DateTimeArithmeticException(e)
77+
}
7378
public fun LocalDate.plus(value: Long, unit: DateTimeUnit.DateBased): LocalDate =
74-
plus(value * unit.calendarScale, unit.calendarUnit)
79+
try {
80+
plus(safeMultiply(value, unit.calendarScale), unit.calendarUnit)
81+
} catch (e: Exception) {
82+
if (e !is ArithmeticException) throw e
83+
throw DateTimeArithmeticException(e)
84+
}
7585

7686
public fun LocalDate.until(other: LocalDate, unit: DateTimeUnit.DateBased): Int = when(unit) {
7787
is DateTimeUnit.DateBased.MonthBased -> (monthsUntil(other) / unit.months).toInt()

core/commonMain/src/math.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright 2019-2020 JetBrains s.r.o.
3+
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
4+
*/
5+
6+
package kotlinx.datetime
7+
8+
internal fun Long.clampToInt(): Int =
9+
when {
10+
this > Int.MAX_VALUE -> Int.MAX_VALUE
11+
this < Int.MIN_VALUE -> Int.MIN_VALUE
12+
else -> toInt()
13+
}
14+
15+
16+
internal expect fun safeMultiply(a: Long, b: Long): Long
17+
internal expect fun safeMultiply(a: Int, b: Int): Int
18+
internal expect fun safeAdd(a: Long, b: Long): Long
19+
internal expect fun safeAdd(a: Int, b: Int): Int

core/jsMain/src/Instant.kt

Lines changed: 117 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import kotlinx.datetime.internal.JSJoda.Instant as jtInstant
1414
import kotlinx.datetime.internal.JSJoda.Duration as jtDuration
1515
import kotlinx.datetime.internal.JSJoda.Clock as jtClock
1616
import kotlinx.datetime.internal.JSJoda.ChronoUnit
17-
import kotlinx.datetime.internal.JSJoda.ZoneId
17+
import kotlinx.datetime.internal.JSJoda.LocalTime
18+
import kotlin.math.nextTowards
19+
import kotlin.math.truncate
1820

1921
@OptIn(kotlin.time.ExperimentalTime::class)
2022
public actual class Instant internal constructor(internal val value: jtInstant) : Comparable<Instant> {
@@ -24,10 +26,23 @@ public actual class Instant internal constructor(internal val value: jtInstant)
2426
actual val nanosecondsOfSecond: Int
2527
get() = value.nano().toInt()
2628

27-
public actual fun toEpochMilliseconds(): Long = value.toEpochMilli().toLong()
29+
public actual fun toEpochMilliseconds(): Long = epochSeconds * 1000 + nanosecondsOfSecond / 1_000_000
30+
31+
actual operator fun plus(duration: Duration): Instant {
32+
val addSeconds = truncate(duration.inSeconds)
33+
val addNanos = (duration.inNanoseconds % 1e9).toInt()
34+
return try {
35+
Instant(plusFix(addSeconds, addNanos))
36+
} catch (e: Throwable) {
37+
if (!e.isJodaDateTimeException()) throw e
38+
if (addSeconds > 0) MAX else MIN
39+
}
40+
}
2841

29-
actual operator fun plus(duration: Duration): Instant = duration.toComponents { seconds, nanoseconds ->
30-
Instant(value.plusSeconds(seconds).plusNanos(nanoseconds.toLong()))
42+
internal fun plusFix(seconds: Double, nanos: Int): jtInstant {
43+
val newSeconds = value.epochSecond().toDouble() + seconds
44+
val newNanos = value.nano().toDouble() + nanos
45+
return jtInstant.ofEpochSecond(newSeconds, newNanos)
3146
}
3247

3348
actual operator fun minus(duration: Duration): Instant = plus(-duration)
@@ -51,50 +66,98 @@ public actual class Instant internal constructor(internal val value: jtInstant)
5166
actual fun now(): Instant =
5267
Instant(jtClock.systemUTC().instant())
5368

54-
actual fun fromEpochMilliseconds(epochMilliseconds: Long): Instant =
55-
Instant(jtInstant.ofEpochMilli(epochMilliseconds.toDouble()))
56-
57-
actual fun parse(isoString: String): Instant =
58-
Instant(jtInstant.parse(isoString))
59-
60-
actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Long): Instant =
61-
Instant(jtInstant.ofEpochSecond(epochSeconds, nanosecondAdjustment))
69+
actual fun fromEpochMilliseconds(epochMilliseconds: Long): Instant = try {
70+
fromEpochSeconds(epochMilliseconds / 1000, epochMilliseconds % 1000 * 1000_000)
71+
} catch (e: Throwable) {
72+
if (!e.isJodaDateTimeException()) throw e
73+
if (epochMilliseconds > 0) MAX else MIN
74+
}
75+
76+
actual fun parse(isoString: String): Instant = try {
77+
Instant(jtInstant.parse(isoString))
78+
} catch (e: Throwable) {
79+
if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e)
80+
throw e
81+
}
82+
83+
actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Long): Instant = try {
84+
Instant(jtInstant.ofEpochSecond(epochSeconds, nanosecondAdjustment))
85+
} catch (e: Throwable) {
86+
if (!e.isJodaDateTimeException()) throw e
87+
if (epochSeconds > 0) MAX else MIN
88+
}
6289

6390
internal actual val MIN: Instant = Instant(jtInstant.MIN)
6491
internal actual val MAX: Instant = Instant(jtInstant.MAX)
6592
}
6693
}
6794

6895

69-
public actual fun Instant.plus(period: DateTimePeriod, zone: TimeZone): Instant {
96+
public actual fun Instant.plus(period: DateTimePeriod, zone: TimeZone): Instant = try {
7097
val thisZdt = this.value.atZone(zone.zoneId)
71-
return with(period) {
98+
with(period) {
7299
thisZdt
73100
.run { if (years != 0 && months == 0) plusYears(years) else this }
74101
.run { if (months != 0) plusMonths(years * 12.0 + months) else this }
75102
.run { if (days != 0) plusDays(days) as ZonedDateTime else this }
76103
.run { if (hours != 0) plusHours(hours) else this }
77104
.run { if (minutes != 0) plusMinutes(minutes) else this }
78-
.run { if (seconds != 0L) plusSeconds(seconds.toDouble()) else this }
79-
.run { if (nanoseconds != 0L) plusNanos(nanoseconds.toDouble()) else this }
105+
.run { plusSecondsFix(seconds) }
106+
.run { plusNanosFix(nanoseconds) }
80107
}.toInstant().let(::Instant)
108+
} catch (e: Throwable) {
109+
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e)
110+
throw e
81111
}
82112

83-
internal actual fun Instant.plus(value: Long, unit: CalendarUnit, zone: TimeZone): Instant =
84-
when (unit) {
85-
CalendarUnit.YEAR -> this.value.atZone(zone.zoneId).plusYears(value).toInstant()
86-
CalendarUnit.MONTH -> this.value.atZone(zone.zoneId).plusMonths(value).toInstant()
87-
CalendarUnit.DAY -> this.value.atZone(zone.zoneId).plusDays(value).let { it as ZonedDateTime }.toInstant()
88-
CalendarUnit.HOUR -> this.value.atZone(zone.zoneId).plusHours(value).toInstant()
89-
CalendarUnit.MINUTE -> this.value.atZone(zone.zoneId).plusMinutes(value).toInstant()
90-
CalendarUnit.SECOND -> this.value.plusSeconds(value)
91-
CalendarUnit.MILLISECOND -> this.value.plusMillis(value)
92-
CalendarUnit.MICROSECOND -> this.value.plusSeconds(value / 1_000_000).plusNanos((value % 1_000_000).toInt() * 1000)
93-
CalendarUnit.NANOSECOND -> this.value.plusNanos(value)
94-
}.let(::Instant)
113+
// workaround for https://github.com/js-joda/js-joda/issues/431
114+
private fun ZonedDateTime.plusSecondsFix(seconds: Long): ZonedDateTime {
115+
val value = seconds.toDouble()
116+
return when {
117+
value == 0.0 -> this
118+
(value.unsafeCast<Int>() or 0) != 0 -> plusSeconds(value)
119+
else -> {
120+
val valueLittleLess = value.nextTowards(0.0)
121+
plusSeconds(valueLittleLess).plusSeconds(value - valueLittleLess)
122+
}
123+
}
124+
}
125+
126+
// workaround for https://github.com/js-joda/js-joda/issues/431
127+
private fun ZonedDateTime.plusNanosFix(nanoseconds: Long): ZonedDateTime {
128+
val value = nanoseconds.toDouble()
129+
return when {
130+
value == 0.0 -> this
131+
(value.unsafeCast<Int>() or 0) != 0 -> plusNanos(value)
132+
else -> {
133+
val valueLittleLess = value.nextTowards(0.0)
134+
plusNanos(valueLittleLess).plusNanos(value - valueLittleLess)
135+
}
136+
}
137+
}
138+
139+
private fun jtInstant.atZone(zone: TimeZone): ZonedDateTime = atZone(zone.zoneId)
140+
141+
internal actual fun Instant.plus(value: Long, unit: CalendarUnit, zone: TimeZone): Instant = try {
142+
val thisZdt = this.value.atZone(zone)
143+
when (unit) {
144+
CalendarUnit.YEAR -> thisZdt.plusYears(value).toInstant()
145+
CalendarUnit.MONTH -> thisZdt.plusMonths(value).toInstant()
146+
CalendarUnit.DAY -> thisZdt.plusDays(value).let { it as ZonedDateTime }.toInstant()
147+
CalendarUnit.HOUR -> thisZdt.plusHours(value).toInstant()
148+
CalendarUnit.MINUTE -> thisZdt.plusMinutes(value).toInstant()
149+
CalendarUnit.SECOND -> this.plusFix(value.toDouble(), 0)
150+
CalendarUnit.MILLISECOND -> this.plusFix((value / 1_000).toDouble(), (value % 1_000).toInt() * 1_000_000).also { it.atZone(zone) }
151+
CalendarUnit.MICROSECOND -> this.plusFix((value / 1_000_000).toDouble(), (value % 1_000_000).toInt() * 1000).also { it.atZone(zone) }
152+
CalendarUnit.NANOSECOND -> this.plusFix((value / 1_000_000_000).toDouble(), (value % 1_000_000_000).toInt()).also { it.atZone(zone) }
153+
}.let(::Instant)
154+
} catch (e: Throwable) {
155+
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e)
156+
throw e
157+
}
95158

96159
@OptIn(ExperimentalTime::class)
97-
public actual fun Instant.periodUntil(other: Instant, zone: TimeZone): DateTimePeriod {
160+
public actual fun Instant.periodUntil(other: Instant, zone: TimeZone): DateTimePeriod = try {
98161
var thisZdt = this.value.atZone(zone.zoneId)
99162
val otherZdt = other.value.atZone(zone.zoneId)
100163

@@ -105,21 +168,36 @@ public actual fun Instant.periodUntil(other: Instant, zone: TimeZone): DateTimeP
105168
time.toComponents { hours, minutes, seconds, nanoseconds ->
106169
return DateTimePeriod((months / 12).toInt(), (months % 12).toInt(), days.toInt(), hours, minutes, seconds.toLong(), nanoseconds.toLong())
107170
}
171+
} catch (e: Throwable) {
172+
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) else throw e
173+
}
174+
175+
public actual fun Instant.until(other: Instant, unit: DateTimeUnit, zone: TimeZone): Long = try {
176+
when (unit) {
177+
is DateTimeUnit.DateBased ->
178+
this.value.atZone(zone).until(other.value.atZone(zone), unit.calendarUnit.toChronoUnit()).toLong() / unit.calendarScale
179+
is DateTimeUnit.TimeBased -> {
180+
this.value.atZone(zone)
181+
other.value.atZone(zone)
182+
try {
183+
// TODO: use fused multiplyAddDivide
184+
safeAdd(
185+
safeMultiply(other.epochSeconds - this.epochSeconds, LocalTime.NANOS_PER_SECOND.toLong()),
186+
(other.nanosecondsOfSecond - this.nanosecondsOfSecond).toLong()
187+
) / unit.nanoseconds
188+
} catch (e: ArithmeticException) {
189+
if (this < other) Long.MAX_VALUE else Long.MIN_VALUE
190+
}
191+
}
192+
}
193+
} catch (e: Throwable) {
194+
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) else throw e
108195
}
109-
public actual fun Instant.until(other: Instant, unit: DateTimeUnit, zone: TimeZone): Long =
110-
until(other, unit.calendarUnit.toChronoUnit(), zone.zoneId) / unit.calendarScale
111196

112-
private fun Instant.until(other: Instant, unit: ChronoUnit, zone: ZoneId): Long =
113-
this.value.atZone(zone).until(other.value.atZone(zone), unit).toLong()
114197

115198
private fun CalendarUnit.toChronoUnit(): ChronoUnit = when(this) {
116199
CalendarUnit.YEAR -> ChronoUnit.YEARS
117200
CalendarUnit.MONTH -> ChronoUnit.MONTHS
118201
CalendarUnit.DAY -> ChronoUnit.DAYS
119-
CalendarUnit.HOUR -> ChronoUnit.HOURS
120-
CalendarUnit.MINUTE -> ChronoUnit.MINUTES
121-
CalendarUnit.SECOND -> ChronoUnit.SECONDS
122-
CalendarUnit.MILLISECOND -> ChronoUnit.MILLIS
123-
CalendarUnit.MICROSECOND -> ChronoUnit.MICROS
124-
CalendarUnit.NANOSECOND -> ChronoUnit.NANOS
202+
else -> error("CalendarUnit $this should not be used")
125203
}

core/jsMain/src/JSJodaExceptions.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* Copyright 2019-2020 JetBrains s.r.o.
3+
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
4+
*/
5+
6+
package kotlinx.datetime
7+
8+
internal fun Throwable.isJodaArithmeticException(): Boolean = this.asDynamic().name == "ArithmeticException"
9+
internal fun Throwable.isJodaDateTimeException(): Boolean = this.asDynamic().name == "DateTimeException"
10+
internal fun Throwable.isJodaDateTimeParseException(): Boolean = this.asDynamic().name == "DateTimeParseException"

0 commit comments

Comments
 (0)