-
Notifications
You must be signed in to change notification settings - Fork 110
Implement Instant parsing in common module #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
/* | ||
* Copyright 2019-2021 JetBrains s.r.o. | ||
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. | ||
*/ | ||
|
||
package kotlinx.datetime | ||
|
||
import kotlin.math.min | ||
import kotlin.math.pow | ||
|
||
internal fun parseInstantCommon(string: String): Instant = parseIsoString(string) | ||
|
||
/* | ||
* The algorithm for parsing time and zone offset was adapted from | ||
* https://github.com/square/moshi/blob/aea17e09bc6a3f9015d3de0e951923f1033d299e/adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.java | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please advise on the proper formulation of attribution that shall be added. |
||
*/ | ||
private fun parseIsoString(isoString: String): Instant { | ||
try { | ||
val dateTimeSplit = isoString.split('T', ignoreCase = true) | ||
if (dateTimeSplit.size != 2) { | ||
throw DateTimeFormatException("ISO 8601 datetime must contain exactly one (T|t) delimiter.") | ||
} | ||
val localDate = LocalDate.parse(dateTimeSplit[0]) | ||
|
||
// Iso8601Utils.parse | ||
val timePart = dateTimeSplit[1] | ||
var offset = 0 | ||
val hour = parseInt(timePart, offset, offset + 2).also { offset += 2 } | ||
if (checkOffset(timePart, offset, ':')) { | ||
offset += 1 | ||
} | ||
val minutes = parseInt(timePart, offset, offset + 2).also { offset += 2 } | ||
if (checkOffset(timePart, offset, ':')) { | ||
offset += 1 | ||
} | ||
|
||
var seconds = 0 | ||
var nanosecond = 0 | ||
// seconds and fraction can be optional | ||
if (timePart.length > offset) { | ||
val c = timePart[offset] | ||
if (c != 'Z' && c != 'z' && c != '+' && c != '-') { | ||
seconds = parseInt(timePart, offset, offset + 2).also { offset += 2 } | ||
if (seconds > 59 && seconds < 63) { // https://github.com/Kotlin/kotlinx-datetime/issues/5 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is #5 applicable here, or should this be converted to idiomatic range check? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is applicable. That said, this line is strange in any case: the existing parser for Instant.parse("2020-06-14T01:01:61Z") successfully parsing and LocalDateTime.parse("2020-06-14T01:01:61") failing. Also, it should be noted Java Time's parser throws on seconds outside of |
||
seconds = 59 // truncate up to 3 leap seconds | ||
} | ||
if (checkOffset(timePart, offset, '.')) { | ||
offset += 1 | ||
val endOffset = | ||
indexOfNonDigit(timePart, offset + 1) // assume at least one digit | ||
val parseEndOffset = | ||
min(endOffset, offset + 9) // parse up to 9 digits | ||
val fraction = parseInt(timePart, offset, parseEndOffset) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way this code deals with fractions with more than nine digits that are representable as nanoseconds is by truncating the extra digits towards zero—not even by rounding them. So, Instant.parse("2020-06-14T01:01:59.1234567899Z")
// results in 2020-06-14T01:01:59.123456789Z This is highly questionable, as, again, parsing |
||
nanosecond = (10.0.pow(9 - (parseEndOffset - offset)) * fraction).toInt() | ||
offset = endOffset | ||
} | ||
} | ||
} | ||
|
||
// extract timezone | ||
if (timePart.length <= offset) { | ||
throw DateTimeFormatException("No time zone indicator in '$timePart'") | ||
} | ||
val timezone: TimeZone | ||
val timezoneIndicator = timePart[offset] | ||
if (timezoneIndicator == 'Z' || timezoneIndicator == 'z') { | ||
timezone = TimeZone.UTC | ||
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') { | ||
val timezoneOffset = timePart.substring(offset) | ||
// 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These comments have little meaning without the commit history, so they probably shouldn't be included here. |
||
if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) { | ||
timezone = TimeZone.UTC | ||
} else { | ||
val timezoneId = "UTC$timezoneOffset" | ||
timezone = TimeZone.of(timezoneId) | ||
val act = timezone.id | ||
if (act != timezoneId) { | ||
/* 22-Jan-2015, tatu: Looks like canonical version has colons, | ||
* but we may be given one without. If so, don't sweat. | ||
* Yes, very inefficient. Hopefully not hit often. | ||
* If it becomes a perf problem, add 'loose' comparison instead. | ||
*/ | ||
val cleaned = act.replace(":", "") | ||
if (cleaned != timezoneId) { | ||
throw IllegalTimeZoneException( | ||
"Mismatching time zone indicator: " | ||
+ timezoneId | ||
+ " given, resolves to " | ||
+ timezone.id | ||
) | ||
} | ||
} | ||
} | ||
} else { | ||
throw DateTimeFormatException("Invalid time zone indicator '$timezoneIndicator'") | ||
} | ||
return localDate.atTime(hour, minutes, seconds, nanosecond).toInstant(timezone) | ||
} catch (e: NumberFormatException) { | ||
throw DateTimeFormatException(e) | ||
} | ||
} | ||
|
||
/** | ||
* Check if the expected character exist at the given offset in the value. | ||
* | ||
* @param value the string to check at the specified offset | ||
* @param offset the offset to look for the expected character | ||
* @param expected the expected character | ||
* @return true if the expected character exist at the given offset | ||
*/ | ||
private fun checkOffset(value: String, offset: Int, expected: Char): Boolean { | ||
return (offset < value.length) && (value[offset] == expected) | ||
} | ||
|
||
/** | ||
* Parse an integer located between 2 given offsets in a string | ||
* | ||
* @param value the string to parse | ||
* @param beginIndex the start index for the integer in the string | ||
* @param endIndex the end index for the integer in the string | ||
* @return the int | ||
* @throws NumberFormatException if the value is not a number | ||
*/ | ||
@OptIn(ExperimentalStdlibApi::class) | ||
private fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int { | ||
if ((beginIndex < 0) || (endIndex > value.length) || (beginIndex > endIndex)) { | ||
throw NumberFormatException(value) | ||
} | ||
return value.substring(beginIndex, endIndex).toInt() | ||
} | ||
|
||
/** | ||
* Returns the index of the first character in the string that is not a digit, starting at offset. | ||
*/ | ||
private fun indexOfNonDigit(string: String, offset: Int): Int { | ||
for (i in offset until string.length) { | ||
val c = string[i] | ||
if (c < '0' || c > '9') return i | ||
} | ||
return string.length | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -75,12 +75,7 @@ public actual class Instant internal constructor(internal val value: jtInstant) | |
if (epochMilliseconds > 0) MAX else MIN | ||
} | ||
|
||
actual fun parse(isoString: String): Instant = try { | ||
Instant(jtInstant.parse(isoString)) | ||
} catch (e: Throwable) { | ||
if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) | ||
throw e | ||
} | ||
actual fun parse(isoString: String): Instant = parseInstantCommon(isoString) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could be implemented easily—and consistently with the other parsers—by parsing a The same for the Java implementation. |
||
|
||
actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Long): Instant = try { | ||
/* Performing normalization here because otherwise this fails: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,54 +23,6 @@ public actual enum class DayOfWeek { | |
SUNDAY; | ||
} | ||
|
||
// This is a function and not a value due to https://github.com/Kotlin/kotlinx-datetime/issues/5 | ||
// org.threeten.bp.format.DateTimeFormatterBuilder.InstantPrinterParser#parse | ||
private val instantParser: Parser<Instant> | ||
get() = localDateParser | ||
.chainIgnoring(concreteCharParser('T').or(concreteCharParser('t'))) | ||
.chain(intParser(2, 2)) // hour | ||
.chainIgnoring(concreteCharParser(':')) | ||
.chain(intParser(2, 2)) // minute | ||
.chainIgnoring(concreteCharParser(':')) | ||
.chain(intParser(2, 2)) // second | ||
.chain(optional( | ||
concreteCharParser('.') | ||
.chainSkipping(fractionParser(0, 9, 9)) // nanos | ||
)) | ||
.chainIgnoring(concreteCharParser('Z').or(concreteCharParser('z'))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the other parsers are implemented via |
||
.map { | ||
val (dateHourMinuteSecond, nanosVal) = it | ||
val (dateHourMinute, secondsVal) = dateHourMinuteSecond | ||
val (dateHour, minutesVal) = dateHourMinute | ||
val (dateVal, hoursVal) = dateHour | ||
|
||
val nano = nanosVal ?: 0 | ||
val (days, hours, min, seconds) = if (hoursVal == 24 && minutesVal == 0 && secondsVal == 0 && nano == 0) { | ||
listOf(1, 0, 0, 0) | ||
} else if (hoursVal == 23 && minutesVal == 59 && secondsVal == 60) { | ||
// parsed a leap second, but it seems it isn't used | ||
listOf(0, 23, 59, 59) | ||
} else { | ||
listOf(0, hoursVal, minutesVal, secondsVal) | ||
} | ||
|
||
// never fails: 9_999 years are always supported | ||
val localDate = dateVal.withYear(dateVal.year % 10000).plus(days, DateTimeUnit.DAY) | ||
val localTime = LocalTime.of(hours, min, seconds, 0) | ||
val secDelta: Long = try { | ||
safeMultiply((dateVal.year / 10000).toLong(), SECONDS_PER_10000_YEARS) | ||
} catch (e: ArithmeticException) { | ||
throw DateTimeFormatException(e) | ||
} | ||
val epochDay = localDate.toEpochDay().toLong() | ||
val instantSecs = epochDay * 86400 + localTime.toSecondOfDay() + secDelta | ||
try { | ||
Instant(instantSecs, nano) | ||
} catch (e: IllegalArgumentException) { | ||
throw DateTimeFormatException(e) | ||
} | ||
} | ||
|
||
/** | ||
* The minimum supported epoch second. | ||
*/ | ||
|
@@ -243,8 +195,7 @@ public actual class Instant internal constructor(actual val epochSeconds: Long, | |
actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Int): Instant = | ||
fromEpochSeconds(epochSeconds, nanosecondAdjustment.toLong()) | ||
|
||
actual fun parse(isoString: String): Instant = | ||
instantParser.parse(isoString) | ||
actual fun parse(isoString: String): Instant = parseInstantCommon(isoString) | ||
|
||
actual val DISTANT_PAST: Instant = fromEpochSeconds(DISTANT_PAST_SECONDS, 999_999_999) | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parseInstantCommon facade may try multiple formats before giving up such as #83 if that gets implemented.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the need for such extensibility arises, we have little enough code to be able to add it quickly.