Skip to content

Commit a2539dc

Browse files
committed
Implement parsing of Instant with an offset
1 parent 9455c26 commit a2539dc

File tree

5 files changed

+79
-9
lines changed

5 files changed

+79
-9
lines changed

core/common/src/Instant.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,18 @@ public expect class Instant : Comparable<Instant> {
123123

124124
/**
125125
* Parses a string that represents an instant in ISO-8601 format including date and time components and
126-
* the mandatory `Z` designator of the UTC+0 time zone and returns the parsed [Instant] value.
126+
* the mandatory time zone offset and returns the parsed [Instant] value.
127127
*
128-
* Examples of instants in ISO-8601 format:
128+
* In addition to allowing the `Z` designator of the UTC+0 time zone to be passed as the time zone offset, which
129+
* is required by the ISO-8601, this parser also accepts the other possible offsets, but does not provide a way
130+
* to query the result for which offset was present in the string.
131+
*
132+
* Examples of instants in the ISO-8601 format:
129133
* - `2020-08-30T18:43:00Z`
130134
* - `2020-08-30T18:43:00.500Z`
131135
* - `2020-08-30T18:43:00.123456789Z`
136+
* - `2020-08-30T18:40.00+03:00`
137+
* - `2020-08-30T18:40.00+03:30:20`
132138
*
133139
* @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [Instant] are exceeded.
134140
*/
@@ -166,7 +172,7 @@ public val Instant.isDistantFuture
166172

167173
/**
168174
* Converts this string representing an instant in ISO-8601 format including date and time components and
169-
* the mandatory `Z` designator of the UTC+0 time zone to an [Instant] value.
175+
* the time zone offset to an [Instant] value.
170176
*
171177
* See [Instant.parse] for examples of instant string representations.
172178
*

core/common/test/InstantTest.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,27 @@ class InstantTest {
8585
assertInvalidFormat { Instant.parse("+1000000001-12-31T23:59:59.000000000Z") }
8686
}
8787

88+
@Test
89+
fun parseStringsWithOffsets() {
90+
val instants = arrayOf(
91+
Triple("2020-01-01T00:01:01.02+18:00", 1577772061L, 20_000_000),
92+
Triple("2020-01-01T00:01:01.123456789-17:59:59", 1577901660L, 123456789),
93+
Triple("2020-01-01T00:01:01.010203040+17:59:59", 1577772062L, 10203040),
94+
Triple("2020-01-01T00:01:01.010203040+17:59", 1577772121L, 10203040),
95+
// Triple("2020-01-01T00:01:01+00", 1577836861L, 0), // fails on JS, passes everywhere else
96+
)
97+
instants.forEach {
98+
val (str, seconds, nanos) = it
99+
val instant = Instant.parse(str)
100+
assertEquals(nanos, instant.nanosecondsOfSecond, str)
101+
assertEquals(seconds, instant.epochSeconds, str)
102+
}
103+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+18:01") }
104+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+1801") }
105+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+0") }
106+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+000000") }
107+
}
108+
88109
@OptIn(ExperimentalTime::class)
89110
@Test
90111
fun instantCalendarArithmetic() {

core/js/src/Instant.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import kotlin.time.ExperimentalTime
1111
import kotlin.time.nanoseconds
1212
import kotlin.time.seconds
1313
import kotlinx.datetime.internal.JSJoda.Instant as jtInstant
14+
import kotlinx.datetime.internal.JSJoda.OffsetDateTime as jtOffsetDateTime
1415
import kotlinx.datetime.internal.JSJoda.Duration as jtDuration
1516
import kotlinx.datetime.internal.JSJoda.Clock as jtClock
1617
import kotlinx.datetime.internal.JSJoda.ChronoUnit
@@ -76,7 +77,7 @@ public actual class Instant internal constructor(internal val value: jtInstant)
7677
}
7778

7879
actual fun parse(isoString: String): Instant = try {
79-
Instant(jtInstant.parse(isoString))
80+
Instant(jtOffsetDateTime.parse(isoString).toInstant())
8081
} catch (e: Throwable) {
8182
if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e)
8283
throw e

core/jvm/src/Instant.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import java.time.format.DateTimeParseException
1313
import java.time.temporal.ChronoUnit
1414
import kotlin.time.*
1515
import java.time.Instant as jtInstant
16+
import java.time.OffsetDateTime as jtOffsetDateTime
1617
import java.time.Clock as jtClock
1718

1819
@Serializable(with = InstantIso8601Serializer::class)
@@ -63,7 +64,7 @@ public actual class Instant internal constructor(internal val value: jtInstant)
6364
Instant(jtInstant.ofEpochMilli(epochMilliseconds))
6465

6566
actual fun parse(isoString: String): Instant = try {
66-
Instant(jtInstant.parse(isoString))
67+
Instant(jtOffsetDateTime.parse(isoString).toInstant())
6768
} catch (e: DateTimeParseException) {
6869
throw DateTimeFormatException(e)
6970
}

core/native/src/Instant.kt

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,48 @@ public actual enum class DayOfWeek {
2323
SUNDAY;
2424
}
2525

26+
/** A parser for the string representation of [ZoneOffset] as seen in `OffsetDateTime`.
27+
*
28+
* We can't just reuse the parsing logic of [ZoneOffset.of], as that version is more lenient: here, strings like
29+
* "0330" are not considered valid zone offsets, whereas [ZoneOffset.of] sees treats the example above as "03:30". */
30+
private val zoneOffsetParser: Parser<ZoneOffset>
31+
get() = (concreteCharParser('z').or(concreteCharParser('Z')).map { ZoneOffset.UTC })
32+
.or(
33+
concreteCharParser('+').or(concreteCharParser('-'))
34+
.chain(intParser(2, 2))
35+
.chain(
36+
optional(
37+
// minutes
38+
concreteCharParser(':').chainSkipping(intParser(2, 2))
39+
.chain(optional(
40+
// seconds
41+
concreteCharParser(':').chainSkipping(intParser(2, 2))
42+
))))
43+
.map {
44+
val (signHours, minutesSeconds) = it
45+
val (sign, hours) = signHours
46+
val minutes: Int
47+
val seconds: Int
48+
if (minutesSeconds == null) {
49+
minutes = 0
50+
seconds = 0
51+
} else {
52+
minutes = minutesSeconds.first
53+
seconds = minutesSeconds.second ?: 0
54+
}
55+
try {
56+
if (sign == '-')
57+
ZoneOffset.ofHoursMinutesSeconds(-hours, -minutes, -seconds)
58+
else
59+
ZoneOffset.ofHoursMinutesSeconds(hours, minutes, seconds)
60+
} catch (e: IllegalTimeZoneException) {
61+
throw DateTimeFormatException(e)
62+
}
63+
}
64+
)
65+
2666
// This is a function and not a value due to https://github.com/Kotlin/kotlinx-datetime/issues/5
27-
// org.threeten.bp.format.DateTimeFormatterBuilder.InstantPrinterParser#parse
67+
// org.threeten.bp.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME
2868
private val instantParser: Parser<Instant>
2969
get() = localDateParser
3070
.chainIgnoring(concreteCharParser('T').or(concreteCharParser('t')))
@@ -37,9 +77,10 @@ private val instantParser: Parser<Instant>
3777
concreteCharParser('.')
3878
.chainSkipping(fractionParser(0, 9, 9)) // nanos
3979
))
40-
.chainIgnoring(concreteCharParser('Z').or(concreteCharParser('z')))
80+
.chain(zoneOffsetParser)
4181
.map {
42-
val (dateHourMinuteSecond, nanosVal) = it
82+
val (localDateTime, offset) = it
83+
val (dateHourMinuteSecond, nanosVal) = localDateTime
4384
val (dateHourMinute, secondsVal) = dateHourMinuteSecond
4485
val (dateHour, minutesVal) = dateHourMinute
4586
val (dateVal, hoursVal) = dateHour
@@ -63,7 +104,7 @@ private val instantParser: Parser<Instant>
63104
throw DateTimeFormatException(e)
64105
}
65106
val epochDay = localDate.toEpochDay().toLong()
66-
val instantSecs = epochDay * 86400 + localTime.toSecondOfDay() + secDelta
107+
val instantSecs = epochDay * 86400 - offset.totalSeconds + localTime.toSecondOfDay() + secDelta
67108
try {
68109
Instant(instantSecs, nano)
69110
} catch (e: IllegalArgumentException) {

0 commit comments

Comments
 (0)