Skip to content

Commit 1dd7d04

Browse files
authored
Implement parsing Instant with offset (#107)
1 parent 661ae98 commit 1dd7d04

File tree

7 files changed

+1103
-845
lines changed

7 files changed

+1103
-845
lines changed

core/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ kotlin {
192192
val jsMain by getting {
193193
dependencies {
194194
api("org.jetbrains.kotlin:kotlin-stdlib-js")
195-
implementation(npm("@js-joda/core", "3.1.0"))
195+
implementation(npm("@js-joda/core", "3.2.0"))
196196
}
197197
}
198198

core/common/src/Instant.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,19 @@ 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+
* Supports the following ways of specifying the time zone offset:
129+
* - the `Z` designator for the UTC+0 time zone,
130+
* - a custom time zone offset specified with `+hh`, or `+hh:mm`, or `+hh:mm:ss`
131+
* (with `+` being replaced with `-` for the negative offsets)
132+
*
133+
* Examples of instants in the ISO-8601 format:
129134
* - `2020-08-30T18:43:00Z`
130135
* - `2020-08-30T18:43:00.500Z`
131136
* - `2020-08-30T18:43:00.123456789Z`
137+
* - `2020-08-30T18:40.00+03:00`
138+
* - `2020-08-30T18:40.00+03:30:20`
132139
*
133140
* @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [Instant] are exceeded.
134141
*/
@@ -166,7 +173,7 @@ public val Instant.isDistantFuture: Boolean
166173

167174
/**
168175
* 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.
176+
* the time zone offset to an [Instant] value.
170177
*
171178
* See [Instant.parse] for examples of instant string representations.
172179
*
@@ -470,3 +477,10 @@ public fun Instant.minus(other: Instant, unit: DateTimeUnit.TimeBased): Long =
470477

471478
internal const val DISTANT_PAST_SECONDS = -3217862419201
472479
internal const val DISTANT_FUTURE_SECONDS = 3093527980800
480+
481+
/**
482+
* Displays the given Instant in the given [offset].
483+
*
484+
* Be careful: this function may throw for some values of the [Instant].
485+
*/
486+
internal expect fun Instant.toStringWithOffset(offset: ZoneOffset): String

core/common/test/InstantTest.kt

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

88+
@Test
89+
fun parseStringsWithOffsets() {
90+
val strings = arrayOf(
91+
Pair("2020-01-01T00:01:01.02+18:00", "2019-12-31T06:01:01.020Z"),
92+
Pair("2020-01-01T00:01:01.123456789-17:59:59", "2020-01-01T18:01:00.123456789Z"),
93+
Pair("2020-01-01T00:01:01.010203040+17:59:59", "2019-12-31T06:01:02.010203040Z"),
94+
Pair("2020-01-01T00:01:01.010203040+17:59", "2019-12-31T06:02:01.010203040Z"),
95+
Pair("2020-01-01T00:01:01+00", "2020-01-01T00:01:01Z"),
96+
)
97+
strings.forEach { (str, strInZ) ->
98+
val instant = Instant.parse(str)
99+
assertEquals(Instant.parse(strInZ), instant, str)
100+
assertEquals(strInZ, instant.toString(), str)
101+
}
102+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+18:01") }
103+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+1801") }
104+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+0") }
105+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+") }
106+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01") }
107+
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+000000") }
108+
109+
val instants = listOf(
110+
Instant.DISTANT_FUTURE,
111+
Instant.DISTANT_PAST,
112+
Instant.fromEpochSeconds(0, 0))
113+
114+
val offsets = listOf(
115+
TimeZone.of("Z") as ZoneOffset,
116+
TimeZone.of("+03:12:14") as ZoneOffset,
117+
TimeZone.of("-03:12:14") as ZoneOffset,
118+
TimeZone.of("+02:35") as ZoneOffset,
119+
TimeZone.of("-02:35") as ZoneOffset,
120+
TimeZone.of("+04") as ZoneOffset,
121+
TimeZone.of("-04") as ZoneOffset,
122+
)
123+
124+
for (instant in instants) {
125+
for (offset in offsets) {
126+
val str = instant.toStringWithOffset(offset)
127+
assertEquals(instant, Instant.parse(str))
128+
}
129+
}
130+
}
131+
88132
@OptIn(ExperimentalTime::class)
89133
@Test
90134
fun instantCalendarArithmetic() {

core/js/src/Instant.kt

Lines changed: 16 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,12 +77,23 @@ public actual class Instant internal constructor(internal val value: jtInstant)
7677
}
7778

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

86+
/** A workaround for the string representations of Instant that have an offset of the form
87+
* "+XX" not being recognized by [jtOffsetDateTime.parse], while "+XX:XX" work fine. */
88+
private fun fixOffsetRepresentation(isoString: String): String {
89+
val time = isoString.indexOf('T', ignoreCase = true)
90+
if (time == -1) return isoString // the string is malformed
91+
val offset = isoString.indexOfLast { c -> c == '+' || c == '-' }
92+
if (offset < time) return isoString // the offset is 'Z' and not +/- something else
93+
val separator = isoString.indexOf(':', offset) // if there is a ':' in the offset, no changes needed
94+
return if (separator != -1) isoString else "$isoString:00"
95+
}
96+
8597
public actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Long): Instant = try {
8698
/* Performing normalization here because otherwise this fails:
8799
assertEquals((Long.MAX_VALUE % 1_000_000_000).toInt(),
@@ -210,3 +222,6 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti
210222
} catch (e: Throwable) {
211223
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) else throw e
212224
}
225+
226+
internal actual fun Instant.toStringWithOffset(offset: ZoneOffset): String =
227+
jtOffsetDateTime.ofInstant(this.value, offset.zoneId).toString()

0 commit comments

Comments
 (0)