From e517b811e7a232a92eed17c37cda4e4aae41ca3c Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Sun, 26 Feb 2023 16:02:02 +0100 Subject: [PATCH 001/105] WIP: first draft of locale-invariant parsing and formatting --- core/common/src/format/FormatBuilder.kt | 133 ++++++ core/common/src/format/LocalDateFormat.kt | 184 ++++++++ core/common/src/format/LocalDateTimeFormat.kt | 131 ++++++ core/common/src/format/LocalTimeFormat.kt | 257 ++++++++++ core/common/src/format/UtcOffsetFormat.kt | 151 ++++++ core/common/src/format/ValueBagFormat.kt | 437 ++++++++++++++++++ core/common/src/internal/LruCache.kt | 10 + core/common/src/internal/dateCalculations.kt | 42 ++ core/common/src/internal/format/Builder.kt | 33 ++ .../internal/format/FieldFormatDirective.kt | 176 +++++++ core/common/src/internal/format/FieldSpec.kt | 127 +++++ core/common/src/internal/format/Format.kt | 246 ++++++++++ .../src/internal/format/FormatStrings.kt | 170 +++++++ core/common/src/internal/format/Predicate.kt | 30 ++ .../internal/format/formatter/Formatter.kt | 61 +++ .../format/formatter/FormatterOperation.kt | 102 ++++ .../internal/format/parser/NumberConsumer.kt | 104 +++++ .../src/internal/format/parser/ParseResult.kt | 23 + .../src/internal/format/parser/Parser.kt | 174 +++++++ .../internal/format/parser/ParserOperation.kt | 241 ++++++++++ core/common/src/internal/math.kt | 50 ++ core/commonJs/src/internal/LruCache.kt | 24 + core/jvm/src/internal/LruCacheJvm.kt | 29 ++ core/native/src/Instant.kt | 164 +------ core/native/src/LocalDate.kt | 105 +---- core/native/src/LocalDateTime.kt | 20 +- core/native/src/LocalTime.kt | 61 +-- core/native/src/UtcOffset.kt | 71 +-- core/native/src/internal/LruCache.kt | 39 ++ core/native/src/internal/dateCalculations.kt | 23 - core/native/src/internal/mathNative.kt | 2 +- core/native/test/ThreeTenBpLocalDateTest.kt | 2 +- 32 files changed, 3021 insertions(+), 401 deletions(-) create mode 100644 core/common/src/format/FormatBuilder.kt create mode 100644 core/common/src/format/LocalDateFormat.kt create mode 100644 core/common/src/format/LocalDateTimeFormat.kt create mode 100644 core/common/src/format/LocalTimeFormat.kt create mode 100644 core/common/src/format/UtcOffsetFormat.kt create mode 100644 core/common/src/format/ValueBagFormat.kt create mode 100644 core/common/src/internal/LruCache.kt create mode 100644 core/common/src/internal/format/Builder.kt create mode 100644 core/common/src/internal/format/FieldFormatDirective.kt create mode 100644 core/common/src/internal/format/FieldSpec.kt create mode 100644 core/common/src/internal/format/Format.kt create mode 100644 core/common/src/internal/format/FormatStrings.kt create mode 100644 core/common/src/internal/format/Predicate.kt create mode 100644 core/common/src/internal/format/formatter/Formatter.kt create mode 100644 core/common/src/internal/format/formatter/FormatterOperation.kt create mode 100644 core/common/src/internal/format/parser/NumberConsumer.kt create mode 100644 core/common/src/internal/format/parser/ParseResult.kt create mode 100644 core/common/src/internal/format/parser/Parser.kt create mode 100644 core/common/src/internal/format/parser/ParserOperation.kt create mode 100644 core/commonJs/src/internal/LruCache.kt create mode 100644 core/jvm/src/internal/LruCacheJvm.kt create mode 100644 core/native/src/internal/LruCache.kt diff --git a/core/common/src/format/FormatBuilder.kt b/core/common/src/format/FormatBuilder.kt new file mode 100644 index 000000000..b93db1cf4 --- /dev/null +++ b/core/common/src/format/FormatBuilder.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.format + +import kotlinx.datetime.internal.format.* +import kotlinx.datetime.internal.format.AlternativesFormatStructure + +@DslMarker +public annotation class DateTimeBuilder + +/** + * Common functions for all the date-time format builders. + */ +public interface FormatBuilder { + /** + * Appends a set of alternative blocks to the format. + * + * When parsing, the blocks are tried in order until one of them succeeds. + * + * When formatting, there is a requirement that the later blocks contain all the fields that are present in the + * earlier blocks. Moreover, the additional fields must have a *default value* defined for them. + * Then, during formatting, the block that has the most information is chosen. + * + * Example: + * ``` + * appendAlternatives({ + * appendLiteral("Z") + * }, { + * appendOffsetHours() + * appendOptional { + * appendLiteral(":") + * appendOffsetMinutes() + * appendOptional { + * appendLiteral(":") + * appendOffsetSeconds() + * } + * } + * }) + * ``` + * Here, all values have the default value of zero, so the first block is chosen when formatting `UtcOffset.ZERO`. + */ + public fun appendAlternatives(vararg blocks: Self.() -> Unit) + + /** + * Appends a literal string to the format. + * When formatting, the string is appended to the result as is, + * and when parsing, the string is expected to be present in the input. + */ + public fun appendLiteral(string: String) + + /** + * Appends a format string to the format. + * + * TODO. For now, see docs for [kotlinx.datetime.internal.format.appendFormatString]. + * + * @throws IllegalArgumentException if the format string is invalid. + */ + public fun appendFormatString(formatString: String) +} + +/** + * Appends an optional section to the format. + * + * When parsing, the section is parsed if it is present in the input. + * + * When formatting, the section is formatted if the value of any field in the block is not equal to the default value. + * Only [appendOptional] calls where all the fields have default values are permitted when formatting. + * + * Example: + * ``` + * appendHours() + * appendLiteral(":") + * appendMinutes() + * appendOptional { + * appendLiteral(":") + * appendSeconds() + * } + * ``` + * + * Here, because seconds have the default value of zero, they are formatted only if they are not equal to zero. + * + * This is a shorthand for `appendAlternatives({}, block)`. + */ +public fun FormatBuilder.appendOptional(block: Self.() -> Unit): Unit = + appendAlternatives({}, block) + +/** + * Appends a literal character to the format. + * + * This is a shorthand for `appendLiteral(char.toString())`. + */ +public fun FormatBuilder.appendLiteral(char: Char): Unit = appendLiteral(char.toString()) + +internal interface AbstractFormatBuilder : + FormatBuilder where ActualSelf : AbstractFormatBuilder { + + val actualBuilder: Builder + fun createEmpty(): ActualSelf + fun castToGeneric(actualSelf: ActualSelf): UserSelf + + override fun appendAlternatives(vararg blocks: UserSelf.() -> Unit) { + actualBuilder.add(AlternativesFormatStructure(blocks.map { block -> + createEmpty().also { block(castToGeneric(it)) }.actualBuilder.build() + })) + } + + override fun appendLiteral(string: String) = actualBuilder.add(ConstantFormatStructure(string)) + + override fun appendFormatString(formatString: String) { + val end = actualBuilder.appendFormatString(formatString) + require(end == formatString.length) { + "Unexpected char '${formatString[end]}' in $formatString at position $end" + } + } + + fun withSharedSign(outputPlus: Boolean, block: UserSelf.() -> Unit) { + actualBuilder.add( + SignedFormatStructure( + createEmpty().also { block(castToGeneric(it)) }.actualBuilder.build(), + outputPlus + ) + ) + } + + fun build(): Format = Format(actualBuilder.build()) +} + +internal interface Copyable { + fun copy(): Self +} diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt new file mode 100644 index 000000000..4ca85afb5 --- /dev/null +++ b/core/common/src/format/LocalDateFormat.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.format + +import kotlinx.datetime.* +import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.LruCache +import kotlinx.datetime.internal.format.* +import kotlinx.datetime.internal.format.parser.* + +public interface DateFormatBuilderFields { + public fun appendYear(minDigits: Int = 1, outputPlusOnExceededPadding: Boolean = false) + public fun appendMonthNumber(minLength: Int = 1) + public fun appendMonthName(names: List) + public fun appendDayOfMonth(minLength: Int = 1) +} + +@DateTimeBuilder +public interface DateFormatBuilder : DateFormatBuilderFields, FormatBuilder + +public class LocalDateFormat private constructor(private val actualFormat: Format) { + public companion object { + public fun build(block: DateFormatBuilder.() -> Unit): LocalDateFormat { + val builder = Builder(DateFieldContainerFormatBuilder()) + builder.block() + return LocalDateFormat(builder.build()) + } + + public fun fromFormatString(formatString: String): LocalDateFormat = build { appendFormatString(formatString) } + + public val ISO: LocalDateFormat = build { + appendYear(4, outputPlusOnExceededPadding = true) + appendFormatString("'-'mm'-'dd") + } + + internal val Cache = LruCache(16) { fromFormatString(it) } + } + + public fun format(date: LocalDate): String = + StringBuilder().also { + actualFormat.formatter.format(date.toIncompleteLocalDate(), it) + }.toString() + + public fun parse(input: String): LocalDate { + val parser = Parser(::IncompleteLocalDate, IncompleteLocalDate::copy, actualFormat.parser) + try { + return parser.match(input).toLocalDate() + } catch (e: ParseException) { + throw DateTimeFormatException("Failed to parse date from '$input'", e) + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Invalid date '$input'", e) + } + } + + private class Builder(override val actualBuilder: DateFieldContainerFormatBuilder) : + AbstractFormatBuilder, DateFormatBuilder { + override fun appendYear(minDigits: Int, outputPlusOnExceededPadding: Boolean) = + actualBuilder.add(BasicFormatStructure(YearDirective(minDigits, outputPlusOnExceededPadding))) + + override fun appendMonthNumber(minLength: Int) = + actualBuilder.add(BasicFormatStructure(MonthDirective(minLength))) + + override fun appendMonthName(names: List) = + actualBuilder.add(BasicFormatStructure(MonthNameDirective(names))) + + override fun appendDayOfMonth(minLength: Int) = actualBuilder.add(BasicFormatStructure(DayDirective(minLength))) + + override fun createEmpty(): Builder = Builder(DateFieldContainerFormatBuilder()) + override fun castToGeneric(actualSelf: Builder): DateFormatBuilder = this + } + +} + +public fun LocalDate.format(formatString: String): String = + LocalDateFormat.Cache.get(formatString).format(this) + +public fun LocalDate.format(format: LocalDateFormat): String = format.format(this) + +public fun LocalDate.Companion.parse(input: String, formatString: String): LocalDate = + LocalDateFormat.Cache.get(formatString).parse(input) + +public fun LocalDate.Companion.parse(input: String, format: LocalDateFormat): LocalDate = format.parse(input) + +internal fun LocalDate.toIncompleteLocalDate(): IncompleteLocalDate = + IncompleteLocalDate(year, monthNumber, dayOfMonth, dayOfWeek.isoDayNumber) + +internal fun getParsedField(field: T?, name: String): T { + if (field == null) { + throw DateTimeFormatException("Can not create a $name from the given input: the field $name is missing") + } + return field +} + +internal interface DateFieldContainer { + var year: Int? + var monthNumber: Int? + var dayOfMonth: Int? + var isoDayOfWeek: Int? +} + +internal object DateFields { + val year = SignedFieldSpec(DateFieldContainer::year, maxAbsoluteValue = null) + val month = UnsignedFieldSpec(DateFieldContainer::monthNumber, minValue = 1, maxValue = 12) + val dayOfMonth = UnsignedFieldSpec(DateFieldContainer::dayOfMonth, minValue = 1, maxValue = 31) + val isoDayOfWeek = UnsignedFieldSpec(DateFieldContainer::isoDayOfWeek, minValue = 1, maxValue = 7) +} + +/** + * A [kotlinx.datetime.LocalDate], but potentially incomplete and inconsistent. + */ +internal class IncompleteLocalDate( + override var year: Int? = null, + override var monthNumber: Int? = null, + override var dayOfMonth: Int? = null, + override var isoDayOfWeek: Int? = null +) : DateFieldContainer, Copyable { + fun toLocalDate(): LocalDate { + val date = LocalDate( + getParsedField(year, "year"), + getParsedField(monthNumber, "monthNumber"), + getParsedField(dayOfMonth, "dayOfMonth") + ) + isoDayOfWeek?.let { + if (it != date.dayOfWeek.isoDayNumber) { + throw DateTimeFormatException( + "Can not create a LocalDate from the given input: " + + "the day of week is ${DayOfWeek(it)} but the date is $date, which is a ${date.dayOfWeek}" + ) + } + } + return date + } + + override fun copy(): IncompleteLocalDate = IncompleteLocalDate(year, monthNumber, dayOfMonth, isoDayOfWeek) + + override fun toString(): String = + "${year ?: "??"}-${monthNumber ?: "??"}-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"})" +} + +internal class YearDirective(digits: Int, outputPlusOnExceededPadding: Boolean) : + SignedIntFieldFormatDirective( + DateFields.year, + minDigits = digits, + maxDigits = null, + outputPlusOnExceededPadding = outputPlusOnExceededPadding, + ) + +internal class MonthDirective(minDigits: Int) : + UnsignedIntFieldFormatDirective(DateFields.month, minDigits) + +internal class MonthNameDirective(names: List) : + NamedUnsignedIntFieldFormatDirective(DateFields.month, names) + +internal class DayDirective(minDigits: Int) : + UnsignedIntFieldFormatDirective(DateFields.dayOfMonth, minDigits) + +internal class DateFieldContainerFormatBuilder : AbstractBuilder() { + + companion object { + const val name = "ld" + } + + override fun formatFromSubBuilder( + name: String, + block: Builder<*>.() -> Unit + ): FormatStructure? = + if (name == DateFieldContainerFormatBuilder.name) + DateFieldContainerFormatBuilder().apply(block).build() + else null + + override fun formatFromDirective(letter: Char, length: Int): FormatStructure? { + return when (letter) { + 'y' -> BasicFormatStructure(YearDirective(length, outputPlusOnExceededPadding = false)) + 'm' -> BasicFormatStructure(MonthDirective(length)) + 'd' -> BasicFormatStructure(DayDirective(length)) + else -> null + } + } + + override fun createSibling(): Builder = DateFieldContainerFormatBuilder() +} diff --git a/core/common/src/format/LocalDateTimeFormat.kt b/core/common/src/format/LocalDateTimeFormat.kt new file mode 100644 index 000000000..d9854823f --- /dev/null +++ b/core/common/src/format/LocalDateTimeFormat.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.format + +import kotlinx.datetime.* +import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.LruCache +import kotlinx.datetime.internal.format.* +import kotlinx.datetime.internal.format.parser.* + +@DateTimeBuilder +public interface DateTimeFormatBuilder : DateFormatBuilderFields, TimeFormatBuilderFields, + FormatBuilder + +public class LocalDateTimeFormat private constructor(private val actualFormat: Format) { + public companion object { + public fun build(block: DateTimeFormatBuilder.() -> Unit): LocalDateTimeFormat { + val builder = Builder(DateTimeFieldContainerFormatBuilder()) + builder.block() + return LocalDateTimeFormat(builder.build()) + } + + public fun fromFormatString(formatString: String): LocalDateTimeFormat = build { appendFormatString(formatString) } + + /** + * ISO-8601 extended format, which is the format used by [LocalDateTime.toString] and [LocalDateTime.parse]. + * + * Examples of date/time in ISO-8601 format: + * - `2020-08-30T18:43` + * - `2020-08-30T18:43:00` + * - `2020-08-30T18:43:00.500` + * - `2020-08-30T18:43:00.123456789` + */ + public val ISO: LocalDateTimeFormat = build { + appendYear(4, outputPlusOnExceededPadding = true) + appendFormatString("ld<'-'mm'-'dd>('T'|'t')lt") + } + + internal val Cache = LruCache(16) { fromFormatString(it) } + } + + public fun format(date: LocalDateTime): String = + StringBuilder().also { + actualFormat.formatter.format(date.toIncompleteLocalDateTime(), it) + }.toString() + + public fun parse(input: String): LocalDateTime { + val parser = Parser(::IncompleteLocalDateTime, IncompleteLocalDateTime::copy, actualFormat.parser) + try { + return parser.match(input).toLocalDateTime() + } catch (e: ParseException) { + throw DateTimeFormatException("Failed to parse date-time from '$input'", e) + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Invalid date-time '$input'", e) + } + } + + private class Builder(override val actualBuilder: DateTimeFieldContainerFormatBuilder) : + AbstractFormatBuilder, DateTimeFormatBuilder { + override fun appendYear(minDigits: Int, outputPlusOnExceededPadding: Boolean) = + actualBuilder.add(BasicFormatStructure(YearDirective(minDigits, outputPlusOnExceededPadding))) + override fun appendMonthNumber(minLength: Int) = + actualBuilder.add(BasicFormatStructure(MonthDirective(minLength))) + + override fun appendMonthName(names: List) = + actualBuilder.add(BasicFormatStructure(MonthNameDirective(names))) + override fun appendDayOfMonth(minLength: Int) = actualBuilder.add(BasicFormatStructure(DayDirective(minLength))) + override fun appendHour(minLength: Int) = actualBuilder.add(BasicFormatStructure(HourDirective(minLength))) + override fun appendMinute(minLength: Int) = actualBuilder.add(BasicFormatStructure(MinuteDirective(minLength))) + override fun appendSecond(minLength: Int) = actualBuilder.add(BasicFormatStructure(SecondDirective(minLength))) + override fun appendSecondFraction(minLength: Int?, maxLength: Int?) = + actualBuilder.add(BasicFormatStructure(FractionalSecondDirective(minLength, maxLength))) + + override fun createEmpty(): Builder = Builder(DateTimeFieldContainerFormatBuilder()) + override fun castToGeneric(actualSelf: Builder): DateTimeFormatBuilder = this + } + +} + +public fun LocalDateTime.format(formatString: String): String = + LocalDateTimeFormat.Cache.get(formatString).format(this) + +public fun LocalDateTime.format(format: LocalDateTimeFormat): String = format.format(this) + +public fun LocalDateTime.Companion.parse(input: String, formatString: String): LocalDateTime = + LocalDateTimeFormat.Cache.get(formatString).parse(input) + +public fun LocalDateTime.Companion.parse(input: String, format: LocalDateTimeFormat): LocalDateTime = format.parse(input) + +internal fun LocalDateTime.toIncompleteLocalDateTime(): IncompleteLocalDateTime = + IncompleteLocalDateTime(date.toIncompleteLocalDate(), time.toIncompleteLocalTime()) + +internal interface DateTimeFieldContainer : DateFieldContainer, TimeFieldContainer + +internal class IncompleteLocalDateTime( + val date: IncompleteLocalDate = IncompleteLocalDate(), + val time: IncompleteLocalTime = IncompleteLocalTime(), +) : DateTimeFieldContainer, DateFieldContainer by date, TimeFieldContainer by time, Copyable { + fun toLocalDateTime(): LocalDateTime = LocalDateTime(date.toLocalDate(), time.toLocalTime()) + + override fun copy(): IncompleteLocalDateTime = IncompleteLocalDateTime(date.copy(), time.copy()) +} + +private class DateTimeFieldContainerFormatBuilder : AbstractBuilder() { + override fun formatFromSubBuilder( + name: String, + block: Builder<*>.() -> Unit + ): FormatStructure? = + when (name) { + DateFieldContainerFormatBuilder.name -> { + val builder = DateFieldContainerFormatBuilder() + block(builder) + builder.build() + } + + TimeFieldContainerFormatBuilder.name -> { + val builder = TimeFieldContainerFormatBuilder() + block(builder) + builder.build() + } + + else -> null + } + + override fun formatFromDirective(letter: Char, length: Int): FormatStructure? = null + + override fun createSibling(): Builder = DateTimeFieldContainerFormatBuilder() +} diff --git a/core/common/src/format/LocalTimeFormat.kt b/core/common/src/format/LocalTimeFormat.kt new file mode 100644 index 000000000..728c6bc91 --- /dev/null +++ b/core/common/src/format/LocalTimeFormat.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.format + +import kotlinx.datetime.* +import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.format.* +import kotlinx.datetime.internal.format.parser.* + + +public interface TimeFormatBuilderFields { + /** + * Appends the number of hours. + * + * The number is padded with zeroes to the specified [minLength] when formatting. + * When parsing, the number is expected to be at least [minLength] digits long. + * + * @throws IllegalArgumentException if [minLength] is not in the range 1..2. + */ + public fun appendHour(minLength: Int = 1) + + /** + * Appends the number of minutes. + * + * The number is padded with zeroes to the specified [minLength] when formatting. + * When parsing, the number is expected to be at least [minLength] digits long. + * + * @throws IllegalArgumentException if [minLength] is not in the range 1..2. + */ + public fun appendMinute(minLength: Int = 1) + + /** + * Appends the number of seconds. + * + * The number is padded with zeroes to the specified [minLength] when formatting. + * When parsing, the number is expected to be at least [minLength] digits long. + * + * @throws IllegalArgumentException if [minLength] is not in the range 1..2. + */ + public fun appendSecond(minLength: Int = 1) + + /** + * Appends the fractional part of the second without the leading dot. + * + * When formatting, the decimal fraction will add trailing zeroes to the specified [minLength] and will round the + * number to fit in the specified [maxLength]. + * + * @throws IllegalArgumentException if [minLength] is greater than [maxLength] or if either is not in the range 1..9. + */ + public fun appendSecondFraction(minLength: Int? = null, maxLength: Int? = null) +} + +@DateTimeBuilder +public interface TimeFormatBuilder : TimeFormatBuilderFields, FormatBuilder { + /** + * Appends a format string to the builder. + * + * For rules common for all format strings, see [FormatBuilder.appendFormatString]. + * + * For time format strings, the following pattern letters are available: + * * `h` - hour of day, 0-23. + * * `m` - minute of hour, 0-59. + * * `s` - second of minute, 0-59. Has the default value of 0. + * * `f` - decimal fraction of the second, between 0 (inclusive) and 1 (non-inclusive), with at most 9 digits + * of precision. Has the default value of 0. + * + * The directives can be repeated to specify the minimum length of the field. + * + * Example: `hh:mm:ss` will format `LocalTime(1, 2, 3)` as `01:02:03`. + */ + // overriding the documentation. + override fun appendFormatString(formatString: String) +} + +public class LocalTimeFormat private constructor(private val actualFormat: Format) { + public companion object { + public fun build(block: TimeFormatBuilder.() -> Unit): LocalTimeFormat { + val builder = Builder(TimeFieldContainerFormatBuilder()) + builder.block() + return LocalTimeFormat(builder.build()) + } + + public fun fromFormatString(formatString: String): LocalTimeFormat = build { appendFormatString(formatString) } + + /** + * ISO-8601 extended format, used by [LocalTime.toString] and [LocalTime.parse]. + * + * Examples: `12:34`, `12:34:56`, `12:34:56.789`. + */ + public val ISO : LocalTimeFormat = fromFormatString("hh':'mm(|':'ss(|'.'f))") + + internal val Cache = LruCache(16) { fromFormatString(it) } + } + + public fun format(time: LocalTime): String = + StringBuilder().also { + actualFormat.formatter.format(time.toIncompleteLocalTime(), it) + }.toString() + + public fun parse(input: String): LocalTime { + val parser = Parser(::IncompleteLocalTime, IncompleteLocalTime::copy, actualFormat.parser) + try { + return parser.match(input).toLocalTime() + } catch (e: ParseException) { + throw DateTimeFormatException("Failed to parse time from '$input'", e) + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Invalid time '$input'", e) + } + } + + private class Builder(override val actualBuilder: TimeFieldContainerFormatBuilder) : + AbstractFormatBuilder, TimeFormatBuilder { + override fun appendHour(minLength: Int) = actualBuilder.add(BasicFormatStructure(HourDirective(minLength))) + override fun appendMinute(minLength: Int) = actualBuilder.add(BasicFormatStructure(MinuteDirective(minLength))) + override fun appendSecond(minLength: Int) = actualBuilder.add(BasicFormatStructure(SecondDirective(minLength))) + override fun appendSecondFraction(minLength: Int?, maxLength: Int?) = + actualBuilder.add(BasicFormatStructure(FractionalSecondDirective(minLength, maxLength))) + + override fun createEmpty(): Builder = Builder(TimeFieldContainerFormatBuilder()) + override fun castToGeneric(actualSelf: Builder): TimeFormatBuilder = this + override fun appendFormatString(formatString: String) = super.appendFormatString(formatString) + } + +} + +public fun LocalTime.format(formatString: String): String = + LocalTimeFormat.Cache.get(formatString).format(this) + +public fun LocalTime.format(format: LocalTimeFormat): String = format.format(this) + +public fun LocalTime.Companion.parse(input: String, formatString: String): LocalTime = + LocalTimeFormat.Cache.get(formatString).parse(input) + +public fun LocalTime.Companion.parse(input: String, format: LocalTimeFormat): LocalTime = format.parse(input) + +internal fun LocalTime.toIncompleteLocalTime(): IncompleteLocalTime = + IncompleteLocalTime(hour, minute, second, nanosecond) + +internal interface TimeFieldContainer { + var minute: Int? + var second: Int? + var nanosecond: Int? + var hour: Int? + var hourOfAmPm: Int? + var isPm: Boolean? + + var fractionOfSecond: DecimalFraction? + get() = nanosecond?.let { DecimalFraction(it, 9) } + set(value) { + nanosecond = value?.fractionalPartWithNDigits(9) + } +} + +internal object TimeFields { + val hour = UnsignedFieldSpec(TimeFieldContainer::hour, minValue = 0, maxValue = 23) + val minute = UnsignedFieldSpec(TimeFieldContainer::minute, minValue = 0, maxValue = 59) + val second = UnsignedFieldSpec(TimeFieldContainer::second, minValue = 0, maxValue = 59, defaultValue = 0) + val fractionOfSecond = GenericFieldSpec(TimeFieldContainer::fractionOfSecond, defaultValue = DecimalFraction(0, 9)) + val amPm = GenericFieldSpec(TimeFieldContainer::isPm) + val hourOfAmPm = UnsignedFieldSpec(TimeFieldContainer::hourOfAmPm, minValue = 1, maxValue = 12) +} + +internal class IncompleteLocalTime( + hour: Int? = null, + isPm: Boolean? = null, + override var minute: Int? = null, + override var second: Int? = null, + override var nanosecond: Int? = null +) : TimeFieldContainer, Copyable { + constructor(hour: Int?, minute: Int?, second: Int?, nanosecond: Int?) : + this(hour, hour?.let { it >= 12 }, minute, second, nanosecond) + + // stores the hour in 24-hour format if `isPm` is not null, otherwise stores the hour in 12-hour format. + var hourField: Int? = hour + + override var hourOfAmPm: Int? + get() = hourField?.let { (it % 12) + 1 } + set(value) { + hourField = value?.let { it + 12 * if (isPm == true) 1 else 0 } + } + + override var isPm: Boolean? = isPm + set(value) { + if (value != null) { + hourField = hourField?.let { (it % 12) + 12 * if (value) 1 else 0 } + } + field = value + } + + override var hour: Int? + get() = if (isPm != null) hourField else null + set(value) { + if (value != null) { + hourField = value + isPm = value >= 12 + } else { + hourField = null + isPm = null + } + } + + fun toLocalTime(): LocalTime = LocalTime( + getParsedField(hour, "hour"), + getParsedField(minute, "minute"), + second ?: 0, + nanosecond ?: 0, + ) + + override fun copy(): IncompleteLocalTime = IncompleteLocalTime(hour, isPm, minute, second, nanosecond) + + override fun toString(): String = + "${hour ?: "??"}:${minute ?: "??"}:${second ?: "??"}.${ + nanosecond?.let { + it.toString().let { it.padStart(9 - it.length, '0') } + } ?: "???" + }" +} + +internal class HourDirective(minDigits: Int) : + UnsignedIntFieldFormatDirective(TimeFields.hour, minDigits) + +internal class MinuteDirective(minDigits: Int) : + UnsignedIntFieldFormatDirective(TimeFields.minute, minDigits) + +internal class SecondDirective(minDigits: Int) : + UnsignedIntFieldFormatDirective(TimeFields.second, minDigits) + +internal class FractionalSecondDirective(minDigits: Int? = null, maxDigits: Int? = null) : + DecimalFractionFieldFormatDirective(TimeFields.fractionOfSecond, minDigits, maxDigits) + +internal class TimeFieldContainerFormatBuilder : AbstractBuilder() { + companion object { + const val name = "lt" + } + + override fun formatFromSubBuilder( + name: String, + block: Builder<*>.() -> Unit + ): FormatStructure? = + if (name == TimeFieldContainerFormatBuilder.name) TimeFieldContainerFormatBuilder().apply(block) + .build() else null + + override fun formatFromDirective(letter: Char, length: Int): FormatStructure? { + return when (letter) { + 'h' -> BasicFormatStructure(HourDirective(length)) + 'm' -> BasicFormatStructure(MinuteDirective(length)) + 's' -> BasicFormatStructure(SecondDirective(length)) + 'f' -> BasicFormatStructure(FractionalSecondDirective(length, null)) + else -> null + } + } + + override fun createSibling(): Builder = TimeFieldContainerFormatBuilder() +} diff --git a/core/common/src/format/UtcOffsetFormat.kt b/core/common/src/format/UtcOffsetFormat.kt new file mode 100644 index 000000000..7fdf37f82 --- /dev/null +++ b/core/common/src/format/UtcOffsetFormat.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.format + +import kotlinx.datetime.* +import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.format.* +import kotlinx.datetime.internal.format.parser.* + +internal interface UtcOffsetFieldContainer { + var totalHours: Int? + var minutesOfHour: Int? + var secondsOfMinute: Int? +} + +public interface UtcOffsetFormatBuilderFields { + public fun appendOffsetTotalHours(minDigits: Int = 1) + public fun appendOffsetMinutesOfHour(minDigits: Int = 1) + public fun appendOffsetSecondsOfMinute(minDigits: Int = 1) +} + +@DateTimeBuilder +public interface UtcOffsetFormatBuilder : UtcOffsetFormatBuilderFields, FormatBuilder { + public fun withSharedSign(outputPlus: Boolean, block: UtcOffsetFormatBuilder.() -> Unit) +} + +public class UtcOffsetFormat internal constructor(private val actualFormat: Format) { + public companion object { + public fun build(block: UtcOffsetFormatBuilder.() -> Unit): UtcOffsetFormat { + val builder = Builder(UtcOffsetFieldContainerFormatBuilder()) + builder.block() + return UtcOffsetFormat(builder.build()) + } + + public fun fromFormatString(formatString: String): UtcOffsetFormat = build { appendFormatString(formatString) } + + internal val Cache = LruCache(16) { fromFormatString(it) } + } + + public fun format(date: UtcOffset): String = + StringBuilder().also { + actualFormat.formatter.format(date.toIncompleteUtcOffset(), it) + }.toString() + + public fun parse(input: String): UtcOffset { + val parser = Parser(::IncompleteUtcOffset, IncompleteUtcOffset::copy, actualFormat.parser) + try { + return parser.match(input).toUtcOffset() + } catch (e: ParseException) { + throw DateTimeFormatException("Failed to parse date from '$input'", e) + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Invalid date '$input'", e) + } + } + + private class Builder(override val actualBuilder: UtcOffsetFieldContainerFormatBuilder) : + AbstractFormatBuilder, UtcOffsetFormatBuilder { + + override fun createEmpty(): Builder = Builder(UtcOffsetFieldContainerFormatBuilder()) + override fun castToGeneric(actualSelf: Builder): UtcOffsetFormatBuilder = this + override fun appendOffsetTotalHours(minDigits: Int) = + actualBuilder.add(BasicFormatStructure(UtcOffsetWholeHoursDirective(minDigits))) + + override fun appendOffsetMinutesOfHour(minDigits: Int) = + actualBuilder.add(BasicFormatStructure(UtcOffsetMinuteOfHourDirective(minDigits))) + + override fun appendOffsetSecondsOfMinute(minDigits: Int) = + actualBuilder.add(BasicFormatStructure(UtcOffsetSecondOfMinuteDirective(minDigits))) + + override fun withSharedSign(outputPlus: Boolean, block: UtcOffsetFormatBuilder.() -> Unit) = + super.withSharedSign(outputPlus, block) + } + +} + +public fun UtcOffset.format(formatString: String): String = + UtcOffsetFormat.Cache.get(formatString).format(this) + +public fun UtcOffset.format(format: UtcOffsetFormat): String = format.format(this) + +public fun UtcOffset.Companion.parse(input: String, formatString: String): UtcOffset = + UtcOffsetFormat.Cache.get(formatString).parse(input) + +public fun UtcOffset.Companion.parse(input: String, format: UtcOffsetFormat): UtcOffset = format.parse(input) + +internal fun UtcOffset.toIncompleteUtcOffset(): IncompleteUtcOffset = + IncompleteUtcOffset(totalSeconds / 3600, (totalSeconds / 60) % 60, totalSeconds % 60) + +internal object OffsetFields { + val totalHours = SignedFieldSpec( + UtcOffsetFieldContainer::totalHours, + defaultValue = 0, + maxAbsoluteValue = 18, + ) + val minutesOfHour = SignedFieldSpec( + UtcOffsetFieldContainer::minutesOfHour, + defaultValue = 0, + maxAbsoluteValue = 59, + ) + val secondsOfMinute = SignedFieldSpec( + UtcOffsetFieldContainer::secondsOfMinute, + defaultValue = 0, + maxAbsoluteValue = 59, + ) +} + +internal class IncompleteUtcOffset( + override var totalHours: Int? = null, + override var minutesOfHour: Int? = null, + override var secondsOfMinute: Int? = null, +) : UtcOffsetFieldContainer, Copyable { + fun toUtcOffset(): UtcOffset = UtcOffset(totalHours, minutesOfHour, secondsOfMinute) + + override fun copy(): IncompleteUtcOffset = IncompleteUtcOffset(totalHours, minutesOfHour, secondsOfMinute) +} + +internal class UtcOffsetWholeHoursDirective(minDigits: Int) : + SignedIntFieldFormatDirective(OffsetFields.totalHours, minDigits) + +internal class UtcOffsetMinuteOfHourDirective(minDigits: Int) : + SignedIntFieldFormatDirective(OffsetFields.minutesOfHour, minDigits) + +internal class UtcOffsetSecondOfMinuteDirective(minDigits: Int) : + SignedIntFieldFormatDirective(OffsetFields.secondsOfMinute, minDigits) + +internal class UtcOffsetFieldContainerFormatBuilder : AbstractBuilder() { + companion object { + const val name = "uo" + } + + override fun formatFromSubBuilder( + name: String, + block: Builder<*>.() -> Unit + ): FormatStructure? = + if (name == UtcOffsetFieldContainerFormatBuilder.name) UtcOffsetFieldContainerFormatBuilder().apply(block) + .build() else null + + override fun formatFromDirective(letter: Char, length: Int): FormatStructure? { + return when (letter) { + 'H' -> BasicFormatStructure(UtcOffsetWholeHoursDirective(length)) + 'm' -> BasicFormatStructure(UtcOffsetMinuteOfHourDirective(length)) + 's' -> BasicFormatStructure(UtcOffsetSecondOfMinuteDirective(length)) + else -> null + } + } + + override fun createSibling(): Builder = UtcOffsetFieldContainerFormatBuilder() +} diff --git a/core/common/src/format/ValueBagFormat.kt b/core/common/src/format/ValueBagFormat.kt new file mode 100644 index 000000000..7f5fd5978 --- /dev/null +++ b/core/common/src/format/ValueBagFormat.kt @@ -0,0 +1,437 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.format + +import kotlinx.datetime.* +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.format.* +import kotlinx.datetime.internal.format.parser.* +import kotlinx.datetime.internal.safeMultiply + +/** + * A collection of date-time fields. + * + * Its main purpose is to provide support for complex date-time formats that don't correspond to any of the standard + * entities in the library. + * + * Accessing the fields of this class is not thread-safe. + * Make sure to apply proper synchronization if you are using a single instance from multiple threads. + */ +public class ValueBag internal constructor(internal val contents: ValueBagContents = ValueBagContents()) { + public companion object; + + /** + * Writes the contents of the specified [localTime] to this [ValueBag]. + * The [localTime] is written to the [hour], [minute], [second] and [nanosecond] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun populateFrom(localTime: LocalTime) { + hour = localTime.hour + minute = localTime.minute + second = localTime.second + nanosecond = localTime.nanosecond + } + + /** + * Writes the contents of the specified [localDate] to this [ValueBag]. + * The [localDate] is written to the [year], [monthNumber] and [dayOfMonth] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun populateFrom(localDate: LocalDate) { + year = localDate.year + monthNumber = localDate.monthNumber + dayOfMonth = localDate.dayOfMonth + } + + /** + * Writes the contents of the specified [localDateTime] to this [ValueBag]. + * The [localDateTime] is written to the + * [year], [monthNumber], [dayOfMonth], [hour], [minute], [second] and [nanosecond] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun populateFrom(localDateTime: LocalDateTime) { + populateFrom(localDateTime.date) + populateFrom(localDateTime.time) + } + + /** + * Writes the contents of the specified [utcOffset] to this [ValueBag]. + * The [utcOffset] is written to the [offsetTotalHours], [offsetMinutesOfHour] and [offsetSecondsOfMinute] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun populateFrom(utcOffset: UtcOffset) { + offsetTotalHours = utcOffset.totalSeconds / 3600 + offsetMinutesOfHour = (utcOffset.totalSeconds % 3600) / 60 + offsetSecondsOfMinute = utcOffset.totalSeconds % 60 + } + + /** + * Writes the contents of the specified [instant] to this [ValueBag]. + * + * This method is almost always equivalent to the following code: + * ``` + * populateFrom(instant.toLocalDateTime(offset)) + * populateFrom(offset) + * ``` + * However, this also works for instants that are too large to be represented as a [LocalDateTime]. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun populateFrom(instant: Instant, offset: UtcOffset) { + val smallerInstant = Instant.fromEpochSeconds( + instant.epochSeconds % SECONDS_PER_10000_YEARS, instant.nanosecondsOfSecond) + populateFrom(smallerInstant.toLocalDateTime(offset)) + populateFrom(offset) + year = year!! + ((instant.epochSeconds / SECONDS_PER_10000_YEARS) * 10000).toInt() + } + + /** Returns the year component of the date. */ + public var year: Int? by contents.date::year + /** Returns the number-of-month (1..12) component of the date. */ + public var monthNumber: Int? by contents.date::monthNumber + /** Returns the month ([Month]) component of the date. */ + public var month: Month? + get() = monthNumber?.let { Month(it) } + set(value) { + monthNumber = value?.number + } + /** Returns the day-of-month component of the date. */ + public var dayOfMonth: Int? by contents.date::dayOfMonth + /** Returns the day-of-week component of the date. */ + public var dayOfWeek: DayOfWeek? + get() = contents.date.isoDayOfWeek?.let { DayOfWeek(it) } + set(value) { + contents.date.isoDayOfWeek = value?.isoDayNumber + } + // /** Returns the day-of-year component of the date. */ + // public var dayOfYear: Int + /** Returns the hour-of-day time component of this date/time value. */ + public var hour: Int? by contents.time::hour + /** Returns the minute-of-hour time component of this date/time value. */ + public var minute: Int? by contents.time::minute + /** Returns the second-of-minute time component of this date/time value. */ + public var second: Int? by contents.time::second + /** Returns the nanosecond-of-second time component of this date/time value. */ + public var nanosecond: Int? by contents.time::nanosecond + + /** The total amount of full hours in the UTC offset. */ + public var offsetTotalHours: Int? by contents.offset::totalHours + /** The amount of minutes that don't add to a whole hour in the UTC offset. */ + public var offsetMinutesOfHour: Int? by contents.offset::minutesOfHour + /** The amount of seconds that don't add to a whole minute in the UTC offset. */ + public var offsetSecondsOfMinute: Int? by contents.offset::secondsOfMinute + + public var timeZoneId: String? by contents::timeZoneId + + /** + * Builds a [UtcOffset] from the fields in this [ValueBag]. + * + * This method uses the following fields: + * * [offsetTotalHours] (default value is 0) + * * [offsetMinutesOfHour] (default value is 0) + * * [offsetSecondsOfMinute] (default value is 0) + * + * Since all of these fields have default values, this method never fails. + */ + public fun toUtcOffset(): UtcOffset = contents.offset.toUtcOffset() + + /** + * Builds a [LocalDate] from the fields in this [ValueBag]. + * + * This method uses the following fields: + * * [year] + * * [monthNumber] + * * [dayOfMonth] + * + * Also, [dayOfWeek] is checked for consistency with the other fields. + * + * @throws IllegalArgumentException if any of the fields is missing or invalid. + */ + public fun toLocaldate(): LocalDate = contents.date.toLocalDate() + + /** + * Builds a [LocalTime] from the fields in this [ValueBag]. + * + * This method uses the following fields: + * * [hour] + * * [minute] + * * [second] (default value is 0) + * * [nanosecond] (default value is 0) + * + * @throws IllegalArgumentException if hours or minutes are not present, or if any of the fields are invalid. + */ + public fun toLocalTime(): LocalTime = contents.time.toLocalTime() + + /** + * Builds a [LocalDateTime] from the fields in this [ValueBag]. + * + * This method uses the following fields: + * * [year] + * * [monthNumber] + * * [dayOfMonth] + * * [hour] + * * [minute] + * * [second] (default value is 0) + * * [nanosecond] (default value is 0) + * + * Also, [dayOfWeek] is checked for consistency with the other fields. + * + * @throws IllegalArgumentException if any of the required fields are not present, + * or if any of the fields are invalid. + * + * @see toLocaldate + * @see toLocalTime + */ + public fun toLocalDateTime(): LocalDateTime = toLocaldate().atTime(toLocalTime()) + + /** + * Builds an [Instant] from the fields in this [ValueBag]. + * + * Uses the fields required for [toLocalDateTime] and [toUtcOffset]. + * + * Almost always equivalent to `toLocalDateTime().toInstant(toUtcOffset())`, but also accounts for cases when + * the year is outside the range representable by [LocalDate] but not outside the range representable by [Instant]. + */ + public fun toInstantUsingUtcOffset(): Instant { + val offset = toUtcOffset() + val time = toLocalTime() + val truncatedDate = contents.date.copy() + /** + * 10_000 is a number that is both + * * guaranteed to be representable as the number of years in a [LocalDate], + * * and is a multiple of 400, which is the number of years in a leap cycle, which means that taking the + * remainder of the year after dividing by 40_000 will not change the leap year status of the year. + */ + truncatedDate.year = getParsedField(truncatedDate.year, "year") % 10_000 + val totalSeconds = try { + val secDelta = safeMultiply((year!! / 10_000).toLong(), SECONDS_PER_10000_YEARS) + val epochDays = truncatedDate.toLocalDate().toEpochDays().toLong() + safeAdd(secDelta, epochDays * SECONDS_PER_DAY + time.toSecondOfDay() - offset.totalSeconds) + } catch (e: ArithmeticException) { + throw DateTimeFormatException("The parsed date is outside the range representable by Instant", e) + } + if (totalSeconds < Instant.MIN.epochSeconds || totalSeconds > Instant.MAX.epochSeconds) + throw DateTimeFormatException("The parsed date is outside the range representable by Instant") + return Instant.fromEpochSeconds(totalSeconds, nanosecond ?: 0) + } +} + +/** + * Builder for [ValueBagFormat] values. + */ +@DateTimeBuilder +public interface ValueBagFormatBuilder : DateFormatBuilderFields, TimeFormatBuilderFields, + UtcOffsetFormatBuilderFields, FormatBuilder +{ + /** + * Appends the IANA time zone identifier. + */ + public fun appendTimeZoneId() + + /** + * Appends a format string to the builder. + * + * For rules common for all format strings, see [FormatBuilder.appendFormatString]. + * + * There are no special pattern letters for [ValueBagFormat], it can only embed nested formats using the + * following names: + * * `ld` for [LocalDate] format, + * * `lt` for [LocalTime] format, + * * `uo` for [UtcOffset] format. + * + * Example: `ld', 'lt' ('uo<+(hhmm)>')'` can format a string like `12-25 03:04 (+0506)`. + */ + // overriding the documentation. + public override fun appendFormatString(formatString: String) +} + +/** + * A [Format] for [ValueBag] values. + */ +public class ValueBagFormat private constructor(private val actualFormat: Format) { + public companion object { + /** + * Creates a [ValueBagFormat] using [ValueBagFormatBuilder]. + */ + public fun build(block: ValueBagFormatBuilder.() -> Unit): ValueBagFormat { + val builder = Builder(ValueBagFormatBuilderImpl()) + builder.block() + return ValueBagFormat(builder.build()) + } + + /** + * Creates a [ValueBagFormat] from a format string. + * + * Building a format is a relatively expensive operation, so it is recommended to store the result and avoid + * rebuilding the format on every use. + * + * @see ValueBagFormatBuilder.appendFormatString for the format string syntax. + */ + public fun fromFormatString(formatString: String): ValueBagFormat = build { appendFormatString(formatString) } + + /** + * ISO-8601 extended format for dates and times with UTC offset. + * + * Examples of valid strings: + * * `2020-01-01T23:59:59+01:00` + * * `2020-01-01T23:59:59+01` + * * `2020-01-01T23:59:59Z` + * + * This format uses the local date, local time, and UTC offset fields of [ValueBag]. + * + * See ISO-8601-1:2019, 5.4.2.1b), excluding the format without the offset. + */ + public val ISO_INSTANT : ValueBagFormat = build { + appendYear(minDigits = 4, outputPlusOnExceededPadding = true) + appendFormatString("ld<'-'mm'-'dd>('T'|'t')ltuo<('Z'|'z')|+(HH(|':'mm(|':'ss)))>") + } + + internal val Cache = LruCache(16) { fromFormatString(it) } + } + + /** + * Formats the given [ValueBag] as a string. + */ + public fun format(bag: ValueBag): String = + StringBuilder().also { + actualFormat.formatter.format(bag.contents, it) + }.toString() + + /** + * Parses a [ValueBag] from the given string. + */ + public fun parse(input: String): ValueBag { + val parser = Parser(::ValueBagContents, ValueBagContents::copy, actualFormat.parser) + try { + return ValueBag(parser.match(input)) + } catch (e: ParseException) { + throw DateTimeFormatException("Failed to parse a value bag from '$input'", e) + } + } + + private class Builder(override val actualBuilder: ValueBagFormatBuilderImpl) : + AbstractFormatBuilder, ValueBagFormatBuilder { + override fun appendYear(minDigits: Int, outputPlusOnExceededPadding: Boolean) = + actualBuilder.add(BasicFormatStructure(YearDirective(minDigits, outputPlusOnExceededPadding))) + + override fun appendMonthNumber(minLength: Int) = + actualBuilder.add(BasicFormatStructure(MonthDirective(minLength))) + + override fun appendMonthName(names: List) = + actualBuilder.add(BasicFormatStructure(MonthNameDirective(names))) + override fun appendDayOfMonth(minLength: Int) = actualBuilder.add(BasicFormatStructure(DayDirective(minLength))) + override fun appendHour(minLength: Int) = actualBuilder.add(BasicFormatStructure(HourDirective(minLength))) + override fun appendMinute(minLength: Int) = actualBuilder.add(BasicFormatStructure(MinuteDirective(minLength))) + override fun appendSecond(minLength: Int) = actualBuilder.add(BasicFormatStructure(SecondDirective(minLength))) + override fun appendSecondFraction(minLength: Int?, maxLength: Int?) = + actualBuilder.add(BasicFormatStructure(FractionalSecondDirective(minLength, maxLength))) + override fun appendOffsetTotalHours(minDigits: Int) = + actualBuilder.add(BasicFormatStructure(UtcOffsetWholeHoursDirective(minDigits))) + + override fun appendOffsetMinutesOfHour(minDigits: Int) = + actualBuilder.add(BasicFormatStructure(UtcOffsetMinuteOfHourDirective(minDigits))) + + override fun appendOffsetSecondsOfMinute(minDigits: Int) = + actualBuilder.add(BasicFormatStructure(UtcOffsetSecondOfMinuteDirective(minDigits))) + + override fun appendTimeZoneId() = + actualBuilder.add(BasicFormatStructure(TimeZoneIdDirective(TimeZone.availableZoneIds))) + + override fun appendFormatString(formatString: String) = super.appendFormatString(formatString) + + override fun createEmpty(): Builder = Builder(ValueBagFormatBuilderImpl()) + override fun castToGeneric(actualSelf: Builder): ValueBagFormatBuilder = this + } + +} + +/** + * Formats a [ValueBag] using the given format string. + * + * This method caches the format for the given string in a least-recently-used cache, so typically, only the first + * invocation of this method for a given format string will require building the format. + * However, if the program uses many format strings, this format may be evicted from the cache and require rebuilding. + * In case more predictable performance is required, it is recommended to use the overload that accepts + * a pre-built format. See [ValueBagFormat.fromFormatString] for more information. + * + * @see ValueBagFormatBuilder.appendFormatString for description of the format used. + */ +public fun ValueBag.format(formatString: String): String = + ValueBagFormat.Cache.get(formatString).format(this) + +public fun ValueBag.format(format: ValueBagFormat): String = format.format(this) + +/** + * Parses a [ValueBag] from [input] using the given format string. + * + * This method caches the format for the given string in a least-recently-used cache, so typically, only the first + * invocation of this method for a given format string will require building the format. + * However, if the program uses many format strings, this format may be evicted from the cache and require rebuilding. + * In case more predictable performance is required, it is recommended to use the overload that accepts + * a pre-built format. See [ValueBagFormat.fromFormatString] for more information. + * + * @see ValueBagFormatBuilder.appendFormatString for description of the format used. + */ +public fun ValueBag.Companion.parse(input: String, formatString: String): ValueBag = + ValueBagFormat.Cache.get(formatString).parse(input) + +/** + * Parses a [ValueBag] from [input] using the given format. + */ +public fun ValueBag.Companion.parse(input: String, format: ValueBagFormat): ValueBag = try { + format.parse(input) +} catch (e: ParseException) { + throw DateTimeFormatException(e) +} + +internal class ValueBagContents internal constructor( + val date: IncompleteLocalDate = IncompleteLocalDate(), + val time: IncompleteLocalTime = IncompleteLocalTime(), + val offset: IncompleteUtcOffset = IncompleteUtcOffset(), + var timeZoneId: String? = null, +) : DateFieldContainer by date, TimeFieldContainer by time, UtcOffsetFieldContainer by offset, + Copyable { + override fun copy(): ValueBagContents = ValueBagContents(date.copy(), time.copy(), offset.copy(), timeZoneId) +} + +internal val timeZoneField = GenericFieldSpec(ValueBagContents::timeZoneId) + +internal class TimeZoneIdDirective(knownZones: Set) : + StringFieldFormatDirective(timeZoneField, knownZones) + +private class ValueBagFormatBuilderImpl : AbstractBuilder() { + override fun formatFromSubBuilder(name: String, block: Builder<*>.() -> Unit): FormatStructure? = + when (name) { + DateFieldContainerFormatBuilder.name -> { + val builder = DateFieldContainerFormatBuilder() + block(builder) + builder.build() + } + + TimeFieldContainerFormatBuilder.name -> { + val builder = TimeFieldContainerFormatBuilder() + block(builder) + builder.build() + } + + UtcOffsetFieldContainerFormatBuilder.name -> { + val builder = UtcOffsetFieldContainerFormatBuilder() + block(builder) + builder.build() + } + + else -> null + } + + override fun formatFromDirective(letter: Char, length: Int): FormatStructure? = null + + override fun createSibling(): Builder = ValueBagFormatBuilderImpl() +} diff --git a/core/common/src/internal/LruCache.kt b/core/common/src/internal/LruCache.kt new file mode 100644 index 000000000..ef6c80942 --- /dev/null +++ b/core/common/src/internal/LruCache.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal + +internal expect class LruCache(size: Int, create: (K) -> V) { + fun get(key: K): V +} diff --git a/core/common/src/internal/dateCalculations.kt b/core/common/src/internal/dateCalculations.kt index 4db38e58c..62ec3f8b9 100644 --- a/core/common/src/internal/dateCalculations.kt +++ b/core/common/src/internal/dateCalculations.kt @@ -39,3 +39,45 @@ internal fun Int.monthLength(isLeapYear: Boolean): Int = 4, 6, 9, 11 -> 30 else -> 31 } + +// org.threeten.bp.LocalDate#toEpochDay +internal fun dateToEpochDays(year: Int, monthNumber: Int, dayOfMonth: Int): Int { + val y = year + val m = monthNumber + var total = 0 + total += 365 * y + if (y >= 0) { + total += (y + 3) / 4 - (y + 99) / 100 + (y + 399) / 400 + } else { + total -= y / -4 - y / -100 + y / -400 + } + total += ((367 * m - 362) / 12) + total += dayOfMonth - 1 + if (m > 2) { + total-- + if (!isLeapYear(year)) { + total-- + } + } + return total - DAYS_0000_TO_1970 +} + +/** + * The number of days in a 400 year cycle. + */ +internal const val DAYS_PER_CYCLE = 146097 + +/** + * The number of days from year zero to year 1970. + * There are five 400 year cycles from year zero to 2000. + * There are 7 leap years from 1970 to 2000. + */ +internal const val DAYS_0000_TO_1970 = DAYS_PER_CYCLE * 5 - (30 * 365 + 7) + +internal fun isoDayOfWeekOnDate(year: Int, monthNumber: Int, dayOfMonth: Int): Int = + (dateToEpochDays(year, monthNumber, dayOfMonth) + 3).mod(7) + 1 + +// days in a 400-year cycle = 146097 +// days in a 10,000-year cycle = 146097 * 25 +// seconds per day = 86400 +internal const val SECONDS_PER_10000_YEARS = 146097L * 25L * 86400L diff --git a/core/common/src/internal/format/Builder.kt b/core/common/src/internal/format/Builder.kt new file mode 100644 index 000000000..327440002 --- /dev/null +++ b/core/common/src/internal/format/Builder.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal.format + +internal interface Builder { + fun build(): ConcatenatedFormatStructure + fun add(format: NonConcatenatedFormatStructure) + fun formatFromSubBuilder(name: String, block: Builder<*>.() -> Unit): FormatStructure? + fun formatFromDirective(letter: Char, length: Int): FormatStructure? + fun createSibling(): Builder +} + +internal abstract class AbstractBuilder: Builder { + private var builder = ConcatenatedFormatStructureBuilder() + override fun build(): ConcatenatedFormatStructure = builder.build() + override fun add(format: NonConcatenatedFormatStructure) { builder.add(format) } + + private class ConcatenatedFormatStructureBuilder( + private val list: MutableList> = mutableListOf() + ) { + fun add(element: NonConcatenatedFormatStructure) = list.add(element) + fun build(): ConcatenatedFormatStructure = ConcatenatedFormatStructure(list) + } +} + +internal fun Builder.add(format: FormatStructure) = when (format) { + is NonConcatenatedFormatStructure -> add(format) + is ConcatenatedFormatStructure -> format.formats.forEach { add(it) } +} + diff --git a/core/common/src/internal/format/FieldFormatDirective.kt b/core/common/src/internal/format/FieldFormatDirective.kt new file mode 100644 index 000000000..b79d0a16d --- /dev/null +++ b/core/common/src/internal/format/FieldFormatDirective.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal.format + +import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.format.formatter.* +import kotlinx.datetime.internal.format.parser.* +import kotlin.math.* + +/** + * A directive that specifies a way to parse and format the [field]. + */ +internal interface FieldFormatDirective { + /** + * The field parsed and formatted by this directive. + */ + val field: FieldSpec + + /** + * For numeric signed values, the way to check if the field is negative. For everything else, `null`. + */ + val signGetter: ((Target) -> Int)? + + /** + * The formatter operation that formats the field. + */ + fun formatter(): FormatterOperation + + /** + * The parser structure that parses the field. + */ + fun parser(signsInverted: Boolean): ParserStructure +} + +/** + * A directive for a decimal format of an integer field that is known to be unsigned. + * The field is formatted with the field padded to [minDigits] with zeroes, + * and the parser expects the field to be at least [minDigits] digits long. + */ +internal abstract class UnsignedIntFieldFormatDirective( + final override val field: UnsignedFieldSpec, + val minDigits: Int, +) : FieldFormatDirective { + + final override val signGetter: ((Target) -> Int)? = null + + val maxDigits: Int = field.maxDigits + + init { + require(minDigits >= 0) + require(maxDigits >= minDigits) + } + + override fun formatter(): FormatterOperation = + UnsignedIntFormatterOperation( + number = field::getNotNull, + zeroPadding = minDigits, + ) + + override fun parser(signsInverted: Boolean): ParserStructure = + ParserStructure( + listOf( + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + minDigits, + maxDigits, + field::setWithoutReassigning, + field.name, + ) + ) + ) + ), + emptyList() + ) +} + +/** + * A directive for a string-based format of an integer field that is known to be unsigned. + */ +internal abstract class NamedUnsignedIntFieldFormatDirective( + final override val field: UnsignedFieldSpec, + private val values: List, +) : FieldFormatDirective { + + init { + require(values.size == field.maxValue - field.minValue + 1) { + "The number of values (${values.size}) in $values does not match the range of the field (${field.maxValue - field.minValue + 1})" + } + } + + final override val signGetter: ((Target) -> Int)? = null + + override fun formatter(): FormatterOperation = + TODO() + + override fun parser(signsInverted: Boolean): ParserStructure = + TODO() +} + +internal abstract class StringFieldFormatDirective( + final override val field: FieldSpec, + private val acceptedStrings: Set, +) : FieldFormatDirective { + + final override val signGetter: ((Target) -> Int)? = null + + init { + require(acceptedStrings.isNotEmpty()) + } + + override fun formatter(): FormatterOperation = + StringFormatterOperation(field::getNotNull) + + override fun parser(signsInverted: Boolean): ParserStructure = + ParserStructure( + listOf(StringSetParserOperation(acceptedStrings, field::setWithoutReassigning, field.name)), + emptyList() + ) +} + +internal abstract class SignedIntFieldFormatDirective( + final override val field: SignedFieldSpec, + private val minDigits: Int?, + private val maxDigits: Int? = field.maxDigits, + private val outputPlusOnExceededPadding: Boolean = false, +) : FieldFormatDirective { + + final override val signGetter: ((Target) -> Int) = ::signGetterImpl + private fun signGetterImpl(target: Target): Int = (field.accessor.get(target) ?: 0).sign + + init { + require(minDigits == null || minDigits >= 0) + require(maxDigits == null || minDigits == null || maxDigits >= minDigits) + } + + override fun formatter(): FormatterOperation = + SignedIntFormatterOperation( + number = field::getNotNull, + zeroPadding = minDigits ?: 0, + outputPlusOnExceedsPad = outputPlusOnExceededPadding, + ) + + override fun parser(signsInverted: Boolean): ParserStructure = + SignedIntParser( + minDigits, + maxDigits, + field::setWithoutReassigning, + field.name, + plusOnExceedsPad = outputPlusOnExceededPadding, + signsInverted = signsInverted + ) +} + +internal abstract class DecimalFractionFieldFormatDirective( + final override val field: FieldSpec, + private val minDigits: Int?, + private val maxDigits: Int?, +) : FieldFormatDirective { + override val signGetter: ((Target) -> Int)? = null + + override fun formatter(): FormatterOperation = + DecimalFractionFormatterOperation(field::getNotNull, minDigits, maxDigits) + + override fun parser(signsInverted: Boolean): ParserStructure = ParserStructure( + listOf( + NumberSpanParserOperation( + listOf(FractionPartConsumer(minDigits, maxDigits, field::setWithoutReassigning, field.name)) + ) + ), + emptyList() + ) +} diff --git a/core/common/src/internal/format/FieldSpec.kt b/core/common/src/internal/format/FieldSpec.kt new file mode 100644 index 000000000..31d5fdaa4 --- /dev/null +++ b/core/common/src/internal/format/FieldSpec.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal.format + +import kotlin.reflect.* + +private typealias Accessor = KMutableProperty1 + +/** + * A specification of a field. + * + * Fields represent parts of objects, regardless of their representation. + * For example, a field "day of week" can be represented with both strings of various kinds ("Monday", "Mon") and + * numbers ("1" for Monday, "2" for Tuesday, etc.), but the field itself is the same. + * + * Fields can typically contain `null` values, which means that the field is not set. + */ +internal interface FieldSpec { + /** + * The function with which the field can be accessed. + */ + val accessor: Accessor + + /** + * The default value of the field, or `null` if the field has none. + */ + val defaultValue: Type? + + /** + * The name of the field. + */ + val name: String +} + +internal abstract class AbstractFieldSpec: FieldSpec { + override fun toString(): String = "The field $name (default value is $defaultValue)" +} + +/** + * Function that returns the value of the field in the given object, + * or throws [IllegalStateException] if the field is not set. + * + * This function is used to access fields during formatting. + */ +internal fun FieldSpec.getNotNull(obj: Object): Field = + accessor.get(obj) ?: throw IllegalStateException("Field $name is not set") + +/** + * If the field is not set, sets it to the given value. + * If the field is set to the given value, does nothing. + * If the field is set to a different value, throws [IllegalArgumentException]. + * + * This function is used to ensure internal consistency during parsing. + * There exist formats where the same data is repeated several times in the same object, for example, + * "14:15 (02:15 PM)". In such cases, we want to ensure that the values are consistent. + */ +internal fun FieldSpec.setWithoutReassigning(obj: Object, value: Field) { + val oldValue = accessor.get(obj) + if (oldValue != null) { + require(oldValue == value) { + "Attempting to assign conflicting values '$oldValue' and '$value' to field '$name'" + } + } else { + accessor.set(obj, value) + } +} + +/** + * A specification of a field that can contain values of any kind. + * Used for fields additional information about which is not that important for parsing/formatting. + */ +internal class GenericFieldSpec( + override val accessor: Accessor, + override val name: String = accessor.name, + override val defaultValue: Type? = null, +) : AbstractFieldSpec() + +/** + * A specification of a field that can only contain non-negative values. + */ +internal class UnsignedFieldSpec( + override val accessor: Accessor, + /** + * The minimum value of the field. + */ + val minValue: Int, + /** + * The maximum value of the field. + */ + val maxValue: Int, + override val name: String = accessor.name, + override val defaultValue: Int? = null, +) : AbstractFieldSpec() { + /** + * The maximum length of the field when represented as a decimal number. + */ + val maxDigits: Int = when { + maxValue < 10 -> 1 + maxValue < 100 -> 2 + maxValue < 1000 -> 3 + else -> throw IllegalArgumentException("Max value $maxValue is too large") + } +} + +internal class SignedFieldSpec( + override val accessor: Accessor, + val maxAbsoluteValue: Int?, + override val name: String = accessor.name, + override val defaultValue: Int? = null, +) : AbstractFieldSpec() { + val maxDigits: Int? = when { + maxAbsoluteValue == null -> null + maxAbsoluteValue < 10 -> 1 + maxAbsoluteValue < 100 -> 2 + maxAbsoluteValue < 1000 -> 3 + maxAbsoluteValue < 10000 -> 4 + maxAbsoluteValue < 100000 -> 5 + maxAbsoluteValue < 1000000 -> 6 + maxAbsoluteValue < 10000000 -> 7 + maxAbsoluteValue < 100000000 -> 8 + maxAbsoluteValue < 1000000000 -> 9 + else -> throw IllegalArgumentException("Max value $maxAbsoluteValue is too large") + } +} diff --git a/core/common/src/internal/format/Format.kt b/core/common/src/internal/format/Format.kt new file mode 100644 index 000000000..0a9f541a3 --- /dev/null +++ b/core/common/src/internal/format/Format.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal.format + +import kotlinx.datetime.internal.format.formatter.* +import kotlinx.datetime.internal.format.parser.* + +internal sealed interface FormatStructure + +internal class BasicFormatStructure( + val directive: FieldFormatDirective +) : NonConcatenatedFormatStructure { + override fun toString(): String = "BasicFormatStructure($directive)" +} + +internal class ConstantFormatStructure( + val string: String +) : NonConcatenatedFormatStructure { + override fun toString(): String = "ConstantFormatStructure($string)" +} + +// TODO: should itself also be a field with the default value "not negative" +internal class SignedFormatStructure( + val format: FormatStructure, + val plusSignRequired: Boolean, +) : NonConcatenatedFormatStructure { + + internal val fields = basicFormats(format).mapNotNull(FieldFormatDirective::signGetter).toSet() + + init { + require(fields.isNotEmpty()) { "Signed format must contain at least one field with a negative sign" } + } + + override fun toString(): String = "SignedFormatStructure($format)" +} + +internal class AlternativesFormatStructure( + val formats: List> +) : NonConcatenatedFormatStructure { + override fun toString(): String = "AlternativesFormatStructure(${formats.joinToString(", ")})" +} + +internal sealed interface NonConcatenatedFormatStructure : FormatStructure + +internal class ConcatenatedFormatStructure( + val formats: List> +) : FormatStructure { + override fun toString(): String = "ConcatenatedFormatStructure(${formats.joinToString(", ")})" +} + +internal fun FormatStructure.formatter(): FormatterStructure { + fun FormatStructure.rec(): Pair, Set>> = when (this) { + is BasicFormatStructure -> BasicFormatter(directive.formatter()) to setOf(directive.field) + is ConstantFormatStructure -> BasicFormatter(ConstantStringFormatterOperation(string)) to emptySet() + is SignedFormatStructure -> { + val (innerFormat, fieldSpecs) = format.rec() + fun checkIfAllNegative(value: T): Boolean { + var seenNonZero = false + for (check in fields) { + val sign = check(value) + if (sign > 0) return false + if (sign < 0) seenNonZero = true + } + return seenNonZero + } + SignedFormatter( + innerFormat, + ::checkIfAllNegative, + plusSignRequired + ) to fieldSpecs + } + + is AlternativesFormatStructure -> { + val maxFieldSet = mutableSetOf>() + var lastFieldSet: Set>? = null + val result = mutableListOf Boolean, FormatterStructure>>() + for (i in formats.indices.reversed()) { + val (formatter, fields) = formats[i].rec() + require(lastFieldSet?.containsAll(fields) != false) { + "The only formatters that include the OR operator are of the form (A|B) " + + "where B contains all fields of A, but $fields is not included in $lastFieldSet. " + + "If your use case requires other usages of the OR operator for formatting, please contact us at " + + "https://github.com/Kotlin/kotlinx-datetime/issues" + } + val fieldsToCheck = lastFieldSet?.minus(fields) ?: emptySet() + val predicate = ConjunctionPredicate(fieldsToCheck.map { + it.toComparisonPredicate() ?: throw IllegalArgumentException( + "The only formatters that include the OR operator are of the form (A|B) " + + "where B contains all fields of A and some other fields that have a default value. " + + "However, the field ${it.name} does not have a default value. " + + "If your use case requires other usages of the OR operator for formatting, please contact us at " + + "https://github.com/Kotlin/kotlinx-datetime/issues" + ) + }) + if (predicate.isConstTrue()) { + result.clear() + } + result.add(predicate::test to formatter) + maxFieldSet.addAll(fields) + lastFieldSet = fields + } + result.reverse() + ConditionalFormatter(result) to maxFieldSet + } + + is ConcatenatedFormatStructure -> { + val (formatters, fields) = formats.map { it.rec() }.unzip() + ConcatenatedFormatter(formatters) to fields.flatten().toSet() + } + } + return rec().first +} + +// A workaround: for some reason, replacing `E` with `*` causes this not to type properly, and `*` is inferred on the +// call site, so we have to move this to a separate function. +internal fun FieldSpec.toComparisonPredicate(): ComparisonPredicate? = + defaultValue?.let { ComparisonPredicate(it, accessor::get) } + +internal fun ConcatenatedFormatStructure.parser(): ParserStructure { + fun ParserStructure.append(other: ParserStructure): ParserStructure = if (followedBy.isEmpty()) { + ParserStructure(operations + other.operations, other.followedBy) + } else { + ParserStructure(operations, followedBy.map { it.append(other) }) + } + + fun FormatStructure.rec(signsInverted: Boolean): ParserStructure = when (this) { + is ConstantFormatStructure -> ParserStructure(listOf(PlainStringParserOperation(string)), emptyList()) + is SignedFormatStructure -> { + ParserStructure( + emptyList(), + listOf( + format.rec(signsInverted = signsInverted).let { + if (!plusSignRequired) it else + ParserStructure( + listOf(PlainStringParserOperation("+")), + emptyList() + ).append(it) + }, + ParserStructure( + listOf(PlainStringParserOperation("-")), + emptyList() + ).append(format.rec(signsInverted = !signsInverted)) + ) + ) + } + + is BasicFormatStructure -> directive.parser(signsInverted) + is AlternativesFormatStructure -> + ParserStructure(emptyList(), formats.map { it.rec(signsInverted) }) + + is ConcatenatedFormatStructure -> { + var accumulator = ParserStructure(emptyList(), emptyList()) + for (format in formats.reversed()) { + accumulator = format.rec(signsInverted).append(accumulator) + } + accumulator + } + } + + fun ParserStructure.simplify(): ParserStructure { + val newOperations = mutableListOf>() + var currentNumberSpan: MutableList>? = null + // joining together the number consumers in this parser before the first alternative + for (op in operations) { + if (op is NumberSpanParserOperation) { + if (currentNumberSpan != null) { + currentNumberSpan.addAll(op.consumers) + } else { + currentNumberSpan = op.consumers.toMutableList() + } + } else { + if (currentNumberSpan != null) { + newOperations.add(NumberSpanParserOperation(currentNumberSpan)) + currentNumberSpan = null + } + newOperations.add(op) + } + } + val simplifiedTails = followedBy.flatMap { + val simplified = it.simplify() + // parser `ParserStructure(emptyList(), p)` is equivalent to `p`, + // unless `p` is empty. For example, ((a|b)|(c|d)) is equivalent to (a|b|c|d). + // In that case, `ParserStructure(emptyList(), emptyList())` represents a parser that recognizes an empty + // string. For example, (|a|b) is not equivalent to (a|b). + if (simplified.operations.isEmpty()) + simplified.followedBy.ifEmpty { listOf(simplified) } + else + listOf(simplified) + } + return if (currentNumberSpan == null) { + // the last operation was not a number span, or it was a number span that we are allowed to interrupt + ParserStructure(newOperations, simplifiedTails) + } else if (simplifiedTails.none { + it.operations.firstOrNull()?.let { it is NumberSpanParserOperation } == true + }) { + // the last operation was a number span, but there are no alternatives that start with a number span. + newOperations.add(NumberSpanParserOperation(currentNumberSpan)) + ParserStructure(newOperations, simplifiedTails) + } else { + val newTails = simplifiedTails.map { + when (val firstOperation = it.operations.firstOrNull()) { + is NumberSpanParserOperation -> { + ParserStructure( + listOf(NumberSpanParserOperation(currentNumberSpan + firstOperation.consumers)) + it.operations.drop( + 1 + ), + it.followedBy + ) + } + null -> ParserStructure( + listOf(NumberSpanParserOperation(currentNumberSpan)), + it.followedBy + ) + else -> ParserStructure( + listOf(NumberSpanParserOperation(currentNumberSpan)) + it.operations, + it.followedBy + ) + } + } + ParserStructure(newOperations, newTails) + } + } + val initialParser = rec(signsInverted = false) + val simplifiedParser = initialParser.simplify() + return simplifiedParser +} + +internal class Format(private val directives: ConcatenatedFormatStructure) { + val formatter: FormatterStructure by lazy { + directives.formatter() + } + val parser: ParserStructure by lazy { + directives.parser() + } +} + +private fun basicFormats(format: FormatStructure): List> = when (format) { + is BasicFormatStructure -> listOf(format.directive) + is ConcatenatedFormatStructure -> format.formats.flatMap { basicFormats(it) } + is AlternativesFormatStructure -> format.formats.flatMap { basicFormats(it) } + is ConstantFormatStructure -> emptyList() + is SignedFormatStructure -> basicFormats(format.format) +} diff --git a/core/common/src/internal/format/FormatStrings.kt b/core/common/src/internal/format/FormatStrings.kt new file mode 100644 index 000000000..cd1dab93b --- /dev/null +++ b/core/common/src/internal/format/FormatStrings.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal.format + +/** + * String format: + * * A string in single or double quotes is a literal. + * * `designator` means that `format` must be parsed and formatted in the context of a sub-builder chosen by + * `designator`. + * For example, in a `LocalDateTime` format builder, `ld` means that the `yyyy'-'mm'-'dd` format + * must be parsed and formatted in the context of a `LocalDate` builder. + * * `format1|format2` means that either `format1` or `format2` must be used. For parsing, this means that, first, + * parsing `format1` is attempted, and if it fails, parsing `format2` is attempted. For formatting, this construct is + * only valid if `format2` includes all the fields from `format1` and also possibly some other fields that have + * default values. If those fields have their default values, `format1` is used for formatting. + * For example, for `UtcOffset`, `'Z'|+HH:mm` is valid and means that, if both the hour and minute components are + * zero, `'Z'` is output, otherwise `+HH:mm` is used. + * * Parentheses, as in `(format)`, are used to establish precedence. For example, `hh:mm(|:ss)` means + * `hh:mm` or `hh:mm:ss`, but `hh:mm|:ss` means `hh:mm` or `:ss`. + * * Symbol `+` before a signed numeric field means that the sign must always be present. + * * Symbol `-` before a signed numeric field means that the sign must be present only if the value is negative. + * This is the default, but the symbol can still be useful, see below. + * * Symbols `+` and `-` can be used before a format grouped in parentheses, as in `-(format)` and `+(format)`. + * In this case, the sign will be output for the whole group, possibly affecting the signs of the fields inside the + * group if necessary. For example, `-('P'yy'Y'mm'M')` in a `DatePeriod` means that, if + * there are `-15` years and `-10` months, `-P15Y10M` is output, but if there are `15` years and `-10` months, + * `P15Y-10M` is output. + */ +internal fun Builder.appendFormatString(format: String, start: Int = 0): Int { + val alternatives = mutableListOf>() + var currentBuilder: Builder = createSibling() + var inSingleQuotes = false + var inDoubleQuotes = false + var fragmentBeginning: Int? = null + var sign: Char? = null + fun add(format: FormatStructure) { + currentBuilder.add( + when (sign) { + null -> format + '+' -> SignedFormatStructure(format, plusSignRequired = true) + '-' -> SignedFormatStructure(format, plusSignRequired = false) + else -> throw IllegalArgumentException("Unexpected sign $sign") + } + ) + sign = null + } + + fun readDirectivesFromFragment() { + while (true) { + val directiveStart = fragmentBeginning ?: break + var directiveEnd = directiveStart + 1 + while (directiveEnd < format.length && format[directiveEnd] == format[directiveStart]) { + ++directiveEnd + } + val parsedDirective = + currentBuilder.formatFromDirective(format[directiveStart], directiveEnd - directiveStart) + require(parsedDirective != null) { + "Builder $currentBuilder does not recognize the directive ${ + format.substring(directiveStart, directiveEnd) + } at position $directiveStart" + } + add(parsedDirective) + fragmentBeginning = if (directiveEnd < format.length && format[directiveEnd].isLetter()) { + directiveEnd + } else { + null + } + } + } + + var i = start + fun checkClosingParenthesis(beginning: Int, end: Int, expected: Char) { + require(end < format.length) { + "Expected '$expected' at position $end to match the start at position $beginning, but got end of string" + } + require(format[end] == expected) { + "Expected '$expected' at position $end to match the start at position $beginning, but got '${format[end]}'" + } + } + while (i < format.length) { + val c = format[i] + if (inSingleQuotes) { + if (c == '\'') { + add(ConstantFormatStructure(format.substring(fragmentBeginning!!, i))) + fragmentBeginning = null + inSingleQuotes = false + } + } else if (inDoubleQuotes) { + if (c == '"') { + add(ConstantFormatStructure(format.substring(fragmentBeginning!!, i))) + fragmentBeginning = null + inDoubleQuotes = false + } + } else { + if (c == '<') { + // we treat the letters before as the marker of a sub-builder + val subBuilderNameStart = fragmentBeginning + require(subBuilderNameStart != null) { + "Got '<' at position $i, but there was no sub-builder name before it" + } + fragmentBeginning = null + val subBuilderName = format.substring(subBuilderNameStart, i) + val subFormat: FormatStructure? = currentBuilder.formatFromSubBuilder(subBuilderName) { + val end = appendFormatString(format, i + 1) + checkClosingParenthesis(subBuilderNameStart, end, '>') + i = end + } + require(subFormat != null) { + "Builder $currentBuilder does not recognize sub-builder $subBuilderName at position $subBuilderNameStart" + } + add(subFormat) + } else if (c.isLetter()) { + // we don't know yet how to treat this letter, so we'll skip over for now + fragmentBeginning = fragmentBeginning ?: i + } else { + // if there were letters before, we'll treat them as directives, as there's no `<` after them + readDirectivesFromFragment() + if (c == '\'') { + inSingleQuotes = true + fragmentBeginning = i + 1 + } else if (c == '"') { + inDoubleQuotes = true + fragmentBeginning = i + 1 + } else if (c == '|') { + alternatives.add(currentBuilder.build()) + currentBuilder = currentBuilder.createSibling() + } else if (c == '+' || c == '-') { + require(sign == null) { + "Found '$c' on position $i, but a sign '$sign' was already specified at position ${i - 1}" + } + sign = c + } else if (c == '(') { + val subBuilder = currentBuilder.createSibling() + val end = subBuilder.appendFormatString(format, i + 1) + checkClosingParenthesis(i, end, ')') + i = end + add(subBuilder.build()) + } else if (c == ')' || c == '>') { + break + } else { + require(c !in reservedChars) { + "Character '$c' is reserved for use in future versions, but was encountered at position $i" + } + add(ConstantFormatStructure(format[i].toString())) + } + } + } + ++i + } + if (inSingleQuotes) checkClosingParenthesis(fragmentBeginning!!, i, '\'') + if (inDoubleQuotes) checkClosingParenthesis(fragmentBeginning!!, i, '"') + readDirectivesFromFragment() + if (sign != null) + throw IllegalArgumentException("Sign $sign is not followed by a format") + alternatives.add(currentBuilder.build()) + this.add( + if (alternatives.size == 1) { + alternatives.first() + } else { + AlternativesFormatStructure(alternatives) + } + ) + return i +} + +// TODO: think about what could eventually become useful +private val reservedChars: List = listOf() diff --git a/core/common/src/internal/format/Predicate.kt b/core/common/src/internal/format/Predicate.kt new file mode 100644 index 000000000..4c793c651 --- /dev/null +++ b/core/common/src/internal/format/Predicate.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal.format + +internal interface Predicate { + fun test(value: T): Boolean +} + +internal class BasicPredicate( + private val operation: T.() -> Boolean +): Predicate { + override fun test(value: T): Boolean = value.operation() +} + +internal class ComparisonPredicate( + private val expectedValue: E, + private val getter: (T) -> E? +): Predicate { + override fun test(value: T): Boolean = getter(value) == expectedValue +} + +internal class ConjunctionPredicate( + private val predicates: List> +): Predicate { + override fun test(value: T): Boolean = predicates.all { it.test(value) } + fun isConstTrue(): Boolean = predicates.isEmpty() +} diff --git a/core/common/src/internal/format/formatter/Formatter.kt b/core/common/src/internal/format/formatter/Formatter.kt new file mode 100644 index 000000000..70384e102 --- /dev/null +++ b/core/common/src/internal/format/formatter/Formatter.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2019-2022 JetBrains s.r.o. and contributors. + * 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.internal.format.formatter + +internal sealed interface FormatterStructure { + fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean = false) +} + +internal sealed interface NonConditionalFormatterStructure: FormatterStructure + +internal class BasicFormatter( + private val operation: FormatterOperation, +): NonConditionalFormatterStructure { + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) = + operation.format(obj, builder, minusNotRequired) +} + +internal class ConditionalFormatter( + private val formatters: List Boolean, FormatterStructure>> +): FormatterStructure { + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + for ((condition, formatter) in formatters) { + if (obj.condition()) { + formatter.format(obj, builder, minusNotRequired) + return + } + } + } +} + +internal class SignedFormatter( + private val formatter: FormatterStructure, + private val allSubFormatsNegative: T.() -> Boolean, + private val alwaysOutputSign: Boolean, +): FormatterStructure { + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + val sign = if (!minusNotRequired && obj.allSubFormatsNegative()) { + '-' + } else if (alwaysOutputSign) { + '+' + } else { + null + } + sign?.let { builder.append(it) } + formatter.format(obj, builder, minusNotRequired = minusNotRequired || sign == '-') + } +} + +internal class ConcatenatedFormatter( + private val formatters: List>, +): NonConditionalFormatterStructure { + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + for (formatter in formatters) { + formatter.format(obj, builder, minusNotRequired) + } + } +} + diff --git a/core/common/src/internal/format/formatter/FormatterOperation.kt b/core/common/src/internal/format/formatter/FormatterOperation.kt new file mode 100644 index 000000000..4b28d2fa9 --- /dev/null +++ b/core/common/src/internal/format/formatter/FormatterOperation.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2019-2022 JetBrains s.r.o. and contributors. + * 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.internal.format.formatter + +import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.POWERS_OF_TEN +import kotlin.math.* + +internal interface FormatterOperation { + fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) +} + +internal class ConstantStringFormatterOperation( + private val string: String, +): FormatterOperation { + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + builder.append(string) + } +} + +internal class UnsignedIntFormatterOperation( + private val number: (T) -> Int, + private val zeroPadding: Int, +): FormatterOperation { + + init { + require(zeroPadding >= 0) + require(zeroPadding <= 9) + } + + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + val numberStr = number(obj).toString() + val zeroPaddingStr = '0'.toString().repeat(maxOf(0, zeroPadding - numberStr.length)) + builder.append(zeroPaddingStr, numberStr) + } +} + +internal class SignedIntFormatterOperation( + private val number: (T) -> Int, + private val zeroPadding: Int, + private val outputPlusOnExceedsPad: Boolean, +): FormatterOperation { + + init { + require(zeroPadding >= 0) + require(zeroPadding <= 9) + } + + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + val innerBuilder = StringBuilder() + val number = number(obj).let { if (minusNotRequired && it < 0) -it else it } + if (number.absoluteValue < POWERS_OF_TEN[zeroPadding - 1]) { + // needs padding + if (number >= 0) { + innerBuilder.append((number + POWERS_OF_TEN[zeroPadding])).deleteAt(0) + } else { + innerBuilder.append((number - POWERS_OF_TEN[zeroPadding])).deleteAt(1) + } + } else { + if (outputPlusOnExceedsPad && number >= POWERS_OF_TEN[zeroPadding]) innerBuilder.append('+') + innerBuilder.append(number) + } + builder.append(innerBuilder) + } +} + +internal class DecimalFractionFormatterOperation( + private val number: (T) -> DecimalFraction, + private val minDigits: Int?, + private val maxDigits: Int?, +): FormatterOperation { + + init { + require(minDigits == null || minDigits in 1..9) + require(maxDigits == null || maxDigits in 1..9) + require(minDigits == null || maxDigits == null || minDigits <= maxDigits) + } + + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + val minDigits = minDigits ?: 1 + val number = number(obj) + val nanoValue = number.fractionalPartWithNDigits(maxDigits ?: 9) + when { + nanoValue % 1000000 == 0 && minDigits <= 3 -> + builder.append((nanoValue / 1000000 + 1000).toString().substring(1)) + nanoValue % 1000 == 0 && minDigits <= 6 -> + builder.append((nanoValue / 1000 + 1000000).toString().substring(1)) + else -> builder.append((nanoValue + 1000000000).toString().substring(1)) + } + } +} + +internal class StringFormatterOperation( + private val string: (T) -> String, +): FormatterOperation { + override fun format(obj: T, builder: StringBuilder, minusNotRequired: Boolean) { + builder.append(string(obj)) + } +} diff --git a/core/common/src/internal/format/parser/NumberConsumer.kt b/core/common/src/internal/format/parser/NumberConsumer.kt new file mode 100644 index 000000000..66c82fbf5 --- /dev/null +++ b/core/common/src/internal/format/parser/NumberConsumer.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2023 JetBrains s.r.o. and contributors. + * 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.internal.format.parser + +import kotlinx.datetime.internal.* + +/** + * A parser that expects to receive a string consisting of [length] digits, or, if [length] is `null`, + * a string consisting of any number of digits. + */ +internal sealed class NumberConsumer( + /** The number of digits to consume. `null` means that the length is variable. */ + val length: Int?, + /** The human-readable name of the entity being parsed here. */ + val whatThisExpects: String +) { + /** + * Wholly consumes the given [input]. Should be called with a string consisting of [length] digits, or, + * if [length] is `null`, with a string consisting of any number of digits. [consume] itself does not + * necessarily check the length of the input string, instead expecting to be passed a valid one. + * + * @throws NumberFormatException if the given [input] is too large a number. + */ + abstract fun Receiver.consume(input: String) +} + +/** + * A parser that accepts an [Int] value in range from `0` to [Int.MAX_VALUE]. + */ +// TODO: should the parser reject excessive padding? +internal class UnsignedIntConsumer( + minLength: Int?, + maxLength: Int?, + private val setter: (Receiver, Int) -> (Unit), + name: String, + private val multiplyByMinus1: Boolean = false, +) : NumberConsumer(if (minLength == maxLength) minLength else null, name) { + + init { + require(length == null || length in 1..9) { "Invalid length for field $whatThisExpects: $length" } + } + + // TODO: ensure length + override fun Receiver.consume(input: String) = when (val result = input.toIntOrNull()) { + null -> throw NumberFormatException("Expected an Int value for $whatThisExpects but got $input") + else -> setter(this, if (multiplyByMinus1) -result else result) + } +} + +/** + * A parser that consumes exactly the string [expected]. + */ +internal class ConstantNumberConsumer( + private val expected: String +) : NumberConsumer(expected.length, "the predefined string $expected") { + override fun Receiver.consume(input: String) { + require(input == expected) { "Expected '$expected' but got $input" } + } +} + +/** + * A parser that accepts a [Long] value in range from `0` to [Long.MAX_VALUE]. + */ +internal class UnsignedLongConsumer( + length: Int?, + private val setter: (Receiver, Long) -> (Unit), + name: String, +) : NumberConsumer(length, name) { + + init { + require(length == null || length in 1..18) { "Invalid length for field $whatThisExpects: $length" } + } + + override fun Receiver.consume(input: String) = when (val result = input.toLongOrNull()) { + null -> throw NumberFormatException("Expected a Long value for $whatThisExpects but got $input") + else -> setter(this, result) + } +} + +internal class FractionPartConsumer( + private val minLength: Int?, + private val maxLength: Int?, + private val setter: (Receiver, DecimalFraction) -> (Unit), + name: String, +) : NumberConsumer(if (minLength == maxLength) minLength else null, name) { + init { + require(minLength == null || minLength in 1..9) { "Invalid length for field $whatThisExpects: $length" } + // TODO: bounds on maxLength + } + + override fun Receiver.consume(input: String) { + if (minLength != null && input.length < minLength) + throw NumberFormatException("Expected at least $minLength digits for $whatThisExpects but got $input") + if (maxLength != null && input.length > maxLength) + throw NumberFormatException("Expected at most $maxLength digits for $whatThisExpects but got $input") + when (val numerator = input.toIntOrNull()) { + null -> throw NumberFormatException("Expected at most a 9-digit value for $whatThisExpects but got $input") + else -> setter(this, DecimalFraction(numerator, input.length)) + } + } +} diff --git a/core/common/src/internal/format/parser/ParseResult.kt b/core/common/src/internal/format/parser/ParseResult.kt new file mode 100644 index 000000000..27fcdb3dc --- /dev/null +++ b/core/common/src/internal/format/parser/ParseResult.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2019-2022 JetBrains s.r.o. and contributors. + * 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.internal.format.parser + +import kotlin.jvm.* + +@JvmInline +internal value class ParseResult(val value: Any) { + companion object { + fun Ok(indexOfNextUnparsed: Int) = ParseResult(indexOfNextUnparsed) + fun Error(message: String, position: Int, cause: Throwable? = null) = + ParseResult(ParseException(message, position, cause)) + } + + fun isOk() = value is Int + fun tryGetIndex(): Int? = if (value is Int) value else null + fun tryGetError(): ParseException? = if (value is ParseException) value else null +} + +internal class ParseException(message: String, val position: Int, cause: Throwable? = null) : Exception("Position $position: $message", cause) diff --git a/core/common/src/internal/format/parser/Parser.kt b/core/common/src/internal/format/parser/Parser.kt new file mode 100644 index 000000000..e5996afd7 --- /dev/null +++ b/core/common/src/internal/format/parser/Parser.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2023 JetBrains s.r.o. and contributors. + * 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.internal.format.parser + +/** + * Describes the commands that the parser must execute, in two portions: + * * [operations], which are executed in order, and + * * [followedBy], which are executed *in parallel* after [operations]. + * + * An example of a [ParserStructure]: + * ``` + * // number - dash - number - dash - number + * // | + * // \ + * // letter 'W' - number + * ParserStructure( + * listOf(numberParser), + * listOf( + * ParserStructure( + * listOf(stringParser("-"), numberParser, stringParser("-"), numberParser), + * emptyList() + * ), + * ParserStructure( + * listOf(stringParser("W"), numberParser), + * emptyList() + * ), + * ) + * ) + * ``` + */ +internal class ParserStructure( + val operations: List>, + val followedBy: List>, +) { + override fun toString(): String = + "${operations.joinToString(", ")}(${followedBy.joinToString(";")})" +} + +internal class Parser( + private val defaultState: () -> Output, + private val copyState: (Output) -> Output, + private val commands: ParserStructure +) { + /** + * [startIndex] is the index of the first character that is not yet consumed. + * + * [allowDanglingInput] determines whether the match is only successful if the whole string after [startIndex] + * is consumed. + * + * [onSuccess] is invoked as soon as some parsing attempt succeeds. + * [onError] is invoked when some parsing attempt fails. + */ + // Would be a great place to use the `Flow` from `kotlinx.coroutines` here instead of `onSuccess` and + // `onError`, but alas. + private inline fun parse( + input: CharSequence, + startIndex: Int, + allowDanglingInput: Boolean, + onError: (ParseException) -> Unit, + onSuccess: (Output) -> Unit + ) { + var states = mutableListOf(ParserState(defaultState(), StructureIndex(0, commands), startIndex)) + while (states.isNotEmpty()) { + states = states.flatMap { state -> + val index = state.commandPosition + if (index.operationIndex < index.parserStructure.operations.size) { + val newIndex = StructureIndex(index.operationIndex + 1, index.parserStructure) + val command = state.commandPosition.parserStructure.operations[state.commandPosition.operationIndex] + val result = with(command) { state.output.consume(input, state.inputPosition) } + if (result.isOk()) { + listOf(ParserState(state.output, newIndex, result.tryGetIndex()!!)) + } else { + onError(result.tryGetError()!!) + emptyList() + } + } else { + index.parserStructure.followedBy.map { nextStructure -> + ParserState(copyState(state.output), StructureIndex(0, nextStructure), state.inputPosition) + }.also { + if (it.isEmpty()) { + if (allowDanglingInput || state.inputPosition == input.length) { + onSuccess(state.output) + } else { + onError(ParseException("There is more input to consume", state.inputPosition)) + } + } + } + } + }.toMutableList() + } + } + + + fun match(input: CharSequence, startIndex: Int = 0): Output { + val errors = mutableListOf() + parse(input, startIndex, allowDanglingInput = false, { errors.add(it) }, { return@match it }) + errors.sortByDescending { it.position } + // `errors` can not be empty because each parser will have (successes + failures) >= 1, and here, successes == 0 + errors.first().let { + for (error in errors.drop(1)) { + it.addSuppressed(error) + } + throw it + } + } + + // TODO: only take the longest match from the given start position + fun find(input: CharSequence, startIndex: Int = 0): Output? { + forEachNonInternalIndex(input, startIndex) { index -> + parse(input, index, allowDanglingInput = true, {}, { return@find it }) + } + return null + } + + // TODO: skip the indices in already-parsed parts of the input + // TODO: only take the longest match from the given start position + fun findAll(input: CharSequence, startIndex: Int = 0): List = + mutableListOf().apply { + forEachNonInternalIndex(input, startIndex) { index -> + parse(input, index, allowDanglingInput = true, {}, { add(it) }) + } + } + + private inner class ParserState( + val output: Output, + val commandPosition: StructureIndex, + val inputPosition: Int, + ) + + private class StructureIndex( + val operationIndex: Int, + val parserStructure: ParserStructure + ) +} + +/** + * Iterates over all the indices in [input] that are not in the middle of either a word or a number. + * + * For example, in the string "Tom is 10", the start indices of the following strings are returned: + * - "Tom is 10" + * - " is 10" + * - "is 10" + * - " 10" + * - "10" + * + * The purpose is to avoid parsing "UTC" in "OUTCOME" or "2020-01-01" in "202020-01-01". + */ +// TODO: permit starting parsing in the middle of the word, since, in some languages, spaces are not used +private inline fun forEachNonInternalIndex(input: CharSequence, startIndex: Int, block: (Int) -> Unit) { + val OUTSIDE_SPAN = 0 + val IN_NUMBER = 1 + val IN_WORD = 2 + var currentState = OUTSIDE_SPAN + // `Regex.find` allows lookbehinds to see the string before the `startIndex`, so we do that as well + if (startIndex != 0) { + if (input[startIndex - 1].isDigit()) { + currentState = IN_NUMBER + } else if (input[startIndex - 1].isLetter()) { + currentState = IN_WORD + } + } + for (i in startIndex..input.length) { + if (input[i].isDigit() && currentState == IN_NUMBER || input[i].isLetter() && currentState == IN_WORD) { + continue + } + if (input[i].isDigit()) currentState = IN_NUMBER + if (input[i].isLetter()) currentState = IN_WORD + block(i) + } +} + diff --git a/core/common/src/internal/format/parser/ParserOperation.kt b/core/common/src/internal/format/parser/ParserOperation.kt new file mode 100644 index 000000000..f48a65b06 --- /dev/null +++ b/core/common/src/internal/format/parser/ParserOperation.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2023 JetBrains s.r.o. and contributors. + * 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.internal.format.parser + +internal interface ParserOperation { + fun Output.consume(input: CharSequence, startIndex: Int): ParseResult +} + +/** + * Consumes exactly the string [string]. + */ +internal class PlainStringParserOperation(val string: String) : ParserOperation { + init { + require(string.isNotEmpty()) { "Empty string is not allowed" } + require(!string[0].isDigit()) + require(!string[string.length - 1].isDigit()) + } + + override fun Output.consume(input: CharSequence, startIndex: Int): ParseResult { + if (startIndex + string.length > input.length) + return ParseResult.Error("Unexpected end of input: yet to parse '$string'", startIndex) + for (i in string.indices) { + if (input[startIndex + i] != string[i]) return ParseResult.Error( + "Expected $string but got ${input[startIndex + i]}", + startIndex + ) + } + return ParseResult.Ok(startIndex + string.length) + } + + // TODO: properly escape + override fun toString(): String = "'$string'" +} + +/** + * Greedily consumes as many digits as possible and redistributes them among [consumers]. + * + * At most one consumer is allowed to have a variable length. + * If more digits are supplied than the sum of lengths of all consumers, this is the consumer that will receive the + * extra. + */ +internal class NumberSpanParserOperation( + val consumers: List>, +) : ParserOperation { + + private val minLength = consumers.sumOf { it.length ?: 1 } + private val isFlexible = consumers.any { it.length == null } + + init { + require(consumers.all { (it.length ?: Int.MAX_VALUE) > 0 }) + require(consumers.count { it.length == null } <= 1) + } + + private val whatThisExpects: String + get() { + val consumerLengths = consumers.map { + when (val length = it.length) { + null -> "at least one digit" + else -> "$length digits" + } + " for ${it.whatThisExpects}" + } + return if (isFlexible) { + "a number with at least $minLength digits: $consumerLengths" + } else { + "a number with exactly $minLength digits: $consumerLengths" + } + } + + override fun Output.consume(input: CharSequence, startIndex: Int): ParseResult { + if (startIndex + minLength > input.length) + return ParseResult.Error("Unexpected end of input: yet to parse $whatThisExpects", startIndex) + var digitsInRow = 0 + while (startIndex + digitsInRow < input.length && input[startIndex + digitsInRow].isDigit()) { + ++digitsInRow + } + if (digitsInRow < minLength) + return ParseResult.Error( + "Only found $digitsInRow digits in a row, but need to parse $whatThisExpects", + startIndex + ) + val lengths = consumers.map { it.length ?: (digitsInRow - minLength + 1) } + var index = startIndex + for (i in lengths.indices) { + val numberString = input.substring(index, index + lengths[i]) + try { + with(consumers[i]) { consume(numberString) } + } catch (e: Throwable) { + return ParseResult.Error( + "Can not interpret the string '$numberString' as ${consumers[i].whatThisExpects}", + index, + e + ) + } + index += lengths[i] + } + return ParseResult.Ok(index) + } + + override fun toString(): String = whatThisExpects +} + +/** + * Matches the longest suitable string from `strings` and calls [consume] with the matched string. + */ +internal class StringSetParserOperation( + strings: Set, + private val setter: (Output, String) -> Unit, + private val whatThisExpects: String +) : + ParserOperation { + + private class TrieNode( + val children: MutableList> = mutableListOf(), + var isTerminal: Boolean = false + ) + + private val trie = TrieNode() + + init { + for (string in strings) { + var node = trie + for (char in string) { + val searchResult = node.children.binarySearchBy(char.toString()) { it.first } + node = if (searchResult < 0) { + TrieNode().also { node.children.add(-searchResult + 1, char.toString() to it) } + } else { + node.children[searchResult].second + } + } + node.isTerminal = true + } + fun reduceTrie(trie: TrieNode) { + for ((_, child) in trie.children) { + reduceTrie(child) + } + val newChildren = mutableListOf>() + for ((key, child) in trie.children) { + if (!child.isTerminal && child.children.size == 1) { + val (grandChildKey, grandChild) = child.children.single() + newChildren.add(key + grandChildKey to grandChild) + } else { + newChildren.add(key to child) + } + } + trie.children.clear() + trie.children.addAll(newChildren.sortedBy { it.first }) + } + reduceTrie(trie) + } + + override fun Output.consume(input: CharSequence, startIndex: Int): ParseResult { + var node = trie + var index = startIndex + var lastMatch: Int? = null + loop@ while (index < input.length) { + if (node.isTerminal) lastMatch = index + for ((key, child) in node.children) { + if (input.startsWith(key, index)) { + node = child + index += key.length + continue@loop + } + } + break // nothing found + } + return if (lastMatch != null) { + setter(this, input.subSequence(startIndex, lastMatch).toString()) + ParseResult.Ok(lastMatch) + } else { + ParseResult.Error("Expected $whatThisExpects but got ${input[startIndex]}", startIndex) + } + } +} + +internal fun SignedIntParser( + minDigits: Int?, + maxDigits: Int?, + setter: (Output, Int) -> Unit, + name: String, + plusOnExceedsPad: Boolean = false, + signsInverted: Boolean = false, +): ParserStructure { + val parsers = mutableListOf>>( + listOf( + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + minDigits, + minDigits, + setter, + name, + multiplyByMinus1 = signsInverted + ) + ) + ) + ), + ) + if (!signsInverted) { + parsers.add( + listOf( + PlainStringParserOperation("-"), + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + minDigits, + maxDigits, + setter, + name, + multiplyByMinus1 = !signsInverted + ) + ) + ) + ), + ) + } + if (plusOnExceedsPad) { + parsers.add( + listOf( + PlainStringParserOperation("+"), + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + minDigits?.let { it + 1 }, + maxDigits, + setter, + name, + multiplyByMinus1 = signsInverted + ) + ) + ) + ) + ) + } + return ParserStructure( + emptyList(), + parsers.map { ParserStructure(it, emptyList()) }, + ) +} diff --git a/core/common/src/internal/math.kt b/core/common/src/internal/math.kt index 87e614a19..e638120f7 100644 --- a/core/common/src/internal/math.kt +++ b/core/common/src/internal/math.kt @@ -5,6 +5,8 @@ package kotlinx.datetime.internal +import kotlin.native.concurrent.* + internal fun Long.clampToInt(): Int = when { this > Int.MAX_VALUE -> Int.MAX_VALUE @@ -179,3 +181,51 @@ internal fun multiplyAndAdd(d: Long, n: Long, r: Long): Long { } return safeAdd(safeMultiply(md, n), mr) } + +@ThreadLocal +internal val POWERS_OF_TEN = intArrayOf( + 1, + 10, + 100, + 1000, + 10000, + 100000, + 1000000, + 10000000, + 100000000, + 1000000000 +) + +/** + * The fraction [fractionalPart]/10^[digits]. + */ +internal class DecimalFraction( + /** + * The numerator of the fraction. + */ + val fractionalPart: Int, + /** + * The number of digits after the decimal point. + */ + val digits: Int +): Comparable { + init { + require(digits >= 0) { "Digits must be non-negative, but was $digits" } + } + + /** + * The integral numerator of the fraction, but with [newDigits] digits after the decimal point. + */ + fun fractionalPartWithNDigits(newDigits: Int): Int = when { + newDigits == digits -> fractionalPart + newDigits > digits -> fractionalPart * POWERS_OF_TEN[newDigits - digits] + else -> (fractionalPart / POWERS_OF_TEN[digits - newDigits - 1] + 5) / 10 + } + + override fun compareTo(other: DecimalFraction): Int = + maxOf(digits, other.digits).let { maxPrecision -> + fractionalPartWithNDigits(maxPrecision).compareTo(other.fractionalPartWithNDigits(maxPrecision)) + } + + override fun equals(other: Any?): Boolean = other is DecimalFraction && compareTo(other) == 0 +} diff --git a/core/commonJs/src/internal/LruCache.kt b/core/commonJs/src/internal/LruCache.kt new file mode 100644 index 000000000..1d86f6d9d --- /dev/null +++ b/core/commonJs/src/internal/LruCache.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal + +internal actual class LruCache actual constructor(private val size: Int, private val create: (K) -> V) { + private val cache = HashMap() + private val history = HashMap() + private var counter = 0 + + actual fun get(key: K): V { + history[key] = ++counter + return cache.getOrPut(key) { + if (history.size > size) { + val oldest = history.minByOrNull { it.value }!!.key + history.remove(oldest) + cache.remove(oldest) + } + create(key) + } + } +} diff --git a/core/jvm/src/internal/LruCacheJvm.kt b/core/jvm/src/internal/LruCacheJvm.kt new file mode 100644 index 000000000..b6f61dcf2 --- /dev/null +++ b/core/jvm/src/internal/LruCacheJvm.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal + +import java.util.concurrent.* +import java.util.concurrent.atomic.* + +internal actual class LruCache actual constructor(private val size: Int, private val create: (K) -> V) { + private val history = ConcurrentHashMap() + private val cache = ConcurrentHashMap() + private val counter = AtomicInteger() + + actual fun get(key: K): V { + history[key] = counter.incrementAndGet() + return cache.getOrPut(key) { + synchronized(history) { + if (history.size > size) { + val oldest = history.minByOrNull { it.value }!!.key + history.remove(oldest) + cache.remove(oldest) + } + create(key) + } + } + } +} diff --git a/core/native/src/Instant.kt b/core/native/src/Instant.kt index 3b21d33e0..5e85c0642 100644 --- a/core/native/src/Instant.kt +++ b/core/native/src/Instant.kt @@ -8,7 +8,9 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* +import kotlinx.datetime.internal.format.* import kotlinx.datetime.serializers.InstantIso8601Serializer import kotlinx.serialization.Serializable import kotlin.math.* @@ -26,94 +28,7 @@ public actual enum class DayOfWeek { SUNDAY; } -/** A parser for the string representation of [ZoneOffset] as seen in `OffsetDateTime`. - * - * We can't just reuse the parsing logic of [ZoneOffset.of], as that version is more lenient: here, strings like - * "0330" are not considered valid zone offsets, whereas [ZoneOffset.of] sees treats the example above as "03:30". */ -private val zoneOffsetParser: Parser - get() = (concreteCharParser('z').or(concreteCharParser('Z')).map { UtcOffset.ZERO }) - .or( - concreteCharParser('+').or(concreteCharParser('-')) - .chain(intParser(2, 2)) - .chain( - optional( - // minutes - concreteCharParser(':').chainSkipping(intParser(2, 2)) - .chain(optional( - // seconds - concreteCharParser(':').chainSkipping(intParser(2, 2)) - )))) - .map { - val (signHours, minutesSeconds) = it - val (sign, hours) = signHours - val minutes: Int - val seconds: Int - if (minutesSeconds == null) { - minutes = 0 - seconds = 0 - } else { - minutes = minutesSeconds.first - seconds = minutesSeconds.second ?: 0 - } - try { - if (sign == '-') - UtcOffset.ofHoursMinutesSeconds(-hours, -minutes, -seconds) - else - UtcOffset.ofHoursMinutesSeconds(hours, minutes, seconds) - } catch (e: IllegalArgumentException) { - throw DateTimeFormatException(e) - } - } - ) - -// This is a function and not a value due to https://github.com/Kotlin/kotlinx-datetime/issues/5 -// org.threeten.bp.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME -private val instantParser: Parser - 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 - )) - .chain(zoneOffsetParser) - .map { - val (localDateTime, offset) = it - val (dateHourMinuteSecond, nanosVal) = localDateTime - 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) { - // TODO: throw an error on leap seconds to match what the other platforms do - 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.toEpochDays().toLong() - val instantSecs = epochDay * 86400 - offset.totalSeconds + localTime.toSecondOfDay() + secDelta - try { - Instant(instantSecs, nano) - } catch (e: IllegalArgumentException) { - throw DateTimeFormatException(e) - } - } +private const val instantFormat = "ld'T'ltuo<'Z'|'z'|+HH(|':'mm(|':'ss))>" /** * The minimum supported epoch second. @@ -125,7 +40,7 @@ private const val MIN_SECOND = -31619119219200L // -1000000-01-01T00:00:00Z */ private const val MAX_SECOND = 31494816403199L // +1000000-12-31T23:59:59 -private fun isValidInstantSecond(second: Long) = second >= MIN_SECOND && second <= MAX_SECOND +private fun isValidInstantSecond(second: Long) = second in MIN_SECOND..MAX_SECOND internal expect fun currentTime(): Instant @@ -228,8 +143,11 @@ public actual class Instant internal constructor(public actual val epochSeconds: public actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Int): Instant = fromEpochSeconds(epochSeconds, nanosecondAdjustment.toLong()) - public actual fun parse(isoString: String): Instant = - instantParser.parse(isoString) + public actual fun parse(isoString: String): Instant = try { + ValueBag.parse(isoString, ValueBagFormat.ISO_INSTANT).toInstantUsingUtcOffset() + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Failed to parse an instant from '$isoString'", e) + } public actual val DISTANT_PAST: Instant = fromEpochSeconds(DISTANT_PAST_SECONDS, 999_999_999) @@ -331,65 +249,5 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti } } -internal actual fun Instant.toStringWithOffset(offset: UtcOffset): String { - val buf = StringBuilder() - val inNano: Int = nanosecondsOfSecond - val seconds = epochSeconds + offset.totalSeconds - if (seconds >= -SECONDS_0000_TO_1970) { // current era - val zeroSecs: Long = seconds - SECONDS_PER_10000_YEARS + SECONDS_0000_TO_1970 - val hi: Long = zeroSecs.floorDiv(SECONDS_PER_10000_YEARS) + 1 - val lo: Long = zeroSecs.mod(SECONDS_PER_10000_YEARS) - val ldt: LocalDateTime = Instant(lo - SECONDS_0000_TO_1970, 0) - .toLocalDateTime(TimeZone.UTC) - if (hi > 0) { - buf.append('+').append(hi) - } - buf.append(ldt) - if (ldt.second == 0) { - buf.append(":00") - } - } else { // before current era - val zeroSecs: Long = seconds + SECONDS_0000_TO_1970 - val hi: Long = zeroSecs / SECONDS_PER_10000_YEARS - val lo: Long = zeroSecs % SECONDS_PER_10000_YEARS - val ldt: LocalDateTime = Instant(lo - SECONDS_0000_TO_1970, 0) - .toLocalDateTime(TimeZone.UTC) - val pos = buf.length - buf.append(ldt) - if (ldt.second == 0) { - buf.append(":00") - } - if (hi < 0) { - when { - ldt.year == -10000 -> { - buf.deleteAt(pos) - buf.deleteAt(pos) - buf.insert(pos, (hi - 1).toString()) - } - lo == 0L -> { - buf.insert(pos, hi) - } - else -> { - buf.insert(pos + 1, abs(hi)) - } - } - } - } - //fraction - if (inNano != 0) { - buf.append('.') - when { - inNano % 1000000 == 0 -> { - buf.append((inNano / 1000000 + 1000).toString().substring(1)) - } - inNano % 1000 == 0 -> { - buf.append((inNano / 1000 + 1000000).toString().substring(1)) - } - else -> { - buf.append((inNano + 1000000000).toString().substring(1)) - } - } - } - buf.append(offset) - return buf.toString() -} +internal actual fun Instant.toStringWithOffset(offset: UtcOffset): String = + ValueBag().also { it.populateFrom(this, offset) }.format(ValueBagFormat.ISO_INSTANT) diff --git a/core/native/src/LocalDate.kt b/core/native/src/LocalDate.kt index e08fa6297..b18c42ee1 100644 --- a/core/native/src/LocalDate.kt +++ b/core/native/src/LocalDate.kt @@ -8,6 +8,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* import kotlinx.datetime.internal.safeAdd import kotlinx.datetime.internal.safeMultiply @@ -15,52 +16,42 @@ import kotlinx.datetime.serializers.LocalDateIso8601Serializer import kotlinx.serialization.Serializable import kotlin.math.* -// This is a function and not a value due to https://github.com/Kotlin/kotlinx-datetime/issues/5 -// org.threeten.bp.format.DateTimeFormatter#ISO_LOCAL_DATE -internal val localDateParser: Parser - get() = intParser(4, 10, SignStyle.EXCEEDS_PAD) - .chainIgnoring(concreteCharParser('-')) - .chain(intParser(2, 2)) - .chainIgnoring(concreteCharParser('-')) - .chain(intParser(2, 2)) - .map { - val (yearMonth, day) = it - val (year, month) = yearMonth - try { - LocalDate(year, month, day) - } catch (e: IllegalArgumentException) { - throw DateTimeFormatException(e) - } - } +// org.threeten.bp.LocalDate#create +private fun checkDateConsistency(year: Int?, monthNumber: Int?, dayOfMonth: Int?, isoDayOfWeek: Int?) { + if (dayOfMonth == null || monthNumber == null || dayOfMonth <= 28) + return + if (dayOfMonth > monthNumber.monthLength(true)) + throw IllegalArgumentException("Invalid date '${Month(monthNumber).name} $dayOfMonth'") + if (year != null) { + if (dayOfMonth == 29 && monthNumber == 2 && !isLeapYear(year)) + throw IllegalArgumentException("Invalid date 'February 29' as '$year' is not a leap year") + if (isoDayOfWeek != null && isoDayOfWeek != isoDayOfWeekOnDate(year, monthNumber, dayOfMonth)) + throw IllegalArgumentException( + "'$year-${Month(monthNumber).name}-$dayOfMonth' is not a ${DayOfWeek(isoDayOfWeek)}" + ) + } +} internal const val YEAR_MIN = -999_999 internal const val YEAR_MAX = 999_999 private fun isValidYear(year: Int): Boolean = - year >= YEAR_MIN && year <= YEAR_MAX + year in YEAR_MIN..YEAR_MAX @Serializable(with = LocalDateIso8601Serializer::class) public actual class LocalDate actual constructor(public actual val year: Int, public actual val monthNumber: Int, public actual val dayOfMonth: Int) : Comparable { init { - // org.threeten.bp.LocalDate#create require(isValidYear(year)) { "Invalid date: the year is out of range" } require(monthNumber in 1..12) { "Invalid date: month must be a number between 1 and 12, got $monthNumber" } require(dayOfMonth in 1..31) { "Invalid date: day of month must be a number between 1 and 31, got $dayOfMonth" } - if (dayOfMonth > 28 && dayOfMonth > monthNumber.monthLength(isLeapYear(year))) { - if (dayOfMonth == 29) { - throw IllegalArgumentException("Invalid date 'February 29' as '$year' is not a leap year") - } else { - throw IllegalArgumentException("Invalid date '${month.name} $dayOfMonth'") - } - } + checkDateConsistency(year, monthNumber, dayOfMonth, null) } public actual constructor(year: Int, month: Month, dayOfMonth: Int) : this(year, month.number, dayOfMonth) public actual companion object { - public actual fun parse(isoString: String): LocalDate = - localDateParser.parse(isoString) + public actual fun parse(isoString: String): LocalDate = parse(isoString, LocalDateFormat.ISO) // org.threeten.bp.LocalDate#toEpochDay public actual fun fromEpochDays(epochDays: Int): LocalDate { @@ -105,43 +96,14 @@ public actual class LocalDate actual constructor(public actual val year: Int, pu } // org.threeten.bp.LocalDate#toEpochDay - public actual fun toEpochDays(): Int { - val y = year - val m = monthNumber - var total = 0 - total += 365 * y - if (y >= 0) { - total += (y + 3) / 4 - (y + 99) / 100 + (y + 399) / 400 - } else { - total -= y / -4 - y / -100 + y / -400 - } - total += ((367 * m - 362) / 12) - total += dayOfMonth - 1 - if (m > 2) { - total-- - if (!isLeapYear(year)) { - total-- - } - } - return total - DAYS_0000_TO_1970 - } - - // org.threeten.bp.LocalDate#withYear - /** - * @throws IllegalArgumentException if the result exceeds the boundaries - */ - internal fun withYear(newYear: Int): LocalDate = - if (newYear == year) this else resolvePreviousValid(newYear, monthNumber, dayOfMonth) + public actual fun toEpochDays(): Int = dateToEpochDays(year, monthNumber, dayOfMonth) public actual val month: Month get() = Month(monthNumber) // org.threeten.bp.LocalDate#getDayOfWeek public actual val dayOfWeek: DayOfWeek - get() { - val dow0 = (toEpochDays() + 3).mod(7) - return DayOfWeek(dow0 + 1) - } + get() = DayOfWeek(isoDayOfWeekOnDate(year, monthNumber, dayOfMonth)) // org.threeten.bp.LocalDate#getDayOfYear public actual val dayOfYear: Int @@ -206,30 +168,7 @@ public actual class LocalDate actual constructor(public actual val year: Int, pu } // org.threeten.bp.LocalDate#toString - actual override fun toString(): String { - val yearValue = year - val monthValue: Int = monthNumber - val dayValue: Int = dayOfMonth - val absYear: Int = abs(yearValue) - val buf = StringBuilder(10) - if (absYear < 1000) { - if (yearValue < 0) { - buf.append(yearValue - 10000).deleteAt(1) - } else { - buf.append(yearValue + 10000).deleteAt(0) - } - } else { - if (yearValue > 9999) { - buf.append('+') - } - buf.append(yearValue) - } - return buf.append(if (monthValue < 10) "-0" else "-") - .append(monthValue) - .append(if (dayValue < 10) "-0" else "-") - .append(dayValue) - .toString() - } + actual override fun toString(): String = format(LocalDateFormat.ISO) } @Deprecated("Use the plus overload with an explicit number of units", ReplaceWith("this.plus(1, unit)")) diff --git a/core/native/src/LocalDateTime.kt b/core/native/src/LocalDateTime.kt index 54b23aff7..39ddc1bc5 100644 --- a/core/native/src/LocalDateTime.kt +++ b/core/native/src/LocalDateTime.kt @@ -8,26 +8,16 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* -import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer -import kotlinx.serialization.Serializable - -// This is a function and not a value due to https://github.com/Kotlin/kotlinx-datetime/issues/5 -// org.threeten.bp.format.DateTimeFormatter#ISO_LOCAL_DATE_TIME -internal val localDateTimeParser: Parser - get() = localDateParser - .chainIgnoring(concreteCharParser('T').or(concreteCharParser('t'))) - .chain(localTimeParser) - .map { (date, time) -> - LocalDateTime(date, time) - } +import kotlinx.datetime.serializers.* +import kotlinx.serialization.* @Serializable(with = LocalDateTimeIso8601Serializer::class) public actual class LocalDateTime public actual constructor(public actual val date: LocalDate, public actual val time: LocalTime) : Comparable { public actual companion object { - public actual fun parse(isoString: String): LocalDateTime = - localDateTimeParser.parse(isoString) + public actual fun parse(isoString: String): LocalDateTime = parse(isoString, LocalDateTimeFormat.ISO) internal actual val MIN: LocalDateTime = LocalDateTime(LocalDate.MIN, LocalTime.MIN) internal actual val MAX: LocalDateTime = LocalDateTime(LocalDate.MAX, LocalTime.MAX) @@ -68,7 +58,7 @@ public actual constructor(public actual val date: LocalDate, public actual val t } // org.threeten.bp.LocalDateTime#toString - actual override fun toString(): String = date.toString() + 'T' + time.toString() + actual override fun toString(): String = format(LocalDateTimeFormat.ISO) // org.threeten.bp.chrono.ChronoLocalDateTime#toEpochSecond internal fun toEpochSecond(offset: UtcOffset): Long { diff --git a/core/native/src/LocalTime.kt b/core/native/src/LocalTime.kt index e12711333..fc257b918 100644 --- a/core/native/src/LocalTime.kt +++ b/core/native/src/LocalTime.kt @@ -9,37 +9,10 @@ package kotlinx.datetime import kotlinx.datetime.internal.* +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.LocalTimeIso8601Serializer import kotlinx.serialization.Serializable -// This is a function and not a value due to https://github.com/Kotlin/kotlinx-datetime/issues/5 -// org.threeten.bp.format.DateTimeFormatter#ISO_LOCAL_TIME -internal val localTimeParser: Parser - get() = intParser(2, 2) // hour - .chainIgnoring(concreteCharParser(':')) - .chain(intParser(2, 2)) // minute - .chain(optional( - concreteCharParser(':') - .chainSkipping(intParser(2, 2)) // second - .chain(optional( - concreteCharParser('.') - .chainSkipping(fractionParser(0, 9, 9)) - )) - )) - .map { - val (hourMinute, secNano) = it - val (hour, minute) = hourMinute - val (sec, nanosecond) = when (secNano) { - null -> Pair(0, 0) - else -> Pair(secNano.first, secNano.second ?: 0) - } - try { - LocalTime.of(hour, minute, sec, nanosecond) - } catch (e: IllegalArgumentException) { - throw DateTimeFormatException(e) - } - } - @Serializable(LocalTimeIso8601Serializer::class) public actual class LocalTime actual constructor( public actual val hour: Int, @@ -60,8 +33,7 @@ public actual class LocalTime actual constructor( } public actual companion object { - public actual fun parse(isoString: String): LocalTime = - localTimeParser.parse(isoString) + public actual fun parse(isoString: String): LocalTime = parse(isoString, LocalTimeFormat.ISO) public actual fun fromSecondOfDay(secondOfDay: Int): LocalTime = ofSecondOfDay(secondOfDay, 0) @@ -146,34 +118,7 @@ public actual class LocalTime actual constructor( return total } - // org.threeten.bp.LocalTime#toString - actual override fun toString(): String { - val buf = StringBuilder(18) - val hourValue = hour - val minuteValue = minute - val secondValue = second - val nanoValue: Int = nanosecond - buf.append(if (hourValue < 10) "0" else "").append(hourValue) - .append(if (minuteValue < 10) ":0" else ":").append(minuteValue) - if (secondValue > 0 || nanoValue > 0) { - buf.append(if (secondValue < 10) ":0" else ":").append(secondValue) - if (nanoValue > 0) { - buf.append('.') - when { - nanoValue % 1000000 == 0 -> { - buf.append((nanoValue / 1000000 + 1000).toString().substring(1)) - } - nanoValue % 1000 == 0 -> { - buf.append((nanoValue / 1000 + 1000000).toString().substring(1)) - } - else -> { - buf.append((nanoValue + 1000000000).toString().substring(1)) - } - } - } - } - return buf.toString() - } + actual override fun toString(): String = format(LocalTimeFormat.ISO) override fun equals(other: Any?): Boolean = other is LocalTime && this.compareTo(other) == 0 diff --git a/core/native/src/UtcOffset.kt b/core/native/src/UtcOffset.kt index 7f904cd5a..68eac1e46 100644 --- a/core/native/src/UtcOffset.kt +++ b/core/native/src/UtcOffset.kt @@ -6,6 +6,7 @@ package kotlinx.datetime import kotlinx.datetime.internal.* +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.UtcOffsetSerializer import kotlinx.serialization.Serializable import kotlin.math.abs @@ -23,59 +24,7 @@ public actual class UtcOffset private constructor(public actual val totalSeconds public actual val ZERO: UtcOffset = UtcOffset(totalSeconds = 0) - public actual fun parse(offsetString: String): UtcOffset { - if (offsetString == "Z") { - return ZERO - } - - // parse - +h, +hh, +hhmm, +hh:mm, +hhmmss, +hh:mm:ss - val hours: Int - val minutes: Int - val seconds: Int - when (offsetString.length) { - 2 -> return parse(offsetString[0].toString() + "0" + offsetString[1]) - 3 -> { - hours = parseNumber(offsetString, 1, false) - minutes = 0 - seconds = 0 - } - 5 -> { - hours = parseNumber(offsetString, 1, false) - minutes = parseNumber(offsetString, 3, false) - seconds = 0 - } - 6 -> { - hours = parseNumber(offsetString, 1, false) - minutes = parseNumber(offsetString, 4, true) - seconds = 0 - } - 7 -> { - hours = parseNumber(offsetString, 1, false) - minutes = parseNumber(offsetString, 3, false) - seconds = parseNumber(offsetString, 5, false) - } - 9 -> { - hours = parseNumber(offsetString, 1, false) - minutes = parseNumber(offsetString, 4, true) - seconds = parseNumber(offsetString, 7, true) - } - else -> throw DateTimeFormatException("Invalid ID for UtcOffset, invalid format: $offsetString") - } - val first: Char = offsetString[0] - if (first != '+' && first != '-') { - throw DateTimeFormatException( - "Invalid ID for UtcOffset, plus/minus not found when expected: $offsetString") - } - try { - return if (first == '-') { - ofHoursMinutesSeconds(-hours, -minutes, -seconds) - } else { - ofHoursMinutesSeconds(hours, minutes, seconds) - } - } catch (e: IllegalArgumentException) { - throw DateTimeFormatException(e) - } - } + public actual fun parse(offsetString: String): UtcOffset = parse(offsetString, lenientFormat) private fun validateTotal(totalSeconds: Int) { if (totalSeconds !in -18 * SECONDS_PER_HOUR .. 18 * SECONDS_PER_HOUR) { @@ -129,24 +78,11 @@ public actual class UtcOffset private constructor(public actual val totalSeconds UtcOffset(totalSeconds = seconds) } } - - // org.threeten.bp.ZoneOffset#parseNumber - private fun parseNumber(offsetId: CharSequence, pos: Int, precededByColon: Boolean): Int { - if (precededByColon && offsetId[pos - 1] != ':') { - throw DateTimeFormatException("Invalid ID for UtcOffset, colon not found when expected: $offsetId") - } - val ch1 = offsetId[pos] - val ch2 = offsetId[pos + 1] - if (ch1 < '0' || ch1 > '9' || ch2 < '0' || ch2 > '9') { - throw DateTimeFormatException("Invalid ID for UtcOffset, non numeric characters found: $offsetId") - } - return (ch1 - '0') * 10 + (ch2 - '0') - } } } @ThreadLocal -private var utcOffsetCache: MutableMap = mutableMapOf() +private var utcOffsetCache: MutableMap = mutableMapOf(0 to UtcOffset.ZERO) @Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS") public actual fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: Int? = null): UtcOffset = @@ -160,3 +96,4 @@ public actual fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: I } } +private const val lenientFormat = "('Z'|'z')|+(HH':'mm(|':'ss))|+(H|HH(|mm(|ss)))" diff --git a/core/native/src/internal/LruCache.kt b/core/native/src/internal/LruCache.kt new file mode 100644 index 000000000..320f1745a --- /dev/null +++ b/core/native/src/internal/LruCache.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019-2023 JetBrains s.r.o. and contributors. + * 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.internal + +internal actual class LruCache actual constructor(private val size: Int, private val create: (K) -> V) { + private val marker = Any() + @Suppress("UNCHECKED_CAST") + actual fun get(key: K): V { + val cache: SingleThreadedLru = + Caches.mappings.getOrPut(marker) { SingleThreadedLru(size, create) } as SingleThreadedLru + return cache.get(key) + } +} + +private class SingleThreadedLru(val size: Int, val create: (K) -> V) { + private val cache = HashMap() + private val history = HashMap() + private var counter = 0 + + fun get(key: K): V { + history[key] = ++counter + return cache.getOrPut(key) { + if (history.size > size) { + val oldest = history.minByOrNull { it.value }!!.key + history.remove(oldest) + cache.remove(oldest) + } + create(key) + } + } +} + +@ThreadLocal +private object Caches { + val mappings = mutableMapOf>() +} diff --git a/core/native/src/internal/dateCalculations.kt b/core/native/src/internal/dateCalculations.kt index f185be183..d97d0f8ac 100644 --- a/core/native/src/internal/dateCalculations.kt +++ b/core/native/src/internal/dateCalculations.kt @@ -7,29 +7,6 @@ package kotlinx.datetime.internal import kotlin.math.* -/** - * All code below was taken from various places of https://github.com/ThreeTen/threetenbp with few changes - */ - -/** - * The number of days in a 400 year cycle. - */ -internal const val DAYS_PER_CYCLE = 146097 - -/** - * The number of days from year zero to year 1970. - * There are five 400 year cycles from year zero to 2000. - * There are 7 leap years from 1970 to 2000. - */ -internal const val DAYS_0000_TO_1970 = DAYS_PER_CYCLE * 5 - (30 * 365 + 7) - -// days in a 400 year cycle = 146097 -// days in a 10,000 year cycle = 146097 * 25 -// seconds per day = 86400 -internal const val SECONDS_PER_10000_YEARS = 146097L * 25L * 86400L - -internal const val SECONDS_0000_TO_1970 = (146097L * 5L - (30L * 365L + 7L)) * 86400L - // org.threeten.bp.ZoneOffset#buildId internal fun zoneIdByOffset(totalSeconds: Int): String { return if (totalSeconds == 0) { diff --git a/core/native/src/internal/mathNative.kt b/core/native/src/internal/mathNative.kt index 9fc4c7e0b..1cd6478de 100644 --- a/core/native/src/internal/mathNative.kt +++ b/core/native/src/internal/mathNative.kt @@ -73,4 +73,4 @@ internal actual fun safeMultiply(a: Int, b: Int): Int { throw ArithmeticException("Multiplication overflows an int: $a * $b") } return total.toInt() -} \ No newline at end of file +} diff --git a/core/native/test/ThreeTenBpLocalDateTest.kt b/core/native/test/ThreeTenBpLocalDateTest.kt index 8aed1862e..fad4c5de0 100644 --- a/core/native/test/ThreeTenBpLocalDateTest.kt +++ b/core/native/test/ThreeTenBpLocalDateTest.kt @@ -16,7 +16,7 @@ class ThreeTenBpLocalDateTest { @Test fun dayOfWeek() { - var dow = DayOfWeek.MONDAY + var dow = kotlinx.datetime.DayOfWeek.MONDAY for (month in 1..12) { val length = month.monthLength(false) for (i in 1..length) { From 9bb6ca8a85a16ea32b33ed908b51f42f7b80c497 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 15 Mar 2023 16:46:42 +0100 Subject: [PATCH 002/105] Refactor: reify the string builder directives --- core/common/src/format/FormatBuilder.kt | 2 +- core/common/src/format/LocalDateFormat.kt | 42 ++++++------------ core/common/src/format/LocalDateTimeFormat.kt | 39 +++++------------ core/common/src/format/LocalTimeFormat.kt | 41 +++++++----------- core/common/src/format/UtcOffsetFormat.kt | 39 ++++++----------- core/common/src/format/ValueBagFormat.kt | 41 +++++------------- core/common/src/internal/format/Builder.kt | 43 ++++++++++++------- .../src/internal/format/FormatStrings.kt | 4 +- 8 files changed, 95 insertions(+), 156 deletions(-) diff --git a/core/common/src/format/FormatBuilder.kt b/core/common/src/format/FormatBuilder.kt index b93db1cf4..45cde3b2b 100644 --- a/core/common/src/format/FormatBuilder.kt +++ b/core/common/src/format/FormatBuilder.kt @@ -97,7 +97,7 @@ public fun FormatBuilder.appendLiteral(char: Char): Unit = appendLi internal interface AbstractFormatBuilder : FormatBuilder where ActualSelf : AbstractFormatBuilder { - val actualBuilder: Builder + val actualBuilder: AppendableFormatStructure fun createEmpty(): ActualSelf fun castToGeneric(actualSelf: ActualSelf): UserSelf diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt index 4ca85afb5..01f6773eb 100644 --- a/core/common/src/format/LocalDateFormat.kt +++ b/core/common/src/format/LocalDateFormat.kt @@ -7,7 +7,6 @@ package kotlinx.datetime.format import kotlinx.datetime.* import kotlinx.datetime.internal.* -import kotlinx.datetime.internal.LruCache import kotlinx.datetime.internal.format.* import kotlinx.datetime.internal.format.parser.* @@ -24,7 +23,7 @@ public interface DateFormatBuilder : DateFormatBuilderFields, FormatBuilder) { public companion object { public fun build(block: DateFormatBuilder.() -> Unit): LocalDateFormat { - val builder = Builder(DateFieldContainerFormatBuilder()) + val builder = Builder(AppendableFormatStructure(DateFormatBuilderSpec)) builder.block() return LocalDateFormat(builder.build()) } @@ -55,7 +54,7 @@ public class LocalDateFormat private constructor(private val actualFormat: Forma } } - private class Builder(override val actualBuilder: DateFieldContainerFormatBuilder) : + private class Builder(override val actualBuilder: AppendableFormatStructure) : AbstractFormatBuilder, DateFormatBuilder { override fun appendYear(minDigits: Int, outputPlusOnExceededPadding: Boolean) = actualBuilder.add(BasicFormatStructure(YearDirective(minDigits, outputPlusOnExceededPadding))) @@ -68,7 +67,7 @@ public class LocalDateFormat private constructor(private val actualFormat: Forma override fun appendDayOfMonth(minLength: Int) = actualBuilder.add(BasicFormatStructure(DayDirective(minLength))) - override fun createEmpty(): Builder = Builder(DateFieldContainerFormatBuilder()) + override fun createEmpty(): Builder = Builder(actualBuilder.createSibling()) override fun castToGeneric(actualSelf: Builder): DateFormatBuilder = this } @@ -157,28 +156,15 @@ internal class MonthNameDirective(names: List) : internal class DayDirective(minDigits: Int) : UnsignedIntFieldFormatDirective(DateFields.dayOfMonth, minDigits) -internal class DateFieldContainerFormatBuilder : AbstractBuilder() { - - companion object { - const val name = "ld" - } - - override fun formatFromSubBuilder( - name: String, - block: Builder<*>.() -> Unit - ): FormatStructure? = - if (name == DateFieldContainerFormatBuilder.name) - DateFieldContainerFormatBuilder().apply(block).build() - else null - - override fun formatFromDirective(letter: Char, length: Int): FormatStructure? { - return when (letter) { - 'y' -> BasicFormatStructure(YearDirective(length, outputPlusOnExceededPadding = false)) - 'm' -> BasicFormatStructure(MonthDirective(length)) - 'd' -> BasicFormatStructure(DayDirective(length)) - else -> null - } - } - - override fun createSibling(): Builder = DateFieldContainerFormatBuilder() +internal object DateFormatBuilderSpec: BuilderSpec( + mapOf( + "ld" to DateFormatBuilderSpec + ), + mapOf( + 'y' to { length -> BasicFormatStructure(YearDirective(length, outputPlusOnExceededPadding = false)) }, + 'm' to { length -> BasicFormatStructure(MonthDirective(length)) }, + 'd' to { length -> BasicFormatStructure(DayDirective(length)) }, + ) +) { + const val name = "ld" } diff --git a/core/common/src/format/LocalDateTimeFormat.kt b/core/common/src/format/LocalDateTimeFormat.kt index d9854823f..2d9093dfb 100644 --- a/core/common/src/format/LocalDateTimeFormat.kt +++ b/core/common/src/format/LocalDateTimeFormat.kt @@ -6,7 +6,6 @@ package kotlinx.datetime.format import kotlinx.datetime.* -import kotlinx.datetime.internal.* import kotlinx.datetime.internal.LruCache import kotlinx.datetime.internal.format.* import kotlinx.datetime.internal.format.parser.* @@ -18,7 +17,7 @@ public interface DateTimeFormatBuilder : DateFormatBuilderFields, TimeFormatBuil public class LocalDateTimeFormat private constructor(private val actualFormat: Format) { public companion object { public fun build(block: DateTimeFormatBuilder.() -> Unit): LocalDateTimeFormat { - val builder = Builder(DateTimeFieldContainerFormatBuilder()) + val builder = Builder(AppendableFormatStructure(DateTimeFormatBuilderSpec)) builder.block() return LocalDateTimeFormat(builder.build()) } @@ -58,7 +57,7 @@ public class LocalDateTimeFormat private constructor(private val actualFormat: F } } - private class Builder(override val actualBuilder: DateTimeFieldContainerFormatBuilder) : + private class Builder(override val actualBuilder: AppendableFormatStructure) : AbstractFormatBuilder, DateTimeFormatBuilder { override fun appendYear(minDigits: Int, outputPlusOnExceededPadding: Boolean) = actualBuilder.add(BasicFormatStructure(YearDirective(minDigits, outputPlusOnExceededPadding))) @@ -74,7 +73,7 @@ public class LocalDateTimeFormat private constructor(private val actualFormat: F override fun appendSecondFraction(minLength: Int?, maxLength: Int?) = actualBuilder.add(BasicFormatStructure(FractionalSecondDirective(minLength, maxLength))) - override fun createEmpty(): Builder = Builder(DateTimeFieldContainerFormatBuilder()) + override fun createEmpty(): Builder = Builder(actualBuilder.createSibling()) override fun castToGeneric(actualSelf: Builder): DateTimeFormatBuilder = this } @@ -104,28 +103,12 @@ internal class IncompleteLocalDateTime( override fun copy(): IncompleteLocalDateTime = IncompleteLocalDateTime(date.copy(), time.copy()) } -private class DateTimeFieldContainerFormatBuilder : AbstractBuilder() { - override fun formatFromSubBuilder( - name: String, - block: Builder<*>.() -> Unit - ): FormatStructure? = - when (name) { - DateFieldContainerFormatBuilder.name -> { - val builder = DateFieldContainerFormatBuilder() - block(builder) - builder.build() - } - - TimeFieldContainerFormatBuilder.name -> { - val builder = TimeFieldContainerFormatBuilder() - block(builder) - builder.build() - } - - else -> null - } - - override fun formatFromDirective(letter: Char, length: Int): FormatStructure? = null - - override fun createSibling(): Builder = DateTimeFieldContainerFormatBuilder() +internal object DateTimeFormatBuilderSpec: BuilderSpec( + mapOf( + DateFormatBuilderSpec.name to DateFormatBuilderSpec, + TimeFormatBuilderSpec.name to TimeFormatBuilderSpec, + ), + emptyMap() +) { + const val name = "ld" } diff --git a/core/common/src/format/LocalTimeFormat.kt b/core/common/src/format/LocalTimeFormat.kt index 728c6bc91..42e4514eb 100644 --- a/core/common/src/format/LocalTimeFormat.kt +++ b/core/common/src/format/LocalTimeFormat.kt @@ -78,7 +78,7 @@ public interface TimeFormatBuilder : TimeFormatBuilderFields, FormatBuilder