Skip to content

Commit 8664b70

Browse files
authored
Fix some parsing bugs in default parsers (#447)
Fixes #443
1 parent f63a621 commit 8664b70

File tree

9 files changed

+58
-5
lines changed

9 files changed

+58
-5
lines changed

core/common/src/DateTimePeriod.kt

+4
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ public sealed class DateTimePeriod {
260260
var minutes = 0
261261
var seconds = 0
262262
var nanoseconds = 0
263+
var someComponentParsed = false
263264
while (true) {
264265
if (i >= text.length) {
265266
if (state == START)
@@ -270,6 +271,8 @@ public sealed class DateTimePeriod {
270271
in Int.MIN_VALUE..Int.MAX_VALUE -> n.toInt()
271272
else -> parseException("The total number of days under 'D' and 'W' designators should fit into an Int", 0)
272273
}
274+
if (!someComponentParsed)
275+
parseException("At least one component is required, but none were found", 0)
273276
return DateTimePeriod(years, months, daysTotal, hours, minutes, seconds, nanoseconds.toLong())
274277
}
275278
if (state == START) {
@@ -316,6 +319,7 @@ public sealed class DateTimePeriod {
316319
}
317320
i += 1
318321
}
322+
someComponentParsed = true
319323
number *= localSign
320324
if (i == text.length)
321325
parseException("Expected a designator after the numerical value", i)

core/common/src/internal/util.kt

+30
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,33 @@ package kotlinx.datetime.internal
88
internal fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
99

1010
internal fun Char.asciiDigitToInt(): Int = this - '0'
11+
12+
/** Working around the JSR-310 behavior of failing to parse long year numbers even when they start with leading zeros */
13+
private fun removeLeadingZerosFromLongYearForm(input: String, minStringLengthAfterYear: Int): String {
14+
// the smallest string where the issue can occur is "+00000002024", its length is 12
15+
val failingYearStringLength = 12
16+
// happy path: the input is too short or the first character is not a sign, so the year is not in the long form
17+
if (input.length < failingYearStringLength + minStringLengthAfterYear || input[0] !in "+-") return input
18+
// the year is in the long form, so we need to remove the leading zeros
19+
// find the `-` that separates the year from the month
20+
val yearEnd = input.indexOf('-', 1)
21+
// if (yearEnd == -1) return input // implied by the next condition
22+
// if the year is too short, no need to remove the leading zeros, and if the string is malformed, just leave it
23+
if (yearEnd < failingYearStringLength) return input
24+
// how many leading zeroes are there?
25+
var leadingZeros = 0
26+
while (input[1 + leadingZeros] == '0') leadingZeros++ // no overflow, we know `-` is there
27+
// even if we removed all leading zeros, the year would still be too long
28+
if (yearEnd - leadingZeros >= failingYearStringLength) return input
29+
// we remove just enough leading zeros to make the year the right length
30+
// We need the resulting length to be `failYearStringLength - 1`, the current length is `yearEnd`.
31+
// The difference is `yearEnd - failingYearStringLength + 1` characters to remove.
32+
// Both the start index and the end index are shifted by 1 because of the sign.
33+
return input.removeRange(startIndex = 1, endIndex = yearEnd - failingYearStringLength + 2)
34+
}
35+
36+
internal fun removeLeadingZerosFromLongYearFormLocalDate(input: String) =
37+
removeLeadingZerosFromLongYearForm(input.toString(), 6) // 6 = "-01-02".length
38+
39+
internal fun removeLeadingZerosFromLongYearFormLocalDateTime(input: String) =
40+
removeLeadingZerosFromLongYearForm(input.toString(), 12) // 12 = "-01-02T23:59".length

core/common/test/DateTimePeriodTest.kt

+4
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,14 @@ class DateTimePeriodTest {
110110
assertEquals(DateTimePeriod(nanoseconds = -999_999_999), DateTimePeriod.parse("-PT0.999999999S"))
111111
assertEquals(DateTimePeriod(days = 1, nanoseconds = -999_999_999), DateTimePeriod.parse("P1DT-0.999999999S"))
112112
assertPeriodComponents(DateTimePeriod.parse("P1DT-0.999999999S"), days = 1, nanoseconds = -999_999_999)
113+
assertEquals(DatePeriod(days = 1), DateTimePeriod.parse("P000000000000000000000000000001D"))
114+
115+
assertFailsWith<IllegalArgumentException> { DateTimePeriod.parse("P") }
113116

114117
// overflow of `Int.MAX_VALUE` months
115118
assertFailsWith<IllegalArgumentException> { DateTimePeriod.parse("P2000000000Y") }
116119
assertFailsWith<IllegalArgumentException> { DateTimePeriod.parse("P1Y2147483640M") }
120+
assertFailsWith<IllegalArgumentException> { DateTimePeriod.parse("PT+-2H") }
117121

118122
// too large a number in a field
119123
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P3000000000Y") }

core/common/test/LocalDateTest.kt

+4
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ class LocalDateTest {
6262
checkParsedComponents("-10000-01-01", -10000, 1, 1)
6363
checkParsedComponents("+123456-01-01", 123456, 1, 1)
6464
checkParsedComponents("-123456-01-01", -123456, 1, 1)
65+
for (i in 1..30) {
66+
checkComponents(LocalDate.parse("+${"0".repeat(i)}2024-01-01"), 2024, 1, 1)
67+
checkComponents(LocalDate.parse("-${"0".repeat(i)}2024-01-01"), -2024, 1, 1)
68+
}
6569
}
6670

6771
@Test

core/common/test/LocalDateTimeTest.kt

+5
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ class LocalDateTimeTest {
2828
assertFailsWith<DateTimeFormatException> { LocalDateTime.parse("x") }
2929
assertFailsWith<DateTimeFormatException> { "+1000000000-03-26T04:00:00".toLocalDateTime() }
3030

31+
for (i in 1..30) {
32+
checkComponents(LocalDateTime.parse("+${"0".repeat(i)}2024-01-01T23:59"), 2024, 1, 1, 23, 59)
33+
checkComponents(LocalDateTime.parse("-${"0".repeat(i)}2024-01-01T23:59:03"), -2024, 1, 1, 23, 59, 3)
34+
}
35+
3136
/* Based on the ThreeTenBp project.
3237
* Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos
3338
*/

core/commonJs/src/LocalDate.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package kotlinx.datetime
77

88
import kotlinx.datetime.format.*
9+
import kotlinx.datetime.internal.removeLeadingZerosFromLongYearFormLocalDate
910
import kotlinx.datetime.serializers.LocalDateIso8601Serializer
1011
import kotlinx.serialization.Serializable
1112
import kotlinx.datetime.internal.JSJoda.LocalDate as jtLocalDate
@@ -20,7 +21,8 @@ public actual class LocalDate internal constructor(internal val value: jtLocalDa
2021
format: DateTimeFormat<LocalDate>
2122
): LocalDate = if (format === Formats.ISO) {
2223
try {
23-
jsTry { jtLocalDate.parse(input.toString()) }.let(::LocalDate)
24+
val sanitizedInput = removeLeadingZerosFromLongYearFormLocalDate(input.toString())
25+
jsTry { jtLocalDate.parse(sanitizedInput.toString()) }.let(::LocalDate)
2426
} catch (e: Throwable) {
2527
if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e)
2628
throw e

core/commonJs/src/LocalDateTime.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package kotlinx.datetime
77
import kotlinx.datetime.format.*
88
import kotlinx.datetime.format.ISO_DATETIME
99
import kotlinx.datetime.format.LocalDateTimeFormat
10+
import kotlinx.datetime.internal.removeLeadingZerosFromLongYearFormLocalDateTime
1011
import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer
1112
import kotlinx.serialization.Serializable
1213
import kotlinx.datetime.internal.JSJoda.LocalDateTime as jtLocalDateTime
@@ -57,7 +58,8 @@ public actual class LocalDateTime internal constructor(internal val value: jtLoc
5758
public actual fun parse(input: CharSequence, format: DateTimeFormat<LocalDateTime>): LocalDateTime =
5859
if (format === Formats.ISO) {
5960
try {
60-
jsTry { jtLocalDateTime.parse(input.toString()) }.let(::LocalDateTime)
61+
val sanitizedInput = removeLeadingZerosFromLongYearFormLocalDateTime(input.toString())
62+
jsTry { jtLocalDateTime.parse(sanitizedInput.toString()) }.let(::LocalDateTime)
6163
} catch (e: Throwable) {
6264
if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e)
6365
throw e

core/jvm/src/LocalDate.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ public actual class LocalDate internal constructor(internal val value: jtLocalDa
2222
public actual fun parse(input: CharSequence, format: DateTimeFormat<LocalDate>): LocalDate =
2323
if (format === Formats.ISO) {
2424
try {
25-
jtLocalDate.parse(input).let(::LocalDate)
25+
val sanitizedInput = removeLeadingZerosFromLongYearFormLocalDate(input.toString())
26+
jtLocalDate.parse(sanitizedInput).let(::LocalDate)
2627
} catch (e: DateTimeParseException) {
2728
throw DateTimeFormatException(e)
2829
}

core/jvm/src/LocalDateTime.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package kotlinx.datetime
77

88
import kotlinx.datetime.format.*
9+
import kotlinx.datetime.internal.removeLeadingZerosFromLongYearFormLocalDateTime
910
import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer
1011
import kotlinx.serialization.Serializable
1112
import java.time.DateTimeException
@@ -60,7 +61,8 @@ public actual class LocalDateTime internal constructor(internal val value: jtLoc
6061
public actual fun parse(input: CharSequence, format: DateTimeFormat<LocalDateTime>): LocalDateTime =
6162
if (format === Formats.ISO) {
6263
try {
63-
jtLocalDateTime.parse(input).let(::LocalDateTime)
64+
val sanitizedInput = removeLeadingZerosFromLongYearFormLocalDateTime(input.toString())
65+
jtLocalDateTime.parse(sanitizedInput).let(::LocalDateTime)
6466
} catch (e: DateTimeParseException) {
6567
throw DateTimeFormatException(e)
6668
}
@@ -84,4 +86,3 @@ public actual class LocalDateTime internal constructor(internal val value: jtLoc
8486
}
8587

8688
}
87-

0 commit comments

Comments
 (0)