diff --git a/.gitignore b/.gitignore index f86806d09..552551ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ *.iml target build -/local.properties \ No newline at end of file +/local.properties +benchmarks.jar diff --git a/README.md b/README.md index 7dac270ed..a5f22a887 100644 --- a/README.md +++ b/README.md @@ -172,22 +172,19 @@ val hourMinute = LocalTime(hour = 12, minute = 13) An `Instant` can be converted to a number of milliseconds since the Unix/POSIX epoch with the `toEpochMilliseconds()` function. To convert back, use the companion object function `Instant.fromEpochMilliseconds(Long)`. -### Converting instant and local date/time to and from string +### Converting instant and local date/time to and from the ISO 8601 string -Currently, `Instant`, `LocalDateTime`, `LocalDate` and `LocalTime` only support ISO-8601 format. +`Instant`, `LocalDateTime`, `LocalDate` and `LocalTime` provide shortcuts for +parsing and formatting them using the extended ISO-8601 format. The `toString()` function is used to convert the value to a string in that format, and the `parse` function in companion object is used to parse a string representation back. - ```kotlin val instantNow = Clock.System.now() instantNow.toString() // returns something like 2015-12-31T12:30:00Z val instantBefore = Instant.parse("2010-06-01T22:19:44.475Z") ``` -Alternatively, the `String.to...()` extension functions can be used instead of `parse`, -where it feels more convenient: - `LocalDateTime` uses a similar format, but without `Z` UTC time zone designator in the end. `LocalDate` uses a format with just year, month, and date components, e.g. `2010-06-01`. @@ -195,11 +192,125 @@ where it feels more convenient: `LocalTime` uses a format with just hour, minute, second and (if non-zero) nanosecond components, e.g. `12:01:03`. ```kotlin -"2010-06-01T22:19:44.475Z".toInstant() -"2010-06-01T22:19:44".toLocalDateTime() -"2010-06-01".toLocalDate() -"12:01:03".toLocalTime() -"12:0:03.999".toLocalTime() +LocalDateTime.parse("2010-06-01T22:19:44") +LocalDate.parse("2010-06-01") +LocalTime.parse("12:01:03") +LocalTime.parse("12:00:03.999") +LocalTime.parse("12:0:03.999") // fails with an IllegalArgumentException +``` + +### Working with other string formats + +When some data needs to be formatted in some format other than ISO-8601, one +can define their own format or use some of the predefined ones: + +```kotlin +// import kotlinx.datetime.format.* + +val dateFormat = LocalDate.Format { + monthNumber(padding = Padding.SPACE) + char('/') + dayOfMonth() + char(' ') + year() +} + +val date = dateFormat.parse("12/24 2023") +println(date.format(LocalDate.Formats.ISO_BASIC)) // "20231224" +``` + +#### Using Unicode format strings (like `yyyy-MM-dd`) + +Given a constant format string like the ones used by Java's +[DateTimeFormatter.ofPattern](https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html) can be +converted to Kotlin code using the following invocation: + +```kotlin +// import kotlinx.datetime.format.* + +println(DateTimeFormat.formatAsKotlinBuilderDsl(DateTimeComponents.Format { + byUnicodePattern("uuuu-MM-dd'T'HH:mm:ss[.SSS]Z") +})) + +// will print: +/* +date(LocalDate.Formats.ISO) +char('T') +hour() +char(':') +minute() +char(':') +second() +alternativeParsing({ +}) { + char('.') + secondFraction(3) +} +offset(UtcOffset.Formats.FOUR_DIGITS) + */ +``` + +When your format string is not constant, with the `FormatStringsInDatetimeFormats` opt-in, +you can use the format without converting it to Kotlin code beforehand: + +```kotlin +val formatPattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]" + +@OptIn(FormatStringsInDatetimeFormats::class) +val dateTimeFormat = LocalDateTime.Format { + byUnicodePattern(formatPattern) +} + +dateTimeFormat.parse("2023-12-24T23:59:59") +``` + +### Parsing and formatting partial, compound or out-of-bounds data + +Sometimes, the required string format doesn't fully correspond to any of the +classes `kotlinx-datetime` provides. In these cases, `DateTimeComponents`, a +collection of all date-time fields, can be used instead. + +```kotlin +// import kotlinx.datetime.format.* + +val yearMonth = DateTimeComponents.Format { year(); char('-'); monthNumber() } + .parse("2024-01") +println(yearMonth.year) +println(yearMonth.monthNumber) + +val dateTimeOffset = DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET + .parse("2023-01-07T23:16:15.53+02:00") +println(dateTimeOffset.toUtcOffset()) // +02:00 +println(dateTimeOffset.toLocalDateTime()) // 2023-01-07T23:16:15.53 +``` + +Occasionally, one can encounter strings where the values are slightly off: +for example, `23:59:60`, where `60` is an invalid value for the second. +`DateTimeComponents` allows parsing such values as well and then mutating them +before conversion. + +```kotlin +val time = DateTimeComponents.Format { time(LocalTime.Formats.ISO) } + .parse("23:59:60").apply { + if (second == 60) second = 59 + }.toLocalTime() +println(time) // 23:59:59 +``` + +Because `DateTimeComponents` is provided specifically for parsing and +formatting, there is no way to construct it normally. If one needs to format +partial, complex or out-of-bounds data, the `format` function allows building +`DateTimeComponents` specifically for formatting it: + +```kotlin +DateTimeComponents.Formats.RFC_1123.format { + // the receiver of this lambda is DateTimeComponents + setDate(LocalDate(2023, 1, 7)) + hour = 23 + minute = 59 + second = 60 + setOffset(UtcOffset(hours = 2)) +} // Sat, 7 Jan 2023 23:59:60 +0200 ``` ### Instant arithmetic @@ -388,3 +499,5 @@ For local builds, you can use a later version of JDK if you don't have that version installed. Specify the version of this JDK with the `java.mainToolchainVersion` Gradle property. After that, the project can be opened in IDEA and built with Gradle. + +For building and running benchmarks, see [README.md](benchmarks/README.md) diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..cc0c505b6 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,28 @@ +### Benchmarks utility module + +Module that provides benchmarking infrastructure for kotlinx-datetime. +Please note that these benchmarks are typically written with the specific target, hypothesis and effect in mind. + +They provide numbers, not insights, and shouldn't be used as the generic comparison and statements like +"X implementaiton or format is faster/slower than Y" + + +#### Usage + +``` +// Build `benchmarks.jar` into the project's root +./gradlew :benchmarks:jmhJar + +// Run all benchmarks +java -jar benchmarks.jar + +// Run dedicated benchmark(s) +java -jar benchmarks.jar Formatting +java -jar benchmarks.jar FormattingBenchmark.formatIso + +// Run with the specified number of warmup iterations, measurement iterations, timeunit and mode +java -jar benchmarks.jar -wi 5 -i 5 -tu us -bm thrpt Formatting + +// Extensive help +java -jar benchmarks.jar -help +``` diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts new file mode 100644 index 000000000..b3ff81470 --- /dev/null +++ b/benchmarks/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +plugins { + id("kotlin") + id("me.champeau.jmh") +} + + +val mainJavaToolchainVersion by ext(project.property("java.mainToolchainVersion")) +val modularJavaToolchainVersion by ext(project.property("java.modularToolchainVersion")) + +sourceSets { + dependencies { + implementation(project(":kotlinx-datetime")) + implementation("org.openjdk.jmh:jmh-core:1.35") + } +} + +// Publish benchmarks to the root for the easier 'java -jar benchmarks.jar` +tasks.named("jmhJar") { + val nullString: String? = null + archiveBaseName.set("benchmarks") + archiveClassifier.set(nullString) + archiveVersion.set(nullString) + archiveVersion.convention(nullString) + destinationDirectory.set(file("$rootDir")) +} + +repositories { + mavenCentral() +} diff --git a/benchmarks/src/jmh/kotlin/FormattingBenchmark.kt b/benchmarks/src/jmh/kotlin/FormattingBenchmark.kt new file mode 100644 index 000000000..70819a283 --- /dev/null +++ b/benchmarks/src/jmh/kotlin/FormattingBenchmark.kt @@ -0,0 +1,27 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import java.util.concurrent.* + +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@State(Scope.Benchmark) +@Fork(1) +open class FormattingBenchmark { + + private val localDateTime = LocalDateTime(2023, 11, 9, 12, 21, 31, 41) + private val formatted = LocalDateTime.Formats.ISO.format(localDateTime) + + @Benchmark + fun formatIso() = LocalDateTime.Formats.ISO.format(localDateTime) + + @Benchmark + fun parseIso() = LocalDateTime.Formats.ISO.parse(formatted) +} diff --git a/core/common/src/DateTimePeriod.kt b/core/common/src/DateTimePeriod.kt index 03623a1b7..bb17289f3 100644 --- a/core/common/src/DateTimePeriod.kt +++ b/core/common/src/DateTimePeriod.kt @@ -298,14 +298,9 @@ public sealed class DateTimePeriod { } /** - * Parses the ISO-8601 duration representation as a [DateTimePeriod]. - * - * See [DateTimePeriod.parse] for examples. - * - * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [DateTimePeriod] are exceeded. - * - * @see DateTimePeriod.parse + * @suppress */ +@Deprecated("Removed to support more idiomatic code. See https://github.com/Kotlin/kotlinx-datetime/issues/339", ReplaceWith("DateTimePeriod.parse(this)"), DeprecationLevel.WARNING) public fun String.toDateTimePeriod(): DateTimePeriod = DateTimePeriod.parse(this) /** @@ -358,16 +353,9 @@ public class DatePeriod internal constructor( } /** - * Parses the ISO-8601 duration representation as a [DatePeriod]. - * - * This function is equivalent to [DateTimePeriod.parse], but will fail if any of the time components are not - * zero. - * - * @throws IllegalArgumentException if the text cannot be parsed, the boundaries of [DatePeriod] are exceeded, - * or any time components are not zero. - * - * @see DateTimePeriod.parse + * @suppress */ +@Deprecated("Removed to support more idiomatic code. See https://github.com/Kotlin/kotlinx-datetime/issues/339", ReplaceWith("DatePeriod.parse(this)"), DeprecationLevel.WARNING) public fun String.toDatePeriod(): DatePeriod = DatePeriod.parse(this) private class DateTimePeriodImpl( diff --git a/core/common/src/Instant.kt b/core/common/src/Instant.kt index 7a50ea62a..659fe5b7a 100644 --- a/core/common/src/Instant.kt +++ b/core/common/src/Instant.kt @@ -5,6 +5,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* import kotlinx.datetime.serializers.InstantIso8601Serializer import kotlinx.serialization.Serializable @@ -115,6 +116,9 @@ public expect class Instant : Comparable { * where the component for seconds is 60, and for any day, it's possible to observe 23:59:59. * * @see Instant.parse + * @see DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET for a very similar format. The difference is that + * [DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET] will not add trailing zeros for readability to the + * fractional part of the second. */ public override fun toString(): String @@ -149,20 +153,10 @@ public expect class Instant : Comparable { public fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Int): Instant /** - * Parses a string that represents an instant in ISO-8601 format including date and time components and - * the mandatory time zone offset and returns the parsed [Instant] value. + * A shortcut for calling [DateTimeFormat.parse], followed by [DateTimeComponents.toInstantUsingOffset]. * - * Supports the following ways of specifying the time zone offset: - * - the `Z` designator for the UTC+0 time zone, - * - a custom time zone offset specified with `+hh`, or `+hh:mm`, or `+hh:mm:ss` - * (with `+` being replaced with `-` for the negative offsets) - * - * Examples of instants in the ISO-8601 format: - * - `2020-08-30T18:43:00Z` - * - `2020-08-30T18:43:00.500Z` - * - `2020-08-30T18:43:00.123456789Z` - * - `2020-08-30T18:40:00+03:00` - * - `2020-08-30T18:40:00+03:30:20` + * Parses a string that represents an instant including date and time components and a mandatory + * time zone offset and returns the parsed [Instant] value. * * The string is considered to represent time on the UTC-SLS time scale instead of UTC. * In practice, this means that, even if there is a leap second on the given day, it will not affect how the @@ -170,10 +164,17 @@ public expect class Instant : Comparable { * Instead, even if there is a negative leap second on the given day, 23:59:59 is still considered valid time. * 23:59:60 is invalid on UTC-SLS, so parsing it will fail. * + * If the format is not specified, [DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET] is used. + * * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [Instant] are exceeded. + * + * @see Instant.toString for formatting using the default format. + * @see Instant.format for formatting using a custom format. */ - public fun parse(isoString: String): Instant - + public fun parse( + input: CharSequence, + format: DateTimeFormat = DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET + ): Instant /** * An instant value that is far in the past. @@ -205,13 +206,9 @@ public val Instant.isDistantFuture: Boolean get() = this >= Instant.DISTANT_FUTURE /** - * Converts this string representing an instant in ISO-8601 format including date and time components and - * the time zone offset to an [Instant] value. - * - * See [Instant.parse] for examples of instant string representations and discussion of leap seconds. - * - * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [Instant] are exceeded. + * @suppress */ +@Deprecated("Removed to support more idiomatic code. See https://github.com/Kotlin/kotlinx-datetime/issues/339", ReplaceWith("Instant.parse(this)"), DeprecationLevel.WARNING) public fun String.toInstant(): Instant = Instant.parse(this) /** @@ -512,12 +509,20 @@ public fun Instant.minus(other: Instant, unit: DateTimeUnit, timeZone: TimeZone) public fun Instant.minus(other: Instant, unit: DateTimeUnit.TimeBased): Long = other.until(this, unit) -internal const val DISTANT_PAST_SECONDS = -3217862419201 -internal const val DISTANT_FUTURE_SECONDS = 3093527980800 - /** - * Displays the given Instant in the given [offset]. + * Formats this value using the given [format] using the given [offset]. * - * Be careful: this function may throw for some values of the [Instant]. + * Equivalent to calling [DateTimeFormat.format] on [format] and using [DateTimeComponents.setDateTimeOffset] in + * the lambda. + * + * [DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET] is a format very similar to the one used by [toString]. + * The only difference is that [Instant.toString] adds trailing zeros to the fraction-of-second component so that the + * number of digits after a dot is a multiple of three. */ -internal expect fun Instant.toStringWithOffset(offset: UtcOffset): String +public fun Instant.format(format: DateTimeFormat, offset: UtcOffset = UtcOffset.ZERO): String { + val instant = this + return format.format { setDateTimeOffset(instant, offset) } +} + +internal const val DISTANT_PAST_SECONDS = -3217862419201 +internal const val DISTANT_FUTURE_SECONDS = 3093527980800 diff --git a/core/common/src/LocalDate.kt b/core/common/src/LocalDate.kt index b423fd085..cd9047e25 100644 --- a/core/common/src/LocalDate.kt +++ b/core/common/src/LocalDate.kt @@ -5,6 +5,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.LocalDateIso8601Serializer import kotlinx.serialization.Serializable @@ -23,14 +24,18 @@ import kotlinx.serialization.Serializable public expect class LocalDate : Comparable { public companion object { /** - * Parses a string that represents a date in ISO-8601 format - * and returns the parsed [LocalDate] value. + * A shortcut for calling [DateTimeFormat.parse]. * - * An example of a local date in ISO-8601 format: `2020-08-30`. + * Parses a string that represents a date and returns the parsed [LocalDate] value. + * + * If [format] is not specified, [Formats.ISO] is used. * * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [LocalDate] are exceeded. + * + * @see LocalDate.toString for formatting using the default format. + * @see LocalDate.format for formatting using a custom format. */ - public fun parse(isoString: String): LocalDate + public fun parse(input: CharSequence, format: DateTimeFormat = getIsoDateFormat()): LocalDate /** * Returns a [LocalDate] that is [epochDays] number of days from the epoch day `1970-01-01`. @@ -41,10 +46,67 @@ public expect class LocalDate : Comparable { */ public fun fromEpochDays(epochDays: Int): LocalDate + /** + * Creates a new format for parsing and formatting [LocalDate] values. + * + * Example: + * ``` + * // 2020 Jan 05 + * LocalDate.Format { + * year() + * char(' ') + * monthName(MonthNames.ENGLISH_ABBREVIATED) + * char(' ') + * dayOfMonth() + * } + * ``` + * + * Only parsing and formatting of well-formed values is supported. If the input does not fit the boundaries + * (for example, [dayOfMonth] is 31 for February), consider using [DateTimeComponents.Format] instead. + * + * There is a collection of predefined formats in [LocalDate.Formats]. + */ + @Suppress("FunctionName") + public fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat + internal val MIN: LocalDate internal val MAX: LocalDate } + /** + * A collection of predefined formats for parsing and formatting [LocalDate] values. + * + * See [LocalDate.Formats.ISO] and [LocalDate.Formats.ISO_BASIC] for popular predefined formats. + * [LocalDate.parse] and [LocalDate.toString] can be used as convenient shortcuts for the + * [LocalDate.Formats.ISO] format. + * + * If predefined formats are not sufficient, use [LocalDate.Format] to create a custom + * [kotlinx.datetime.format.DateTimeFormat] for [LocalDate] values. + */ + public object Formats { + /** + * ISO 8601 extended format, which is the format used by [LocalDate.toString] and [LocalDate.parse]. + * + * Examples of dates in ISO 8601 format: + * - `2020-08-30` + * - `+12020-08-30` + * - `0000-08-30` + * - `-0001-08-30` + */ + public val ISO: DateTimeFormat + + /** + * ISO 8601 basic format. + * + * Examples of dates in ISO 8601 basic format: + * - `20200830` + * - `+120200830` + * - `00000830` + * - `-00010830` + */ + public val ISO_BASIC: DateTimeFormat + } + /** * Constructs a [LocalDate] instance from the given date components. * @@ -77,14 +139,19 @@ public expect class LocalDate : Comparable { /** Returns the year component of the date. */ public val year: Int + /** Returns the number-of-month (1..12) component of the date. */ public val monthNumber: Int + /** Returns the month ([Month]) component of the date. */ public val month: Month + /** Returns the day-of-month component of the date. */ public val dayOfMonth: Int + /** Returns the day-of-week component of the date. */ public val dayOfWeek: DayOfWeek + /** Returns the day-of-year component of the date. */ public val dayOfYear: Int @@ -106,20 +173,25 @@ public expect class LocalDate : Comparable { public override fun compareTo(other: LocalDate): Int /** - * Converts this date to the ISO-8601 string representation. + * Converts this date to the extended ISO-8601 string representation. * - * @see LocalDate.parse + * @see Formats.ISO for the format details. + * @see parse for the dual operation: obtaining [LocalDate] from a string. + * @see LocalDate.format for formatting using a custom format. */ public override fun toString(): String } /** - * Converts this string representing a date in ISO-8601 format to a [LocalDate] value. - * - * See [LocalDate.parse] for examples of local date string representations. - * - * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [LocalDate] are exceeded. + * Formats this value using the given [format]. + * Equivalent to calling [DateTimeFormat.format] on [format] with `this`. */ +public fun LocalDate.format(format: DateTimeFormat): String = format.format(this) + +/** + * @suppress + */ +@Deprecated("Removed to support more idiomatic code. See https://github.com/Kotlin/kotlinx-datetime/issues/339", ReplaceWith("LocalDate.parse(this)"), DeprecationLevel.WARNING) public fun String.toLocalDate(): LocalDate = LocalDate.parse(this) /** @@ -162,9 +234,8 @@ public operator fun LocalDate.minus(period: DatePeriod): LocalDate = if (period.days != Int.MIN_VALUE && period.months != Int.MIN_VALUE) { plus(with(period) { DatePeriod(-years, -months, -days) }) } else { - minus(period.years, DateTimeUnit.YEAR). - minus(period.months, DateTimeUnit.MONTH). - minus(period.days, DateTimeUnit.DAY) + minus(period.years, DateTimeUnit.YEAR).minus(period.months, DateTimeUnit.MONTH) + .minus(period.days, DateTimeUnit.DAY) } /** @@ -302,3 +373,6 @@ public expect fun LocalDate.plus(value: Long, unit: DateTimeUnit.DateBased): Loc * @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate]. */ public fun LocalDate.minus(value: Long, unit: DateTimeUnit.DateBased): LocalDate = plus(-value, unit) + +// workaround for https://youtrack.jetbrains.com/issue/KT-65484 +internal fun getIsoDateFormat() = LocalDate.Formats.ISO diff --git a/core/common/src/LocalDateTime.kt b/core/common/src/LocalDateTime.kt index 2588fa6c0..da40fbc14 100644 --- a/core/common/src/LocalDateTime.kt +++ b/core/common/src/LocalDateTime.kt @@ -5,6 +5,9 @@ package kotlinx.datetime +import kotlinx.datetime.LocalDate.* +import kotlinx.datetime.LocalDate.Companion.parse +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer import kotlinx.serialization.Serializable @@ -27,24 +30,73 @@ public expect class LocalDateTime : Comparable { public companion object { /** - * Parses a string that represents a date/time value in ISO-8601 format including date and time components + * A shortcut for calling [DateTimeFormat.parse]. + * + * Parses a string that represents a date/time value including date and time components * but without any time zone component and returns the parsed [LocalDateTime] value. * - * 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` + * If [format] is not specified, [Formats.ISO] is used. * * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [LocalDateTime] are * exceeded. */ - public fun parse(isoString: String): LocalDateTime + public fun parse(input: CharSequence, format: DateTimeFormat = getIsoDateTimeFormat()): LocalDateTime + + /** + * Creates a new format for parsing and formatting [LocalDateTime] values. + * + * Examples: + * ``` + * // `2020-08-30 18:43:13`, using predefined date and time formats + * LocalDateTime.Format { date(LocalDate.Formats.ISO); char(' '); time(LocalTime.Formats.ISO) } + * + * // `08/30 18:43:13`, using a custom format: + * LocalDateTime.Format { + * monthNumber(); char('/'); dayOfMonth() + * char(' ') + * hour(); char(':'); minute() + * optional { char(':'); second() } + * } + * ``` + * + * Only parsing and formatting of well-formed values is supported. If the input does not fit the boundaries + * (for example, [dayOfMonth] is 31 for February), consider using [DateTimeComponents.Format] instead. + * + * There is a collection of predefined formats in [LocalDateTime.Formats]. + */ + @Suppress("FunctionName") + public fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat internal val MIN: LocalDateTime internal val MAX: LocalDateTime } + /** + * A collection of predefined formats for parsing and formatting [LocalDateTime] values. + * + * [LocalDateTime.Formats.ISO] is a popular predefined format. + * + * If predefined formats are not sufficient, use [LocalDateTime.Format] to create a custom + * [kotlinx.datetime.format.DateTimeFormat] for [LocalDateTime] values. + */ + public object Formats { + /** + * ISO 8601 extended format. + * + * Examples of date/time in ISO 8601 format: + * - `2020-08-30T18:43` + * - `+12020-08-30T18:43:00` + * - `0000-08-30T18:43:00.5` + * - `-0001-08-30T18:43:00.123456789` + * + * When formatting, seconds are always included, even if they are zero. + * Fractional parts of the second are included if non-zero. + * + * Guaranteed to parse all strings that [LocalDateTime.toString] produces. + */ + public val ISO: DateTimeFormat + } + /** * Constructs a [LocalDateTime] instance from the given date and time components. * @@ -63,7 +115,15 @@ public expect class LocalDateTime : Comparable { * @throws IllegalArgumentException if any parameter is out of range, or if [dayOfMonth] is invalid for the given [monthNumber] and * [year]. */ - public constructor(year: Int, monthNumber: Int, dayOfMonth: Int, hour: Int, minute: Int, second: Int = 0, nanosecond: Int = 0) + public constructor( + year: Int, + monthNumber: Int, + dayOfMonth: Int, + hour: Int, + minute: Int, + second: Int = 0, + nanosecond: Int = 0 + ) /** * Constructs a [LocalDateTime] instance from the given date and time components. @@ -81,7 +141,15 @@ public expect class LocalDateTime : Comparable { * @throws IllegalArgumentException if any parameter is out of range, or if [dayOfMonth] is invalid for the given [month] and * [year]. */ - public constructor(year: Int, month: Month, dayOfMonth: Int, hour: Int, minute: Int, second: Int = 0, nanosecond: Int = 0) + public constructor( + year: Int, + month: Month, + dayOfMonth: Int, + hour: Int, + minute: Int, + second: Int = 0, + nanosecond: Int = 0 + ) /** * Constructs a [LocalDateTime] instance by combining the given [date] and [time] parts. @@ -90,22 +158,31 @@ public expect class LocalDateTime : Comparable { /** Returns the year component of the date. */ public val year: Int + /** Returns the number-of-month (1..12) component of the date. */ public val monthNumber: Int + /** Returns the month ([Month]) component of the date. */ public val month: Month + /** Returns the day-of-month component of the date. */ public val dayOfMonth: Int + /** Returns the day-of-week component of the date. */ public val dayOfWeek: DayOfWeek + /** Returns the day-of-year component of the date. */ public val dayOfYear: Int + /** Returns the hour-of-day time component of this date/time value. */ public val hour: Int + /** Returns the minute-of-hour time component of this date/time value. */ public val minute: Int + /** Returns the second-of-minute time component of this date/time value. */ public val second: Int + /** Returns the nanosecond-of-second time component of this date/time value. */ public val nanosecond: Int @@ -125,20 +202,38 @@ public expect class LocalDateTime : Comparable { public override operator fun compareTo(other: LocalDateTime): Int /** - * Converts this date/time value to the ISO-8601 string representation. + * Converts this date/time value to the ISO 8601 string representation. + * + * For readability, if the time represents a round minute (without seconds or fractional seconds), + * the string representation will not include seconds. Also, fractions of seconds will add trailing zeros to + * the fractional part until its length is a multiple of three. * - * @see LocalDateTime.parse + * Examples of output: + * - `2020-08-30T18:43` + * - `2020-08-30T18:43:00` + * - `2020-08-30T18:43:00.500` + * - `2020-08-30T18:43:00.123456789` + * + * @see LocalTime.toString for details of how the time part is formatted. + * @see Formats.ISO for a very similar format. The difference is that [Formats.ISO] will always include seconds, + * even if they are zero, and will not add trailing zeros to the fractional part of the second for readability. + * @see parse for the dual operation: obtaining [LocalDateTime] from a string. + * @see LocalDateTime.format for formatting using a custom format. */ public override fun toString(): String } /** - * Converts this string representing a date/time value in ISO-8601 format including date and time components - * but without any time zone component to a [LocalDateTime] value. - * - * See [LocalDateTime.parse] for examples of date/time string representations. - * - * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [LocalDateTime] are exceeded. + * Formats this value using the given [format]. + * Equivalent to calling [DateTimeFormat.format] on [format] with `this`. + */ +public fun LocalDateTime.format(format: DateTimeFormat): String = format.format(this) + +/** + * @suppress */ +@Deprecated("Removed to support more idiomatic code. See https://github.com/Kotlin/kotlinx-datetime/issues/339", ReplaceWith("LocalDateTime.parse(this)"), DeprecationLevel.WARNING) public fun String.toLocalDateTime(): LocalDateTime = LocalDateTime.parse(this) +// workaround for https://youtrack.jetbrains.com/issue/KT-65484 +internal fun getIsoDateTimeFormat() = LocalDateTime.Formats.ISO diff --git a/core/common/src/LocalTime.kt b/core/common/src/LocalTime.kt index 46ac3ce31..dd1aa3ee8 100644 --- a/core/common/src/LocalTime.kt +++ b/core/common/src/LocalTime.kt @@ -5,9 +5,13 @@ package kotlinx.datetime +import kotlinx.datetime.LocalDate.* +import kotlinx.datetime.LocalDate.Companion.parse +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.LocalTimeIso8601Serializer import kotlinx.serialization.Serializable + /** * The time part of [LocalDateTime]. * @@ -27,18 +31,17 @@ public expect class LocalTime : Comparable { public companion object { /** - * Parses a string that represents a time value in ISO-8601 and returns the parsed [LocalTime] value. + * A shortcut for calling [DateTimeFormat.parse]. * - * Examples of time in ISO-8601 format: - * - `18:43` - * - `18:43:00` - * - `18:43:00.500` - * - `18:43:00.123456789` + * Parses a string that represents time-of-day and returns the parsed [LocalTime] value. * * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [LocalTime] are * exceeded. + * + * @see LocalTime.toString for formatting using the default format. + * @see LocalTime.format for formatting using a custom format. */ - public fun parse(isoString: String): LocalTime + public fun parse(input: CharSequence, format: DateTimeFormat = getIsoTimeFormat()): LocalTime /** * Constructs a [LocalTime] that represents the specified number of seconds since the start of a calendar day. @@ -80,10 +83,51 @@ public expect class LocalTime : Comparable { */ public fun fromNanosecondOfDay(nanosecondOfDay: Long): LocalTime + /** + * Creates a new format for parsing and formatting [LocalTime] values. + * + * Example: + * ``` + * LocalTime.Format { + * hour(); char(':'); minute(); char(':'); second() + * optional { char('.'); secondFraction() } + * } + * ``` + * + * Only parsing and formatting of well-formed values is supported. If the input does not fit the boundaries + * (for example, [second] is 60), consider using [DateTimeComponents.Format] instead. + * + * There is a collection of predefined formats in [LocalTime.Formats]. + */ + @Suppress("FunctionName") + public fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat + internal val MIN: LocalTime internal val MAX: LocalTime } + /** + * A collection of predefined formats for parsing and formatting [LocalDateTime] values. + * + * [LocalTime.Formats.ISO] is a popular predefined format. + * + * If predefined formats are not sufficient, use [LocalTime.Format] to create a custom + * [kotlinx.datetime.format.DateTimeFormat] for [LocalTime] values. + */ + public object Formats { + /** + * ISO 8601 extended format. + * + * Examples: `12:34`, `12:34:56`, `12:34:56.789`, `12:34:56.1234`. + * + * When formatting, seconds are always included, even if they are zero. + * Fractional parts of the second are included if non-zero. + * + * Guaranteed to parse all strings that [LocalTime.toString] produces. + */ + public val ISO: DateTimeFormat + } + /** * Constructs a [LocalTime] instance from the given time components. * @@ -99,10 +143,13 @@ public expect class LocalTime : Comparable { /** Returns the hour-of-day time component of this time value. */ public val hour: Int + /** Returns the minute-of-hour time component of this time value. */ public val minute: Int + /** Returns the second-of-minute time component of this time value. */ public val second: Int + /** Returns the nanosecond-of-second time component of this time value. */ public val nanosecond: Int @@ -128,20 +175,36 @@ public expect class LocalTime : Comparable { public override operator fun compareTo(other: LocalTime): Int /** - * Converts this time value to the ISO-8601 string representation. + * Converts this time value to the extended ISO-8601 string representation. + * + * For readability, if the time represents a round minute (without seconds or fractional seconds), + * the string representation will not include seconds. Also, fractions of seconds will add trailing zeros to + * the fractional part until the number of digits after the dot is a multiple of three. * - * @see LocalDateTime.parse + * Examples of output: + * - `18:43` + * - `18:43:00` + * - `18:43:00.500` + * - `18:43:00.123456789` + * + * @see Formats.ISO for a very similar format. The difference is that [Formats.ISO] will always include seconds, + * even if they are zero, and will not add trailing zeros to the fractional part of the second for readability. + * @see parse for the dual operation: obtaining [LocalTime] from a string. + * @see LocalTime.format for formatting using a custom format. */ public override fun toString(): String } /** - * Converts this string representing a time value in ISO-8601 format to a [LocalTime] value. - * - * See [LocalTime.parse] for examples of time string representations. - * - * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [LocalTime] are exceeded. + * Formats this value using the given [format]. + * Equivalent to calling [DateTimeFormat.format] on [format] with `this`. */ +public fun LocalTime.format(format: DateTimeFormat): String = format.format(this) + +/** + * @suppress + */ +@Deprecated("Removed to support more idiomatic code. See https://github.com/Kotlin/kotlinx-datetime/issues/339", ReplaceWith("LocalTime.parse(this)"), DeprecationLevel.WARNING) public fun String.toLocalTime(): LocalTime = LocalTime.parse(this) /** @@ -160,3 +223,6 @@ public fun LocalTime.atDate(year: Int, month: Month, dayOfMonth: Int = 0): Local * Combines this time's components with the specified [LocalDate] components into a [LocalDateTime] value. */ public fun LocalTime.atDate(date: LocalDate): LocalDateTime = LocalDateTime(date, this) + +// workaround for https://youtrack.jetbrains.com/issue/KT-65484 +internal fun getIsoTimeFormat() = LocalTime.Formats.ISO diff --git a/core/common/src/UtcOffset.kt b/core/common/src/UtcOffset.kt index 0ef82951a..ce2c85284 100644 --- a/core/common/src/UtcOffset.kt +++ b/core/common/src/UtcOffset.kt @@ -5,6 +5,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.UtcOffsetSerializer import kotlinx.serialization.Serializable @@ -36,20 +37,116 @@ public expect class UtcOffset { public val ZERO: UtcOffset /** - * Parses a string that represents an offset in an ISO-8601 time shift extended format, also supporting - * specifying the number of seconds or not specifying the number of minutes. + * A shortcut for calling [DateTimeFormat.parse]. * - * Examples of valid strings: + * Parses a string that represents a UTC offset and returns the parsed [UtcOffset] value. + * + * If [format] is not specified, [Formats.ISO] is used. + * + * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [UtcOffset] are + * exceeded. + */ + public fun parse(input: CharSequence, format: DateTimeFormat = getIsoUtcOffestFormat()): UtcOffset + + /** + * Creates a new format for parsing and formatting [UtcOffset] values. + * + * Example: + * ``` + * // `GMT` on zero, `+4:30:15`, using a custom format: + * UtcOffset.Format { + * optional("GMT") { + * offsetHours(Padding.NONE); char(':'); offsetMinutesOfHour() + * optional { char(':'); offsetSecondsOfMinute() } + * } + * } + * ``` + * + * Since [UtcOffset] values are rarely formatted and parsed on their own, + * instances of [DateTimeFormat] obtained here will likely need to be passed to + * [DateTimeFormatBuilder.WithUtcOffset.offset] in a format builder for a larger data structure. + * + * There is a collection of predefined formats in [UtcOffset.Formats]. + */ + @Suppress("FunctionName") + public fun Format(block: DateTimeFormatBuilder.WithUtcOffset.() -> Unit): DateTimeFormat + } + + /** + * A collection of predefined formats for parsing and formatting [UtcOffset] values. + * + * See [UtcOffset.Formats.ISO], [UtcOffset.Formats.ISO_BASIC], and [UtcOffset.Formats.FOUR_DIGITS] + * for popular predefined formats. + * + * If predefined formats are not sufficient, use [UtcOffset.Format] to create a custom + * [kotlinx.datetime.format.DateTimeFormat] for [UtcOffset] values. + */ + public object Formats { + /** + * ISO 8601 extended format, which is the format used by [UtcOffset.parse] and [UtcOffset.toString]. + * + * An extension of the ISO 8601 is that this format allows parsing and formatting seconds. + * + * When formatting, seconds are omitted if they are zero. If the whole offset is zero, the letter `Z` is output. + * + * Examples of UTC offsets in ISO 8601 format: * - `Z` or `+00:00`, an offset of zero; - * - `+05`, five hours; - * - `-02`, minus two hours; - * - `+03:30`, three hours and thirty minutes; - * - `+01:23:45`, an hour, 23 minutes, and 45 seconds. + * - `+05:00`, five hours; + * - `-02:00`, minus two hours; + * - `-17:16` + * - `+10:36:30` */ - public fun parse(offsetString: String): UtcOffset + public val ISO: DateTimeFormat + + /** + * ISO 8601 basic format. + * + * An extension of the ISO 8601 is that this format allows parsing and formatting seconds. + * + * When formatting, seconds are omitted if they are zero. If the whole offset is zero, the letter `Z` is output. + * + * Examples of UTC offsets in ISO 8601 basic format: + * - `Z` + * - `+05` + * - `-1716` + * - `+103630` + * + * @see UtcOffset.Formats.FOUR_DIGITS + */ + public val ISO_BASIC: DateTimeFormat + + /** + * A subset of the ISO 8601 basic format that always outputs and parses exactly a numeric sign and four digits: + * two digits for the hour and two digits for the minute. If the offset has a non-zero number of seconds, + * they are truncated. + * + * Examples of UTC offsets in this format: + * - `+0000` + * - `+0500` + * - `-1716` + * - `+1036` + * + * @see UtcOffset.Formats.ISO_BASIC + */ + public val FOUR_DIGITS: DateTimeFormat } + + /** + * Converts this UTC offset to the extended ISO-8601 string representation. + * + * @see Formats.ISO for the format details. + * @see parse for the dual operation: obtaining [UtcOffset] from a string. + * @see UtcOffset.format for formatting using a custom format. + */ + public override fun toString(): String } +/** + * Formats this value using the given [format]. + * Equivalent to calling [DateTimeFormat.format] on [format] with `this`. + */ +public fun UtcOffset.format(format: DateTimeFormat): String = format.format(this) + /** * Constructs a [UtcOffset] from hours, minutes, and seconds components. * @@ -73,4 +170,7 @@ public fun UtcOffset(): UtcOffset = UtcOffset.ZERO /** * Returns the fixed-offset time zone with the given UTC offset. */ -public fun UtcOffset.asTimeZone(): FixedOffsetTimeZone = FixedOffsetTimeZone(this) \ No newline at end of file +public fun UtcOffset.asTimeZone(): FixedOffsetTimeZone = FixedOffsetTimeZone(this) + +// workaround for https://youtrack.jetbrains.com/issue/KT-65484 +internal fun getIsoUtcOffestFormat() = UtcOffset.Formats.ISO diff --git a/core/common/src/format/DateTimeComponents.kt b/core/common/src/format/DateTimeComponents.kt new file mode 100644 index 000000000..4524e23d0 --- /dev/null +++ b/core/common/src/format/DateTimeComponents.kt @@ -0,0 +1,553 @@ +/* + * 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.Copyable +import kotlinx.datetime.internal.safeMultiply +import kotlin.reflect.* + +/** + * A collection of date-time fields, used specifically for parsing and formatting. + * + * 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. For example, a format that includes only the month and the day of the month, but not the + * year, can not be represented and parsed as a [LocalDate], but it is valid for a [DateTimeComponents]. + * + * Example: + * ``` + * val input = "2020-03-16T23:59:59.999999999+03:00" + * val components = DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET.parse(input) + * val localDateTime = components.toLocalDateTime() // LocalDateTime(2020, 3, 16, 23, 59, 59, 999_999_999) + * val instant = components.toInstantUsingOffset() // Instant.parse("2020-03-16T20:59:59.999999999Z") + * val offset = components.toUtcOffset() // UtcOffset(hours = 3) + * ``` + * + * Another purpose is to support parsing and formatting data with out-of-bounds values. For example, parsing + * `23:59:60` as a [LocalTime] is not possible, but it is possible to parse it as a [DateTimeComponents], adjust the value by + * setting [second] to `59`, and then convert it to a [LocalTime] via [toLocalTime]. + * + * Example: + * ``` + * val input = "23:59:60" + * val extraDay: Boolean + * val time = DateTimeComponents.Format { + * time(LocalTime.Formats.ISO) + * }.parse(input).apply { + * if (hour == 23 && minute == 59 && second == 60) { + * hour = 0; minute = 0; second = 0; extraDay = true + * } else { + * extraDay = false + * } + * }.toLocalTime() + * ``` + * + * Because this class has limited applications, constructing it directly is not possible. + * For formatting, use the [format] overload that accepts a lambda with a [DateTimeComponents] receiver. + * + * Example: + * ``` + * // Mon, 16 Mar 2020 23:59:59 +0300 + * DateTimeComponents.Formats.RFC_1123.format { + * setDateTimeOffset(LocalDateTime(2020, 3, 16, 23, 59, 59, 999_999_999)) + * setDateTimeOffset(UtcOffset(hours = 3)) + * } + * ``` + * + * 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 DateTimeComponents internal constructor(internal val contents: DateTimeComponentsContents = DateTimeComponentsContents()) { + public companion object { + /** + * Creates a [DateTimeFormat] for [DateTimeComponents] values using [DateTimeFormatBuilder.WithDateTimeComponents]. + * + * There is a collection of predefined formats in [DateTimeComponents.Formats]. + */ + @Suppress("FunctionName") + public fun Format(block: DateTimeFormatBuilder.WithDateTimeComponents.() -> Unit): DateTimeFormat { + val builder = DateTimeComponentsFormat.Builder(AppendableFormatStructure()) + block(builder) + return DateTimeComponentsFormat(builder.build()) + } + } + + /** + * A collection of formats for parsing and formatting [DateTimeComponents] values. + * + * If predefined formats are not sufficient, use [DateTimeComponents.Format] to create a custom + * [kotlinx.datetime.format.DateTimeFormat] for [DateTimeComponents] values. + */ + public object Formats { + + /** + * ISO 8601 extended format for dates and times with UTC offset. + * + * For specifying the time zone offset, the format uses the [UtcOffset.Formats.ISO] format, except that during + * parsing, specifying the minutes is optional. + * + * This format differs from [LocalTime.Formats.ISO] in its time part in that + * specifying the seconds is *not* optional. + * + * Examples of instants in the ISO 8601 format: + * - `2020-08-30T18:43:00Z` + * - `2020-08-30T18:43:00.50Z` + * - `2020-08-30T18:43:00.123456789Z` + * - `2020-08-30T18:40:00+03:00` + * - `2020-08-30T18:40:00+03:30:20` + * * `2020-01-01T23:59:59.123456789+01` + * * `+12020-01-31T23:59:59Z` + * + * This format uses the local date, local time, and UTC offset fields of [DateTimeComponents]. + * + * See ISO-8601-1:2019, 5.4.2.1b), excluding the format without the offset. + * + * Guaranteed to parse all strings that [Instant.toString] produces. + */ + public val ISO_DATE_TIME_OFFSET: DateTimeFormat = Format { + date(ISO_DATE) + alternativeParsing({ + char('t') + }) { + char('T') + } + hour() + char(':') + minute() + char(':') + second() + optional { + char('.') + secondFraction(1, 9) + } + alternativeParsing({ + offsetHours() + }) { + offset(UtcOffset.Formats.ISO) + } + } + + /** + * RFC 1123 format for dates and times with UTC offset. + * + * Examples of valid strings: + * * `Mon, 30 Jun 2008 11:05:30 GMT` + * * `Mon, 30 Jun 2008 11:05:30 -0300` + * * `30 Jun 2008 11:05:30 UT` + * + * North American and military time zone abbreviations are not supported. + */ + public val RFC_1123: DateTimeFormat = Format { + alternativeParsing({ + // the day of week may be missing + }) { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + chars(", ") + } + dayOfMonth(Padding.NONE) + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + year() + char(' ') + hour() + char(':') + minute() + optional { + char(':') + second() + } + chars(" ") + alternativeParsing({ + chars("UT") + }, { + chars("Z") + }) { + optional("GMT") { + offset(UtcOffset.Formats.FOUR_DIGITS) + } + } + } + } + + /** + * Writes the contents of the specified [localTime] to this [DateTimeComponents]. + * The [localTime] is written to the [hour], [hourOfAmPm], [amPm], [minute], [second] and [nanosecond] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun setTime(localTime: LocalTime) { contents.time.populateFrom(localTime) } + + /** + * Writes the contents of the specified [localDate] to this [DateTimeComponents]. + * The [localDate] is written to the [year], [monthNumber], [dayOfMonth], and [dayOfWeek] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun setDate(localDate: LocalDate) { contents.date.populateFrom(localDate) } + + /** + * Writes the contents of the specified [localDateTime] to this [DateTimeComponents]. + * The [localDateTime] is written to the + * [year], [monthNumber], [dayOfMonth], [dayOfWeek], + * [hour], [hourOfAmPm], [amPm], [minute], [second] and [nanosecond] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun setDateTime(localDateTime: LocalDateTime) { + contents.date.populateFrom(localDateTime.date) + contents.time.populateFrom(localDateTime.time) + } + + /** + * Writes the contents of the specified [utcOffset] to this [DateTimeComponents]. + * The [utcOffset] is written to the [offsetHours], [offsetMinutesOfHour], [offsetSecondsOfMinute], and + * [offsetIsNegative] fields. + * + * If any of the fields are already set, they will be overwritten. + */ + public fun setOffset(utcOffset: UtcOffset) { contents.offset.populateFrom(utcOffset) } + + /** + * Writes the contents of the specified [instant] to this [DateTimeComponents]. + * + * This method is almost always equivalent to the following code: + * ``` + * setDateTime(instant.toLocalDateTime(offset)) + * setOffset(utcOffset) + * ``` + * 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 setDateTimeOffset(instant: Instant, utcOffset: UtcOffset) { + val smallerInstant = Instant.fromEpochSeconds( + instant.epochSeconds % SECONDS_PER_10000_YEARS, instant.nanosecondsOfSecond + ) + setDateTime(smallerInstant.toLocalDateTime(utcOffset)) + setOffset(utcOffset) + year = year!! + ((instant.epochSeconds / SECONDS_PER_10000_YEARS) * 10000).toInt() + } + + /** + * Writes the contents of the specified [localDateTime] and [utcOffset] to this [DateTimeComponents]. + * + * A shortcut for calling [setDateTime] and [setOffset] separately. + * + * If [localDateTime] is obtained from an [Instant] using [LocalDateTime.toInstant], it is recommended to use + * [setDateTimeOffset] that accepts an [Instant] directly. + */ + public fun setDateTimeOffset(localDateTime: LocalDateTime, utcOffset: UtcOffset) { + setDateTime(localDateTime) + setOffset(utcOffset) + } + + /** The year component of the date. */ + public var year: Int? by contents.date::year + + /** + * The number-of-month (1..12) component of the date. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var monthNumber: Int? by TwoDigitNumber(contents.date::monthNumber) + + /** + * The month ([Month]) component of the date. + * @throws IllegalArgumentException during getting if [monthNumber] is outside the `1..12` range. + */ + public var month: Month? + get() = monthNumber?.let { Month(it) } + set(value) { + monthNumber = value?.number + } + + /** + * The day-of-month component of the date. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var dayOfMonth: Int? by TwoDigitNumber(contents.date::dayOfMonth) + + /** 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 + + /** + * The hour-of-day (0..23) time component. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var hour: Int? by TwoDigitNumber(contents.time::hour) + + /** + * The 12-hour (1..12) time component. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + * @see amPm + */ + public var hourOfAmPm: Int? by TwoDigitNumber(contents.time::hourOfAmPm) + + /** + * The AM/PM state of the time component. + * @see hourOfAmPm + */ + public var amPm: AmPmMarker? by contents.time::amPm + + /** + * The minute-of-hour component. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var minute: Int? by TwoDigitNumber(contents.time::minute) + + /** + * The second-of-minute component. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var second: Int? by TwoDigitNumber(contents.time::second) + + /** + * The nanosecond-of-second component. + * @throws IllegalArgumentException during assignment if the value is outside the `0..999_999_999` range. + */ + public var nanosecond: Int? + get() = contents.time.nanosecond + set(value) { + require(value == null || value in 0..999_999_999) { + "Nanosecond must be in the range [0, 999_999_999]." + } + contents.time.nanosecond = value + } + + /** True if the offset is negative. */ + public var offsetIsNegative: Boolean? by contents.offset::isNegative + + /** + * The total amount of full hours in the UTC offset, in the range [0; 18]. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var offsetHours: Int? by TwoDigitNumber(contents.offset::totalHoursAbs) + + /** + * The amount of minutes that don't add to a whole hour in the UTC offset, in the range [0; 59]. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var offsetMinutesOfHour: Int? by TwoDigitNumber(contents.offset::minutesOfHour) + + /** + * The amount of seconds that don't add to a whole minute in the UTC offset, in the range [0; 59]. + * @throws IllegalArgumentException during assignment if the value is outside the `0..99` range. + */ + public var offsetSecondsOfMinute: Int? by TwoDigitNumber(contents.offset::secondsOfMinute) + + /** The timezone identifier, for example, "Europe/Berlin". */ + public var timeZoneId: String? by contents::timeZoneId + + /** + * Builds a [UtcOffset] from the fields in this [DateTimeComponents]. + * + * This method uses the following fields: + * * [offsetIsNegative] (default value is `false`) + * * [offsetHours] (default value is 0) + * * [offsetMinutesOfHour] (default value is 0) + * * [offsetSecondsOfMinute] (default value is 0) + * + * @throws IllegalArgumentException if any of the fields has an out-of-range value. + */ + public fun toUtcOffset(): UtcOffset = contents.offset.toUtcOffset() + + /** + * Builds a [LocalDate] from the fields in this [DateTimeComponents]. + * + * 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 [DateTimeComponents]. + * + * This method uses the following fields: + * * [hour], [hourOfAmPm], and [amPm] + * * [minute] + * * [second] (default value is 0) + * * [nanosecond] (default value is 0) + * + * @throws IllegalArgumentException if hours or minutes are not present, if any of the fields are invalid, or + * [hourOfAmPm] and [amPm] are inconsistent with [hour]. + */ + public fun toLocalTime(): LocalTime = contents.time.toLocalTime() + + /** + * Builds a [LocalDateTime] from the fields in this [DateTimeComponents]. + * + * This method uses the following fields: + * * [year] + * * [monthNumber] + * * [dayOfMonth] + * * [hour], [hourOfAmPm], and [amPm] + * * [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, + * any of the fields are invalid, or there's inconsistency. + * + * @see toLocalDate + * @see toLocalTime + */ + public fun toLocalDateTime(): LocalDateTime = toLocalDate().atTime(toLocalTime()) + + /** + * Builds an [Instant] from the fields in this [DateTimeComponents]. + * + * 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]. + * + * @throws IllegalArgumentException if any of the required fields are not present, out-of-range, or inconsistent + * with one another. + */ + public fun toInstantUsingOffset(): 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 = requireParsedField(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) + } +} + +/** + * Uses this format to format an unstructured [DateTimeComponents]. + * + * [block] is called on an initially-empty [DateTimeComponents] before formatting. + * + * Example: + * ``` + * // Mon, 16 Mar 2020 23:59:59 +0300 + * DateTimeComponents.Formats.RFC_1123.format { + * setDateTime(LocalDateTime(2020, 3, 16, 23, 59, 59, 999_999_999)) + * setOffset(UtcOffset(hours = 3)) + * } + * ``` + * + * @throws IllegalStateException if some values needed for the format are not present or can not be formatted: + * for example, trying to format [DateTimeFormatBuilder.WithDate.monthName] using a [DateTimeComponents.monthNumber] + * value of 20. + */ +public fun DateTimeFormat.format(block: DateTimeComponents.() -> Unit): String = format(DateTimeComponents().apply { block() }) + +/** + * Parses a [DateTimeComponents] from [input] using the given format. + * Equivalent to calling [DateTimeFormat.parse] on [format] with [input]. + * + * [DateTimeComponents] does not perform any validation, so even invalid values may be parsed successfully if the string pattern + * matches. + * + * @throws IllegalArgumentException if the text does not match the format. + */ +public fun DateTimeComponents.Companion.parse(input: CharSequence, format: DateTimeFormat): DateTimeComponents = + format.parse(input) + +internal class DateTimeComponentsContents 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, + DateTimeFieldContainer, Copyable { + override fun copy(): DateTimeComponentsContents = DateTimeComponentsContents(date.copy(), time.copy(), offset.copy(), timeZoneId) + + override fun equals(other: Any?): Boolean = + other is DateTimeComponentsContents && other.date == date && other.time == time && + other.offset == offset && other.timeZoneId == timeZoneId + + override fun hashCode(): Int = + date.hashCode() xor time.hashCode() xor offset.hashCode() xor (timeZoneId?.hashCode() ?: 0) +} + +internal val timeZoneField = GenericFieldSpec(PropertyAccessor(DateTimeComponentsContents::timeZoneId)) + +internal class TimeZoneIdDirective(private val knownZones: Set) : + StringFieldFormatDirective(timeZoneField, knownZones) { + + override val builderRepresentation: String get() = + "${DateTimeFormatBuilder.WithDateTimeComponents::timeZoneId.name}()" + + override fun equals(other: Any?): Boolean = other is TimeZoneIdDirective && other.knownZones == knownZones + override fun hashCode(): Int = knownZones.hashCode() +} + +internal class DateTimeComponentsFormat(override val actualFormat: StringFormat) : + AbstractDateTimeFormat() { + override fun intermediateFromValue(value: DateTimeComponents): DateTimeComponentsContents = value.contents + + override fun valueFromIntermediate(intermediate: DateTimeComponentsContents): DateTimeComponents = DateTimeComponents(intermediate) + + override val emptyIntermediate get() = emptyDateTimeComponentsContents + + class Builder(override val actualBuilder: AppendableFormatStructure) : + AbstractDateTimeFormatBuilder, AbstractWithDateTimeBuilder, + AbstractWithOffsetBuilder, DateTimeFormatBuilder.WithDateTimeComponents + { + override fun addFormatStructureForDateTime(structure: FormatStructure) { + actualBuilder.add(structure) + } + + override fun addFormatStructureForOffset(structure: FormatStructure) { + actualBuilder.add(structure) + } + + override fun timeZoneId() = + actualBuilder.add(BasicFormatStructure(TimeZoneIdDirective(TimeZone.availableZoneIds))) + + @Suppress("NO_ELSE_IN_WHEN") + override fun dateTimeComponents(format: DateTimeFormat) = when (format) { + is DateTimeComponentsFormat -> actualBuilder.add(format.actualFormat.directives) + } + + override fun createEmpty(): Builder = Builder(AppendableFormatStructure()) + } +} + +private class TwoDigitNumber(private val reference: KMutableProperty0) { + operator fun getValue(thisRef: Any?, property: KProperty<*>) = reference.getValue(thisRef, property) + + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int?) { + require(value === null || value in 0..99) { "${property.name} must be a two-digit number, got '$value'" } + reference.setValue(thisRef, property, value) + } +} + +private val emptyDateTimeComponentsContents = DateTimeComponentsContents() diff --git a/core/common/src/format/DateTimeFormat.kt b/core/common/src/format/DateTimeFormat.kt new file mode 100644 index 000000000..70c8b027b --- /dev/null +++ b/core/common/src/format/DateTimeFormat.kt @@ -0,0 +1,144 @@ +/* + * 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.format.* +import kotlinx.datetime.internal.format.parser.* + +/** + * A format for parsing and formatting date-time-related values. + */ +public sealed interface DateTimeFormat { + /** + * Formats the given [value] into a string, using this format. + */ + public fun format(value: T): String + + /** + * Formats the given [value] into the given [appendable] using this format. + */ + public fun formatTo(appendable: A, value: T): A + + /** + * Parses the given [input] string as [T] using this format. + * + * @throws IllegalArgumentException if the input string is not in the expected format or the value is invalid. + */ + public fun parse(input: CharSequence): T + + /** + * Parses the given [input] string as [T] using this format. + * + * @return the parsed value, or `null` if the input string is not in the expected format or the value is invalid. + */ + public fun parseOrNull(input: CharSequence): T? + + public companion object { + /** + * Produces Kotlin code that, when pasted into a Kotlin source file, creates a [DateTimeFormat] instance that + * behaves identically to [format]. + * + * The typical use case for this is to create a [DateTimeFormat] instance using a non-idiomatic approach and + * then convert it to a builder DSL. + */ + public fun formatAsKotlinBuilderDsl(format: DateTimeFormat<*>): String = when (format) { + is AbstractDateTimeFormat<*, *> -> format.actualFormat.builderString(allFormatConstants) + } + } +} + +/** + * The style of padding to use when formatting a value. + */ +public enum class Padding { + /** + * No padding. + */ + NONE, + + /** + * Pad with zeros. + */ + ZERO, + + /** + * Pad with spaces. + */ + SPACE +} + +internal fun Padding.toKotlinCode(): String = when (this) { + Padding.NONE -> "Padding.NONE" + Padding.ZERO -> "Padding.ZERO" + Padding.SPACE -> "Padding.SPACE" +} + +internal inline fun Padding.minDigits(width: Int) = if (this == Padding.ZERO) width else 1 +internal inline fun Padding.spaces(width: Int) = if (this == Padding.SPACE) width else null + +/** [T] is the user-visible type, whereas [U] is its mutable representation for parsing and formatting. */ +internal sealed class AbstractDateTimeFormat> : DateTimeFormat { + + abstract val actualFormat: StringFormat + + abstract fun intermediateFromValue(value: T): U + + abstract fun valueFromIntermediate(intermediate: U): T + + abstract val emptyIntermediate: U // should be part of the `Copyable` interface once the language allows this + + open fun valueFromIntermediateOrNull(intermediate: U): T? = try { + valueFromIntermediate(intermediate) + } catch (e: IllegalArgumentException) { + null + } + + override fun format(value: T): String = StringBuilder().also { + actualFormat.formatter.format(intermediateFromValue(value), it) + }.toString() + + override fun formatTo(appendable: A, value: T): A = appendable.apply { + actualFormat.formatter.format(intermediateFromValue(value), this) + } + + override fun parse(input: CharSequence): T { + val matched = try { + // without the fully qualified name, the compilation fails for some reason + Parser(actualFormat.parser).match(input, emptyIntermediate) + } catch (e: ParseException) { + throw DateTimeFormatException("Failed to parse value from '$input'", e) + } + try { + return valueFromIntermediate(matched) + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException(e.message!!) + } + } + + override fun parseOrNull(input: CharSequence): T? = + // without the fully qualified name, the compilation fails for some reason + Parser(actualFormat.parser).matchOrNull(input, emptyIntermediate)?.let { valueFromIntermediateOrNull(it) } + +} + +private val allFormatConstants: List>> by lazy { + fun unwrap(format: DateTimeFormat<*>): StringFormat<*> = (format as AbstractDateTimeFormat<*, *>).actualFormat + // the formats are ordered vaguely by decreasing length, as the topmost among suitable ones is chosen. + listOf( + "${DateTimeFormatBuilder.WithDateTimeComponents::dateTimeComponents.name}(DateTimeComponents.Formats.RFC_1123)" to + unwrap(DateTimeComponents.Formats.RFC_1123), + "${DateTimeFormatBuilder.WithDateTimeComponents::dateTimeComponents.name}(DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET)" to + unwrap(DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET), + "${DateTimeFormatBuilder.WithDateTime::date.name}(LocalDateTime.Formats.ISO)" to unwrap(LocalDateTime.Formats.ISO), + "${DateTimeFormatBuilder.WithDate::date.name}(LocalDate.Formats.ISO)" to unwrap(LocalDate.Formats.ISO), + "${DateTimeFormatBuilder.WithDate::date.name}(LocalDate.Formats.ISO_BASIC)" to unwrap(LocalDate.Formats.ISO_BASIC), + "${DateTimeFormatBuilder.WithTime::time.name}(LocalTime.Formats.ISO)" to unwrap(LocalTime.Formats.ISO), + "${DateTimeFormatBuilder.WithUtcOffset::offset.name}(UtcOffset.Formats.ISO)" to unwrap(UtcOffset.Formats.ISO), + "${DateTimeFormatBuilder.WithUtcOffset::offset.name}(UtcOffset.Formats.ISO_BASIC)" to unwrap(UtcOffset.Formats.ISO_BASIC), + "${DateTimeFormatBuilder.WithUtcOffset::offset.name}(UtcOffset.Formats.FOUR_DIGITS)" to unwrap(UtcOffset.Formats.FOUR_DIGITS), + ) +} diff --git a/core/common/src/format/DateTimeFormatBuilder.kt b/core/common/src/format/DateTimeFormatBuilder.kt new file mode 100644 index 000000000..b8134708f --- /dev/null +++ b/core/common/src/format/DateTimeFormatBuilder.kt @@ -0,0 +1,488 @@ +/* + * 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.* + +/** + * Common functions for all format builders. + */ +public sealed interface DateTimeFormatBuilder { + /** + * A literal string. + * + * When formatting, the string is appended to the result as is, + * and when parsing, the string is expected to be present in the input verbatim. + */ + public fun chars(value: String) + + /** + * Functions specific to the date-time format builders containing the local-date fields. + */ + public sealed interface WithDate : DateTimeFormatBuilder { + /** + * A year number. + * + * By default, for years [-9999..9999], it's formatted as a decimal number, zero-padded to four digits, though + * this padding can be disabled or changed to space padding by passing [padding]. + * For years outside this range, it's formatted as a decimal number with a leading sign, so the year 12345 + * is formatted as "+12345". + */ + public fun year(padding: Padding = Padding.ZERO) + + /** + * The last two digits of the ISO year. + * + * [baseYear] is the base year for the two-digit year. + * For example, if [baseYear] is 1960, then this format correctly works with years [1960..2059]. + * + * On formatting, when given a year in the valid range, it returns the last two digits of the year, + * so 1993 becomes "93". When given a year outside the valid range, it returns the full year number + * with a leading sign, so 1850 becomes "+1850", and -200 becomes "-200". + * + * On parsing, it accepts either a two-digit year or a full year number with a leading sign. + * When given a two-digit year, it returns a year in the valid range, so "93" becomes 1993, + * and when given a full year number with a leading sign, it parses the full year number, + * so "+1850" becomes 1850. + */ + public fun yearTwoDigits(baseYear: Int) + + /** + * A month-of-year number, from 1 to 12. + * + * By default, it's padded with zeros to two digits. This can be changed by passing [padding]. + */ + public fun monthNumber(padding: Padding = Padding.ZERO) + + /** + * A month name (for example, "January"). + * + * Example: + * ``` + * monthName(MonthNames.ENGLISH_FULL) + * ``` + */ + public fun monthName(names: MonthNames) + + /** + * A day-of-month number, from 1 to 31. + * + * By default, it's padded with zeros to two digits. This can be changed by passing [padding]. + */ + public fun dayOfMonth(padding: Padding = Padding.ZERO) + + /** + * A day-of-week name (for example, "Thursday"). + * + * Example: + * ``` + * dayOfWeek(DayOfWeekNames.ENGLISH_FULL) + * ``` + */ + public fun dayOfWeek(names: DayOfWeekNames) + + /** + * An existing [DateTimeFormat] for the date part. + * + * Example: + * ``` + * date(LocalDate.Formats.ISO) + * ``` + */ + public fun date(format: DateTimeFormat) + } + + /** + * Functions specific to the date-time format builders containing the local-time fields. + */ + public sealed interface WithTime : DateTimeFormatBuilder { + /** + * The hour of the day, from 0 to 23. + * + * By default, it's zero-padded to two digits, but this can be changed with [padding]. + */ + public fun hour(padding: Padding = Padding.ZERO) + + /** + * The hour of the day in the 12-hour clock: + * + * * Midnight is 12, + * * Hours 1-11 are 1-11, + * * Noon is 12, + * * Hours 13-23 are 1-11. + * + * To disambiguate between the first and the second halves of the day, [amPmMarker] should be used. + * + * By default, it's zero-padded to two digits, but this can be changed with [padding]. + * + * @see [amPmMarker] + */ + public fun amPmHour(padding: Padding = Padding.ZERO) + + /** + * The AM/PM marker, using the specified strings. + * + * [am] is used for the AM marker (0-11 hours), [pm] is used for the PM marker (12-23 hours). + * + * @see [amPmHour] + */ + public fun amPmMarker(am: String, pm: String) + + /** + * The minute of hour. + * + * By default, it's zero-padded to two digits, but this can be changed with [padding]. + */ + public fun minute(padding: Padding = Padding.ZERO) + + /** + * The second of minute. + * + * By default, it's zero-padded to two digits, but this can be changed with [padding]. + * + * This field has the default value of 0. If you want to omit it, use [optional]. + */ + public fun second(padding: Padding = Padding.ZERO) + + /** + * The fractional part of the second without the leading dot. + * + * When formatting, the decimal fraction will be rounded to fit in the specified [maxLength] and will add + * trailing zeroes to the specified [minLength]. + * Rounding is performed using the round-toward-zero rounding mode. + * + * When parsing, the parser will require that the fraction is at least [minLength] and at most [maxLength] + * digits long. + * + * This field has the default value of 0. If you want to omit it, use [optional]. + * + * See also the [secondFraction] overload that accepts just one parameter, the exact length of the fractional + * part. + * + * @throws IllegalArgumentException if [minLength] is greater than [maxLength] or if either is not in the range 1..9. + */ + public fun secondFraction(minLength: Int = 1, maxLength: Int = 9) + + /** + * The fractional part of the second without the leading dot. + * + * When formatting, the decimal fraction will add trailing zeroes or be rounded as necessary to always output + * exactly the number of digits specified in [fixedLength]. + * Rounding is performed using the round-toward-zero rounding mode. + * + * When parsing, exactly [fixedLength] digits will be consumed. + * + * This field has the default value of 0. If you want to omit it, use [optional]. + * + * See also the [secondFraction] overload that accepts two parameters, the minimum and maximum length of the + * fractional part. + * + * @throws IllegalArgumentException if [fixedLength] is not in the range 1..9. + * + * @see secondFraction that accepts two parameters. + */ + public fun secondFraction(fixedLength: Int) { + secondFraction(fixedLength, fixedLength) + } + + /** + * An existing [DateTimeFormat] for the time part. + * + * Example: + * ``` + * time(LocalTime.Formats.ISO) + * ``` + */ + public fun time(format: DateTimeFormat) + } + + /** + * Functions specific to the date-time format builders containing the local-date and local-time fields. + */ + public sealed interface WithDateTime : WithDate, WithTime { + /** + * An existing [DateTimeFormat] for the date-time part. + * + * Example: + * ``` + * dateTime(LocalDateTime.Formats.ISO) + * ``` + */ + public fun dateTime(format: DateTimeFormat) + } + + /** + * Functions specific to the date-time format builders containing the UTC-offset fields. + */ + public sealed interface WithUtcOffset : DateTimeFormatBuilder { + /** + * The total number of hours in the UTC offset, including the sign. + * + * By default, it's zero-padded to two digits, but this can be changed with [padding]. + * + * This field has the default value of 0. If you want to omit it, use [optional]. + */ + public fun offsetHours(padding: Padding = Padding.ZERO) + + /** + * The minute-of-hour of the UTC offset. + * + * By default, it's zero-padded to two digits, but this can be changed with [padding]. + * + * This field has the default value of 0. If you want to omit it, use [optional]. + */ + public fun offsetMinutesOfHour(padding: Padding = Padding.ZERO) + + /** + * The second-of-minute of the UTC offset. + * + * By default, it's zero-padded to two digits, but this can be changed with [padding]. + * + * This field has the default value of 0. If you want to omit it, use [optional]. + */ + public fun offsetSecondsOfMinute(padding: Padding = Padding.ZERO) + + /** + * An existing [DateTimeFormat] for the UTC offset part. + * + * Example: + * ``` + * offset(UtcOffset.Formats.FOUR_DIGITS) + * ``` + */ + public fun offset(format: DateTimeFormat) + } + + /** + * Builder for formats for values that have all the date-time components: + * date, time, UTC offset, and the timezone ID. + */ + public sealed interface WithDateTimeComponents : WithDateTime, WithUtcOffset { + /** + * The IANA time zone identifier, for example, "Europe/Berlin". + * + * When formatting, the timezone identifier is supplied as is, without any validation. + * On parsing, [TimeZone.availableZoneIds] is used to validate the identifier. + */ + public fun timeZoneId() + + /** + * An existing [DateTimeFormat]. + * + * Example: + * ``` + * dateTimeComponents(DateTimeComponents.Formats.RFC_1123) + * ``` + */ + public fun dateTimeComponents(format: DateTimeFormat) + } +} + +/** + * The fractional part of the second without the leading dot. + * + * When formatting, the decimal fraction will round the number to fit in the specified [maxLength] and will add + * trailing zeroes to the specified [minLength]. + * + * Additionally, [grouping] is a list, where the i'th (1-based) element specifies how many trailing zeros to add during + * formatting when the number would have i digits. + * + * When parsing, the parser will require that the fraction is at least [minLength] and at most [maxLength] + * digits long. + * + * This field has the default value of 0. If you want to omit it, use [optional]. + * + * @throws IllegalArgumentException if [minLength] is greater than [maxLength] or if either is not in the range 1..9. + */ +internal fun DateTimeFormatBuilder.WithTime.secondFractionInternal(minLength: Int, maxLength: Int, grouping: List) { + @Suppress("NO_ELSE_IN_WHEN") + when (this) { + is AbstractWithTimeBuilder -> addFormatStructureForTime( + BasicFormatStructure(FractionalSecondDirective(minLength, maxLength, grouping)) + ) + } +} + +/** + * A format along with other ways to parse the same portion of the value. + * + * When parsing, first, [primaryFormat] is used; if parsing the whole string fails using that, the formats + * from [alternativeFormats] are tried in order. + * + * When formatting, the [primaryFormat] is used to format the value, and [alternativeFormats] are ignored. + * + * Example: + * ``` + * alternativeParsing( + * { dayOfMonth(); char('-'); monthNumber() }, + * { monthNumber(); char(' '); dayOfMonth() }, + * ) { monthNumber(); char('/'); dayOfMonth() } + * ``` + * + * This will always format a date as `MM/DD`, but will also accept `DD-MM` and `MM DD`. + */ +@Suppress("UNCHECKED_CAST") +public fun T.alternativeParsing( + vararg alternativeFormats: T.() -> Unit, + primaryFormat: T.() -> Unit +): Unit = when (this) { + is AbstractDateTimeFormatBuilder<*, *> -> + appendAlternativeParsingImpl(*alternativeFormats as Array.() -> Unit>, + mainFormat = primaryFormat as (AbstractDateTimeFormatBuilder<*, *>.() -> Unit)) + else -> throw IllegalStateException("impossible") +} + +/** + * An optional section. + * + * When formatting, the section is formatted if the value of any field in the block is not equal to the default value. + * Only [optional] calls where all the fields have default values are permitted. + * + * Example: + * ``` + * offsetHours(); char(':'); offsetMinutesOfHour() + * optional { char(':'); offsetSecondsOfMinute() } + * ``` + * + * Here, because seconds have the default value of zero, they are formatted only if they are not equal to zero, so the + * UTC offset `+18:30:00` gets formatted as `"+18:30"`, but `+18:30:01` becomes `"+18:30:01"`. + * + * When parsing, either [format] or, if that fails, the literal [ifZero] are parsed. If the [ifZero] string is parsed, + * the values in [format] get assigned their default values. + * + * [ifZero] defines the string that is used if values are the default ones. + * + * @throws IllegalArgumentException if not all fields used in [format] have a default value. + */ +@Suppress("UNCHECKED_CAST") +public fun T.optional(ifZero: String = "", format: T.() -> Unit): Unit = when (this) { + is AbstractDateTimeFormatBuilder<*, *> -> appendOptionalImpl(onZero = ifZero, format as (AbstractDateTimeFormatBuilder<*, *>.() -> Unit)) + else -> throw IllegalStateException("impossible") +} + +/** + * A literal character. + * + * This is a shorthand for `chars(value.toString())`. + */ +public fun DateTimeFormatBuilder.char(value: Char): Unit = chars(value.toString()) + +internal interface AbstractDateTimeFormatBuilder : + DateTimeFormatBuilder where ActualSelf : AbstractDateTimeFormatBuilder { + + val actualBuilder: AppendableFormatStructure + fun createEmpty(): ActualSelf + + fun appendAlternativeParsingImpl( + vararg otherFormats: ActualSelf.() -> Unit, + mainFormat: ActualSelf.() -> Unit + ) { + val others = otherFormats.map { block -> + createEmpty().also { block(it) }.actualBuilder.build() + } + val main = createEmpty().also { mainFormat(it) }.actualBuilder.build() + actualBuilder.add(AlternativesParsingFormatStructure(main, others)) + } + + fun appendOptionalImpl( + onZero: String, + format: ActualSelf.() -> Unit + ) { + actualBuilder.add(OptionalFormatStructure(onZero, createEmpty().also { format(it) }.actualBuilder.build())) + } + + override fun chars(value: String) = actualBuilder.add(ConstantFormatStructure(value)) + + fun build(): StringFormat = StringFormat(actualBuilder.build()) +} + +internal inline fun StringFormat.builderString(constants: List>>): String = + directives.builderString(constants) + +private fun FormatStructure.builderString(constants: List>>): String = when (this) { + is BasicFormatStructure -> directive.builderRepresentation + is ConstantFormatStructure -> if (string.length == 1) { + "${DateTimeFormatBuilder::char.name}(${string[0].toKotlinCode()})" + } else { + "${DateTimeFormatBuilder::chars.name}(${string.toKotlinCode()})" + } + is SignedFormatStructure -> { + if (format is BasicFormatStructure && format.directive is UtcOffsetWholeHoursDirective) { + format.directive.builderRepresentation + } else { + buildString { + if (withPlusSign) appendLine("withSharedSign(outputPlus = true) {") + else appendLine("withSharedSign {") + appendLine(format.builderString(constants).prependIndent(CODE_INDENT)) + append("}") + } + } + } + is OptionalFormatStructure -> buildString { + if (onZero == "") { + appendLine("${DateTimeFormatBuilder::optional.name} {") + } else { + appendLine("${DateTimeFormatBuilder::optional.name}(${onZero.toKotlinCode()}) {") + } + val subformat = format.builderString(constants) + if (subformat.isNotEmpty()) { + appendLine(subformat.prependIndent(CODE_INDENT)) + } + append("}") + } + is AlternativesParsingFormatStructure -> buildString { + append("${DateTimeFormatBuilder::alternativeParsing.name}(") + for (alternative in formats) { + appendLine("{") + val subformat = alternative.builderString(constants) + if (subformat.isNotEmpty()) { + appendLine(subformat.prependIndent(CODE_INDENT)) + } + append("}, ") + } + if (this[length - 2] == ',') { + repeat(2) { + deleteAt(length - 1) + } + } + appendLine(") {") + appendLine(mainFormat.builderString(constants).prependIndent(CODE_INDENT)) + append("}") + } + is ConcatenatedFormatStructure -> buildString { + if (formats.isNotEmpty()) { + var index = 0 + loop@while (index < formats.size) { + searchConstant@for (constant in constants) { + val constantDirectives = constant.second.directives.formats + if (formats.size - index >= constantDirectives.size) { + for (i in constantDirectives.indices) { + if (formats[index + i] != constantDirectives[i]) { + continue@searchConstant + } + } + append(constant.first) + index += constantDirectives.size + if (index < formats.size) { + appendLine() + } + continue@loop + } + } + if (index == formats.size - 1) { + append(formats.last().builderString(constants)) + } else { + appendLine(formats[index].builderString(constants)) + } + ++index + } + } + } +} + +private const val CODE_INDENT = " " diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt new file mode 100644 index 000000000..eae6d6fdc --- /dev/null +++ b/core/common/src/format/LocalDateFormat.kt @@ -0,0 +1,363 @@ +/* + * 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.Copyable + +/** + * A description of how month names are formatted. + */ +public class MonthNames( + /** + * A list of month names, in order from January to December. + */ + public val names: List +) { + init { + require(names.size == 12) { "Month names must contain exactly 12 elements" } + } + + /** + * Create a [MonthNames] using the month names in order from January to December. + */ + public constructor( + january: String, february: String, march: String, april: String, may: String, june: String, + july: String, august: String, september: String, october: String, november: String, december: String + ) : + this(listOf(january, february, march, april, may, june, july, august, september, october, november, december)) + + public companion object { + /** + * English month names, 'January' to 'December'. + */ + public val ENGLISH_FULL: MonthNames = MonthNames( + listOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ) + ) + + /** + * Shortened English month names, 'Jan' to 'Dec'. + */ + public val ENGLISH_ABBREVIATED: MonthNames = MonthNames( + listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + ) + ) + } +} + +internal fun MonthNames.toKotlinCode(): String = when (this.names) { + MonthNames.ENGLISH_FULL.names -> "MonthNames.${DayOfWeekNames.Companion::ENGLISH_FULL.name}" + MonthNames.ENGLISH_ABBREVIATED.names -> "MonthNames.${DayOfWeekNames.Companion::ENGLISH_ABBREVIATED.name}" + else -> names.joinToString(", ", "MonthNames(", ")", transform = String::toKotlinCode) +} + +/** + * A description of how day of week names are formatted. + */ +public class DayOfWeekNames( + /** + * A list of day of week names, in order from Monday to Sunday. + */ + public val names: List +) { + init { + require(names.size == 7) { "Day of week names must contain exactly 7 elements" } + } + + /** + * A constructor that takes the day of week names, in order from Monday to Sunday. + */ + public constructor( + monday: String, + tuesday: String, + wednesday: String, + thursday: String, + friday: String, + saturday: String, + sunday: String + ) : + this(listOf(monday, tuesday, wednesday, thursday, friday, saturday, sunday)) + + public companion object { + /** + * English day of week names, 'Monday' to 'Sunday'. + */ + public val ENGLISH_FULL: DayOfWeekNames = DayOfWeekNames( + listOf( + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" + ) + ) + + /** + * Shortened English day of week names, 'Mon' to 'Sun'. + */ + public val ENGLISH_ABBREVIATED: DayOfWeekNames = DayOfWeekNames( + listOf( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" + ) + ) + } +} + +internal fun DayOfWeekNames.toKotlinCode(): String = when (this.names) { + DayOfWeekNames.ENGLISH_FULL.names -> "DayOfWeekNames.${DayOfWeekNames.Companion::ENGLISH_FULL.name}" + DayOfWeekNames.ENGLISH_ABBREVIATED.names -> "DayOfWeekNames.${DayOfWeekNames.Companion::ENGLISH_ABBREVIATED.name}" + else -> names.joinToString(", ", "DayOfWeekNames(", ")", transform = String::toKotlinCode) +} + +internal fun requireParsedField(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? +} + +private object DateFields { + val year = SignedFieldSpec(PropertyAccessor(DateFieldContainer::year), maxAbsoluteValue = null) + val month = UnsignedFieldSpec(PropertyAccessor(DateFieldContainer::monthNumber), minValue = 1, maxValue = 12) + val dayOfMonth = UnsignedFieldSpec(PropertyAccessor(DateFieldContainer::dayOfMonth), minValue = 1, maxValue = 31) + val isoDayOfWeek = UnsignedFieldSpec(PropertyAccessor(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( + requireParsedField(year, "year"), + requireParsedField(monthNumber, "monthNumber"), + requireParsedField(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 + } + + fun populateFrom(date: LocalDate) { + year = date.year + monthNumber = date.monthNumber + dayOfMonth = date.dayOfMonth + isoDayOfWeek = date.dayOfWeek.isoDayNumber + } + + override fun copy(): IncompleteLocalDate = IncompleteLocalDate(year, monthNumber, dayOfMonth, isoDayOfWeek) + + override fun equals(other: Any?): Boolean = + other is IncompleteLocalDate && year == other.year && monthNumber == other.monthNumber && + dayOfMonth == other.dayOfMonth && isoDayOfWeek == other.isoDayOfWeek + + override fun hashCode(): Int = + year.hashCode() * 31 + monthNumber.hashCode() * 31 + dayOfMonth.hashCode() * 31 + isoDayOfWeek.hashCode() * 31 + + override fun toString(): String = + "${year ?: "??"}-${monthNumber ?: "??"}-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"})" +} + +private class YearDirective(private val padding: Padding, private val isYearOfEra: Boolean = false) : + SignedIntFieldFormatDirective( + DateFields.year, + minDigits = padding.minDigits(4), + maxDigits = null, + spacePadding = padding.spaces(4), + outputPlusOnExceededWidth = 4, + ) { + override val builderRepresentation: String get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithDate::year.name}()" + else -> "${DateTimeFormatBuilder.WithDate::year.name}(${padding.toKotlinCode()})" + }.let { + if (isYearOfEra) { it + YEAR_OF_ERA_COMMENT } else it + } + + override fun equals(other: Any?): Boolean = + other is YearDirective && padding == other.padding && isYearOfEra == other.isYearOfEra + override fun hashCode(): Int = padding.hashCode() * 31 + isYearOfEra.hashCode() +} + +private class ReducedYearDirective(val base: Int, private val isYearOfEra: Boolean = false) : + ReducedIntFieldDirective( + DateFields.year, + digits = 2, + base = base, + ) { + override val builderRepresentation: String get() = + "${DateTimeFormatBuilder.WithDate::yearTwoDigits.name}($base)".let { + if (isYearOfEra) { it + YEAR_OF_ERA_COMMENT } else it + } + + override fun equals(other: Any?): Boolean = + other is ReducedYearDirective && base == other.base && isYearOfEra == other.isYearOfEra + override fun hashCode(): Int = base.hashCode() * 31 + isYearOfEra.hashCode() +} + +private const val YEAR_OF_ERA_COMMENT = + " /** TODO: the original format had an `y` directive, so the behavior is different on years earlier than 1 AD. See the [kotlinx.datetime.format.byUnicodePattern] documentation for details. */" + +/** + * A special directive for year-of-era that behaves equivalently to [DateTimeFormatBuilder.WithDate.year]. + * This is the result of calling [byUnicodePattern] on a pattern that uses the ubiquitous "y" symbol. + * We need a separate directive so that, when using [DateTimeFormat.formatAsKotlinBuilderDsl], we can print an + * additional comment and explain that the behavior was not preserved exactly. + */ +internal fun DateTimeFormatBuilder.WithDate.yearOfEra(padding: Padding) { + @Suppress("NO_ELSE_IN_WHEN") + when (this) { + is AbstractWithDateBuilder -> addFormatStructureForDate( + BasicFormatStructure(YearDirective(padding, isYearOfEra = true)) + ) + } +} + +/** + * A special directive for year-of-era that behaves equivalently to [DateTimeFormatBuilder.WithDate.year]. + * This is the result of calling [byUnicodePattern] on a pattern that uses the ubiquitous "y" symbol. + * We need a separate directive so that, when using [DateTimeFormat.formatAsKotlinBuilderDsl], we can print an + * additional comment and explain that the behavior was not preserved exactly. + */ +internal fun DateTimeFormatBuilder.WithDate.yearOfEraTwoDigits(baseYear: Int) { + @Suppress("NO_ELSE_IN_WHEN") + when (this) { + is AbstractWithDateBuilder -> addFormatStructureForDate( + BasicFormatStructure(ReducedYearDirective(baseYear, isYearOfEra = true)) + ) + } +} + +private class MonthDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + DateFields.month, + minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2), + ) { + override val builderRepresentation: String get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithDate::monthNumber.name}()" + else -> "${DateTimeFormatBuilder.WithDate::monthNumber.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is MonthDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +private class MonthNameDirective(private val names: MonthNames) : + NamedUnsignedIntFieldFormatDirective(DateFields.month, names.names, "monthName") { + override val builderRepresentation: String get() = + "${DateTimeFormatBuilder.WithDate::monthName.name}(${names.toKotlinCode()})" + + override fun equals(other: Any?): Boolean = other is MonthNameDirective && names.names == other.names.names + override fun hashCode(): Int = names.names.hashCode() +} + +private class DayDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + DateFields.dayOfMonth, + minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2), + ) { + override val builderRepresentation: String get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithDate::dayOfMonth.name}()" + else -> "${DateTimeFormatBuilder.WithDate::dayOfMonth.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is DayDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +private class DayOfWeekDirective(private val names: DayOfWeekNames) : + NamedUnsignedIntFieldFormatDirective(DateFields.isoDayOfWeek, names.names, "dayOfWeekName") { + + override val builderRepresentation: String get() = + "${DateTimeFormatBuilder.WithDate::dayOfWeek.name}(${names.toKotlinCode()})" + + override fun equals(other: Any?): Boolean = other is DayOfWeekDirective && names.names == other.names.names + override fun hashCode(): Int = names.names.hashCode() +} + +internal class LocalDateFormat(override val actualFormat: StringFormat) : + AbstractDateTimeFormat() { + override fun intermediateFromValue(value: LocalDate): IncompleteLocalDate = + IncompleteLocalDate().apply { populateFrom(value) } + + override fun valueFromIntermediate(intermediate: IncompleteLocalDate): LocalDate = intermediate.toLocalDate() + + override val emptyIntermediate get() = emptyIncompleteLocalDate + + companion object { + fun build(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat { + val builder = Builder(AppendableFormatStructure()) + builder.block() + return LocalDateFormat(builder.build()) + } + } + + internal class Builder(override val actualBuilder: AppendableFormatStructure) : + AbstractDateTimeFormatBuilder, AbstractWithDateBuilder { + + override fun addFormatStructureForDate(structure: FormatStructure) = + actualBuilder.add(structure) + + override fun createEmpty(): Builder = Builder(AppendableFormatStructure()) + } +} + +internal interface AbstractWithDateBuilder: DateTimeFormatBuilder.WithDate { + fun addFormatStructureForDate(structure: FormatStructure) + + override fun year(padding: Padding) = + addFormatStructureForDate(BasicFormatStructure(YearDirective(padding))) + + override fun yearTwoDigits(baseYear: Int) = + addFormatStructureForDate(BasicFormatStructure(ReducedYearDirective(baseYear))) + + override fun monthNumber(padding: Padding) = + addFormatStructureForDate(BasicFormatStructure(MonthDirective(padding))) + + override fun monthName(names: MonthNames) = + addFormatStructureForDate(BasicFormatStructure(MonthNameDirective(names))) + + override fun dayOfMonth(padding: Padding) = addFormatStructureForDate(BasicFormatStructure(DayDirective(padding))) + override fun dayOfWeek(names: DayOfWeekNames) = + addFormatStructureForDate(BasicFormatStructure(DayOfWeekDirective(names))) + + @Suppress("NO_ELSE_IN_WHEN") + override fun date(format: DateTimeFormat) = when (format) { + is LocalDateFormat -> addFormatStructureForDate(format.actualFormat.directives) + } +} + +// these are constants so that the formats are not recreated every time they are used +internal val ISO_DATE by lazy { + LocalDateFormat.build { year(); char('-'); monthNumber(); char('-'); dayOfMonth() } +} +internal val ISO_DATE_BASIC by lazy { + LocalDateFormat.build { year(); monthNumber(); dayOfMonth() } +} + +private val emptyIncompleteLocalDate = IncompleteLocalDate() diff --git a/core/common/src/format/LocalDateTimeFormat.kt b/core/common/src/format/LocalDateTimeFormat.kt new file mode 100644 index 000000000..30a7fde4a --- /dev/null +++ b/core/common/src/format/LocalDateTimeFormat.kt @@ -0,0 +1,86 @@ +/* + * 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.format.* +import kotlinx.datetime.internal.format.parser.Copyable +import kotlin.native.concurrent.* + +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()) + + fun populateFrom(dateTime: LocalDateTime) { + date.populateFrom(dateTime.date) + time.populateFrom(dateTime.time) + } + + override fun copy(): IncompleteLocalDateTime = IncompleteLocalDateTime(date.copy(), time.copy()) +} + +internal class LocalDateTimeFormat(override val actualFormat: StringFormat) : + AbstractDateTimeFormat() { + override fun intermediateFromValue(value: LocalDateTime): IncompleteLocalDateTime = + IncompleteLocalDateTime().apply { populateFrom(value) } + + override fun valueFromIntermediate(intermediate: IncompleteLocalDateTime): LocalDateTime = + intermediate.toLocalDateTime() + + override val emptyIntermediate: IncompleteLocalDateTime get() = emptyIncompleteLocalDateTime + + companion object { + fun build(block: DateTimeFormatBuilder.WithDateTime.() -> Unit): LocalDateTimeFormat { + val builder = Builder(AppendableFormatStructure()) + builder.block() + return LocalDateTimeFormat(builder.build()) + } + } + + internal class Builder(override val actualBuilder: AppendableFormatStructure) : + AbstractDateTimeFormatBuilder, AbstractWithDateTimeBuilder { + + override fun addFormatStructureForDateTime(structure: FormatStructure) { + actualBuilder.add(structure) + } + + override fun createEmpty(): Builder = Builder(AppendableFormatStructure()) + } +} + +internal interface AbstractWithDateTimeBuilder: + AbstractWithDateBuilder, AbstractWithTimeBuilder, DateTimeFormatBuilder.WithDateTime +{ + fun addFormatStructureForDateTime(structure: FormatStructure) + + override fun addFormatStructureForDate(structure: FormatStructure) { + addFormatStructureForDateTime(structure) + } + + override fun addFormatStructureForTime(structure: FormatStructure) { + addFormatStructureForDateTime(structure) + } + + @Suppress("NO_ELSE_IN_WHEN") + override fun dateTime(format: DateTimeFormat) = when (format) { + is LocalDateTimeFormat -> addFormatStructureForDateTime(format.actualFormat.directives) + } +} + +// these are constants so that the formats are not recreated every time they are used +internal val ISO_DATETIME by lazy { + LocalDateTimeFormat.build { + date(ISO_DATE) + alternativeParsing({ char('t') }) { char('T') } + time(ISO_TIME) + } +} + +private val emptyIncompleteLocalDateTime = IncompleteLocalDateTime() diff --git a/core/common/src/format/LocalTimeFormat.kt b/core/common/src/format/LocalTimeFormat.kt new file mode 100644 index 000000000..28e63ec46 --- /dev/null +++ b/core/common/src/format/LocalTimeFormat.kt @@ -0,0 +1,284 @@ +/* + * 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.Copyable + +/** + * The AM/PM marker that indicates whether the hour in range `1..12` is before or after noon. + */ +public enum class AmPmMarker { + /** The time is before noon. */ + AM, + /** The time is after noon. */ + PM, +} + +internal interface TimeFieldContainer { + var minute: Int? + var second: Int? + var nanosecond: Int? + var hour: Int? + var hourOfAmPm: Int? + var amPm: AmPmMarker? + + var fractionOfSecond: DecimalFraction? + get() = nanosecond?.let { DecimalFraction(it, 9) } + set(value) { + nanosecond = value?.fractionalPartWithNDigits(9) + } +} + +private object TimeFields { + val hour = UnsignedFieldSpec(PropertyAccessor(TimeFieldContainer::hour), minValue = 0, maxValue = 23) + val minute = UnsignedFieldSpec(PropertyAccessor(TimeFieldContainer::minute), minValue = 0, maxValue = 59) + val second = UnsignedFieldSpec(PropertyAccessor(TimeFieldContainer::second), minValue = 0, maxValue = 59, defaultValue = 0) + val fractionOfSecond = GenericFieldSpec(PropertyAccessor(TimeFieldContainer::fractionOfSecond), defaultValue = DecimalFraction(0, 9)) + val amPm = GenericFieldSpec(PropertyAccessor(TimeFieldContainer::amPm)) + val hourOfAmPm = UnsignedFieldSpec(PropertyAccessor(TimeFieldContainer::hourOfAmPm), minValue = 1, maxValue = 12) +} + +internal class IncompleteLocalTime( + override var hour: Int? = null, + override var hourOfAmPm: Int? = null, + override var amPm: AmPmMarker? = null, + override var minute: Int? = null, + override var second: Int? = null, + override var nanosecond: Int? = null +) : TimeFieldContainer, Copyable { + fun toLocalTime(): LocalTime { + val hour: Int = hour?.let { hour -> + hourOfAmPm?.let { + require((hour + 11) % 12 + 1 == it) { "Inconsistent hour and hour-of-am-pm: hour is $hour, but hour-of-am-pm is $it" } + } + amPm?.let { amPm -> + require((amPm == AmPmMarker.PM) == (hour >= 12)) { + "Inconsistent hour and the AM/PM marker: hour is $hour, but the AM/PM marker is $amPm" + } + } + hour + } ?: hourOfAmPm?.let { hourOfAmPm -> + amPm?.let { amPm -> + hourOfAmPm.let { if (it == 12) 0 else it } + if (amPm == AmPmMarker.PM) 12 else 0 + } + } ?: throw DateTimeFormatException("Incomplete time: missing hour") + return LocalTime( + hour, + requireParsedField(minute, "minute"), + second ?: 0, + nanosecond ?: 0, + ) + } + + fun populateFrom(localTime: LocalTime) { + hour = localTime.hour + hourOfAmPm = (localTime.hour + 11) % 12 + 1 + amPm = if (localTime.hour >= 12) AmPmMarker.PM else AmPmMarker.AM + minute = localTime.minute + second = localTime.second + nanosecond = localTime.nanosecond + } + + override fun copy(): IncompleteLocalTime = IncompleteLocalTime(hour, hourOfAmPm, amPm, minute, second, nanosecond) + + override fun equals(other: Any?): Boolean = + other is IncompleteLocalTime && hour == other.hour && hourOfAmPm == other.hourOfAmPm && amPm == other.amPm && + minute == other.minute && second == other.second && nanosecond == other.nanosecond + + override fun hashCode(): Int = + (hour ?: 0) * 31 + (hourOfAmPm ?: 0) * 31 + (amPm?.hashCode() ?: 0) * 31 + (minute ?: 0) * 31 + + (second ?: 0) * 31 + (nanosecond ?: 0) + + override fun toString(): String = + "${hour ?: "??"}:${minute ?: "??"}:${second ?: "??"}.${ + nanosecond?.let { nanos -> + nanos.toString().let { it.padStart(9 - it.length, '0') } + } ?: "???" + }" +} + +internal interface AbstractWithTimeBuilder: DateTimeFormatBuilder.WithTime { + fun addFormatStructureForTime(structure: FormatStructure) + + override fun hour(padding: Padding) = addFormatStructureForTime(BasicFormatStructure(HourDirective(padding))) + override fun amPmHour(padding: Padding) = + addFormatStructureForTime(BasicFormatStructure(AmPmHourDirective(padding))) + + override fun amPmMarker(am: String, pm: String) = + addFormatStructureForTime(BasicFormatStructure(AmPmMarkerDirective(am, pm))) + + override fun minute(padding: Padding) = addFormatStructureForTime(BasicFormatStructure(MinuteDirective(padding))) + override fun second(padding: Padding) = addFormatStructureForTime(BasicFormatStructure(SecondDirective(padding))) + override fun secondFraction(minLength: Int, maxLength: Int) = + addFormatStructureForTime(BasicFormatStructure(FractionalSecondDirective(minLength, maxLength))) + + @Suppress("NO_ELSE_IN_WHEN") + override fun time(format: DateTimeFormat) = when (format) { + is LocalTimeFormat -> addFormatStructureForTime(format.actualFormat.directives) + } +} + +private class HourDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + TimeFields.hour, + minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2) + ) { + override val builderRepresentation: String get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithTime::hour.name}()" + else -> "${DateTimeFormatBuilder.WithTime::hour.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is HourDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +private class AmPmHourDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + TimeFields.hourOfAmPm, minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2) + ) { + override val builderRepresentation: String get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithTime::amPmHour.name}()" + else -> "${DateTimeFormatBuilder.WithTime::amPmHour.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is AmPmHourDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +private class AmPmMarkerDirective(private val amString: String, private val pmString: String) : + NamedEnumIntFieldFormatDirective( + TimeFields.amPm, mapOf( + AmPmMarker.AM to amString, + AmPmMarker.PM to pmString, + ), + "AM/PM marker" + ) { + + override val builderRepresentation: String get() = + "${DateTimeFormatBuilder.WithTime::amPmMarker.name}($amString, $pmString)" + + override fun equals(other: Any?): Boolean = + other is AmPmMarkerDirective && amString == other.amString && pmString == other.pmString + override fun hashCode(): Int = 31 * amString.hashCode() + pmString.hashCode() +} + +private class MinuteDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + TimeFields.minute, + minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2) + ) { + + override val builderRepresentation: String get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithTime::minute.name}()" + else -> "${DateTimeFormatBuilder.WithTime::minute.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is MinuteDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +private class SecondDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + TimeFields.second, + minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2) + ) { + + override val builderRepresentation: String get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithTime::second.name}()" + else -> "${DateTimeFormatBuilder.WithTime::second.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is SecondDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +internal class FractionalSecondDirective( + private val minDigits: Int, + private val maxDigits: Int, + zerosToAdd: List = NO_EXTRA_ZEROS, +) : + DecimalFractionFieldFormatDirective(TimeFields.fractionOfSecond, minDigits, maxDigits, zerosToAdd) { + + override val builderRepresentation: String get() { + val ref = "secondFraction" // can't directly reference `secondFraction` due to resolution ambiguity + // we ignore `grouping`, as it's not representable in the end users' code + return when { + minDigits == 1 && maxDigits == 9 -> "$ref()" + minDigits == 1 -> "$ref(maxLength = $maxDigits)" + maxDigits == 1 -> "$ref(minLength = $minDigits)" + maxDigits == minDigits -> "$ref($minDigits)" + else -> "$ref($minDigits, $maxDigits)" + } + } + + override fun equals(other: Any?): Boolean = + other is FractionalSecondDirective && minDigits == other.minDigits && maxDigits == other.maxDigits + + override fun hashCode(): Int = 31 * minDigits + maxDigits + + companion object { + val NO_EXTRA_ZEROS = listOf(0, 0, 0, 0, 0, 0, 0, 0, 0) + val GROUP_BY_THREE = listOf(2, 1, 0, 2, 1, 0, 2, 1, 0) + } +} + +internal class LocalTimeFormat(override val actualFormat: StringFormat) : + AbstractDateTimeFormat() { + override fun intermediateFromValue(value: LocalTime): IncompleteLocalTime = + IncompleteLocalTime().apply { populateFrom(value) } + + override fun valueFromIntermediate(intermediate: IncompleteLocalTime): LocalTime = intermediate.toLocalTime() + + override val emptyIntermediate: IncompleteLocalTime get() = emptyIncompleteLocalTime + + companion object { + fun build(block: DateTimeFormatBuilder.WithTime.() -> Unit): LocalTimeFormat { + val builder = Builder(AppendableFormatStructure()) + builder.block() + return LocalTimeFormat(builder.build()) + } + + } + + private class Builder(override val actualBuilder: AppendableFormatStructure) : + AbstractDateTimeFormatBuilder, AbstractWithTimeBuilder { + + override fun addFormatStructureForTime(structure: FormatStructure) { + actualBuilder.add(structure) + } + + override fun createEmpty(): Builder = Builder(AppendableFormatStructure()) + } + +} + +// these are constants so that the formats are not recreated every time they are used +internal val ISO_TIME by lazy { + LocalTimeFormat.build { + hour() + char(':') + minute() + alternativeParsing({ + // intentionally empty + }) { + char(':') + second() + optional { + char('.') + secondFraction(1, 9) + } + } + } +} + +private val emptyIncompleteLocalTime = IncompleteLocalTime() diff --git a/core/common/src/format/Unicode.kt b/core/common/src/format/Unicode.kt new file mode 100644 index 000000000..5b87f0c72 --- /dev/null +++ b/core/common/src/format/Unicode.kt @@ -0,0 +1,633 @@ +/* + * 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 kotlin.native.concurrent.* + +/** + * Marks declarations in the datetime library that use format strings to define datetime formats. + * + * Format strings are discouraged, because they require gaining proficiency in another tiny language. + * When possible, please use the builder-style Kotlin API instead. + * If the format string is a constant, the corresponding builder-style Kotlin code can be obtained by calling + * [DateTimeFormat.formatAsKotlinBuilderDsl] on the resulting format. For example: + * ``` + * DateTimeFormat.formatAsKotlinBuilderDsl(LocalTime.Format { byUnicodePattern("HH:mm") }) + * ``` + */ +@MustBeDocumented +@Retention(value = AnnotationRetention.BINARY) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "Using format strings is discouraged." + + " If the format string is a constant, the corresponding builder-style Kotlin code can be obtained by calling" + + " `DateTimeFormat.formatAsKotlinBuilderDsl` on the resulting format." +) +public annotation class FormatStringsInDatetimeFormats + +/** + * Appends a Unicode date/time format string to the [DateTimeFormatBuilder]. + * + * This is the format string syntax used by the Java Time's `DateTimeFormatter` class, Swift's and Objective-C's + * `NSDateFormatter` class, and the ICU library. + * The syntax is specified at + * . + * + * Currently, locale-aware directives are not supported, due to no locale support in Kotlin. + * + * In addition to the standard syntax, this function also supports the following extensions: + * * `[]` denote optional sections. For example, `hh:mm[:ss]` will allow parsing seconds optionally. + * This is similar to what is supported by the Java Time's `DateTimeFormatter` class. + * + * Usage example: + * ``` + * DateTimeComponents.Format { + * // 2023-01-20T23:53:16.312+03:30[Asia/Tehran] + * byUnicodePattern("uuuu-MM-dd'T'HH:mm[:ss[.SSS]]xxxxx'['VV']'") + * } + * ``` + * + * The list of supported directives is as follows: + * + * | **Directive** | **Meaning** | + * | `'string'` | literal `string`, without quotes | + * | `'''` | literal char `'` | + * | `[fmt]` | equivalent to `fmt` during formatting, but during parsing also accepts the empty string | + * | `u` | ISO year without padding | + * | `uu` | last two digits of the ISO year, with the base year 2000 | + * | `uuuu` | ISO year, zero-padded to four digits | + * | `M`, `L` | month number (1-12), without padding | + * | `MM`, `LL` | month number (01-12), zero-padded to two digits | + * | `d` | day-of-month (1-31), without padding | + * | `H` | hour-of-day (0-23), without padding | + * | `HH` | hour-of-day (00-23), zero-padded to two digits | + * | `m` | minute-of-hour (0-59), without padding | + * | `mm` | minute-of-hour (00-59), zero-padded to two digits | + * | `s` | second-of-hour (0-59), without padding | + * | `ss` | second-of-hour (00-59), zero-padded to two digits | + * | `S`, `SS`, `SSS`... | fraction-of-second without a leading dot, with as many digits as the format length | + * | `VV` | timezone name (for example, `Europe/Berlin`) | + * + * The UTC offset is formatted using one of the following directives. In every one of these formats, hours, minutes, + * and seconds are zero-padded to two digits. Also, hours are unconditionally present. + * + * | **Directive** | **Minutes** | **Seconds** | **Separator** | **Representation of zero** | + * | `X` | unless zero | never | none | `Z` | + * | `XX` | always | never | none | `Z` | + * | `XXX` | always | never | colon | `Z` | + * | `XXXX` | always | unless zero | none | `Z` | + * | `XXXXX`, `ZZZZZ` | always | unless zero | colon | `Z` | + * | `x` | unless zero | never | none | `+00` | + * | `xx`, `Z`, `ZZ`, `ZZZ` | always | never | none | `+0000` | + * | `xxx` | always | never | colon | `+00:00` | + * | `xxxx` | always | unless zero | none | `+0000` | + * | `xxxxx` | always | unless zero | colon | `+00:00` | + * + * Additionally, because the `y` directive is very often used instead of `u`, they are taken to mean the same. + * This may lead to unexpected results if the year is negative: `y` would always produce a positive number, whereas + * `u` may sometimes produce a negative one. For example: + * ``` + * LocalDate(-10, 1, 5).format { byUnicodeFormat("yyyy-MM-dd") } // -0010-01-05 + * LocalDate(-10, 1, 5).toJavaLocalDate().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd")) // 0011-01-05 + * ``` + * + * Note that, when the format includes the era directive, [byUnicodePattern] will fail with an exception, so almost all + * of the intentional usages of `y` will correctly report an error instead of behaving slightly differently. + * + * @throws IllegalArgumentException if the pattern is invalid or contains unsupported directives. + * @throws IllegalArgumentException if the builder is incompatible with the specified directives. + * @throws UnsupportedOperationException if the kotlinx-datetime library does not support the specified directives. + */ +@FormatStringsInDatetimeFormats +public fun DateTimeFormatBuilder.byUnicodePattern(pattern: String) { + val directives = UnicodeFormat.parse(pattern) + fun rec(builder: DateTimeFormatBuilder, format: UnicodeFormat) { + when (format) { + is UnicodeFormat.StringLiteral -> builder.chars(format.literal) + is UnicodeFormat.Sequence -> format.formats.forEach { rec(builder, it) } + is UnicodeFormat.OptionalGroup -> builder.alternativeParsing({}) { + rec(this, format.format) + } + is UnicodeFormat.Directive -> { + when (format) { + is UnicodeFormat.Directive.TimeBased -> { + require(builder is DateTimeFormatBuilder.WithTime) { + "A time-based directive $format was used in a format builder that doesn't support time components" + } + format.addToFormat(builder) + } + + is UnicodeFormat.Directive.DateBased -> { + require(builder is DateTimeFormatBuilder.WithDate) { + "A date-based directive $format was used in a format builder that doesn't support date components" + } + format.addToFormat(builder) + } + + is UnicodeFormat.Directive.ZoneBased -> { + require(builder is DateTimeFormatBuilder.WithDateTimeComponents) { + "A time-zone-based directive $format was used in a format builder that doesn't support time-zone components" + } + format.addToFormat(builder) + } + + is UnicodeFormat.Directive.OffsetBased -> { + require(builder is DateTimeFormatBuilder.WithUtcOffset) { + "A UTC-offset-based directive $format was used in a format builder that doesn't support UTC offset components" + } + format.addToFormat(builder) + } + + is UnknownUnicodeDirective -> { + throw IllegalArgumentException("The meaning of the directive '$format' is unknown") + } + } + } + } + } + rec(this, directives) +} + +/* +The code that translates Unicode directives to the kotlinx-datetime format is based on these references: +* https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/time/format/DateTimeFormatterBuilder.html#appendPattern(java.lang.String) +* https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + */ +internal sealed interface UnicodeFormat { + companion object { + fun parse(pattern: String): UnicodeFormat { + val groups: MutableList?> = mutableListOf(mutableListOf()) + var insideLiteral = false + var literal = "" + var lastCharacter: Char? = null + var lastCharacterCount = 0 + for (character in pattern) { + if (character == lastCharacter) { + ++lastCharacterCount + } else if (insideLiteral) { + if (character == '\'') { + groups.last()?.add(StringLiteral(literal.ifEmpty { "'" })) + insideLiteral = false + literal = "" + } else literal += character + } else { + if (lastCharacterCount > 0) { + groups.last()?.add(unicodeDirective(lastCharacter!!, lastCharacterCount)) + lastCharacter = null + lastCharacterCount = 0 + } + if (character !in nonPlainCharacters) { + literal += character + continue + } + if (literal != "") { + groups.last()?.add(StringLiteral(literal)) + literal = "" + } + when (character) { + '\'' -> { + insideLiteral = true + literal = "" + } + + '[' -> { + groups.add(mutableListOf()) + } + + ']' -> { + val group = + groups.removeLast() ?: throw IllegalArgumentException("Unmatched closing bracket") + groups.last()?.add(OptionalGroup(Sequence(group))) + } + + else -> { + lastCharacter = character + lastCharacterCount = 1 + } + } + } + } + if (lastCharacterCount > 0) { + groups.last()?.add(unicodeDirective(lastCharacter!!, lastCharacterCount)) + } + if (literal != "") { + groups.last()?.add(StringLiteral(literal)) + } + return Sequence(groups.removeLast() ?: throw IllegalArgumentException("Unmatched opening bracket")) + } + } + + data class OptionalGroup(val format: UnicodeFormat) : UnicodeFormat { + override fun toString(): String = "[$format]" + } + + data class Sequence(val formats: List) : UnicodeFormat { + override fun toString(): String = formats.joinToString("") + } + + data class StringLiteral(val literal: String) : UnicodeFormat { + override fun toString(): String = if (literal == "'") "''" else + if (literal.any { it.isLetter() }) "'$literal'" + else if (literal.isEmpty()) "" + else literal + } + + sealed class Directive : UnicodeFormat { + abstract val formatLength: Int + abstract val formatLetter: Char + override fun toString(): String = "$formatLetter".repeat(formatLength) + override fun equals(other: Any?): Boolean = + other is Directive && formatLetter == other.formatLetter && formatLength == other.formatLength + + override fun hashCode(): Int = formatLetter.hashCode() * 31 + formatLength + + sealed class DateBased : Directive() { + abstract fun addToFormat(builder: DateTimeFormatBuilder.WithDate) + + class Era(override val formatLength: Int) : DateBased() { + override val formatLetter = 'G' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = localizedDirective() + } + + class Year(override val formatLength: Int) : DateBased() { + override val formatLetter = 'u' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + when (formatLength) { + 1 -> builder.year(padding = Padding.NONE) + 2 -> builder.yearTwoDigits(baseYear = 2000) + 3 -> unsupportedPadding(formatLength) + 4 -> builder.year(padding = Padding.ZERO) + else -> unsupportedPadding(formatLength) + } + } + } + + class YearOfEra(override val formatLength: Int) : DateBased() { + override val formatLetter = 'y' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = when (formatLength) { + 1 -> builder.yearOfEra(padding = Padding.NONE) + 2 -> builder.yearOfEraTwoDigits(baseYear = 2000) + 3 -> unsupportedPadding(formatLength) + 4 -> builder.yearOfEra(padding = Padding.ZERO) + else -> unsupportedPadding(formatLength) + } + } + + class CyclicYearName(override val formatLength: Int) : DateBased() { + override val formatLetter = 'U' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = unsupportedDirective("cyclic-year") + } + + // https://cldr.unicode.org/development/development-process/design-proposals/pattern-character-for-related-year + class RelatedGregorianYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'r' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = + unsupportedDirective("related-gregorian-year") + } + + class DayOfYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'D' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = unsupportedDirective("day-of-year") + } + + class MonthOfYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'M' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + when (formatLength) { + 1 -> builder.monthNumber(Padding.NONE) + 2 -> builder.monthNumber(Padding.ZERO) + 3, 4, 5 -> localizedDirective() + else -> unknownLength() + } + } + } + + class StandaloneMonthOfYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'L' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + when (formatLength) { + 1 -> builder.monthNumber(Padding.NONE) + 2 -> builder.monthNumber(Padding.ZERO) + 3, 4, 5 -> localizedDirective() + else -> unknownLength() + } + } + } + + class DayOfMonth(override val formatLength: Int) : DateBased() { + override val formatLetter = 'd' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = when (formatLength) { + 1 -> builder.dayOfMonth(Padding.NONE) + 2 -> builder.dayOfMonth(Padding.ZERO) + else -> unknownLength() + } + } + + class ModifiedJulianDay(override val formatLength: Int) : DateBased() { + override val formatLetter = 'g' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = + unsupportedDirective("modified-julian-day") + } + + class QuarterOfYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'Q' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + when (formatLength) { + 1, 2 -> unsupportedDirective("quarter-of-year") + 3, 4, 5 -> localizedDirective() + else -> unknownLength() + } + } + } + + class StandaloneQuarterOfYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'q' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + when (formatLength) { + 1, 2 -> unsupportedDirective("standalone-quarter-of-year") + 3, 4, 5 -> localizedDirective() + else -> unknownLength() + } + } + } + + class WeekBasedYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'Y' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = + unsupportedDirective("week-based-year") + } + + class WeekOfWeekBasedYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'w' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = + unsupportedDirective("week-of-week-based-year") + } + + class WeekOfMonth(override val formatLength: Int) : DateBased() { + override val formatLetter = 'W' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = + unsupportedDirective("week-of-month") + } + + class DayOfWeek(override val formatLength: Int) : DateBased() { + override val formatLetter = 'E' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = localizedDirective() + } + + class LocalizedDayOfWeek(override val formatLength: Int) : DateBased() { + override val formatLetter = 'e' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = localizedDirective() + } + + class StandaloneLocalizedDayOfWeek(override val formatLength: Int) : DateBased() { + override val formatLetter = 'c' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = localizedDirective() + } + + class DayOfWeekInMonth(override val formatLength: Int) : DateBased() { + override val formatLetter = 'F' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = + unsupportedDirective("day-of-week-in-month") + } + + } + + sealed class TimeBased : Directive() { + abstract fun addToFormat(builder: DateTimeFormatBuilder.WithTime) + + class AmPmMarker(override val formatLength: Int) : TimeBased() { + override val formatLetter = 'a' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = localizedDirective() + } + + class AmPmHourOfDay(override val formatLength: Int) : TimeBased() { + override val formatLetter = 'h' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = localizedDirective() + } + + class HourOfDay(override val formatLength: Int) : TimeBased() { + override val formatLetter = 'H' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = when (formatLength) { + 1 -> builder.hour(Padding.NONE) + 2 -> builder.hour(Padding.ZERO) + else -> unknownLength() + } + } + + class MinuteOfHour(override val formatLength: Int) : TimeBased() { + override val formatLetter = 'm' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = when (formatLength) { + 1 -> builder.minute(Padding.NONE) + 2 -> builder.minute(Padding.ZERO) + else -> unknownLength() + } + } + + + sealed class WithSecondPrecision : TimeBased() { + class SecondOfMinute(override val formatLength: Int) : WithSecondPrecision() { + override val formatLetter = 's' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = when (formatLength) { + 1 -> builder.second(Padding.NONE) + 2 -> builder.second(Padding.ZERO) + else -> unknownLength() + } + } + + } + + sealed class WithSubsecondPrecision : WithSecondPrecision() { + class FractionOfSecond(override val formatLength: Int) : WithSubsecondPrecision() { + override val formatLetter = 'S' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = + builder.secondFraction(formatLength) + } + + class MilliOfDay(override val formatLength: Int) : WithSubsecondPrecision() { + override val formatLetter = 'A' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = + unsupportedDirective("millisecond-of-day") + } + + class NanoOfSecond(override val formatLength: Int) : WithSubsecondPrecision() { + override val formatLetter = 'n' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = + unsupportedDirective("nano-of-second", "Maybe you meant 'S' instead of 'n'?") + } + + class NanoOfDay(override val formatLength: Int) : WithSubsecondPrecision() { + override val formatLetter = 'N' + override fun addToFormat(builder: DateTimeFormatBuilder.WithTime) = + unsupportedDirective("nanosecond-of-day") + } + } + } + + sealed class ZoneBased : Directive() { + abstract fun addToFormat(builder: DateTimeFormatBuilder.WithDateTimeComponents) + + + class TimeZoneId(override val formatLength: Int) : ZoneBased() { + override val formatLetter = 'V' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDateTimeComponents) = when (formatLength) { + 2 -> builder.timeZoneId() + else -> unknownLength() + } + } + + class GenericTimeZoneName(override val formatLength: Int) : ZoneBased() { + override val formatLetter = 'v' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDateTimeComponents) = localizedDirective() + } + + class TimeZoneName(override val formatLength: Int) : ZoneBased() { + override val formatLetter = 'z' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDateTimeComponents) = + localizedDirective("Format 'V' can be used to format time zone IDs in a locale-invariant manner.") + } + } + + sealed class OffsetBased : Directive() { + abstract fun addToFormat(builder: DateTimeFormatBuilder.WithUtcOffset) + + abstract fun outputMinutes(): WhenToOutput + + abstract fun outputSeconds(): WhenToOutput + + fun DateTimeFormatBuilder.WithUtcOffset.offset(zOnZero: Boolean, useSeparator: Boolean) { + isoOffset(zOnZero, useSeparator, outputMinutes(), outputSeconds()) + } + + class LocalizedZoneOffset(override val formatLength: Int) : OffsetBased() { + override val formatLetter = 'O' + override fun addToFormat(builder: DateTimeFormatBuilder.WithUtcOffset) = localizedDirective() + + override fun outputMinutes(): WhenToOutput = localizedDirective() + + override fun outputSeconds(): WhenToOutput = localizedDirective() + } + + class ZoneOffset1(override val formatLength: Int) : OffsetBased() { + override val formatLetter = 'X' + override fun addToFormat(builder: DateTimeFormatBuilder.WithUtcOffset) { + when (formatLength) { + 1 -> builder.offset(zOnZero = true, useSeparator = false) + 2 -> builder.offset(zOnZero = true, useSeparator = false) + 3 -> builder.offset(zOnZero = true, useSeparator = true) + 4 -> builder.offset(zOnZero = true, useSeparator = false) + 5 -> builder.offset(zOnZero = true, useSeparator = true) + else -> unknownLength() + } + } + + override fun outputMinutes(): WhenToOutput = + if (formatLength == 1) WhenToOutput.IF_NONZERO else WhenToOutput.ALWAYS + + override fun outputSeconds(): WhenToOutput = + if (formatLength <= 3) WhenToOutput.NEVER else WhenToOutput.IF_NONZERO + } + + class ZoneOffset2(override val formatLength: Int) : OffsetBased() { + override val formatLetter = 'x' + override fun addToFormat(builder: DateTimeFormatBuilder.WithUtcOffset) { + when (formatLength) { + 1 -> builder.offset(zOnZero = false, useSeparator = false) + 2 -> builder.offset(zOnZero = false, useSeparator = false) + 3 -> builder.offset(zOnZero = false, useSeparator = true) + 4 -> builder.offset(zOnZero = false, useSeparator = false) + 5 -> builder.offset(zOnZero = false, useSeparator = true) + else -> unknownLength() + } + } + + override fun outputMinutes(): WhenToOutput = + if (formatLength == 1) WhenToOutput.IF_NONZERO else WhenToOutput.ALWAYS + + override fun outputSeconds(): WhenToOutput = + if (formatLength <= 3) WhenToOutput.NEVER else WhenToOutput.IF_NONZERO + } + + class ZoneOffset3(override val formatLength: Int) : OffsetBased() { + override val formatLetter = 'Z' + override fun addToFormat(builder: DateTimeFormatBuilder.WithUtcOffset) { + when (formatLength) { + 1, 2, 3 -> builder.offset(zOnZero = false, useSeparator = false) + 4 -> LocalizedZoneOffset(4).addToFormat(builder) + 5 -> builder.offset(zOnZero = false, useSeparator = true) + else -> unknownLength() + } + } + + override fun outputMinutes(): WhenToOutput = WhenToOutput.ALWAYS + + override fun outputSeconds(): WhenToOutput = + if (formatLength <= 3) WhenToOutput.NEVER else WhenToOutput.IF_NONZERO + } + } + } +} + +private class UnknownUnicodeDirective(override val formatLetter: Char, override val formatLength: Int) : UnicodeFormat.Directive() + +private fun unicodeDirective(char: Char, formatLength: Int): UnicodeFormat = when (char) { + 'G' -> UnicodeFormat.Directive.DateBased.Era(formatLength) + 'y' -> UnicodeFormat.Directive.DateBased.YearOfEra(formatLength) + 'Y' -> UnicodeFormat.Directive.DateBased.WeekBasedYear(formatLength) + 'u' -> UnicodeFormat.Directive.DateBased.Year(formatLength) + 'U' -> UnicodeFormat.Directive.DateBased.CyclicYearName(formatLength) + 'r' -> UnicodeFormat.Directive.DateBased.RelatedGregorianYear(formatLength) + 'Q' -> UnicodeFormat.Directive.DateBased.QuarterOfYear(formatLength) + 'q' -> UnicodeFormat.Directive.DateBased.StandaloneQuarterOfYear(formatLength) + 'M' -> UnicodeFormat.Directive.DateBased.MonthOfYear(formatLength) + 'L' -> UnicodeFormat.Directive.DateBased.StandaloneMonthOfYear(formatLength) + 'w' -> UnicodeFormat.Directive.DateBased.WeekOfWeekBasedYear(formatLength) + 'W' -> UnicodeFormat.Directive.DateBased.WeekOfMonth(formatLength) + 'd' -> UnicodeFormat.Directive.DateBased.DayOfMonth(formatLength) + 'D' -> UnicodeFormat.Directive.DateBased.DayOfYear(formatLength) + 'F' -> UnicodeFormat.Directive.DateBased.DayOfWeekInMonth(formatLength) + 'g' -> UnicodeFormat.Directive.DateBased.ModifiedJulianDay(formatLength) + 'E' -> UnicodeFormat.Directive.DateBased.DayOfWeek(formatLength) + 'e' -> UnicodeFormat.Directive.DateBased.LocalizedDayOfWeek(formatLength) + 'c' -> UnicodeFormat.Directive.DateBased.StandaloneLocalizedDayOfWeek(formatLength) + 'a' -> UnicodeFormat.Directive.TimeBased.AmPmMarker(formatLength) + 'h' -> UnicodeFormat.Directive.TimeBased.AmPmHourOfDay(formatLength) + 'H' -> UnicodeFormat.Directive.TimeBased.HourOfDay(formatLength) + 'm' -> UnicodeFormat.Directive.TimeBased.MinuteOfHour(formatLength) + 's' -> UnicodeFormat.Directive.TimeBased.WithSecondPrecision.SecondOfMinute(formatLength) + 'S' -> UnicodeFormat.Directive.TimeBased.WithSubsecondPrecision.FractionOfSecond(formatLength) + 'A' -> UnicodeFormat.Directive.TimeBased.WithSubsecondPrecision.MilliOfDay(formatLength) + 'n' -> UnicodeFormat.Directive.TimeBased.WithSubsecondPrecision.NanoOfSecond(formatLength) + 'N' -> UnicodeFormat.Directive.TimeBased.WithSubsecondPrecision.NanoOfDay(formatLength) + 'V' -> UnicodeFormat.Directive.ZoneBased.TimeZoneId(formatLength) + 'v' -> UnicodeFormat.Directive.ZoneBased.GenericTimeZoneName(formatLength) + 'z' -> UnicodeFormat.Directive.ZoneBased.TimeZoneName(formatLength) + 'O' -> UnicodeFormat.Directive.OffsetBased.LocalizedZoneOffset(formatLength) + 'X' -> UnicodeFormat.Directive.OffsetBased.ZoneOffset1(formatLength) + 'x' -> UnicodeFormat.Directive.OffsetBased.ZoneOffset2(formatLength) + 'Z' -> UnicodeFormat.Directive.OffsetBased.ZoneOffset3(formatLength) + else -> UnknownUnicodeDirective(char, formatLength) +} + +private val nonPlainCharacters = ('a'..'z') + ('A'..'Z') + listOf('[', ']', '\'') + +private fun unsupportedDirective(fieldName: String, recommendation: String? = null): Nothing = + throw UnsupportedOperationException( + "kotlinx.datetime formatting does not support the $fieldName field. " + + (if (recommendation != null) "$recommendation " else "") + + "Please report your use case to https://github.com/Kotlin/kotlinx-datetime/issues" + ) + +private fun UnicodeFormat.Directive.unknownLength(): Nothing = + throw IllegalArgumentException("Unknown length $formatLength for the $formatLetter directive") + +private fun UnicodeFormat.Directive.localizedDirective(recommendation: String? = null): Nothing = + throw IllegalArgumentException( + "The directive '$this' is locale-dependent, but locales are not supported in Kotlin" + + if (recommendation != null) ". $recommendation" else "" + ) + +private fun UnicodeFormat.Directive.unsupportedPadding(digits: Int): Nothing = + throw UnsupportedOperationException("Padding do $digits digits is not supported for the $formatLetter directive") diff --git a/core/common/src/format/UtcOffsetFormat.kt b/core/common/src/format/UtcOffsetFormat.kt new file mode 100644 index 000000000..8b94d0d68 --- /dev/null +++ b/core/common/src/format/UtcOffsetFormat.kt @@ -0,0 +1,270 @@ +/* + * 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.format.* +import kotlinx.datetime.internal.format.parser.Copyable +import kotlin.math.* +import kotlin.native.concurrent.* + +internal interface UtcOffsetFieldContainer { + var isNegative: Boolean? + var totalHoursAbs: Int? + var minutesOfHour: Int? + var secondsOfMinute: Int? +} + +internal interface AbstractWithOffsetBuilder: DateTimeFormatBuilder.WithUtcOffset { + fun addFormatStructureForOffset(structure: FormatStructure) + + override fun offsetHours(padding: Padding) = + addFormatStructureForOffset(SignedFormatStructure( + BasicFormatStructure(UtcOffsetWholeHoursDirective(padding)), + withPlusSign = true + )) + + override fun offsetMinutesOfHour(padding: Padding) = + addFormatStructureForOffset(BasicFormatStructure(UtcOffsetMinuteOfHourDirective(padding))) + + override fun offsetSecondsOfMinute(padding: Padding) = + addFormatStructureForOffset(BasicFormatStructure(UtcOffsetSecondOfMinuteDirective(padding))) + + @Suppress("NO_ELSE_IN_WHEN") + override fun offset(format: DateTimeFormat) = when (format) { + is UtcOffsetFormat -> addFormatStructureForOffset(format.actualFormat.directives) + } +} + +internal class UtcOffsetFormat(override val actualFormat: StringFormat) : + AbstractDateTimeFormat() { + companion object { + fun build(block: DateTimeFormatBuilder.WithUtcOffset.() -> Unit): UtcOffsetFormat { + val builder = Builder(AppendableFormatStructure()) + builder.block() + return UtcOffsetFormat(builder.build()) + } + } + + private class Builder(override val actualBuilder: AppendableFormatStructure) : + AbstractDateTimeFormatBuilder, AbstractWithOffsetBuilder { + + override fun addFormatStructureForOffset(structure: FormatStructure) { + actualBuilder.add(structure) + } + + override fun createEmpty(): Builder = Builder(AppendableFormatStructure()) + } + + override fun intermediateFromValue(value: UtcOffset): IncompleteUtcOffset = + IncompleteUtcOffset().apply { populateFrom(value) } + + override fun valueFromIntermediate(intermediate: IncompleteUtcOffset): UtcOffset = intermediate.toUtcOffset() + + override val emptyIntermediate: IncompleteUtcOffset get() = emptyIncompleteUtcOffset + +} + +internal enum class WhenToOutput { + NEVER, + IF_NONZERO, + ALWAYS; +} + +internal fun T.outputIfNeeded(whenToOutput: WhenToOutput, format: T.() -> Unit) { + when (whenToOutput) { + WhenToOutput.NEVER -> { } + WhenToOutput.IF_NONZERO -> { + optional { + format() + } + } + WhenToOutput.ALWAYS -> { + format() + } + } +} + +internal fun DateTimeFormatBuilder.WithUtcOffset.isoOffset( + zOnZero: Boolean, + useSeparator: Boolean, + outputMinute: WhenToOutput, + outputSecond: WhenToOutput +) { + require(outputMinute >= outputSecond) { "Seconds cannot be included without minutes" } + fun DateTimeFormatBuilder.WithUtcOffset.appendIsoOffsetWithoutZOnZero() { + offsetHours() + outputIfNeeded(outputMinute) { + if (useSeparator) { char(':') } + offsetMinutesOfHour() + outputIfNeeded(outputSecond) { + if (useSeparator) { char(':') } + offsetSecondsOfMinute() + } + } + } + if (zOnZero) { + optional("Z") { + alternativeParsing({ + char('z') + }) { + appendIsoOffsetWithoutZOnZero() + } + } + } else { + appendIsoOffsetWithoutZOnZero() + } +} + +private object OffsetFields { + private val sign = object : FieldSign { + override val isNegative = PropertyAccessor(UtcOffsetFieldContainer::isNegative) + override fun isZero(obj: UtcOffsetFieldContainer): Boolean = + (obj.totalHoursAbs ?: 0) == 0 && (obj.minutesOfHour ?: 0) == 0 && (obj.secondsOfMinute ?: 0) == 0 + } + val totalHoursAbs = UnsignedFieldSpec( + PropertyAccessor(UtcOffsetFieldContainer::totalHoursAbs), + defaultValue = 0, + minValue = 0, + maxValue = 18, + sign = sign, + ) + val minutesOfHour = UnsignedFieldSpec( + PropertyAccessor(UtcOffsetFieldContainer::minutesOfHour), + defaultValue = 0, + minValue = 0, + maxValue = 59, + sign = sign, + ) + val secondsOfMinute = UnsignedFieldSpec( + PropertyAccessor(UtcOffsetFieldContainer::secondsOfMinute), + defaultValue = 0, + minValue = 0, + maxValue = 59, + sign = sign, + ) +} + +internal class IncompleteUtcOffset( + override var isNegative: Boolean? = null, + override var totalHoursAbs: Int? = null, + override var minutesOfHour: Int? = null, + override var secondsOfMinute: Int? = null, +) : UtcOffsetFieldContainer, Copyable { + + fun toUtcOffset(): UtcOffset { + val sign = if (isNegative == true) -1 else 1 + return UtcOffset( + totalHoursAbs?.let { it * sign }, minutesOfHour?.let { it * sign }, secondsOfMinute?.let { it * sign } + ) + } + + fun populateFrom(offset: UtcOffset) { + isNegative = offset.totalSeconds < 0 + val totalSecondsAbs = offset.totalSeconds.absoluteValue + totalHoursAbs = totalSecondsAbs / 3600 + minutesOfHour = (totalSecondsAbs / 60) % 60 + secondsOfMinute = totalSecondsAbs % 60 + } + + override fun equals(other: Any?): Boolean = + other is IncompleteUtcOffset && isNegative == other.isNegative && totalHoursAbs == other.totalHoursAbs && + minutesOfHour == other.minutesOfHour && secondsOfMinute == other.secondsOfMinute + + override fun hashCode(): Int = + isNegative.hashCode() + totalHoursAbs.hashCode() + minutesOfHour.hashCode() + secondsOfMinute.hashCode() + + override fun copy(): IncompleteUtcOffset = + IncompleteUtcOffset(isNegative, totalHoursAbs, minutesOfHour, secondsOfMinute) + + override fun toString(): String = + "${isNegative?.let { if (it) "-" else "+" } ?: " "}${totalHoursAbs ?: "??"}:${minutesOfHour ?: "??"}:${secondsOfMinute ?: "??"}" +} + +internal class UtcOffsetWholeHoursDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + OffsetFields.totalHoursAbs, + minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2) + ) { + + override val builderRepresentation: String get() = + "${DateTimeFormatBuilder.WithUtcOffset::offsetHours.name}(${padding.toKotlinCode()})" + + override fun equals(other: Any?): Boolean = other is UtcOffsetWholeHoursDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +private class UtcOffsetMinuteOfHourDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + OffsetFields.minutesOfHour, + minDigits = padding.minDigits(2), spacePadding = padding.spaces(2) + ) { + + override val builderRepresentation: String get() = when (padding) { + Padding.NONE -> "${DateTimeFormatBuilder.WithUtcOffset::offsetMinutesOfHour.name}()" + else -> "${DateTimeFormatBuilder.WithUtcOffset::offsetMinutesOfHour.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is UtcOffsetMinuteOfHourDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +private class UtcOffsetSecondOfMinuteDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + OffsetFields.secondsOfMinute, + minDigits = padding.minDigits(2), spacePadding = padding.spaces(2) + ) { + + override val builderRepresentation: String get() = when (padding) { + Padding.NONE -> "${DateTimeFormatBuilder.WithUtcOffset::offsetSecondsOfMinute.name}()" + else -> "${DateTimeFormatBuilder.WithUtcOffset::offsetSecondsOfMinute.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is UtcOffsetSecondOfMinuteDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + +// these are constants so that the formats are not recreated every time they are used +internal val ISO_OFFSET by lazy { + UtcOffsetFormat.build { + alternativeParsing({ chars("z") }) { + optional("Z") { + offsetHours() + char(':') + offsetMinutesOfHour() + optional { + char(':') + offsetSecondsOfMinute() + } + } + } + } +} +internal val ISO_OFFSET_BASIC by lazy { + UtcOffsetFormat.build { + alternativeParsing({ chars("z") }) { + optional("Z") { + offsetHours() + optional { + offsetMinutesOfHour() + optional { + offsetSecondsOfMinute() + } + } + } + } + } +} + +internal val FOUR_DIGIT_OFFSET by lazy { + UtcOffsetFormat.build { + offsetHours() + offsetMinutesOfHour() + } +} + +private val emptyIncompleteUtcOffset = IncompleteUtcOffset() 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..dd695135c --- /dev/null +++ b/core/common/src/internal/format/Builder.kt @@ -0,0 +1,17 @@ +/* + * 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 class AppendableFormatStructure { + private val list: MutableList> = mutableListOf() + fun build(): ConcatenatedFormatStructure = ConcatenatedFormatStructure(list) + fun add(format: FormatStructure) { + when (format) { + is NonConcatenatedFormatStructure -> list.add(format) + is ConcatenatedFormatStructure -> format.formats.forEach { list.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..d020b37f9 --- /dev/null +++ b/core/common/src/internal/format/FieldFormatDirective.kt @@ -0,0 +1,240 @@ +/* + * 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.* + +/** + * 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 + + /** + * The formatter operation that formats the field. + */ + fun formatter(): FormatterStructure + + /** + * The parser structure that parses the field. + */ + fun parser(): ParserStructure + + /** + * The string with the code that, when evaluated in the builder context, appends the directive. + */ + val builderRepresentation: String +} + +/** + * 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, + private val minDigits: Int, + private val spacePadding: Int?, +) : FieldFormatDirective { + + private val maxDigits: Int = field.maxDigits + + init { + require(minDigits >= 0) { + "The minimum number of digits ($minDigits) is negative" + } + require(maxDigits >= minDigits) { + "The maximum number of digits ($maxDigits) is less than the minimum number of digits ($minDigits)" + } + if (spacePadding != null) { + require(spacePadding > minDigits) { + "The space padding ($spacePadding) should be more than the minimum number of digits ($minDigits)" + } + } + } + + override fun formatter(): FormatterStructure { + val formatter = UnsignedIntFormatterStructure( + number = field.accessor::getterNotNull, + zeroPadding = minDigits, + ) + return if (spacePadding != null) SpacePaddedFormatter(formatter, spacePadding) else formatter + } + + override fun parser(): ParserStructure = + spaceAndZeroPaddedUnsignedInt(minDigits, maxDigits, spacePadding, field.accessor, field.name) +} + +/** + * 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, + private val name: String, +) : 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})" + } + } + + private fun getStringValue(target: Target): String = field.accessor.getterNotNull(target).let { + values.getOrNull(it-field.minValue) + ?: "The value $it of ${field.name} does not have a corresponding string representation" + } + + private inner class AssignableString: AssignableField { + override fun trySetWithoutReassigning(container: Target, newValue: String): String? = + field.accessor.trySetWithoutReassigning(container, values.indexOf(newValue) + field.minValue)?.let { + values[it - field.minValue] + } + + override val name: String get() = this@NamedUnsignedIntFieldFormatDirective.name + } + + override fun formatter(): FormatterStructure = + StringFormatterStructure(::getStringValue) + + override fun parser(): ParserStructure = + ParserStructure( + listOf( + StringSetParserOperation(values, AssignableString(), "One of $values for $name") + ), emptyList() + ) +} + +/** + * A directive for a string-based format of an enum field. + */ +internal abstract class NamedEnumIntFieldFormatDirective( + final override val field: FieldSpec, + private val mapping: Map, + private val name: String, +) : FieldFormatDirective { + + private val reverseMapping = mapping.entries.associate { it.value to it.key } + + private fun getStringValue(target: Target): String = field.accessor.getterNotNull(target).let { + mapping[field.accessor.getterNotNull(target)] + ?: "The value $it of ${field.name} does not have a corresponding string representation" + } + + private inner class AssignableString: AssignableField { + override fun trySetWithoutReassigning(container: Target, newValue: String): String? = + field.accessor.trySetWithoutReassigning(container, reverseMapping[newValue]!!)?.let { mapping[it] } + + override val name: String get() = this@NamedEnumIntFieldFormatDirective.name + } + + override fun formatter(): FormatterStructure = + StringFormatterStructure(::getStringValue) + + override fun parser(): ParserStructure = + ParserStructure( + listOf( + StringSetParserOperation(mapping.values, AssignableString(), "One of ${mapping.values} for $name") + ), emptyList() + ) +} + +internal abstract class StringFieldFormatDirective( + final override val field: FieldSpec, + private val acceptedStrings: Set, +) : FieldFormatDirective { + + init { + require(acceptedStrings.isNotEmpty()) { + "The set of accepted strings is empty" + } + } + + override fun formatter(): FormatterStructure = + StringFormatterStructure(field.accessor::getterNotNull) + + override fun parser(): ParserStructure = + ParserStructure( + listOf(StringSetParserOperation(acceptedStrings, field.accessor, field.name)), + emptyList() + ) +} + +internal abstract class SignedIntFieldFormatDirective( + final override val field: SignedFieldSpec, + private val minDigits: Int?, + private val maxDigits: Int? = field.maxDigits, + private val spacePadding: Int?, + private val outputPlusOnExceededWidth: Int?, +) : FieldFormatDirective { + + init { + require(minDigits == null || minDigits >= 0) { "The minimum number of digits ($minDigits) is negative" } + require(maxDigits == null || minDigits == null || maxDigits >= minDigits) { + "The maximum number of digits ($maxDigits) is less than the minimum number of digits ($minDigits)" + } + } + + override fun formatter(): FormatterStructure { + val formatter = SignedIntFormatterStructure( + number = field.accessor::getterNotNull, + zeroPadding = minDigits ?: 0, + outputPlusOnExceededWidth = outputPlusOnExceededWidth, + ) + return if (spacePadding != null) SpacePaddedFormatter(formatter, spacePadding) else formatter + } + + override fun parser(): ParserStructure = + SignedIntParser( + minDigits = minDigits, + maxDigits = maxDigits, + spacePadding = spacePadding, + field.accessor, + field.name, + plusOnExceedsWidth = outputPlusOnExceededWidth, + ) +} + +internal abstract class DecimalFractionFieldFormatDirective( + final override val field: FieldSpec, + private val minDigits: Int, + private val maxDigits: Int, + private val zerosToAdd: List, +) : FieldFormatDirective { + override fun formatter(): FormatterStructure = + DecimalFractionFormatterStructure(field.accessor::getterNotNull, minDigits, maxDigits, zerosToAdd) + + override fun parser(): ParserStructure = ParserStructure( + listOf( + NumberSpanParserOperation( + listOf(FractionPartConsumer(minDigits, maxDigits, field.accessor, field.name)) + ) + ), + emptyList() + ) +} + +internal abstract class ReducedIntFieldDirective( + final override val field: SignedFieldSpec, + private val digits: Int, + private val base: Int, +) : FieldFormatDirective { + + override fun formatter(): FormatterStructure = + ReducedIntFormatterStructure( + number = field.accessor::getterNotNull, + digits = digits, + base = base, + ) + + override fun parser(): ParserStructure = + ReducedIntParser(digits = digits, base = base, field.accessor, field.name) +} diff --git a/core/common/src/internal/format/FieldSpec.kt b/core/common/src/internal/format/FieldSpec.kt new file mode 100644 index 000000000..a93a8242f --- /dev/null +++ b/core/common/src/internal/format/FieldSpec.kt @@ -0,0 +1,144 @@ +/* + * 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.parser.AssignableField +import kotlin.reflect.* + +internal interface Accessor: AssignableField { + fun getter(container: Object): Field? + + /** + * 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. + */ + fun getterNotNull(container: Object): Field = + getter(container) ?: throw IllegalStateException("Field $name is not set") +} + +internal class PropertyAccessor(private val property: KMutableProperty1): Accessor { + override val name: String get() = property.name + + override fun trySetWithoutReassigning(container: Object, newValue: Field): Field? { + val oldValue = property.get(container) + return when { + oldValue === null -> { + property.set(container, newValue) + null + } + oldValue == newValue -> null + else -> oldValue + } + } + + override fun getter(container: Object): Field? = property.get(container) +} + +internal interface FieldSign { + /** + * The field that is `true` if the value of the field is known to be negative, and `false` otherwise. + */ + val isNegative: Accessor + fun isZero(obj: Target): Boolean +} + +/** + * 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 + + /** + * The sign corresponding to the field value, or `null` if the field has none. + */ + val sign: FieldSign? +} + +internal abstract class AbstractFieldSpec: FieldSpec { + override fun toString(): String = "The field $name (default value is $defaultValue)" +} + +/** + * 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, + override val sign: FieldSign? = 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, + override val sign: FieldSign? = 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, + override val sign: FieldSign? = 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/Predicate.kt b/core/common/src/internal/format/Predicate.kt new file mode 100644 index 000000000..096e33262 --- /dev/null +++ b/core/common/src/internal/format/Predicate.kt @@ -0,0 +1,35 @@ +/* + * 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 object Truth: Predicate { + override fun test(value: Any?): Boolean = true +} + +internal class ComparisonPredicate( + private val expectedValue: E, + private val getter: (T) -> E? +): Predicate { + override fun test(value: T): Boolean = getter(value) == expectedValue +} + +private class ConjunctionPredicate( + private val predicates: List> +): Predicate { + override fun test(value: T): Boolean = predicates.all { it.test(value) } +} + +internal fun conjunctionPredicate( + predicates: List> +): Predicate = when { + predicates.isEmpty() -> Truth + predicates.size == 1 -> predicates.single() + else -> ConjunctionPredicate(predicates) +} diff --git a/core/common/src/internal/format/StringFormat.kt b/core/common/src/internal/format/StringFormat.kt new file mode 100644 index 000000000..67b0f3bc1 --- /dev/null +++ b/core/common/src/internal/format/StringFormat.kt @@ -0,0 +1,259 @@ +/* + * 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)" + + override fun equals(other: Any?): Boolean = other is BasicFormatStructure<*> && directive == other.directive + override fun hashCode(): Int = directive.hashCode() +} + +internal class ConstantFormatStructure( + val string: String +) : NonConcatenatedFormatStructure { + override fun toString(): String = "ConstantFormatStructure($string)" + + override fun equals(other: Any?): Boolean = other is ConstantFormatStructure<*> && string == other.string + override fun hashCode(): Int = string.hashCode() +} + +internal class SignedFormatStructure( + val format: FormatStructure, + val withPlusSign: Boolean, +) : NonConcatenatedFormatStructure { + + internal val fieldSigns = basicFormats(format).mapNotNull { it.field.sign }.toSet() + + init { + require(fieldSigns.isNotEmpty()) { "Signed format must contain at least one field with a sign" } + } + + override fun toString(): String = "SignedFormatStructure($format)" + + override fun equals(other: Any?): Boolean = + other is SignedFormatStructure<*> && format == other.format && withPlusSign == other.withPlusSign + override fun hashCode(): Int = 31 * format.hashCode() + withPlusSign.hashCode() +} + +internal class AlternativesParsingFormatStructure( + val mainFormat: FormatStructure, + val formats: List>, +) : NonConcatenatedFormatStructure { + override fun toString(): String = "AlternativesParsing($formats)" + + override fun equals(other: Any?): Boolean = + other is AlternativesParsingFormatStructure<*> && mainFormat == other.mainFormat && formats == other.formats + override fun hashCode(): Int = 31 * mainFormat.hashCode() + formats.hashCode() +} + +internal class OptionalFormatStructure( + val onZero: String, + val format: FormatStructure, +) : NonConcatenatedFormatStructure { + override fun toString(): String = "Optional($onZero, $format)" + + internal val fields = basicFormats(format).map { it.field }.distinct().map { field -> + PropertyWithDefault.fromField(field) + } + + override fun equals(other: Any?): Boolean = + other is OptionalFormatStructure<*> && onZero == other.onZero && format == other.format + + override fun hashCode(): Int = 31 * onZero.hashCode() + format.hashCode() +} + +internal sealed interface NonConcatenatedFormatStructure : FormatStructure + +internal class ConcatenatedFormatStructure( + val formats: List> +) : FormatStructure { + override fun toString(): String = "ConcatenatedFormatStructure(${formats.joinToString(", ")})" + + override fun equals(other: Any?): Boolean = other is ConcatenatedFormatStructure<*> && formats == other.formats + override fun hashCode(): Int { + return formats.hashCode() + } +} + +internal fun FormatStructure.formatter(): FormatterStructure = when (this) { + is BasicFormatStructure -> directive.formatter() + is ConstantFormatStructure -> ConstantStringFormatterStructure(string) + is SignedFormatStructure -> { + val innerFormat = format.formatter() + fun checkIfAllNegative(value: T): Boolean { + var seenNonZero = false + for (check in fieldSigns) { + when { + check.isNegative.getter(value) == true -> seenNonZero = true + check.isZero(value) -> continue + else -> return false + } + } + return seenNonZero + } + SignedFormatter( + innerFormat, + ::checkIfAllNegative, + withPlusSign + ) + } + + is AlternativesParsingFormatStructure -> mainFormat.formatter() + is OptionalFormatStructure -> { + val formatter = format.formatter() + val predicate = conjunctionPredicate(fields.map { it.isDefaultComparisonPredicate() }) + ConditionalFormatter( + listOf( + predicate::test to ConstantStringFormatterStructure(onZero), + Truth::test to formatter + ) + ) + } + + is ConcatenatedFormatStructure -> { + val formatters = formats.map { it.formatter() } + if (formatters.size == 1) { + formatters.single() + } else { + ConcatenatedFormatter(formatters) + } + } +} + +private fun FormatStructure.parser(): ParserStructure = when (this) { + is ConstantFormatStructure -> + ParserStructure( + operations = when { + string.isEmpty() -> emptyList() + else -> buildList { + val suffix = if (string[0].isDigit()) { + add(NumberSpanParserOperation(listOf(ConstantNumberConsumer(string.takeWhile { it.isDigit() })))) + string.dropWhile { it.isDigit() } + } else { + string + } + if (suffix.isNotEmpty()) { + if (suffix[suffix.length - 1].isDigit()) { + add(PlainStringParserOperation(suffix.dropLastWhile { it.isDigit() })) + add(NumberSpanParserOperation(listOf(ConstantNumberConsumer(suffix.takeLastWhile { it.isDigit() })))) + } else { + add(PlainStringParserOperation(suffix)) + } + } + } + }, + followedBy = emptyList() + ) + + is SignedFormatStructure -> { + listOf( + ParserStructure( + operations = listOf( + SignParser( + isNegativeSetter = { value, isNegative -> + for (field in fieldSigns) { + val wasNegative = field.isNegative.getter(value) == true + // TODO: replacing `!=` with `xor` fails on JS + field.isNegative.trySetWithoutReassigning(value, isNegative != wasNegative) + } + }, + withPlusSign = withPlusSign, + whatThisExpects = "sign for ${this.fieldSigns}" + ) + ), + emptyList() + ), format.parser() + ).concat() + } + + is BasicFormatStructure -> directive.parser() + + is ConcatenatedFormatStructure -> formats.map { it.parser() }.concat() + is AlternativesParsingFormatStructure -> { + ParserStructure(operations = emptyList(), followedBy = buildList { + add(mainFormat.parser()) + for (format in formats) { + add(format.parser()) + } + }) + } + + is OptionalFormatStructure -> { + ParserStructure( + operations = emptyList(), + followedBy = listOf( + format.parser(), + listOf( + ConstantFormatStructure(onZero).parser(), + ParserStructure( + listOf( + UnconditionalModification { + for (field in fields) { + field.assignDefault(it) + } + } + ), + emptyList() + ) + ).concat() + ) + ) + } +} + +internal class StringFormat(internal val directives: ConcatenatedFormatStructure) { + val formatter: FormatterStructure by lazy { + directives.formatter() + } + val parser: ParserStructure by lazy { + directives.parser() + } + + override fun toString(): String = directives.toString() +} + +private fun basicFormats(format: FormatStructure): List> = buildList { + fun rec(format: FormatStructure) { + when (format) { + is BasicFormatStructure -> add(format.directive) + is ConcatenatedFormatStructure -> format.formats.forEach { rec(it) } + is ConstantFormatStructure -> {} + is SignedFormatStructure -> rec(format.format) + is AlternativesParsingFormatStructure -> { + rec(format.mainFormat); format.formats.forEach { rec(it) } + } + + is OptionalFormatStructure -> rec(format.format) + } + } + rec(format) +} + +internal class PropertyWithDefault private constructor(private val accessor: Accessor, private val defaultValue: E) { + companion object { + fun fromField(field: FieldSpec): PropertyWithDefault { + val default = field.defaultValue + require(default != null) { + "The field '${field.name}' does not define a default value" + } + return PropertyWithDefault(field.accessor, default) + } + } + + inline fun assignDefault(target: T) { + accessor.trySetWithoutReassigning(target, defaultValue) + } + + inline fun isDefaultComparisonPredicate() = ComparisonPredicate(defaultValue, accessor::getter) +} 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..7f25c5faf --- /dev/null +++ b/core/common/src/internal/format/formatter/Formatter.kt @@ -0,0 +1,66 @@ +/* + * 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 interface FormatterStructure { + fun format(obj: T, builder: Appendable, minusNotRequired: Boolean = false) +} + +internal class SpacePaddedFormatter( + private val formatter: FormatterStructure, + private val padding: Int, +): FormatterStructure { + override fun format(obj: T, builder: Appendable, minusNotRequired: Boolean) { + val string = StringBuilder().let { + formatter.format(obj, it, minusNotRequired) + it.toString() + } + repeat(padding - string.length) { builder.append(' ') } + builder.append(string) + } +} + +internal class ConditionalFormatter( + private val formatters: List Boolean, FormatterStructure>> +): FormatterStructure { + override fun format(obj: T, builder: Appendable, 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: Appendable, 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>, +): FormatterStructure { + override fun format(obj: T, builder: Appendable, 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..47a3d5c70 --- /dev/null +++ b/core/common/src/internal/format/formatter/FormatterOperation.kt @@ -0,0 +1,130 @@ +/* + * 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 class ConstantStringFormatterStructure( + private val string: String, +): FormatterStructure { + override fun format(obj: T, builder: Appendable, minusNotRequired: Boolean) { + builder.append(string) + } +} + +internal class UnsignedIntFormatterStructure( + private val number: (T) -> Int, + private val zeroPadding: Int, +): FormatterStructure { + + init { + require(zeroPadding >= 0) { "The minimum number of digits ($zeroPadding) is negative" } + require(zeroPadding <= 9) { "The minimum number of digits ($zeroPadding) exceeds the length of an Int" } + } + + override fun format(obj: T, builder: Appendable, minusNotRequired: Boolean) { + val num = number(obj) + val numberStr = num.toString() + repeat(zeroPadding - numberStr.length) { builder.append('0') } + builder.append(numberStr) + } +} + +internal class SignedIntFormatterStructure( + private val number: (T) -> Int, + private val zeroPadding: Int, + private val outputPlusOnExceededWidth: Int?, +): FormatterStructure { + + init { + require(zeroPadding >= 0) { "The minimum number of digits ($zeroPadding) is negative" } + require(zeroPadding <= 9) { "The minimum number of digits ($zeroPadding) exceeds the length of an Int" } + } + + override fun format(obj: T, builder: Appendable, minusNotRequired: Boolean) { + val innerBuilder = StringBuilder() + val number = number(obj).let { if (minusNotRequired && it < 0) -it else it } + if (outputPlusOnExceededWidth != null && number >= POWERS_OF_TEN[outputPlusOnExceededWidth]) { + innerBuilder.append('+') + } + 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 { + innerBuilder.append(number) + } + builder.append(innerBuilder) + } +} + +internal class DecimalFractionFormatterStructure( + private val number: (T) -> DecimalFraction, + private val minDigits: Int, + private val maxDigits: Int, + private val zerosToAdd: List, +): FormatterStructure { + + init { + require(minDigits in 1..9) { + "The minimum number of digits ($minDigits) is not in range 1..9" + } + require(maxDigits in minDigits..9) { + "The maximum number of digits ($maxDigits) is not in range $minDigits..9" + } + } + + override fun format(obj: T, builder: Appendable, minusNotRequired: Boolean) { + val number = number(obj) + // round the number to `maxDigits` significant figures + val numberWithRequiredPrecision = number.fractionalPartWithNDigits(maxDigits) + // we strip away trailing zeros while we can + var zerosToStrip = 0 + while (maxDigits > minDigits + zerosToStrip && numberWithRequiredPrecision % POWERS_OF_TEN[zerosToStrip + 1] == 0) { + ++zerosToStrip + } + // we add some zeros back if it means making the number that's being output prettier, like `.01` becoming `.010` + val zerosToAddBack = zerosToAdd[maxDigits - zerosToStrip - 1] + if (zerosToStrip >= zerosToAddBack) zerosToStrip -= zerosToAddBack + // the final stage of outputting the number + val digitsToOutput = maxDigits - zerosToStrip + val numberToOutput = numberWithRequiredPrecision / POWERS_OF_TEN[zerosToStrip] + builder.append((numberToOutput + POWERS_OF_TEN[digitsToOutput]).toString().substring(1)) + } +} + +internal class StringFormatterStructure( + private val string: (T) -> String, +): FormatterStructure { + override fun format(obj: T, builder: Appendable, minusNotRequired: Boolean) { + builder.append(string(obj)) + } +} + +internal class ReducedIntFormatterStructure( + private val number: (T) -> Int, + private val digits: Int, + private val base: Int, +): FormatterStructure { + override fun format(obj: T, builder: Appendable, minusNotRequired: Boolean) { + val number = number(obj) + if (number - base in 0 until POWERS_OF_TEN[digits]) { + // the number fits + val numberStr = (number % POWERS_OF_TEN[digits]).toString() + val zeroPaddingStr = '0'.toString().repeat(maxOf(0, digits - numberStr.length)) + builder.append(zeroPaddingStr, numberStr) + } else { + if (number >= 0) + builder.append("+") + builder.append(number.toString()) + } + } +} 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..1c82704a6 --- /dev/null +++ b/core/common/src/internal/format/parser/NumberConsumer.kt @@ -0,0 +1,158 @@ +/* + * 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. */ + open 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. + * + * Returns `null` on success and a `NumberConsumptionError` on failure. + */ + abstract fun consume(storage: Receiver, input: String): NumberConsumptionError? +} + +internal interface NumberConsumptionError { + fun errorMessage(): String + object ExpectedInt: NumberConsumptionError { + override fun errorMessage() = "expected an Int value" + } + object ExpectedLong: NumberConsumptionError { + override fun errorMessage() = "expected a Long value" + } + class TooManyDigits(val maxDigits: Int): NumberConsumptionError { + override fun errorMessage() = "expected at most $maxDigits digits" + } + class TooFewDigits(val minDigits: Int): NumberConsumptionError { + override fun errorMessage() = "expected at least $minDigits digits" + } + class WrongConstant(val expected: String): NumberConsumptionError { + override fun errorMessage() = "expected '$expected'" + } + class Conflicting(val conflicting: Any): NumberConsumptionError { + override fun errorMessage() = "attempted to overwrite the existing value '$conflicting'" + } +} + +/** + * 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( + private val minLength: Int?, + private val maxLength: Int?, + private val setter: AssignableField, + 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" } + } + + override fun consume(storage: Receiver, input: String): NumberConsumptionError? = when { + maxLength != null && input.length > maxLength -> NumberConsumptionError.TooManyDigits(maxLength) + minLength != null && input.length < minLength -> NumberConsumptionError.TooFewDigits(minLength) + else -> when (val result = input.toIntOrNull()) { + null -> NumberConsumptionError.ExpectedInt + else -> setter.setWithoutReassigning(storage, if (multiplyByMinus1) -result else result) + } + } +} + +internal class ReducedIntConsumer( + override val length: Int, + private val setter: AssignableField, + name: String, + val base: Int, +): NumberConsumer(length, name) { + + private val modulo = POWERS_OF_TEN[length] + private val baseMod = base % modulo + private val baseFloor = base - baseMod + + override fun consume(storage: Receiver, input: String): NumberConsumptionError? = when (val result = input.toIntOrNull()) { + null -> NumberConsumptionError.ExpectedInt + else -> setter.setWithoutReassigning(storage, if (result >= baseMod) { + baseFloor + result + } else { + baseFloor + modulo + 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 consume(storage: Receiver, input: String): NumberConsumptionError? = if (input == expected) { + null + } else { + NumberConsumptionError.WrongConstant(expected) + } +} + +/** + * A parser that accepts a [Long] value in range from `0` to [Long.MAX_VALUE]. + */ +internal class UnsignedLongConsumer( + length: Int?, + private val setter: AssignableField, + name: String, +) : NumberConsumer(length, name) { + + init { + require(length == null || length in 1..18) { "Invalid length for field $whatThisExpects: $length" } + } + + override fun consume(storage: Receiver, input: String) = when (val result = input.toLongOrNull()) { + null -> NumberConsumptionError.ExpectedLong + else -> setter.setWithoutReassigning(storage, result) + } +} + +internal class FractionPartConsumer( + private val minLength: Int?, + private val maxLength: Int?, + private val setter: AssignableField, + 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 consume(storage: Receiver, input: String): NumberConsumptionError? = when { + minLength != null && input.length < minLength -> NumberConsumptionError.TooFewDigits(minLength) + maxLength != null && input.length > maxLength -> NumberConsumptionError.TooManyDigits(maxLength) + else -> when (val numerator = input.toIntOrNull()) { + null -> NumberConsumptionError.TooManyDigits(9) + else -> setter.setWithoutReassigning(storage, DecimalFraction(numerator, input.length)) + } + } +} + +private fun AssignableField.setWithoutReassigning( + receiver: Object, + value: Type, +): NumberConsumptionError? { + val conflictingValue = trySetWithoutReassigning(receiver, value) ?: return null + return NumberConsumptionError.Conflicting(conflictingValue) +} 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..57c60c770 --- /dev/null +++ b/core/common/src/internal/format/parser/ParseResult.kt @@ -0,0 +1,26 @@ +/* + * 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 private constructor(val value: Any) { + companion object { + fun Ok(indexOfNextUnparsed: Int) = ParseResult(indexOfNextUnparsed) + fun Error(position: Int, message: () -> String) = + ParseResult(ParseError(position, message)) + } + + inline fun match(onSuccess: (Int) -> T, onFailure: (ParseError) -> T): T = + when (value) { + is Int -> onSuccess(value) + is ParseError -> onFailure(value) + else -> error("Unexpected parse result: $value") + } +} + +internal class ParseError(val position: Int, val message: () -> String) 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..31557833a --- /dev/null +++ b/core/common/src/internal/format/parser/Parser.kt @@ -0,0 +1,203 @@ +/* + * 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 kotlin.jvm.* + +/** + * 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(";")})" +} + +// TODO: O(size of the resulting parser ^ 2), but can be O(size of the resulting parser) +internal fun List>.concat(): 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 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 mergedTails = 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). + // As a special 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, mergedTails) + } else if (mergedTails.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, mergedTails) + } else { + val newTails = mergedTails.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 naiveParser = foldRight(ParserStructure(emptyList(), emptyList())) { parser, acc -> parser.append(acc) } + return naiveParser.simplify() +} + +internal interface Copyable { + fun copy(): Self +} + +@JvmInline +internal value class Parser>( + 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, + initialContainer: Output, + allowDanglingInput: Boolean, + onError: (ParseError) -> Unit, + onSuccess: (Int, Output) -> Unit + ) { + val parseOptions = mutableListOf(ParserState(initialContainer, commands, startIndex)) + iterate_over_alternatives@while (true) { + val state = parseOptions.removeLastOrNull() ?: break + val output = state.output.copy() + var inputPosition = state.inputPosition + val parserStructure = state.parserStructure + run parse_one_alternative@{ + for (ix in parserStructure.operations.indices) { + parserStructure.operations[ix].consume(output, input, inputPosition).match( + { inputPosition = it }, + { + onError(it) + return@parse_one_alternative // continue@iterate_over_alternatives, if that were supported + } + ) + } + if (parserStructure.followedBy.isEmpty()) { + if (allowDanglingInput || inputPosition == input.length) { + onSuccess(inputPosition, output) + } else { + onError(ParseError(inputPosition) { "There is more input to consume" }) + } + } else { + for (ix in parserStructure.followedBy.indices.reversed()) { + parseOptions.add(ParserState(output, parserStructure.followedBy[ix], inputPosition)) + } + } + } + } + } + + fun match(input: CharSequence, initialContainer: Output, startIndex: Int = 0): Output { + val errors = mutableListOf() + parse(input, startIndex, initialContainer, allowDanglingInput = false, { errors.add(it) }, { _, out -> return@match out }) + errors.sortByDescending { it.position } + // `errors` can not be empty because each parser will have (successes + failures) >= 1, and here, successes == 0 + ParseException(errors.first()).let { + for (error in errors.drop(1)) { + it.addSuppressed(ParseException(error)) + } + throw it + } + } + + fun matchOrNull(input: CharSequence, initialContainer: Output, startIndex: Int = 0): Output? { + parse(input, startIndex, initialContainer, allowDanglingInput = false, { }, { _, out -> return@matchOrNull out }) + return null + } + + private class ParserState( + val output: Output, + val parserStructure: ParserStructure, + val inputPosition: Int, + ) +} + +internal class ParseException(error: ParseError) : Exception("Position ${error.position}: ${error.message()}") 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..16acba63c --- /dev/null +++ b/core/common/src/internal/format/parser/ParserOperation.kt @@ -0,0 +1,424 @@ +/* + * 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 consume(storage: Output, 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()) { "String '$string' starts with a digit" } + require(!string[string.length - 1].isDigit()) { "String '$string' ends with a digit" } + } + + override fun consume(storage: Output, input: CharSequence, startIndex: Int): ParseResult { + if (startIndex + string.length > input.length) + return ParseResult.Error(startIndex) { "Unexpected end of input: yet to parse '$string'" } + for (i in string.indices) { + if (input[startIndex + i] != string[i]) return ParseResult.Error(startIndex) { + "Expected $string but got ${input.substring(startIndex, startIndex + i + 1)}" + } + } + 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 consume(storage: Output, input: CharSequence, startIndex: Int): ParseResult { + if (startIndex + minLength > input.length) + return ParseResult.Error(startIndex) { "Unexpected end of input: yet to parse $whatThisExpects" } + var digitsInRow = 0 + while (startIndex + digitsInRow < input.length && input[startIndex + digitsInRow].isDigit()) { + ++digitsInRow + } + if (digitsInRow < minLength) + return ParseResult.Error(startIndex) { + "Only found $digitsInRow digits in a row, but need to parse $whatThisExpects" + } + var index = startIndex + for (i in consumers.indices) { + val length = consumers[i].length ?: (digitsInRow - minLength + 1) + val numberString = input.substring(index, index + length) + val error = consumers[i].consume(storage, numberString) + if (error != null) { + return ParseResult.Error(index) { + "Can not interpret the string '$numberString' as ${consumers[i].whatThisExpects}: ${error.errorMessage()}" + } + } + index += length + } + return ParseResult.Ok(index) + } + + override fun toString(): String = whatThisExpects +} + +internal class SignParser( + private val isNegativeSetter: (Output, Boolean) -> Unit, + private val withPlusSign: Boolean, + private val whatThisExpects: String, +) : ParserOperation { + override fun consume(storage: Output, input: CharSequence, startIndex: Int): ParseResult { + if (startIndex >= input.length) + return ParseResult.Ok(startIndex) + val char = input[startIndex] + if (char == '-') { + isNegativeSetter(storage, true) + return ParseResult.Ok(startIndex + 1) + } + if (char == '+' && withPlusSign) { + isNegativeSetter(storage, false) + return ParseResult.Ok(startIndex + 1) + } + return ParseResult.Error(startIndex) { "Expected $whatThisExpects but got $char" } + } + + override fun toString(): String = whatThisExpects +} + +/** + * Consumes an empty string and unconditionally modifies the accumulator. + */ +internal class UnconditionalModification( + private val operation: (Output) -> Unit +) : ParserOperation { + override fun consume(storage: Output, input: CharSequence, startIndex: Int): ParseResult { + operation(storage) + return ParseResult.Ok(startIndex) + } +} + +/** + * Matches the longest suitable string from `strings` and calls [consume] with the matched string. + */ +internal class StringSetParserOperation( + strings: Collection, + private val setter: AssignableField, + private val whatThisExpects: String, +) : ParserOperation { + + // TODO: tries don't have good performance characteristics for small sets, add a special case for small sets + + 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 consume(storage: Output, 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.setWithoutReassigning(storage, input.substring(startIndex, lastMatch), startIndex, lastMatch) + } else { + ParseResult.Error(startIndex) { "Expected $whatThisExpects but got ${input.substring(startIndex, index)}" } + } + } +} + +internal fun SignedIntParser( + minDigits: Int?, + maxDigits: Int?, + spacePadding: Int?, + setter: AssignableField, + name: String, + plusOnExceedsWidth: Int?, +): ParserStructure { + val parsers = mutableListOf( + spaceAndZeroPaddedUnsignedInt(minDigits, maxDigits, spacePadding, setter, name, withMinus = true) + ) + if (plusOnExceedsWidth != null) { + parsers.add( + spaceAndZeroPaddedUnsignedInt(minDigits, plusOnExceedsWidth, spacePadding, setter, name) + ) + parsers.add( + ParserStructure( + listOf( + PlainStringParserOperation("+"), + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + plusOnExceedsWidth + 1, + maxDigits, + setter, + name, + multiplyByMinus1 = false + ) + ) + ) + ), + emptyList() + ) + ) + } else { + parsers.add( + spaceAndZeroPaddedUnsignedInt(minDigits, maxDigits, spacePadding, setter, name) + ) + } + return ParserStructure( + emptyList(), + parsers, + ) +} + +// With maxWidth = 4, +// padWidth = 7: " " + (four digits | " " (three digits | " " (two digits | " ", one digit))) +// padWidth = 3: three to four digits | " " (two digits | " ", one digit) +internal fun spaceAndZeroPaddedUnsignedInt( + minDigits: Int?, + maxDigits: Int?, + spacePadding: Int?, + setter: AssignableField, + name: String, + withMinus: Boolean = false, +): ParserStructure { + val minNumberLength = (minDigits ?: 1) + if (withMinus) 1 else 0 + val maxNumberLength = maxDigits?.let { if (withMinus) it + 1 else it } ?: Int.MAX_VALUE + val spacePadding = spacePadding ?: 0 + fun numberOfRequiredLengths(minNumberLength: Int, maxNumberLength: Int): ParserStructure { + check(maxNumberLength >= 1 + if (withMinus) 1 else 0) + return ParserStructure( + buildList { + if (withMinus) add(PlainStringParserOperation("-")) + add( + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + minNumberLength - if (withMinus) 1 else 0, + maxNumberLength - if (withMinus) 1 else 0, + setter, + name, + multiplyByMinus1 = withMinus, + ) + ) + ) + ) + }, + emptyList() + ) + } + + val maxPaddedNumberLength = minOf(maxNumberLength, spacePadding) + if (minNumberLength >= maxPaddedNumberLength) return numberOfRequiredLengths(minNumberLength, maxNumberLength) + // invariant: the length of the string parsed by 'accumulated' is exactly 'accumulatedWidth' + var accumulated: ParserStructure = numberOfRequiredLengths(minNumberLength, minNumberLength) + for (accumulatedWidth in minNumberLength until maxPaddedNumberLength) { + accumulated = ParserStructure( + emptyList(), + listOf( + numberOfRequiredLengths(accumulatedWidth + 1, accumulatedWidth + 1), + listOf( + ParserStructure(listOf(PlainStringParserOperation(" ")), emptyList()), + accumulated + ).concat() + ) + ) + } + // accumulatedWidth == maxNumberLength || accumulatedWidth == spacePadding. + // In the first case, we're done, in the second case, we need to add the remaining numeric lengths. + return if (spacePadding > maxNumberLength) { + val prepadding = PlainStringParserOperation(" ".repeat(spacePadding - maxNumberLength)) + listOf( + ParserStructure( + listOf( + prepadding, + ), + emptyList() + ), accumulated + ).concat() + } else if (spacePadding == maxNumberLength) { + accumulated + } else { + val r = ParserStructure( + emptyList(), + listOf( + numberOfRequiredLengths(spacePadding + 1, maxNumberLength), + accumulated + ) + ) + r + } +} + +internal fun ReducedIntParser( + digits: Int, + base: Int, + setter: AssignableField, + name: String, +): ParserStructure = ParserStructure( + emptyList(), + listOf( + ParserStructure( + listOf( + NumberSpanParserOperation( + listOf( + ReducedIntConsumer( + digits, + setter, + name, + base = base + ) + ) + ) + ), + emptyList() + ), + ParserStructure( + listOf( + PlainStringParserOperation("+"), + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + null, + null, + setter, + name, + multiplyByMinus1 = false + ) + ) + ) + ), + emptyList() + ), + ParserStructure( + listOf( + PlainStringParserOperation("-"), + NumberSpanParserOperation( + listOf( + UnsignedIntConsumer( + null, + null, + setter, + name, + multiplyByMinus1 = true + ) + ) + ) + ), + emptyList() + ), + ) +) + +internal interface AssignableField { + /** + * 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. + */ + fun trySetWithoutReassigning(container: Object, newValue: Type): Type? + + /** + * The name of the field. + */ + val name: String +} + +private fun AssignableField.setWithoutReassigning( + receiver: Object, + value: Type, + position: Int, + nextIndex: Int +): ParseResult { + val conflictingValue = trySetWithoutReassigning(receiver, value) + return if (conflictingValue === null) { + ParseResult.Ok(nextIndex) + } else { + ParseResult.Error(position) { + "Attempting to assign conflicting values '$conflictingValue' and '$value' to field '$name'" + } + } +} diff --git a/core/common/src/internal/math.kt b/core/common/src/internal/math.kt index 87e614a19..d8aaf52f3 100644 --- a/core/common/src/internal/math.kt +++ b/core/common/src/internal/math.kt @@ -179,3 +179,62 @@ internal fun multiplyAndAdd(d: Long, n: Long, r: Long): Long { } return safeAdd(safeMultiply(md, n), mr) } + +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. + * The rounding is done using the towards-zero rounding mode. + */ + fun fractionalPartWithNDigits(newDigits: Int): Int = when { + newDigits == digits -> fractionalPart + newDigits > digits -> fractionalPart * POWERS_OF_TEN[newDigits - digits] + else -> fractionalPart / POWERS_OF_TEN[digits - newDigits] + } + + 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 + + override fun toString(): String = buildString { + val denominator = POWERS_OF_TEN[digits] + append(fractionalPart / denominator) + append('.') + append((denominator + (fractionalPart % denominator)).toString().removePrefix("1")) + } + + override fun hashCode(): Int { + throw UnsupportedOperationException("DecimalFraction is not supposed to be used as a hash key") + } +} diff --git a/core/common/src/internal/toKotlinCode.kt b/core/common/src/internal/toKotlinCode.kt new file mode 100644 index 000000000..2cfeec290 --- /dev/null +++ b/core/common/src/internal/toKotlinCode.kt @@ -0,0 +1,27 @@ +/* + * 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 fun String.toKotlinCode(): String = buildString { + append('"') + for (c in this@toKotlinCode) { + when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\t' -> append("\\t") + '\n' -> append("\\n") + '\r' -> append("\\r") + else -> append(c) + } + } + append('"') +} + +internal fun Char.toKotlinCode(): String = when (this) { + '\'' -> "'\\''" + else -> "'$this'" +} diff --git a/core/common/test/InstantTest.kt b/core/common/test/InstantTest.kt index 4b66fd40a..5310c89e0 100644 --- a/core/common/test/InstantTest.kt +++ b/core/common/test/InstantTest.kt @@ -6,12 +6,11 @@ package kotlinx.datetime.test import kotlinx.datetime.* -import kotlinx.datetime.Clock // currently, requires an explicit import due to a conflict with the deprecated Clock from kotlin.time +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* import kotlin.random.* import kotlin.test.* import kotlin.time.* -import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.nanoseconds @@ -85,7 +84,8 @@ class InstantTest { assertEquals(seconds.toLong() * 1000 + nanos / 1000000, instant.toEpochMilliseconds()) } - // TODO: assertInvalidFormat { Instant.parse("1970-01-01T23:59:60Z")} // fails on Native + assertInvalidFormat { Instant.parse("1970-01-01T23:59:60Z")} + assertInvalidFormat { Instant.parse("1970-01-01T24:00:00Z")} assertInvalidFormat { Instant.parse("x") } assertInvalidFormat { Instant.parse("12020-12-31T23:59:59.000000000Z") } // this string represents an Instant that is currently larger than Instant.MAX any of the implementations: @@ -118,19 +118,33 @@ class InstantTest { Instant.DISTANT_PAST, Instant.fromEpochSeconds(0, 0)) - val offsets = listOf( - UtcOffset.parse("Z"), - UtcOffset.parse("+03:12:14"), - UtcOffset.parse("-03:12:14"), - UtcOffset.parse("+02:35"), - UtcOffset.parse("-02:35"), - UtcOffset.parse("+04"), - UtcOffset.parse("-04"), + val offsetStrings = listOf( + "Z", + "+03:12:14", + "-03:12:14", + "+02:35", + "-02:35", + "+04", + "-04", ) + val offsetFormat = UtcOffset.Format { + optional("Z") { + offsetHours() + optional { + char(':'); offsetMinutesOfHour() + optional { char(':'); offsetSecondsOfMinute() } + } + } + } + val offsets = offsetStrings.map { UtcOffset.parse(it, offsetFormat) } + for (instant in instants) { - for (offset in offsets) { - val str = instant.toStringWithOffset(offset) + for (offsetIx in offsets.indices) { + val str = instant.format(DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET, offsets[offsetIx]) + val offsetString = offsets[offsetIx].toString() + assertEquals(offsetString, offsetString.commonSuffixWith(str)) + assertEquals(instant, Instant.parse(str, DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET)) assertEquals(instant, Instant.parse(str)) } } diff --git a/core/common/test/LocalDateTest.kt b/core/common/test/LocalDateTest.kt index 44364067f..a0f2a5ecf 100644 --- a/core/common/test/LocalDateTest.kt +++ b/core/common/test/LocalDateTest.kt @@ -319,4 +319,4 @@ private val LocalDate.previous: LocalDate get() = LocalDate(year, newMonthNumber, newMonthNumber.monthLength(isLeapYear(year))) } else { LocalDate(year - 1, 12, 31) - } \ No newline at end of file + } diff --git a/core/common/test/ReadmeTest.kt b/core/common/test/ReadmeTest.kt new file mode 100644 index 000000000..7dd65f90b --- /dev/null +++ b/core/common/test/ReadmeTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2019-2024 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.test + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* +import kotlin.time.* + +/** + * Tests the code snippets in the README.md file. + */ +@Suppress("UNUSED_VARIABLE") +class ReadmeTest { + @Test + fun testGettingCurrentMoment() { + val currentMoment = Clock.System.now() + } + + @Test + fun testConvertingAnInstantToLocalDateAndTimeComponents() { + val currentMoment: Instant = Clock.System.now() + val datetimeInUtc: LocalDateTime = currentMoment.toLocalDateTime(TimeZone.UTC) + val datetimeInSystemZone: LocalDateTime = currentMoment.toLocalDateTime(TimeZone.currentSystemDefault()) + + val tzBerlin = TimeZone.of("Europe/Berlin") + val datetimeInBerlin = currentMoment.toLocalDateTime(tzBerlin) + + val kotlinReleaseDateTime = LocalDateTime(2016, 2, 15, 16, 57, 0, 0) + + val kotlinReleaseInstant = kotlinReleaseDateTime.toInstant(TimeZone.of("UTC+3")) + } + + @Test + fun testGettingLocalDateComponents() { + val now: Instant = Clock.System.now() + val today: LocalDate = now.toLocalDateTime(TimeZone.currentSystemDefault()).date + // or shorter + val today2: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) + + val knownDate = LocalDate(2020, 2, 21) + } + + @Test + fun testGettingLocalTimeComponents() { + val now: Instant = Clock.System.now() + val thisTime: LocalTime = now.toLocalDateTime(TimeZone.currentSystemDefault()).time + + val knownTime = LocalTime(hour = 23, minute = 59, second = 12) + val timeWithNanos = LocalTime(hour = 23, minute = 59, second = 12, nanosecond = 999) + val hourMinute = LocalTime(hour = 12, minute = 13) + } + + @Test + fun testConvertingInstantToAndFromUnixTime() { + Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) + } + + @Test + fun testConvertingInstantAndLocalDateTimeToAndFromIso8601String() { + val instantNow = Clock.System.now() + instantNow.toString() // returns something like 2015-12-31T12:30:00Z + val instantBefore = Instant.parse("2010-06-01T22:19:44.475Z") + + LocalDateTime.parse("2010-06-01T22:19:44") + LocalDate.parse("2010-06-01") + LocalTime.parse("12:01:03") + LocalTime.parse("12:00:03.999") + assertFailsWith { LocalTime.parse("12:0:03.999") } + } + + @Test + fun testWorkingWithOtherStringFormats() { + val dateFormat = LocalDate.Format { + monthNumber(padding = Padding.SPACE) + char('/') + dayOfMonth() + char(' ') + year() + } + + val date = dateFormat.parse("12/24 2023") + assertEquals("20231224", date.format(LocalDate.Formats.ISO_BASIC)) + } + + @Test + fun testUnicodePatterns() { + assertEquals(""" + date(LocalDate.Formats.ISO) + char('T') + hour() + char(':') + minute() + char(':') + second() + alternativeParsing({ + }) { + char('.') + secondFraction(3) + } + offset(UtcOffset.Formats.FOUR_DIGITS) + """.trimIndent(), + DateTimeFormat.formatAsKotlinBuilderDsl(DateTimeComponents.Format { + byUnicodePattern("uuuu-MM-dd'T'HH:mm:ss[.SSS]Z") + }) + ) + @OptIn(FormatStringsInDatetimeFormats::class) + val dateTimeFormat = LocalDateTime.Format { + byUnicodePattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]") + } + dateTimeFormat.parse("2023-12-24T23:59:59") + } + + @Test + fun testParsingAndFormattingPartialCompoundOrOutOfBoundsData() { + val yearMonth = DateTimeComponents.Format { year(); char('-'); monthNumber() } + .parse("2024-01") + assertEquals(2024, yearMonth.year) + assertEquals(1, yearMonth.monthNumber) + + val dateTimeOffset = DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET + .parse("2023-01-07T23:16:15.53+02:00") + assertEquals("+02:00", dateTimeOffset.toUtcOffset().toString()) + assertEquals("2023-01-07T23:16:15.530", dateTimeOffset.toLocalDateTime().toString()) + + val time = DateTimeComponents.Format { time(LocalTime.Formats.ISO) } + .parse("23:59:60").apply { + if (second == 60) second = 59 + }.toLocalTime() + assertEquals(LocalTime(23, 59, 59), time) + + assertEquals("Sat, 7 Jan 2023 23:59:60 +0200", DateTimeComponents.Formats.RFC_1123.format { + // the receiver of this lambda is DateTimeComponents + setDate(LocalDate(2023, 1, 7)) + hour = 23 + minute = 59 + second = 60 + setOffset(UtcOffset(hours = 2)) + }) + } + + @Test + fun testInstantArithmetic() { + run { + val now = Clock.System.now() + val instantInThePast: Instant = Instant.parse("2020-01-01T00:00:00Z") + val durationSinceThen: Duration = now - instantInThePast + val equidistantInstantInTheFuture: Instant = now + durationSinceThen + + val period: DateTimePeriod = instantInThePast.periodUntil(Clock.System.now(), TimeZone.UTC) + + instantInThePast.yearsUntil(Clock.System.now(), TimeZone.UTC) + instantInThePast.monthsUntil(Clock.System.now(), TimeZone.UTC) + instantInThePast.daysUntil(Clock.System.now(), TimeZone.UTC) + + val diffInMonths = instantInThePast.until(Clock.System.now(), DateTimeUnit.MONTH, TimeZone.UTC) + } + + run { + val now = Clock.System.now() + val systemTZ = TimeZone.currentSystemDefault() + val tomorrow = now.plus(2, DateTimeUnit.DAY, systemTZ) + val threeYearsAndAMonthLater = now.plus(DateTimePeriod(years = 3, months = 1), systemTZ) + } + } + + @Test + fun testDateArithmetic() { + val date = LocalDate(2023, 1, 7) + val date2 = date.plus(1, DateTimeUnit.DAY) + date.plus(DatePeriod(days = 1)) + date.until(date2, DateTimeUnit.DAY) + date.yearsUntil(date2) + date.monthsUntil(date2) + date.daysUntil(date2) + date.periodUntil(date2) + date2 - date + } + + @Test + fun testDateTimeArithmetic() { + val timeZone = TimeZone.of("Europe/Berlin") + val localDateTime = LocalDateTime.parse("2021-03-27T02:16:20") + val instant = localDateTime.toInstant(timeZone) + + val instantOneDayLater = instant.plus(1, DateTimeUnit.DAY, timeZone) + val localDateTimeOneDayLater = instantOneDayLater.toLocalDateTime(timeZone) + assertEquals(LocalDateTime(2021, 3, 28, 3, 16, 20), localDateTimeOneDayLater) + // 2021-03-28T03:16:20, as 02:16:20 that day is in a time gap + + val instantTwoDaysLater = instant.plus(2, DateTimeUnit.DAY, timeZone) + val localDateTimeTwoDaysLater = instantTwoDaysLater.toLocalDateTime(timeZone) + assertEquals(LocalDateTime(2021, 3, 29, 2, 16, 20), localDateTimeTwoDaysLater) + // 2021-03-29T02:16:20 + } +} diff --git a/core/common/test/TimeZoneTest.kt b/core/common/test/TimeZoneTest.kt index 21f4618b0..49b951043 100644 --- a/core/common/test/TimeZoneTest.kt +++ b/core/common/test/TimeZoneTest.kt @@ -129,80 +129,80 @@ class TimeZoneTest { @Test fun newYorkOffset() { val test = TimeZone.of("America/New_York") - val offset = UtcOffset.parse("-5") + val offset = UtcOffset(hours = -5) - fun check(expectedOffset: String, dateTime: LocalDateTime) { - assertEquals(UtcOffset.parse(expectedOffset), dateTime.toInstant(offset).offsetIn(test)) + fun check(expectedHours: Int, dateTime: LocalDateTime) { + assertEquals(UtcOffset(hours = expectedHours), dateTime.toInstant(offset).offsetIn(test)) } - check("-5", LocalDateTime(2008, 1, 1)) - check("-5", LocalDateTime(2008, 2, 1)) - check("-5", LocalDateTime(2008, 3, 1)) - check("-4", LocalDateTime(2008, 4, 1)) - check("-4", LocalDateTime(2008, 5, 1)) - check("-4", LocalDateTime(2008, 6, 1)) - check("-4", LocalDateTime(2008, 7, 1)) - check("-4", LocalDateTime(2008, 8, 1)) - check("-4", LocalDateTime(2008, 9, 1)) - check("-4", LocalDateTime(2008, 10, 1)) - check("-4", LocalDateTime(2008, 11, 1)) - check("-5", LocalDateTime(2008, 12, 1)) - check("-5", LocalDateTime(2008, 1, 28)) - check("-5", LocalDateTime(2008, 2, 28)) - check("-4", LocalDateTime(2008, 3, 28)) - check("-4", LocalDateTime(2008, 4, 28)) - check("-4", LocalDateTime(2008, 5, 28)) - check("-4", LocalDateTime(2008, 6, 28)) - check("-4", LocalDateTime(2008, 7, 28)) - check("-4", LocalDateTime(2008, 8, 28)) - check("-4", LocalDateTime(2008, 9, 28)) - check("-4", LocalDateTime(2008, 10, 28)) - check("-5", LocalDateTime(2008, 11, 28)) - check("-5", LocalDateTime(2008, 12, 28)) + check(-5, LocalDateTime(2008, 1, 1)) + check(-5, LocalDateTime(2008, 2, 1)) + check(-5, LocalDateTime(2008, 3, 1)) + check(-4, LocalDateTime(2008, 4, 1)) + check(-4, LocalDateTime(2008, 5, 1)) + check(-4, LocalDateTime(2008, 6, 1)) + check(-4, LocalDateTime(2008, 7, 1)) + check(-4, LocalDateTime(2008, 8, 1)) + check(-4, LocalDateTime(2008, 9, 1)) + check(-4, LocalDateTime(2008, 10, 1)) + check(-4, LocalDateTime(2008, 11, 1)) + check(-5, LocalDateTime(2008, 12, 1)) + check(-5, LocalDateTime(2008, 1, 28)) + check(-5, LocalDateTime(2008, 2, 28)) + check(-4, LocalDateTime(2008, 3, 28)) + check(-4, LocalDateTime(2008, 4, 28)) + check(-4, LocalDateTime(2008, 5, 28)) + check(-4, LocalDateTime(2008, 6, 28)) + check(-4, LocalDateTime(2008, 7, 28)) + check(-4, LocalDateTime(2008, 8, 28)) + check(-4, LocalDateTime(2008, 9, 28)) + check(-4, LocalDateTime(2008, 10, 28)) + check(-5, LocalDateTime(2008, 11, 28)) + check(-5, LocalDateTime(2008, 12, 28)) } // from 310bp @Test fun newYorkOffsetToDST() { val test = TimeZone.of("America/New_York") - val offset = UtcOffset.parse("-5") + val offset = UtcOffset(hours = -5) - fun check(expectedOffset: String, dateTime: LocalDateTime) { - assertEquals(UtcOffset.parse(expectedOffset), dateTime.toInstant(offset).offsetIn(test)) + fun check(expectedHours: Int, dateTime: LocalDateTime) { + assertEquals(UtcOffset(hours = expectedHours), dateTime.toInstant(offset).offsetIn(test)) } - check("-5", LocalDateTime(2008, 3, 8)) - check("-5", LocalDateTime(2008, 3, 9)) - check("-4", LocalDateTime(2008, 3, 10)) - check("-4", LocalDateTime(2008, 3, 11)) - check("-4", LocalDateTime(2008, 3, 12)) - check("-4", LocalDateTime(2008, 3, 13)) - check("-4", LocalDateTime(2008, 3, 14)) + check(-5, LocalDateTime(2008, 3, 8)) + check(-5, LocalDateTime(2008, 3, 9)) + check(-4, LocalDateTime(2008, 3, 10)) + check(-4, LocalDateTime(2008, 3, 11)) + check(-4, LocalDateTime(2008, 3, 12)) + check(-4, LocalDateTime(2008, 3, 13)) + check(-4, LocalDateTime(2008, 3, 14)) // cutover at 02:00 local - check("-5", LocalDateTime(2008, 3, 9, 1, 59, 59, 999999999)) - check("-4", LocalDateTime(2008, 3, 9, 2, 0, 0, 0)) + check(-5, LocalDateTime(2008, 3, 9, 1, 59, 59, 999999999)) + check(-4, LocalDateTime(2008, 3, 9, 2, 0, 0, 0)) } // from 310bp @Test fun newYorkOffsetFromDST() { val test = TimeZone.of("America/New_York") - val offset = UtcOffset.parse("-4") + val offset = UtcOffset(hours = -4) - fun check(expectedOffset: String, dateTime: LocalDateTime) { - assertEquals(UtcOffset.parse(expectedOffset), dateTime.toInstant(offset).offsetIn(test)) + fun check(expectedHours: Int, dateTime: LocalDateTime) { + assertEquals(UtcOffset(hours = expectedHours), dateTime.toInstant(offset).offsetIn(test)) } - check("-4", LocalDateTime(2008, 11, 1)) - check("-4", LocalDateTime(2008, 11, 2)) - check("-5", LocalDateTime(2008, 11, 3)) - check("-5", LocalDateTime(2008, 11, 4)) - check("-5", LocalDateTime(2008, 11, 5)) - check("-5", LocalDateTime(2008, 11, 6)) - check("-5", LocalDateTime(2008, 11, 7)) + check(-4, LocalDateTime(2008, 11, 1)) + check(-4, LocalDateTime(2008, 11, 2)) + check(-5, LocalDateTime(2008, 11, 3)) + check(-5, LocalDateTime(2008, 11, 4)) + check(-5, LocalDateTime(2008, 11, 5)) + check(-5, LocalDateTime(2008, 11, 6)) + check(-5, LocalDateTime(2008, 11, 7)) // cutover at 02:00 local - check("-4", LocalDateTime(2008, 11, 2, 1, 59, 59, 999999999)) - check("-5", LocalDateTime(2008, 11, 2, 2, 0, 0, 0)) + check(-4, LocalDateTime(2008, 11, 2, 1, 59, 59, 999999999)) + check(-5, LocalDateTime(2008, 11, 2, 2, 0, 0, 0)) } @Test diff --git a/core/common/test/UtcOffsetTest.kt b/core/common/test/UtcOffsetTest.kt index 4f8efc305..c9d488fc2 100644 --- a/core/common/test/UtcOffsetTest.kt +++ b/core/common/test/UtcOffsetTest.kt @@ -27,7 +27,9 @@ class UtcOffsetTest { "@01:00") val fixedOffsetTimeZoneIds = listOf( - "UTC", "UTC+0", "GMT+01", "UT-01", "Etc/UTC" + "UTC", "UTC+0", "GMT+01", "UT-01", "Etc/UTC", + "+0000", "+0100", "+1800", "+180000", + "+4", "+0", "-0", ) val offsetSecondsRange = -18 * 60 * 60 .. +18 * 60 * 60 @@ -123,29 +125,21 @@ class UtcOffsetTest { check(offsetSeconds, "$sign${hours.pad()}:${minutes.pad()}:${seconds.pad()}", canonical = seconds != 0) - check(offsetSeconds, "$sign${hours.pad()}${minutes.pad()}${seconds.pad()}") if (seconds == 0) { check(offsetSeconds, "$sign${hours.pad()}:${minutes.pad()}", canonical = offsetSeconds != 0) - check(offsetSeconds, "$sign${hours.pad()}${minutes.pad()}") - if (minutes == 0) { - check(offsetSeconds, "$sign${hours.pad()}") - check(offsetSeconds, "$sign$hours") - } } } check(0, "+00:00") check(0, "-00:00") - check(0, "+0") - check(0, "-0") check(0, "Z", canonical = true) } @Test fun equality() { val equalOffsets = listOf( - listOf("Z", "+0", "+00", "+0000", "+00:00:00", "-00:00:00"), - listOf("+4", "+04", "+04:00"), - listOf("-18", "-1800", "-18:00:00"), + listOf("Z", "+00:00", "-00:00", "+00:00:00", "-00:00:00"), + listOf("+04:00", "+04:00:00"), + listOf("-18:00", "-18:00:00"), ) for (equalGroup in equalOffsets) { val offsets = equalGroup.map { UtcOffset.parse(it) } @@ -167,4 +161,4 @@ class UtcOffsetTest { assertIs(timeZone) assertEquals(offset, timeZone.offset) } -} \ No newline at end of file +} diff --git a/core/common/test/format/DateTimeComponentsFormatTest.kt b/core/common/test/format/DateTimeComponentsFormatTest.kt new file mode 100644 index 000000000..85d795d3c --- /dev/null +++ b/core/common/test/format/DateTimeComponentsFormatTest.kt @@ -0,0 +1,220 @@ +/* + * 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.test.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class DateTimeComponentsFormatTest { + + @Test + fun testErrorHandling() { + val format = DateTimeComponents.Formats.RFC_1123 + assertDateTimeComponentsEqual( + dateTimeComponents(LocalDate(2008, 6, 3), LocalTime(11, 5, 30), UtcOffset.ZERO), + format.parse("Tue, 3 Jun 2008 11:05:30 GMT")) + // the same date, but with an incorrect day-of-week: + assertDateTimeComponentsEqual( + DateTimeComponents().apply { + year = 2008; monthNumber = 6; dayOfMonth = 3; dayOfWeek = DayOfWeek.MONDAY + setTime(LocalTime(11, 5, 30)) + setOffset(UtcOffset.ZERO) + }, + format.parse("Mon, 3 Jun 2008 11:05:30 GMT")) + assertDateTimeComponentsEqual( + DateTimeComponents().apply { + year = 2008; monthNumber = 6; dayOfMonth = 40; dayOfWeek = DayOfWeek.TUESDAY + setTime(LocalTime(11, 5, 30)) + setOffset(UtcOffset.ZERO) + }, + format.parse("Tue, 40 Jun 2008 11:05:30 GMT")) + assertFailsWith { format.parse("Bue, 3 Jun 2008 11:05:30 GMT") } + } + + @Test + fun testInconsistentLocalTime() { + val formatTime = LocalTime.Format { + hour(); char(':'); minute(); + chars(" ("); amPmHour(); char(':'); minute(); char(' '); amPmMarker("AM", "PM"); char(')') + } + val format = DateTimeComponents.Format { time(formatTime) } + val time1 = "23:15 (11:15 PM)" // a normal time after noon + assertDateTimeComponentsEqual( + DateTimeComponents().apply { hour = 23; hourOfAmPm = 11; minute = 15; amPm = AmPmMarker.PM }, + format.parse(time1) + ) + assertEquals(LocalTime(23, 15), formatTime.parse(time1)) + val time2 = "23:15 (11:15 AM)" // a time with an inconsistent AM/PM marker + assertDateTimeComponentsEqual( + DateTimeComponents().apply { hour = 23; hourOfAmPm = 11; minute = 15; amPm = AmPmMarker.AM }, + format.parse(time2) + ) + assertFailsWith { formatTime.parse(time2) } + val time3 = "23:15 (10:15 PM)" // a time with an inconsistent number of hours + assertDateTimeComponentsEqual( + DateTimeComponents().apply { hour = 23; hourOfAmPm = 10; minute = 15; amPm = AmPmMarker.PM }, + format.parse(time3) + ) + assertFailsWith { formatTime.parse(time3) } + val time4 = "23:15 (11:16 PM)" // a time with an inconsistently duplicated field + assertFailsWith { format.parse(time4) } + assertFailsWith { formatTime.parse(time4) } + } + + @Test + fun testRfc1123() { + val bags = buildMap>> { + put(dateTimeComponents(LocalDate(2008, 6, 3), LocalTime(11, 5, 30), UtcOffset.ZERO), ("Tue, 3 Jun 2008 11:05:30 GMT" to setOf("3 Jun 2008 11:05:30 UT", "3 Jun 2008 11:05:30 Z"))) + put(dateTimeComponents(LocalDate(2008, 6, 30), LocalTime(11, 5, 30), UtcOffset.ZERO), ("Mon, 30 Jun 2008 11:05:30 GMT" to setOf())) + put(dateTimeComponents(LocalDate(2008, 6, 3), LocalTime(11, 5, 30), UtcOffset(hours = 2)), ("Tue, 3 Jun 2008 11:05:30 +0200" to setOf())) + put(dateTimeComponents(LocalDate(2008, 6, 30), LocalTime(11, 5, 30), UtcOffset(hours = -3)), ("Mon, 30 Jun 2008 11:05:30 -0300" to setOf())) + put(dateTimeComponents(LocalDate(2008, 6, 30), LocalTime(11, 5, 0), UtcOffset(hours = -3)), ("Mon, 30 Jun 2008 11:05 -0300" to setOf("Mon, 30 Jun 2008 11:05:00 -0300"))) + } + test(bags, DateTimeComponents.Formats.RFC_1123) + } + + @Test + fun testZonedDateTime() { + val format = DateTimeComponents.Format { + dateTime(LocalDateTime.Formats.ISO) + offset(UtcOffset.Formats.ISO) + char('[') + timeZoneId() + char(']') + } + val berlin = "Europe/Berlin" + val dateTime = LocalDateTime(2008, 6, 3, 11, 5, 30, 123_456_789) + val offset = UtcOffset(hours = 1) + val formatted = "2008-06-03T11:05:30.123456789+01:00[Europe/Berlin]" + assertEquals(formatted, format.format { setDateTime(dateTime); setOffset(offset); timeZoneId = berlin }) + val bag = format.parse("2008-06-03T11:05:30.123456789+01:00[Europe/Berlin]") + assertEquals(dateTime, bag.toLocalDateTime()) + assertEquals(offset, bag.toUtcOffset()) + assertEquals(berlin, bag.timeZoneId) + assertFailsWith { format.parse("2008-06-03T11:05:30.123456789+01:00[Mars/New_York]") } + for (zone in TimeZone.availableZoneIds) { + assertEquals(zone, format.parse("2008-06-03T11:05:30.123456789+01:00[$zone]").timeZoneId) + } + } + + @Test + fun testTimeZoneGreedyParsing() { + val format = DateTimeComponents.Format { timeZoneId(); chars("X") } + for (zone in TimeZone.availableZoneIds) { + assertEquals(zone, format.parse("${zone}X").timeZoneId) + } + } + + private fun dateTimeComponents( + date: LocalDate? = null, + time: LocalTime? = null, + offset: UtcOffset? = null, + zone: TimeZone? = null + ) = DateTimeComponents().apply { + date?.let { setDate(it) } + time?.let { setTime(it) } + offset?.let { setOffset(it) } + timeZoneId = zone?.id + } + + private fun assertDateTimeComponentsEqual(a: DateTimeComponents, b: DateTimeComponents, message: String? = null) { + assertEquals(a.year, b.year, message) + assertEquals(a.monthNumber, b.monthNumber, message) + assertEquals(a.dayOfMonth, b.dayOfMonth, message) + if (a.dayOfWeek != null && b.dayOfWeek != null) + assertEquals(a.dayOfWeek, b.dayOfWeek, message) + assertEquals(a.hour, b.hour, message) + assertEquals(a.minute, b.minute, message) + assertEquals(a.second ?: 0, b.second ?: 0, message) + assertEquals(a.nanosecond ?: 0, b.nanosecond ?: 0, message) + assertEquals(a.toUtcOffset(), b.toUtcOffset(), message) + assertEquals(a.timeZoneId, b.timeZoneId, message) + } + + @Test + fun testDocFormatting() { + val str = DateTimeComponents.Formats.RFC_1123.format { + setDateTime(LocalDateTime(2020, 3, 16, 23, 59, 59, 999_999_999)) + setOffset(UtcOffset(hours = 3)) + } + assertEquals("Mon, 16 Mar 2020 23:59:59 +0300", str) + } + + @Test + fun testDocOutOfBoundsParsing() { + val input = "23:59:60" + val extraDay: Boolean + val time = DateTimeComponents.Format { + time(LocalTime.Formats.ISO) + }.parse(input).apply { + if (hour == 23 && minute == 59 && second == 60) { + hour = 0; minute = 0; second = 0; extraDay = true + } else { + extraDay = false + } + }.toLocalTime() + assertEquals(LocalTime(0, 0, 0), time) + assertTrue(extraDay) + } + + @Test + fun testDocCombinedParsing() { + val input = "2020-03-16T23:59:59.999999999+03:00" + val bag = DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET.parse(input) + val localDateTime = bag.toLocalDateTime() + val instant = bag.toInstantUsingOffset() + val offset = bag.toUtcOffset() + assertEquals(LocalDateTime(2020, 3, 16, 23, 59, 59, 999_999_999), localDateTime) + assertEquals(Instant.parse("2020-03-16T20:59:59.999999999Z"), instant) + assertEquals(UtcOffset(hours = 3), offset) + } + + @Test + fun testDefaultValueAssignment() { + val input = "2020-03-16T23:59" + val bagWithOptional = DateTimeComponents.Format { + date(ISO_DATE); char('T') + hour(); char(':'); minute() + optional { + char(':'); second() + optional { char('.'); secondFraction() } + } + }.parse(input) + assertEquals(0, bagWithOptional.second) + assertEquals(0, bagWithOptional.nanosecond) + val bagWithAlternative = DateTimeComponents.Format { + date(ISO_DATE); char('T') + hour(); char(':'); minute() + alternativeParsing({}) { + char(':'); second() + optional { char('.'); secondFraction() } + } + }.parse(input) + assertNull(bagWithAlternative.second) + assertNull(bagWithAlternative.nanosecond) + } + + @OptIn(FormatStringsInDatetimeFormats::class) + @Test + fun testByUnicodePatternDoc() { + val format = DateTimeComponents.Format { + byUnicodePattern("uuuu-MM-dd'T'HH:mm[:ss[.SSS]]xxxxx'['VV']'") + } + format.parse("2023-01-20T23:53:16.312+03:30[Asia/Tehran]") + } + + private fun test(strings: Map>>, format: DateTimeFormat) { + for ((value, stringsForValue) in strings) { + val (canonicalString, otherStrings) = stringsForValue + assertEquals(canonicalString, format.format(value), "formatting $value with $format") + assertDateTimeComponentsEqual(value, format.parse(canonicalString), "parsing '$canonicalString' with $format") + for (otherString in otherStrings) { + assertDateTimeComponentsEqual(value, format.parse(otherString), "parsing '$otherString' with $format") + } + } + } +} diff --git a/core/common/test/format/DateTimeComponentsTest.kt b/core/common/test/format/DateTimeComponentsTest.kt new file mode 100644 index 000000000..c61841265 --- /dev/null +++ b/core/common/test/format/DateTimeComponentsTest.kt @@ -0,0 +1,67 @@ +/* + * 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.test.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class DateTimeComponentsTest { + @Test + fun testAssigningIllegalValues() { + val dateTimeComponents = DateTimeComponents().apply { setDateTimeOffset(instant, timeZone.offsetAt(instant)) } + for (field in twoDigitFields) { + for (invalidValue in listOf(-1, 100, Int.MIN_VALUE, Int.MAX_VALUE, 1000, 253)) { + assertFailsWith { field.set(dateTimeComponents, invalidValue) } + } + assertEquals(field.get(currentTimeDateTimeComponents), field.get(dateTimeComponents), + "DateTimeComponents should not be modified if an exception is thrown" + ) + } + for (invalidNanosecond in listOf(-5, -1, 1_000_000_000, 1_000_000_001)) { + assertFailsWith { dateTimeComponents.nanosecond = invalidNanosecond } + } + assertEquals(currentTimeDateTimeComponents.nanosecond, dateTimeComponents.nanosecond, + "DateTimeComponents should not be modified if an exception is thrown" + ) + } + + @Test + fun testAssigningLegalValues() { + val dateTimeComponents = DateTimeComponents().apply { setDateTimeOffset(instant, timeZone.offsetAt(instant)) } + for (field in twoDigitFields) { + for (validValue in listOf(null, 0, 5, 10, 43, 99)) { + field.set(dateTimeComponents, validValue) + assertEquals(validValue, field.get(dateTimeComponents)) + } + } + } + + @Test + fun testGettingInvalidMonth() { + for (month in 1..12) { + assertEquals(Month(month), DateTimeComponents().apply { monthNumber = month }.month) + } + for (month in listOf(0, 13, 60, 99)) { + val components = DateTimeComponents().apply { monthNumber = month } + assertFailsWith { components.month } + } + } + + val twoDigitFields = listOf( + DateTimeComponents::monthNumber, + DateTimeComponents::dayOfMonth, + DateTimeComponents::hour, + DateTimeComponents::minute, + DateTimeComponents::second, + DateTimeComponents::offsetHours, + DateTimeComponents::offsetMinutesOfHour, + DateTimeComponents::offsetSecondsOfMinute, + ) + val instant = Clock.System.now() + val timeZone = TimeZone.currentSystemDefault() + val currentTimeDateTimeComponents = DateTimeComponents().apply { setDateTimeOffset(instant, timeZone.offsetAt(instant)) } +} diff --git a/core/common/test/format/DateTimeFormatTest.kt b/core/common/test/format/DateTimeFormatTest.kt new file mode 100644 index 000000000..179e96c59 --- /dev/null +++ b/core/common/test/format/DateTimeFormatTest.kt @@ -0,0 +1,130 @@ +/* + * 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.test.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class DateTimeFormatTest { + @Test + fun testStringRepresentations() { + // not exactly RFC 1123, because it would get recognized as the constant + val almostRfc1123 = DateTimeComponents.Format { + alternativeParsing({ + }) { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + chars(", ") + } + dayOfMonth(Padding.NONE) + char(' ') + monthName(MonthNames("Jan.", "Feb.", "Mar.", "Apr.", "May", "Jun.", "Jul.", "Aug.", "Sep.", "Oct.", "Nov.", "Dec.")) + char(' ') + year() + char(' ') + hour() + char(':') + minute() + optional { + char(':') + second() + } + char(' ') + alternativeParsing({ + chars("UT") + }, { + char('Z') + }) { + optional("GMT") { + offset(UtcOffset.Formats.FOUR_DIGITS) + } + } + } + val kotlinCode = DateTimeFormat.formatAsKotlinBuilderDsl(almostRfc1123) + assertEquals(""" + alternativeParsing({ + }) { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + chars(", ") + } + dayOfMonth(Padding.NONE) + char(' ') + monthName(MonthNames("Jan.", "Feb.", "Mar.", "Apr.", "May", "Jun.", "Jul.", "Aug.", "Sep.", "Oct.", "Nov.", "Dec.")) + char(' ') + year() + char(' ') + hour() + char(':') + minute() + optional { + char(':') + second() + } + char(' ') + alternativeParsing({ + chars("UT") + }, { + char('Z') + }) { + optional("GMT") { + offset(UtcOffset.Formats.FOUR_DIGITS) + } + } + """.trimIndent(), kotlinCode) + } + + /** + * Tests printing of a format that embeds some constants. + */ + @Test + fun testStringRepresentationWithConstants() { + val format = DateTimeComponents.Format { + date(LocalDate.Formats.ISO) + char(' ') + time(LocalTime.Formats.ISO) + optional { + offset(UtcOffset.Formats.ISO) + } + } + val kotlinCode = DateTimeFormat.formatAsKotlinBuilderDsl(format) + assertEquals(""" + date(LocalDate.Formats.ISO) + char(' ') + time(LocalTime.Formats.ISO) + optional { + offset(UtcOffset.Formats.ISO) + } + """.trimIndent(), kotlinCode) + } + + /** + * Check that we mention [byUnicodePattern] in the string representation of the format when the conversion is + * incorrect. + */ + @OptIn(FormatStringsInDatetimeFormats::class) + @Test + fun testStringRepresentationAfterIncorrectConversion() { + for (format in listOf("yyyy-MM-dd", "yy-MM-dd")) { + assertContains(DateTimeFormat.formatAsKotlinBuilderDsl( + DateTimeComponents.Format { byUnicodePattern(format) } + ), "byUnicodePattern") + } + } + + @Test + fun testParseStringWithNumbers() { + val formats = listOf( + "0123x0123", + "0123x", + "x0123", + "0123", + "x" + ) + for (format in formats) { + DateTimeComponents.Format { chars(format) }.parse(format) + } + } +} diff --git a/core/common/test/format/LocalDateFormatTest.kt b/core/common/test/format/LocalDateFormatTest.kt new file mode 100644 index 000000000..cb7c31e26 --- /dev/null +++ b/core/common/test/format/LocalDateFormatTest.kt @@ -0,0 +1,234 @@ +/* + * 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. + */ + +@file:OptIn(FormatStringsInDatetimeFormats::class) + +package kotlinx.datetime.test.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class LocalDateFormatTest { + + @Test + fun testErrorHandling() { + val format = LocalDate.Formats.ISO + assertEquals(LocalDate(2023, 2, 28), format.parse("2023-02-28")) + val error = assertFailsWith { format.parse("2023-02-40") } + assertContains(error.message!!, "40") + assertFailsWith { format.parse("2023-02-XX") } + } + + @Test + fun testBigEndianDates() { + for (s in listOf('/', '-')) { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("2008${s}07${s}05" to setOf())) + put(LocalDate(2007, 12, 31), ("2007${s}12${s}31" to setOf())) + put(LocalDate(999, 12, 31), ("0999${s}12${s}31" to setOf())) + put(LocalDate(-1, 1, 2), ("-0001${s}01${s}02" to setOf())) + put(LocalDate(9999, 12, 31), ("9999${s}12${s}31" to setOf())) + put(LocalDate(-9999, 12, 31), ("-9999${s}12${s}31" to setOf())) + put(LocalDate(10000, 1, 1), ("+10000${s}01${s}01" to setOf())) + put(LocalDate(-10000, 1, 1), ("-10000${s}01${s}01" to setOf())) + put(LocalDate(123456, 1, 1), ("+123456${s}01${s}01" to setOf())) + put(LocalDate(-123456, 1, 1), ("-123456${s}01${s}01" to setOf())) + } + test(dates, LocalDate.Format { + year() + byUnicodePattern("${s}MM${s}dd") + }) + test(dates, LocalDate.Format { + year() + char(s) + monthNumber() + char(s) + dayOfMonth() + }) + } + } + + @Test + fun testSmallEndianDates() { + for (s in listOf('/', '-')) { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("05${s}07${s}2008" to setOf())) + put(LocalDate(2007, 12, 31), ("31${s}12${s}2007" to setOf())) + put(LocalDate(999, 12, 31), ("31${s}12${s}0999" to setOf())) + put(LocalDate(-1, 1, 2), ("02${s}01${s}-0001" to setOf())) + put(LocalDate(9999, 12, 31), ("31${s}12${s}9999" to setOf())) + put(LocalDate(-9999, 12, 31), ("31${s}12${s}-9999" to setOf())) + put(LocalDate(10000, 1, 1), ("01${s}01${s}+10000" to setOf())) + put(LocalDate(-10000, 1, 1), ("01${s}01${s}-10000" to setOf())) + put(LocalDate(123456, 1, 1), ("01${s}01${s}+123456" to setOf())) + put(LocalDate(-123456, 1, 1), ("01${s}01${s}-123456" to setOf())) + } + test(dates, LocalDate.Format { + byUnicodePattern("dd${s}MM${s}") + year() + }) + test(dates, LocalDate.Format { + dayOfMonth() + char(s) + monthNumber() + char(s) + year() + }) + } + } + + @Test + fun testSingleNumberDates() { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("20080705" to setOf())) + put(LocalDate(2007, 12, 31), ("20071231" to setOf())) + put(LocalDate(999, 12, 31), ("09991231" to setOf())) + put(LocalDate(-1, 1, 2), ("-00010102" to setOf())) + put(LocalDate(9999, 12, 31), ("99991231" to setOf())) + put(LocalDate(-9999, 12, 31), ("-99991231" to setOf())) + put(LocalDate(10000, 1, 1), ("+100000101" to setOf())) + put(LocalDate(-10000, 1, 1), ("-100000101" to setOf())) + put(LocalDate(123456, 1, 1), ("+1234560101" to setOf())) + put(LocalDate(-123456, 1, 1), ("-1234560101" to setOf())) + } + test(dates, LocalDate.Format { + year() + byUnicodePattern("MMdd") + }) + test(dates, LocalDate.Format { + year() + monthNumber() + dayOfMonth() + }) + } + + @Test + fun testDayMonthNameYear() { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("05 July 2008" to setOf())) + put(LocalDate(2007, 12, 31), ("31 December 2007" to setOf())) + put(LocalDate(999, 11, 30), ("30 November 0999" to setOf())) + put(LocalDate(-1, 1, 2), ("02 January -0001" to setOf())) + put(LocalDate(9999, 10, 31), ("31 October 9999" to setOf())) + put(LocalDate(-9999, 9, 30), ("30 September -9999" to setOf())) + put(LocalDate(10000, 8, 1), ("01 August +10000" to setOf())) + put(LocalDate(-10000, 7, 1), ("01 July -10000" to setOf())) + put(LocalDate(123456, 6, 1), ("01 June +123456" to setOf())) + put(LocalDate(-123456, 5, 1), ("01 May -123456" to setOf())) + } + val format = LocalDate.Format { + dayOfMonth() + char(' ') + monthName(MonthNames.ENGLISH_FULL) + char(' ') + year() + } + test(dates, format) + } + + @Test + fun testRomanNumerals() { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("05 VII 2008" to setOf())) + put(LocalDate(2007, 12, 31), ("31 XII 2007" to setOf())) + put(LocalDate(999, 11, 30), ("30 XI 999" to setOf())) + put(LocalDate(-1, 1, 2), ("02 I -1" to setOf())) + put(LocalDate(9999, 10, 31), ("31 X 9999" to setOf())) + put(LocalDate(-9999, 9, 30), ("30 IX -9999" to setOf())) + put(LocalDate(10000, 8, 1), ("01 VIII +10000" to setOf())) + put(LocalDate(-10000, 7, 1), ("01 VII -10000" to setOf())) + put(LocalDate(123456, 6, 1), ("01 VI +123456" to setOf())) + put(LocalDate(-123456, 5, 1), ("01 V -123456" to setOf())) + } + val format = LocalDate.Format { + dayOfMonth() + char(' ') + monthName(MonthNames("I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII")) + char(' ') + year(Padding.NONE) + } + test(dates, format) + } + + @Test + fun testReducedYear() { + val dates = buildMap>> { + put(LocalDate(1960, 2, 3), ("600203" to setOf())) + put(LocalDate(1961, 2, 3), ("610203" to setOf())) + put(LocalDate(1959, 2, 3), ("+19590203" to setOf())) + put(LocalDate(2059, 2, 3), ("590203" to setOf())) + put(LocalDate(2060, 2, 3), ("+20600203" to setOf())) + put(LocalDate(1, 2, 3), ("+10203" to setOf())) + put(LocalDate(-1, 2, 3), ("-10203" to setOf())) + put(LocalDate(-2003, 2, 3), ("-20030203" to setOf())) + put(LocalDate(-12003, 2, 3), ("-120030203" to setOf())) + put(LocalDate(12003, 2, 3), ("+120030203" to setOf())) + } + val format = LocalDate.Format { + yearTwoDigits(baseYear = 1960) + monthNumber() + dayOfMonth() + } + test(dates, format) + } + + @Test + fun testIso() { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("2008-07-05" to setOf())) + put(LocalDate(2007, 12, 31), ("2007-12-31" to setOf())) + put(LocalDate(999, 11, 30), ("0999-11-30" to setOf())) + put(LocalDate(-1, 1, 2), ("-0001-01-02" to setOf())) + put(LocalDate(9999, 10, 31), ("9999-10-31" to setOf())) + put(LocalDate(-9999, 9, 30), ("-9999-09-30" to setOf())) + put(LocalDate(10000, 8, 1), ("+10000-08-01" to setOf())) + put(LocalDate(-10000, 7, 1), ("-10000-07-01" to setOf())) + put(LocalDate(123456, 6, 1), ("+123456-06-01" to setOf())) + put(LocalDate(-123456, 5, 1), ("-123456-05-01" to setOf())) + } + test(dates, LocalDate.Formats.ISO) + } + + @Test + fun testBasicIso() { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("20080705" to setOf())) + put(LocalDate(2007, 12, 31), ("20071231" to setOf())) + put(LocalDate(999, 11, 30), ("09991130" to setOf())) + put(LocalDate(-1, 1, 2), ("-00010102" to setOf())) + put(LocalDate(9999, 10, 31), ("99991031" to setOf())) + put(LocalDate(-9999, 9, 30), ("-99990930" to setOf())) + put(LocalDate(10000, 8, 1), ("+100000801" to setOf())) + put(LocalDate(-10000, 7, 1), ("-100000701" to setOf())) + put(LocalDate(123456, 6, 1), ("+1234560601" to setOf())) + put(LocalDate(-123456, 5, 1), ("-1234560501" to setOf())) + } + test(dates, LocalDate.Formats.ISO_BASIC) + } + + @Test + fun testDoc() { + val format = LocalDate.Format { + year() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + dayOfMonth() + } + assertEquals("2020 Jan 05", format.format(LocalDate(2020, 1, 5))) + } + + private fun test(strings: Map>>, format: DateTimeFormat) { + for ((date, stringsForDate) in strings) { + val (canonicalString, otherStrings) = stringsForDate + assertEquals(canonicalString, format.format(date), "formatting $date with $format") + assertEquals(date, format.parse(canonicalString), "parsing '$canonicalString' with $format") + for (otherString in otherStrings) { + assertEquals(date, format.parse(otherString), "parsing '$otherString' with $format") + } + } + } +} diff --git a/core/common/test/format/LocalDateTimeFormatTest.kt b/core/common/test/format/LocalDateTimeFormatTest.kt new file mode 100644 index 000000000..a48d6e4bb --- /dev/null +++ b/core/common/test/format/LocalDateTimeFormatTest.kt @@ -0,0 +1,250 @@ +/* + * 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. + */ + +@file:OptIn(FormatStringsInDatetimeFormats::class) + +package kotlinx.datetime.test.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class LocalDateTimeFormatTest { + + @Test + fun testErrorHandling() { + val format = LocalDateTime.Formats.ISO + assertEquals(LocalDateTime(2023, 2, 28, 15, 36), format.parse("2023-02-28T15:36")) + val error = assertFailsWith { format.parse("2023-02-40T15:36") } + assertContains(error.message!!, "40") + assertFailsWith { format.parse("2023-02-XXT15:36") } + } + + @Test + fun testPythonDateTime() { + val dateTimes = buildMap>> { + put(LocalDateTime(2008, 7, 5, 0, 0, 0, 0), ("2008-07-05 00:00:00" to setOf())) + put(LocalDateTime(2007, 12, 31, 1, 0, 0, 0), ("2007-12-31 01:00:00" to setOf())) + put(LocalDateTime(999, 12, 31, 23, 0, 0, 0), ("0999-12-31 23:00:00" to setOf())) + put(LocalDateTime(-1, 1, 2, 0, 1, 0, 0), ("-0001-01-02 00:01:00" to setOf())) + put(LocalDateTime(9999, 12, 31, 12, 30, 0, 0), ("9999-12-31 12:30:00" to setOf())) + put(LocalDateTime(-9999, 12, 31, 23, 59, 0, 0), ("-9999-12-31 23:59:00" to setOf())) + put(LocalDateTime(10000, 1, 1, 0, 0, 1, 0), ("+10000-01-01 00:00:01" to setOf())) + put(LocalDateTime(-10000, 1, 1, 0, 0, 59, 0), ("-10000-01-01 00:00:59" to setOf())) + put(LocalDateTime(123456, 1, 1, 13, 44, 0, 0), ("+123456-01-01 13:44:00" to setOf())) + put(LocalDateTime(-123456, 1, 1, 13, 44, 0, 0), ("-123456-01-01 13:44:00" to setOf())) + } + test(dateTimes, LocalDateTime.Format { + year() + byUnicodePattern("-MM-dd HH:mm:ss") + }) + test(dateTimes, LocalDateTime.Format { + year() + char('-') + monthNumber() + char('-') + dayOfMonth() + char(' ') + hour() + char(':') + minute() + char(':') + second() + }) + } + + @Test + fun testPythonDateTimeWithoutSeconds() { + val dateTimes = buildMap>> { + put(LocalDateTime(2008, 7, 5, 0, 0, 0, 0), ("2008-07-05 00:00" to setOf())) + put(LocalDateTime(2007, 12, 31, 1, 0, 0, 0), ("2007-12-31 01:00" to setOf())) + put(LocalDateTime(999, 12, 31, 23, 0, 0, 0), ("0999-12-31 23:00" to setOf())) + put(LocalDateTime(-1, 1, 2, 0, 1, 0, 0), ("-0001-01-02 00:01" to setOf())) + put(LocalDateTime(9999, 12, 31, 12, 30, 0, 0), ("9999-12-31 12:30" to setOf())) + put(LocalDateTime(-9999, 12, 31, 23, 59, 0, 0), ("-9999-12-31 23:59" to setOf())) + put(LocalDateTime(10000, 1, 1, 0, 0, 0, 0), ("+10000-01-01 00:00" to setOf())) + put(LocalDateTime(-10000, 1, 1, 0, 0, 0, 0), ("-10000-01-01 00:00" to setOf())) + put(LocalDateTime(123456, 1, 1, 13, 44, 0, 0), ("+123456-01-01 13:44" to setOf())) + put(LocalDateTime(-123456, 1, 1, 13, 44, 0, 0), ("-123456-01-01 13:44" to setOf())) + } + test(dateTimes, LocalDateTime.Format { + year() + byUnicodePattern("-MM-dd HH:mm") + }) + test(dateTimes, LocalDateTime.Format { + year() + char('-') + monthNumber() + char('-') + dayOfMonth() + char(' ') + hour() + char(':') + minute() + }) + } + + @Test + fun testSingleNumberDateTimes() { + val dateTimes = buildMap>> { + put(LocalDateTime(2008, 7, 5, 0, 0, 0, 0), ("20080705000000" to setOf())) + put(LocalDateTime(2007, 12, 31, 1, 0, 0, 0), ("20071231010000" to setOf())) + put(LocalDateTime(999, 12, 31, 23, 0, 0, 0), ("09991231230000" to setOf())) + put(LocalDateTime(-1, 1, 2, 0, 1, 0, 0), ("-00010102000100" to setOf())) + put(LocalDateTime(9999, 12, 31, 12, 30, 0, 0), ("99991231123000" to setOf())) + put(LocalDateTime(-9999, 12, 31, 23, 59, 0, 0), ("-99991231235900" to setOf())) + put(LocalDateTime(10000, 1, 1, 0, 0, 1, 0), ("+100000101000001" to setOf())) + put(LocalDateTime(-10000, 1, 1, 0, 0, 59, 0), ("-100000101000059" to setOf())) + put(LocalDateTime(123456, 1, 1, 13, 44, 0, 0), ("+1234560101134400" to setOf())) + put(LocalDateTime(-123456, 1, 1, 13, 44, 0, 0), ("-1234560101134400" to setOf())) + } + test(dateTimes, LocalDateTime.Format { + year() + byUnicodePattern("MMddHHmmss") + }) + test(dateTimes, LocalDateTime.Format { + year() + monthNumber() + dayOfMonth() + hour() + minute() + second() + }) + } + + @Test + fun testNoPadding() { + val dateTimes = buildMap>> { + put(LocalDateTime(2008, 7, 5, 0, 0, 0, 0), ("2008-7-5 0:0:0" to setOf())) + put(LocalDateTime(2007, 12, 31, 1, 0, 0, 0), ("2007-12-31 1:0:0" to setOf())) + put(LocalDateTime(999, 12, 31, 23, 0, 0, 0), ("999-12-31 23:0:0" to setOf())) + put(LocalDateTime(-1, 1, 2, 0, 1, 0, 0), ("-1-1-2 0:1:0" to setOf())) + put(LocalDateTime(9999, 12, 31, 12, 30, 0, 0), ("9999-12-31 12:30:0" to setOf())) + put(LocalDateTime(-9999, 12, 31, 23, 59, 0, 0), ("-9999-12-31 23:59:0" to setOf())) + put(LocalDateTime(10000, 1, 1, 0, 0, 1, 0), ("+10000-1-1 0:0:1" to setOf())) + put(LocalDateTime(-10000, 1, 1, 0, 0, 59, 0), ("-10000-1-1 0:0:59" to setOf())) + put(LocalDateTime(123456, 1, 1, 13, 44, 0, 0), ("+123456-1-1 13:44:0" to setOf())) + put(LocalDateTime(-123456, 1, 1, 13, 44, 0, 0), ("-123456-1-1 13:44:0" to setOf())) + } + test(dateTimes, LocalDateTime.Format { + year(Padding.NONE) + byUnicodePattern("-M-d H:m:s") + }) + test(dateTimes, LocalDateTime.Format { + year(Padding.NONE) + char('-') + monthNumber(Padding.NONE) + char('-') + dayOfMonth(Padding.NONE) + char(' ') + hour(Padding.NONE) + char(':') + minute(Padding.NONE) + char(':') + second(Padding.NONE) + }) + } + + + @Test + fun testSpacePadding() { + val dateTimes = buildMap>> { + put(LocalDateTime(2008, 7, 5, 0, 0, 0, 0), ("2008- 7- 5 0: 0: 0" to setOf())) + put(LocalDateTime(2007, 12, 31, 1, 0, 0, 0), ("2007-12-31 1: 0: 0" to setOf())) + put(LocalDateTime(999, 12, 31, 23, 0, 0, 0), (" 999-12-31 23: 0: 0" to setOf())) + put(LocalDateTime(1, 1, 2, 0, 1, 0, 0), (" 1- 1- 2 0: 1: 0" to setOf())) + put(LocalDateTime(-1, 1, 2, 0, 1, 0, 0), (" -1- 1- 2 0: 1: 0" to setOf())) + put(LocalDateTime(9999, 12, 31, 12, 30, 0, 0), ("9999-12-31 12:30: 0" to setOf())) + put(LocalDateTime(-9999, 12, 31, 23, 59, 0, 0), ("-9999-12-31 23:59: 0" to setOf())) + put(LocalDateTime(10000, 1, 1, 0, 0, 1, 0), ("+10000- 1- 1 0: 0: 1" to setOf())) + put(LocalDateTime(-10000, 1, 1, 0, 0, 59, 0), ("-10000- 1- 1 0: 0:59" to setOf())) + put(LocalDateTime(123456, 1, 1, 13, 44, 0, 0), ("+123456- 1- 1 13:44: 0" to setOf())) + put(LocalDateTime(-123456, 1, 1, 13, 44, 0, 0), ("-123456- 1- 1 13:44: 0" to setOf())) + } + val format = LocalDateTime.Format { + year(Padding.SPACE) + char('-') + monthNumber(Padding.SPACE) + char('-') + dayOfMonth(Padding.SPACE) + char(' ') + hour(Padding.SPACE) + char(':') + minute(Padding.SPACE) + char(':') + second(Padding.SPACE) + } + test(dateTimes, format) + format.parse(" 008- 7- 5 0: 0: 0") + assertFailsWith { format.parse(" 008- 7- 5 0: 0: 0") } + assertFailsWith { format.parse(" 8- 7- 5 0: 0: 0") } + assertFailsWith { format.parse(" 008- 7- 5 0: 0: 0") } + assertFailsWith { format.parse(" 008-7- 5 0: 0: 0") } + assertFailsWith { format.parse("+008- 7- 5 0: 0: 0") } + assertFailsWith { format.parse(" -08- 7- 5 0: 0: 0") } + assertFailsWith { format.parse(" -08- 7- 5 0: 0: 0") } + assertFailsWith { format.parse("-8- 7- 5 0: 0: 0") } + } + + @Test + fun testIso() { + val dateTimes = buildMap>> { + put(LocalDateTime(2008, 7, 5, 0, 0, 0, 0), ("2008-07-05T00:00:00" to setOf("2008-07-05T00:00"))) + put(LocalDateTime(2007, 12, 31, 1, 0, 0, 0), ("2007-12-31T01:00:00" to setOf("2007-12-31t01:00"))) + put(LocalDateTime(999, 11, 30, 23, 0, 0, 0), ("0999-11-30T23:00:00" to setOf())) + put(LocalDateTime(-1, 1, 2, 0, 1, 0, 0), ("-0001-01-02T00:01:00" to setOf())) + put(LocalDateTime(9999, 10, 31, 12, 30, 0, 0), ("9999-10-31T12:30:00" to setOf())) + put(LocalDateTime(-9999, 9, 30, 23, 59, 0, 0), ("-9999-09-30T23:59:00" to setOf())) + put(LocalDateTime(10000, 8, 1, 0, 0, 1, 0), ("+10000-08-01T00:00:01" to setOf())) + put(LocalDateTime(-10000, 7, 1, 0, 0, 59, 0), ("-10000-07-01T00:00:59" to setOf())) + put(LocalDateTime(123456, 6, 1, 13, 44, 0, 0), ("+123456-06-01T13:44:00" to setOf())) + put(LocalDateTime(-123456, 5, 1, 13, 44, 0, 0), ("-123456-05-01T13:44:00" to setOf())) + put(LocalDateTime(123456, 6, 1, 0, 0, 0, 100000000), ("+123456-06-01T00:00:00.1" to setOf("+123456-06-01T00:00:00.10", "+123456-06-01T00:00:00.100"))) + put(LocalDateTime(-123456, 5, 1, 0, 0, 0, 10000000), ("-123456-05-01T00:00:00.01" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 1000000), ("2022-01-02T00:00:00.001" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 100000), ("2022-01-02T00:00:00.0001" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 10000), ("2022-01-02T00:00:00.00001" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 1000), ("2022-01-02T00:00:00.000001" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 100), ("2022-01-02T00:00:00.0000001" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 10), ("2022-01-02T00:00:00.00000001" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 1), ("2022-01-02T00:00:00.000000001" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 999999999), ("2022-01-02T00:00:00.999999999" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 99999999), ("2022-01-02T00:00:00.099999999" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 9999999), ("2022-01-02T00:00:00.009999999" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 999999), ("2022-01-02T00:00:00.000999999" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 99999), ("2022-01-02T00:00:00.000099999" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 9999), ("2022-01-02T00:00:00.000009999" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 999), ("2022-01-02T00:00:00.000000999" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 99), ("2022-01-02T00:00:00.000000099" to setOf())) + put(LocalDateTime(2022, 1, 2, 0, 0, 0, 9), ("2022-01-02T00:00:00.000000009" to setOf())) + } + test(dateTimes, LocalDateTime.Formats.ISO) + } + + @Test + fun testDoc() { + val dateTime = LocalDateTime(2020, 8, 30, 18, 43, 13, 0) + val format1 = LocalDateTime.Format { date(LocalDate.Formats.ISO); char(' '); time(LocalTime.Formats.ISO) } + assertEquals("2020-08-30 18:43:13", dateTime.format(format1)) + val format2 = LocalDateTime.Format { + monthNumber(); char('/'); dayOfMonth() + char(' ') + hour(); char(':'); minute() + optional { char(':'); second() } + } + assertEquals("08/30 18:43:13", dateTime.format(format2)) + } + + private fun test(strings: Map>>, format: DateTimeFormat) { + for ((date, stringsForDate) in strings) { + val (canonicalString, otherStrings) = stringsForDate + assertEquals(canonicalString, format.format(date), "formatting $date with $format") + assertEquals(date, format.parse(canonicalString), "parsing '$canonicalString' with $format") + for (otherString in otherStrings) { + assertEquals(date, format.parse(otherString), "parsing '$otherString' with $format") + } + } + } +} diff --git a/core/common/test/format/LocalTimeFormatTest.kt b/core/common/test/format/LocalTimeFormatTest.kt new file mode 100644 index 000000000..da38d1de1 --- /dev/null +++ b/core/common/test/format/LocalTimeFormatTest.kt @@ -0,0 +1,212 @@ +/* + * 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. + */ + +@file:OptIn(FormatStringsInDatetimeFormats::class) + +package kotlinx.datetime.test.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class LocalTimeFormatTest { + + @Test + fun testErrorHandling() { + val format = LocalTime.Formats.ISO + assertEquals(LocalTime(15, 36), format.parse("15:36")) + val error = assertFailsWith { format.parse("40:36") } + assertContains(error.message!!, "40") + assertFailsWith { format.parse("XX:36") } + } + + @Test + fun testHoursMinutes() { + val times = buildMap>> { + put(LocalTime(0, 0, 0, 0), ("00:00" to setOf())) + put(LocalTime(1, 0, 0, 0), ("01:00" to setOf())) + put(LocalTime(23, 0, 0, 0), ("23:00" to setOf())) + put(LocalTime(0, 1, 0, 0), ("00:01" to setOf())) + put(LocalTime(12, 30, 0, 0), ("12:30" to setOf())) + put(LocalTime(23, 59, 0, 0), ("23:59" to setOf())) + } + test(times, LocalTime.Format { + byUnicodePattern("HH:mm") + }) + test(times, LocalTime.Format { + hour() + char(':') + minute() + }) + } + + @Test + fun testHoursMinutesSeconds() { + val times = buildMap>> { + put(LocalTime(0, 0, 0, 0), ("00:00:00" to setOf())) + put(LocalTime(1, 0, 0, 0), ("01:00:00" to setOf())) + put(LocalTime(23, 0, 0, 0), ("23:00:00" to setOf())) + put(LocalTime(0, 1, 0, 0), ("00:01:00" to setOf())) + put(LocalTime(12, 30, 0, 0), ("12:30:00" to setOf())) + put(LocalTime(23, 59, 0, 0), ("23:59:00" to setOf())) + put(LocalTime(0, 0, 1, 0), ("00:00:01" to setOf())) + put(LocalTime(0, 0, 59, 0), ("00:00:59" to setOf())) + } + test(times, LocalTime.Format { + byUnicodePattern("HH:mm:ss") + }) + test(times, LocalTime.Format { + hour() + char(':') + minute() + char(':') + second() + }) + } + + @Test + fun testAmPmHour() { + val times = buildMap>> { + put(LocalTime(0, 0, 0, 0), ("12:00 AM" to setOf())) + put(LocalTime(1, 0, 0, 0), ("01:00 AM" to setOf())) + put(LocalTime(11, 0, 0, 0), ("11:00 AM" to setOf())) + put(LocalTime(12, 0, 0, 0), ("12:00 PM" to setOf())) + put(LocalTime(13, 0, 0, 0), ("01:00 PM" to setOf())) + put(LocalTime(23, 0, 0, 0), ("11:00 PM" to setOf())) + } + test(times, LocalTime.Format { + amPmHour() + char(':') + minute() + char(' ') + amPmMarker("AM", "PM") + }) + } + + @Test + fun testIso() { + val times = buildMap>> { + put(LocalTime(0, 0, 0, 0), ("00:00:00" to setOf("00:00"))) + put(LocalTime(1, 0, 0, 0), ("01:00:00" to setOf("01:00"))) + put(LocalTime(23, 0, 0, 0), ("23:00:00" to setOf("23:00"))) + put(LocalTime(0, 1, 0, 0), ("00:01:00" to setOf("00:01"))) + put(LocalTime(12, 30, 0, 0), ("12:30:00" to setOf("12:30"))) + put(LocalTime(23, 59, 0, 0), ("23:59:00" to setOf("23:59"))) + put(LocalTime(0, 0, 1, 0), ("00:00:01" to setOf())) + put(LocalTime(0, 0, 59, 0), ("00:00:59" to setOf())) + put(LocalTime(0, 0, 0, 100000000), ("00:00:00.1" to setOf("00:00:00.10"))) + put(LocalTime(0, 0, 0, 10000000), ("00:00:00.01" to setOf("00:00:00.010"))) + put(LocalTime(0, 0, 0, 1000000), ("00:00:00.001" to setOf())) + put(LocalTime(0, 0, 0, 100000), ("00:00:00.0001" to setOf("00:00:00.000100"))) + put(LocalTime(0, 0, 0, 10000), ("00:00:00.00001" to setOf())) + put(LocalTime(0, 0, 0, 1000), ("00:00:00.000001" to setOf())) + put(LocalTime(0, 0, 0, 100), ("00:00:00.0000001" to setOf())) + put(LocalTime(0, 0, 0, 10), ("00:00:00.00000001" to setOf())) + put(LocalTime(0, 0, 0, 1), ("00:00:00.000000001" to setOf())) + put(LocalTime(0, 0, 0, 999999999), ("00:00:00.999999999" to setOf())) + put(LocalTime(0, 0, 0, 99999999), ("00:00:00.099999999" to setOf())) + put(LocalTime(0, 0, 0, 9999999), ("00:00:00.009999999" to setOf())) + put(LocalTime(0, 0, 0, 999999), ("00:00:00.000999999" to setOf())) + put(LocalTime(0, 0, 0, 99999), ("00:00:00.000099999" to setOf())) + put(LocalTime(0, 0, 0, 9999), ("00:00:00.000009999" to setOf())) + put(LocalTime(0, 0, 0, 999), ("00:00:00.000000999" to setOf())) + put(LocalTime(0, 0, 0, 99), ("00:00:00.000000099" to setOf())) + put(LocalTime(0, 0, 0, 9), ("00:00:00.000000009" to setOf())) + } + test(times, LocalTime.Formats.ISO) + } + + @Test + fun testFormattingSecondFractions() { + fun check(nanoseconds: Int, minLength: Int?, maxLength: Int?, string: String) { + fun DateTimeFormatBuilder.WithTime.secondFractionWithThisLength() { + when { + minLength != null && maxLength != null -> secondFraction(minLength, maxLength) + maxLength != null -> secondFraction(maxLength = maxLength) + minLength != null -> secondFraction(minLength = minLength) + else -> secondFraction() + } + } + val format = LocalTime.Format { secondFractionWithThisLength() } + val time = LocalTime(0, 0, 0, nanoseconds) + assertEquals(string, format.format(time)) + val format2 = LocalTime.Format { + hour(); minute(); second() + char('.'); secondFractionWithThisLength() + } + val time2 = format2.parse("123456.$string") + assertEquals((string + "0".repeat(9 - string.length)).toInt(), time2.nanosecond) + } + check(1, null, null, "000000001") + check(1, null, 9, "000000001") + check(1, null, 8, "0") + check(1, null, 7, "0") + check(999_999_999, null, null, "999999999") + check(999_999_999, null, 9, "999999999") + check(999_999_999, null, 8, "99999999") + check(999_999_999, null, 7, "9999999") + check(100000000, null, null, "1") + check(100000000, null, 4, "1") + check(100000000, null, 3, "1") + check(100000000, null, 2, "1") + check(100000000, null, 1, "1") + check(100000000, 4, null, "1000") + check(100000000, 3, null, "100") + check(100000000, 2, null, "10") + check(100000000, 1, null, "1") + check(100000000, 4, 4, "1000") + check(100000000, 3, 4, "100") + check(100000000, 3, 3, "100") + check(100000000, 2, 3, "10") + check(100000000, 2, 2, "10") + check(987654321, null, null, "987654321") + check(987654320, null, null, "98765432") + check(987654300, null, null, "9876543") + check(987654000, null, null, "987654") + check(987650000, null, null, "98765") + check(987600000, null, null, "9876") + check(987000000, null, null, "987") + check(980000000, null, null, "98") + check(900000000, null, null, "9") + check(0, null, null, "0") + check(987654321, null, 9, "987654321") + check(987654321, null, 8, "98765432") + check(987654321, null, 7, "9876543") + check(987654321, null, 6, "987654") + check(987654321, null, 5, "98765") + check(987654321, null, 4, "9876") + check(987654321, null, 3, "987") + check(987654321, null, 2, "98") + check(987654321, null, 1, "9") + } + + @Test + fun testDoc() { + val format = LocalTime.Format { + hour() + char(':') + minute() + char(':') + second() + optional { + char('.') + secondFraction() + } + } + assertEquals("12:34:56", format.format(LocalTime(12, 34, 56))) + assertEquals("12:34:56.123", format.format(LocalTime(12, 34, 56, 123000000))) + } + + private fun test(strings: Map>>, format: DateTimeFormat) { + for ((date, stringsForDate) in strings) { + val (canonicalString, otherStrings) = stringsForDate + assertEquals(canonicalString, format.format(date), "formatting $date with $format") + assertEquals(date, format.parse(canonicalString), "parsing '$canonicalString' with $format") + for (otherString in otherStrings) { + assertEquals(date, format.parse(otherString), "parsing '$otherString' with $format") + } + } + } +} diff --git a/core/common/test/format/UtcOffsetFormatTest.kt b/core/common/test/format/UtcOffsetFormatTest.kt new file mode 100644 index 000000000..515c92696 --- /dev/null +++ b/core/common/test/format/UtcOffsetFormatTest.kt @@ -0,0 +1,192 @@ +/* + * 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.test.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class UtcOffsetFormatTest { + + @Test + fun testErrorHandling() { + val format = UtcOffset.Format { + isoOffset( + zOnZero = true, + useSeparator = true, + outputMinute = WhenToOutput.ALWAYS, + outputSecond = WhenToOutput.IF_NONZERO + ) + } + assertEquals(UtcOffset(hours = -4, minutes = -30), format.parse("-04:30")) + val error = assertFailsWith { format.parse("-04:60") } + assertContains(error.message!!, "60") + assertFailsWith { format.parse("-04:XX") } + } + + @Test + fun testLenientIso8601() { + val offsets = buildMap>> { + put(UtcOffset(-18, 0, 0), ("-18:00" to setOf("-18", "-1800", "-180000", "-18:00:00"))) + put(UtcOffset(-17, -59, -58), ("-17:59:58" to setOf())) + put(UtcOffset(-4, -3, -2), ("-04:03:02" to setOf())) + put(UtcOffset(0, 0, -1), ("-00:00:01" to setOf())) + put(UtcOffset(0, -1, 0), ("-00:01" to setOf())) + put(UtcOffset(0, -1, -1), ("-00:01:01" to setOf())) + put(UtcOffset(-1, 0, 0), ("-01:00" to setOf())) + put(UtcOffset(-1, 0, -1), ("-01:00:01" to setOf())) + put(UtcOffset(-1, -1, 0), ("-01:01" to setOf())) + put(UtcOffset(-1, -1, -1), ("-01:01:01" to setOf())) + put(UtcOffset(0, 0, 0), ("Z" to setOf())) + put(UtcOffset(0, 1, 0), ("+00:01" to setOf())) + put(UtcOffset(0, 1, 1), ("+00:01:01" to setOf())) + put(UtcOffset(1, 0, 0), ("+01:00" to setOf())) + put(UtcOffset(1, 0, 1), ("+01:00:01" to setOf())) + put(UtcOffset(1, 1, 0), ("+01:01" to setOf())) + put(UtcOffset(1, 1, 1), ("+01:01:01" to setOf())) + put(UtcOffset(4, 3, 2), ("+04:03:02" to setOf())) + put(UtcOffset(17, 59, 58), ("+17:59:58" to setOf())) + put(UtcOffset(18, 0, 0), ("+18:00" to setOf())) + } + val lenientFormat = UtcOffsetFormat.build { + alternativeParsing({ + isoOffset( + zOnZero = false, + useSeparator = false, + outputMinute = WhenToOutput.IF_NONZERO, + outputSecond = WhenToOutput.IF_NONZERO + ) + }) { + isoOffset( + zOnZero = true, + useSeparator = true, + outputMinute = WhenToOutput.ALWAYS, + outputSecond = WhenToOutput.IF_NONZERO + ) + } + } + test(offsets, lenientFormat) + } + + @Test + fun testIso() { + val offsets = buildMap>> { + put(UtcOffset(-18, -0, -0), ("-18:00" to setOf())) + put(UtcOffset(-17, -59, -58), ("-17:59:58" to setOf())) + put(UtcOffset(-4, -3, -2), ("-04:03:02" to setOf())) + put(UtcOffset(-0, -0, -1), ("-00:00:01" to setOf())) + put(UtcOffset(-0, -1, -0), ("-00:01" to setOf())) + put(UtcOffset(-0, -1, -1), ("-00:01:01" to setOf())) + put(UtcOffset(-1, -0, -0), ("-01:00" to setOf())) + put(UtcOffset(-1, -0, -1), ("-01:00:01" to setOf())) + put(UtcOffset(-1, -1, -0), ("-01:01" to setOf())) + put(UtcOffset(-1, -1, -1), ("-01:01:01" to setOf())) + put(UtcOffset(+0, 0, 0), ("Z" to setOf("z"))) + put(UtcOffset(+0, 1, 0), ("+00:01" to setOf())) + put(UtcOffset(+0, 1, 1), ("+00:01:01" to setOf())) + put(UtcOffset(+1, 0, 0), ("+01:00" to setOf())) + put(UtcOffset(+1, 0, 1), ("+01:00:01" to setOf())) + put(UtcOffset(+1, 1, 0), ("+01:01" to setOf())) + put(UtcOffset(+1, 1, 1), ("+01:01:01" to setOf())) + put(UtcOffset(+4, 3, 2), ("+04:03:02" to setOf())) + put(UtcOffset(+17, 59, 58), ("+17:59:58" to setOf())) + put(UtcOffset(+18, 0, 0), ("+18:00" to setOf())) + } + test(offsets, UtcOffset.Formats.ISO) + } + + @Test + fun testBasicIso() { + val offsets = buildMap>> { + put(UtcOffset(-18, -0, -0), ("-18" to setOf())) + put(UtcOffset(-17, -59, -58), ("-175958" to setOf())) + put(UtcOffset(-4, -3, -2), ("-040302" to setOf())) + put(UtcOffset(-0, -0, -1), ("-000001" to setOf())) + put(UtcOffset(-0, -1, -0), ("-0001" to setOf())) + put(UtcOffset(-0, -1, -1), ("-000101" to setOf())) + put(UtcOffset(-1, -0, -0), ("-01" to setOf())) + put(UtcOffset(-1, -0, -1), ("-010001" to setOf())) + put(UtcOffset(-1, -1, -0), ("-0101" to setOf())) + put(UtcOffset(-1, -1, -1), ("-010101" to setOf())) + put(UtcOffset(+0, 0, 0), ("Z" to setOf("z"))) + put(UtcOffset(+0, 1, 0), ("+0001" to setOf())) + put(UtcOffset(+0, 1, 1), ("+000101" to setOf())) + put(UtcOffset(+1, 0, 0), ("+01" to setOf())) + put(UtcOffset(+1, 0, 1), ("+010001" to setOf())) + put(UtcOffset(+1, 1, 0), ("+0101" to setOf())) + put(UtcOffset(+1, 1, 1), ("+010101" to setOf())) + put(UtcOffset(+4, 3, 2), ("+040302" to setOf())) + put(UtcOffset(+17, 59, 58), ("+175958" to setOf())) + put(UtcOffset(+18, 0, 0), ("+18" to setOf())) + } + test(offsets, UtcOffset.Formats.ISO_BASIC) + } + + @Test + fun testCompact() { + val offsets = buildMap>> { + put(UtcOffset(-18, 0), ("-1800" to setOf())) + put(UtcOffset(-17, -59), ("-1759" to setOf())) + put(UtcOffset(-4, -3), ("-0403" to setOf())) + put(UtcOffset(-0, -0), ("-0000" to setOf())) + put(UtcOffset(-0, -1), ("-0001" to setOf())) + put(UtcOffset(-1, -0), ("-0100" to setOf())) + put(UtcOffset(-1, -1), ("-0101" to setOf())) + put(UtcOffset(+0, 0), ("+0000" to setOf())) + put(UtcOffset(+0, 1), ("+0001" to setOf())) + put(UtcOffset(+1, 0), ("+0100" to setOf())) + put(UtcOffset(+1, 1), ("+0101" to setOf())) + put(UtcOffset(+4, 3), ("+0403" to setOf())) + put(UtcOffset(+17, 59), ("+1759" to setOf())) + put(UtcOffset(+18, 0), ("+1800" to setOf())) + } + test(offsets, UtcOffset.Formats.FOUR_DIGITS) + // formatting that loses precision and can't be parsed back: + for ((offset, string) in listOf( + UtcOffset(-17, -59, -58) to "-1759", + UtcOffset(-4, -3, -2) to "-0403", + UtcOffset(-0, -0, -1) to "-0000", + UtcOffset(-0, -1, -1) to "-0001", + UtcOffset(-1, -0, -1) to "-0100", + UtcOffset(-1, -1, -1) to "-0101", + UtcOffset(+0, 1, 1) to "+0001", + UtcOffset(+1, 0, 1) to "+0100", + UtcOffset(+1, 1, 1) to "+0101", + UtcOffset(+4, 3, 2) to "+0403", + UtcOffset(+17, 59, 58) to "+1759", + )) { + assertEquals(string, UtcOffset.Formats.FOUR_DIGITS.format(offset)) + } + } + + @Test + fun testDoc() { + val format = UtcOffset.Format { + optional("GMT") { + offsetHours(Padding.NONE) + char(':') + offsetMinutesOfHour() + optional { + char(':') + offsetSecondsOfMinute() + } + } + } + assertEquals("GMT", UtcOffset.ZERO.format(format)) + assertEquals("+4:30:15", UtcOffset(4, 30, 15).format(format)) + } + + private fun test(strings: Map>>, format: DateTimeFormat) { + for ((offset, stringsForDate) in strings) { + val (canonicalString, otherStrings) = stringsForDate + assertEquals(canonicalString, format.format(offset), "formatting $offset with $format") + assertEquals(offset, format.parse(canonicalString), "parsing '$canonicalString' with $format") + for (otherString in otherStrings) { + assertEquals(offset, format.parse(otherString), "parsing '$otherString' with $format") + } + } + } +} diff --git a/core/commonJs/src/Instant.kt b/core/commonJs/src/Instant.kt index dfd92e73b..f15268615 100644 --- a/core/commonJs/src/Instant.kt +++ b/core/commonJs/src/Instant.kt @@ -5,6 +5,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.JSJoda.Instant as jtInstant import kotlinx.datetime.internal.JSJoda.OffsetDateTime as jtOffsetDateTime import kotlinx.datetime.internal.JSJoda.Duration as jtDuration @@ -73,12 +74,21 @@ public actual class Instant internal constructor(internal val value: jtInstant) if (epochMilliseconds > 0) MAX else MIN } - public actual fun parse(isoString: String): Instant = try { - Instant(jsTry { jtOffsetDateTime.parse(fixOffsetRepresentation(isoString)) }.toInstant()) - } catch (e: Throwable) { - if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) - throw e - } + public actual fun parse(input: CharSequence, format: DateTimeFormat): Instant = + if (format === DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET) { + try { + Instant(jsTry { jtOffsetDateTime.parse(fixOffsetRepresentation(input.toString())) }.toInstant()) + } catch (e: Throwable) { + if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) + throw e + } + } else { + try { + format.parse(input).toInstantUsingOffset() + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Failed to parse an instant from '$input'", e) + } + } /** A workaround for the string representations of Instant that have an offset of the form * "+XX" not being recognized by [jtOffsetDateTime.parse], while "+XX:XX" work fine. */ @@ -219,6 +229,3 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti } catch (e: Throwable) { if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) else throw e } - -internal actual fun Instant.toStringWithOffset(offset: UtcOffset): String = - jtOffsetDateTime.ofInstant(this.value, offset.zoneOffset).toString() \ No newline at end of file diff --git a/core/commonJs/src/LocalDate.kt b/core/commonJs/src/LocalDate.kt index 30a2dd8f9..892490c9b 100644 --- a/core/commonJs/src/LocalDate.kt +++ b/core/commonJs/src/LocalDate.kt @@ -5,6 +5,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.LocalDateIso8601Serializer import kotlinx.serialization.Serializable import kotlinx.datetime.internal.JSJoda.LocalDate as jtLocalDate @@ -13,11 +14,19 @@ import kotlinx.datetime.internal.JSJoda.ChronoUnit as jtChronoUnit @Serializable(with = LocalDateIso8601Serializer::class) public actual class LocalDate internal constructor(internal val value: jtLocalDate) : Comparable { public actual companion object { - public actual fun parse(isoString: String): LocalDate = try { - jsTry { jtLocalDate.parse(isoString) }.let(::LocalDate) - } catch (e: Throwable) { - if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) - throw e + + public actual fun parse( + input: CharSequence, + format: DateTimeFormat + ): LocalDate = if (format === Formats.ISO) { + try { + jsTry { jtLocalDate.parse(input.toString()) }.let(::LocalDate) + } catch (e: Throwable) { + if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) + throw e + } + } else { + format.parse(input) } internal actual val MIN: LocalDate = LocalDate(jtLocalDate.MIN) @@ -29,6 +38,16 @@ public actual class LocalDate internal constructor(internal val value: jtLocalDa if (e.isJodaDateTimeException()) throw IllegalArgumentException(e) throw e } + + @Suppress("FunctionName") + public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat = + LocalDateFormat.build(block) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_DATE + + public actual val ISO_BASIC: DateTimeFormat = ISO_DATE_BASIC } public actual constructor(year: Int, monthNumber: Int, dayOfMonth: Int) : diff --git a/core/commonJs/src/LocalDateTime.kt b/core/commonJs/src/LocalDateTime.kt index ca266a480..2a7ea8dd4 100644 --- a/core/commonJs/src/LocalDateTime.kt +++ b/core/commonJs/src/LocalDateTime.kt @@ -4,6 +4,9 @@ */ package kotlinx.datetime +import kotlinx.datetime.format.* +import kotlinx.datetime.format.ISO_DATETIME +import kotlinx.datetime.format.LocalDateTimeFormat import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer import kotlinx.serialization.Serializable import kotlinx.datetime.internal.JSJoda.LocalDateTime as jtLocalDateTime @@ -51,15 +54,28 @@ public actual class LocalDateTime internal constructor(internal val value: jtLoc actual override fun compareTo(other: LocalDateTime): Int = this.value.compareTo(other.value) public actual companion object { - public actual fun parse(isoString: String): LocalDateTime = try { - jsTry { jtLocalDateTime.parse(isoString) }.let(::LocalDateTime) - } catch (e: Throwable) { - if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) - throw e - } + public actual fun parse(input: CharSequence, format: DateTimeFormat): LocalDateTime = + if (format === Formats.ISO) { + try { + jsTry { jtLocalDateTime.parse(input.toString()) }.let(::LocalDateTime) + } catch (e: Throwable) { + if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) + throw e + } + } else { + format.parse(input) + } internal actual val MIN: LocalDateTime = LocalDateTime(jtLocalDateTime.MIN) internal actual val MAX: LocalDateTime = LocalDateTime(jtLocalDateTime.MAX) + + @Suppress("FunctionName") + public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat = + LocalDateTimeFormat.build(builder) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat = ISO_DATETIME } -} \ No newline at end of file +} diff --git a/core/commonJs/src/LocalTime.kt b/core/commonJs/src/LocalTime.kt index 185d87954..3ed089ce2 100644 --- a/core/commonJs/src/LocalTime.kt +++ b/core/commonJs/src/LocalTime.kt @@ -4,6 +4,9 @@ */ package kotlinx.datetime +import kotlinx.datetime.format.* +import kotlinx.datetime.format.ISO_TIME +import kotlinx.datetime.format.LocalTimeFormat import kotlinx.datetime.internal.* import kotlinx.datetime.serializers.LocalTimeIso8601Serializer import kotlinx.serialization.Serializable @@ -41,12 +44,17 @@ public actual class LocalTime internal constructor(internal val value: jtLocalTi actual override fun compareTo(other: LocalTime): Int = this.value.compareTo(other.value) public actual companion object { - public actual fun parse(isoString: String): LocalTime = try { - jsTry { jtLocalTime.parse(isoString) }.let(::LocalTime) - } catch (e: Throwable) { - if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) - throw e - } + public actual fun parse(input: CharSequence, format: DateTimeFormat): LocalTime = + if (format === Formats.ISO) { + try { + jsTry { jtLocalTime.parse(input.toString()) }.let(::LocalTime) + } catch (e: Throwable) { + if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) + throw e + } + } else { + format.parse(input) + } public actual fun fromSecondOfDay(secondOfDay: Int): LocalTime = try { jsTry { jtLocalTime.ofSecondOfDay(secondOfDay, 0) }.let(::LocalTime) @@ -69,5 +77,13 @@ public actual class LocalTime internal constructor(internal val value: jtLocalTi internal actual val MIN: LocalTime = LocalTime(jtLocalTime.MIN) internal actual val MAX: LocalTime = LocalTime(jtLocalTime.MAX) + + @Suppress("FunctionName") + public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat = + LocalTimeFormat.build(builder) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_TIME } -} \ No newline at end of file +} diff --git a/core/commonJs/src/UtcOffset.kt b/core/commonJs/src/UtcOffset.kt index 89ca752e5..f2ed832ab 100644 --- a/core/commonJs/src/UtcOffset.kt +++ b/core/commonJs/src/UtcOffset.kt @@ -6,6 +6,11 @@ package kotlinx.datetime import kotlinx.datetime.internal.JSJoda.ZoneOffset as jtZoneOffset +import kotlinx.datetime.internal.JSJoda.ChronoField as jtChronoField +import kotlinx.datetime.internal.JSJoda.DateTimeFormatterBuilder as jtDateTimeFormatterBuilder +import kotlinx.datetime.internal.JSJoda.DateTimeFormatter as jtDateTimeFormatter +import kotlinx.datetime.internal.JSJoda.ResolverStyle as jtResolverStyle +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.UtcOffsetSerializer import kotlinx.serialization.Serializable @@ -15,18 +20,29 @@ public actual class UtcOffset internal constructor(internal val zoneOffset: jtZo override fun hashCode(): Int = zoneOffset.hashCode() override fun equals(other: Any?): Boolean = other is UtcOffset && (this.zoneOffset === other.zoneOffset || this.zoneOffset.equals(other.zoneOffset)) - override fun toString(): String = zoneOffset.toString() + actual override fun toString(): String = zoneOffset.toString() public actual companion object { + private val isoFormat = jtDateTimeFormatterBuilder().appendOffsetId().toFormatter(jtResolverStyle.STRICT) public actual val ZERO: UtcOffset = UtcOffset(jtZoneOffset.UTC) - public actual fun parse(offsetString: String): UtcOffset = try { - jsTry { jtZoneOffset.of(offsetString) }.let(::UtcOffset) - } catch (e: Throwable) { - if (e.isJodaDateTimeException()) throw DateTimeFormatException(e) - throw e + public actual fun parse(input: CharSequence, format: DateTimeFormat): UtcOffset = when { + format === Formats.ISO -> parseWithFormat(input, isoFormat) + format === Formats.ISO_BASIC -> parseWithFormat(input, isoBasicFormat) + format === Formats.FOUR_DIGITS -> parseWithFormat(input, fourDigitsFormat) + else -> format.parse(input) } + + @Suppress("FunctionName") + public actual fun Format(block: DateTimeFormatBuilder.WithUtcOffset.() -> Unit): DateTimeFormat = + UtcOffsetFormat.build(block) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_OFFSET + public actual val ISO_BASIC: DateTimeFormat get() = ISO_OFFSET_BASIC + public actual val FOUR_DIGITS: DateTimeFormat get() = FOUR_DIGIT_OFFSET } } @@ -45,3 +61,21 @@ public actual fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: I } catch (e: Throwable) { if (e.isJodaDateTimeException()) throw IllegalArgumentException(e) else throw e } + +private val isoFormat by lazy { + jtDateTimeFormatterBuilder().parseCaseInsensitive().appendOffsetId().toFormatter(jtResolverStyle.STRICT) +} +private val isoBasicFormat by lazy { + jtDateTimeFormatterBuilder().parseCaseInsensitive().appendOffset("+HHmmss", "Z").toFormatter(jtResolverStyle.STRICT) +} +private val fourDigitsFormat by lazy { + jtDateTimeFormatterBuilder().parseCaseInsensitive().appendOffset("+HHMM", "+0000").toFormatter(jtResolverStyle.STRICT) +} + +private fun parseWithFormat(input: CharSequence, format: jtDateTimeFormatter) = UtcOffset(seconds = try { + jsTry { format.parse(input.toString()).get(jtChronoField.OFFSET_SECONDS) } +} catch (e: Throwable) { + if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e) + if (e.isJodaDateTimeException()) throw DateTimeFormatException(e) + throw e +}) diff --git a/core/jvm/src/Instant.kt b/core/jvm/src/Instant.kt index 5fc849088..ec7792e18 100644 --- a/core/jvm/src/Instant.kt +++ b/core/jvm/src/Instant.kt @@ -6,6 +6,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.safeMultiply import kotlinx.datetime.internal.* import kotlinx.datetime.serializers.InstantIso8601Serializer @@ -66,15 +67,24 @@ public actual class Instant internal constructor(internal val value: jtInstant) public actual fun fromEpochMilliseconds(epochMilliseconds: Long): Instant = Instant(jtInstant.ofEpochMilli(epochMilliseconds)) - public actual fun parse(isoString: String): Instant = try { - Instant(jtOffsetDateTime.parse(fixOffsetRepresentation(isoString)).toInstant()) - } catch (e: DateTimeParseException) { - throw DateTimeFormatException(e) - } + public actual fun parse(input: CharSequence, format: DateTimeFormat): Instant = + if (format === DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET) { + try { + Instant(jtOffsetDateTime.parse(fixOffsetRepresentation(input)).toInstant()) + } catch (e: DateTimeParseException) { + throw DateTimeFormatException(e) + } + } else { + try { + format.parse(input).toInstantUsingOffset() + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Failed to parse an instant from '$input'", e) + } + } /** A workaround for a quirk of the JDKs older than 11 where the string representations of Instant that have an * offset of the form "+XX" are not recognized by [jtOffsetDateTime.parse], while "+XX:XX" work fine. */ - private fun fixOffsetRepresentation(isoString: String): String { + private fun fixOffsetRepresentation(isoString: CharSequence): CharSequence { val time = isoString.indexOf('T', ignoreCase = true) if (time == -1) return isoString // the string is malformed val offset = isoString.indexOfLast { c -> c == '+' || c == '-' } @@ -184,6 +194,3 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti } catch (e: ArithmeticException) { if (this.value < other.value) Long.MAX_VALUE else Long.MIN_VALUE } - -internal actual fun Instant.toStringWithOffset(offset: UtcOffset): String = - jtOffsetDateTime.ofInstant(this.value, offset.zoneOffset).toString() diff --git a/core/jvm/src/LocalDate.kt b/core/jvm/src/LocalDate.kt index a70a351e1..5bbd07423 100644 --- a/core/jvm/src/LocalDate.kt +++ b/core/jvm/src/LocalDate.kt @@ -5,6 +5,7 @@ @file:JvmName("LocalDateJvmKt") package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.safeAdd import kotlinx.datetime.internal.safeMultiply import kotlinx.datetime.internal.* @@ -18,17 +19,32 @@ import java.time.LocalDate as jtLocalDate @Serializable(with = LocalDateIso8601Serializer::class) public actual class LocalDate internal constructor(internal val value: jtLocalDate) : Comparable { public actual companion object { - public actual fun parse(isoString: String): LocalDate = try { - jtLocalDate.parse(isoString).let(::LocalDate) - } catch (e: DateTimeParseException) { - throw DateTimeFormatException(e) - } + public actual fun parse(input: CharSequence, format: DateTimeFormat): LocalDate = + if (format === Formats.ISO) { + try { + jtLocalDate.parse(input).let(::LocalDate) + } catch (e: DateTimeParseException) { + throw DateTimeFormatException(e) + } + } else { + format.parse(input) + } internal actual val MIN: LocalDate = LocalDate(jtLocalDate.MIN) internal actual val MAX: LocalDate = LocalDate(jtLocalDate.MAX) public actual fun fromEpochDays(epochDays: Int): LocalDate = LocalDate(jtLocalDate.ofEpochDay(epochDays.toLong())) + + @Suppress("FunctionName") + public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat = + LocalDateFormat.build(block) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_DATE + + public actual val ISO_BASIC: DateTimeFormat = ISO_DATE_BASIC } public actual constructor(year: Int, monthNumber: Int, dayOfMonth: Int) : diff --git a/core/jvm/src/LocalDateTime.kt b/core/jvm/src/LocalDateTime.kt index 208d9798c..95eed9c0d 100644 --- a/core/jvm/src/LocalDateTime.kt +++ b/core/jvm/src/LocalDateTime.kt @@ -5,6 +5,7 @@ @file:JvmName("LocalDateTimeJvmKt") package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer import kotlinx.serialization.Serializable import java.time.DateTimeException @@ -56,14 +57,27 @@ public actual class LocalDateTime internal constructor(internal val value: jtLoc actual override fun compareTo(other: LocalDateTime): Int = this.value.compareTo(other.value) public actual companion object { - public actual fun parse(isoString: String): LocalDateTime = try { - jtLocalDateTime.parse(isoString).let(::LocalDateTime) - } catch (e: DateTimeParseException) { - throw DateTimeFormatException(e) - } + public actual fun parse(input: CharSequence, format: DateTimeFormat): LocalDateTime = + if (format === Formats.ISO) { + try { + jtLocalDateTime.parse(input).let(::LocalDateTime) + } catch (e: DateTimeParseException) { + throw DateTimeFormatException(e) + } + } else { + format.parse(input) + } internal actual val MIN: LocalDateTime = LocalDateTime(jtLocalDateTime.MIN) internal actual val MAX: LocalDateTime = LocalDateTime(jtLocalDateTime.MAX) + + @Suppress("FunctionName") + public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat = + LocalDateTimeFormat.build(builder) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat = ISO_DATETIME } } diff --git a/core/jvm/src/LocalTime.kt b/core/jvm/src/LocalTime.kt index 335c5be7c..e918e1928 100644 --- a/core/jvm/src/LocalTime.kt +++ b/core/jvm/src/LocalTime.kt @@ -6,6 +6,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* import kotlinx.datetime.serializers.LocalTimeIso8601Serializer import kotlinx.serialization.Serializable @@ -44,11 +45,16 @@ public actual class LocalTime internal constructor(internal val value: jtLocalTi actual override fun compareTo(other: LocalTime): Int = this.value.compareTo(other.value) public actual companion object { - public actual fun parse(isoString: String): LocalTime = try { - jtLocalTime.parse(isoString).let(::LocalTime) - } catch (e: DateTimeParseException) { - throw DateTimeFormatException(e) - } + public actual fun parse(input: CharSequence, format: DateTimeFormat): LocalTime = + if (format === Formats.ISO) { + try { + jtLocalTime.parse(input).let(::LocalTime) + } catch (e: DateTimeParseException) { + throw DateTimeFormatException(e) + } + } else { + format.parse(input) + } public actual fun fromSecondOfDay(secondOfDay: Int): LocalTime = try { jtLocalTime.ofSecondOfDay(secondOfDay.toLong()).let(::LocalTime) @@ -70,5 +76,14 @@ public actual class LocalTime internal constructor(internal val value: jtLocalTi internal actual val MIN: LocalTime = LocalTime(jtLocalTime.MIN) internal actual val MAX: LocalTime = LocalTime(jtLocalTime.MAX) + + @Suppress("FunctionName") + public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat = + LocalTimeFormat.build(builder) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_TIME + } } diff --git a/core/jvm/src/UtcOffsetJvm.kt b/core/jvm/src/UtcOffsetJvm.kt index ccde301a8..66358f302 100644 --- a/core/jvm/src/UtcOffsetJvm.kt +++ b/core/jvm/src/UtcOffsetJvm.kt @@ -5,10 +5,12 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.UtcOffsetSerializer import kotlinx.serialization.Serializable import java.time.DateTimeException import java.time.ZoneOffset +import java.time.format.* @Serializable(with = UtcOffsetSerializer::class) public actual class UtcOffset(internal val zoneOffset: ZoneOffset) { @@ -16,17 +18,27 @@ public actual class UtcOffset(internal val zoneOffset: ZoneOffset) { override fun hashCode(): Int = zoneOffset.hashCode() override fun equals(other: Any?): Boolean = other is UtcOffset && this.zoneOffset == other.zoneOffset - override fun toString(): String = zoneOffset.toString() + actual override fun toString(): String = zoneOffset.toString() public actual companion object { - public actual val ZERO: UtcOffset = UtcOffset(ZoneOffset.UTC) - public actual fun parse(offsetString: String): UtcOffset = try { - ZoneOffset.of(offsetString).let(::UtcOffset) - } catch (e: DateTimeException) { - throw DateTimeFormatException(e) + public actual fun parse(input: CharSequence, format: DateTimeFormat): UtcOffset = when { + format === Formats.ISO -> parseWithFormat(input, isoFormat) + format === Formats.ISO_BASIC -> parseWithFormat(input, isoBasicFormat) + format === Formats.FOUR_DIGITS -> parseWithFormat(input, fourDigitsFormat) + else -> format.parse(input) } + + @Suppress("FunctionName") + public actual fun Format(block: DateTimeFormatBuilder.WithUtcOffset.() -> Unit): DateTimeFormat = + UtcOffsetFormat.build(block) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_OFFSET + public actual val ISO_BASIC: DateTimeFormat get() = ISO_OFFSET_BASIC + public actual val FOUR_DIGITS: DateTimeFormat get() = FOUR_DIGIT_OFFSET } } @@ -45,3 +57,19 @@ public actual fun UtcOffset(hours: Int? = null, minutes: Int? = null, seconds: I } catch (e: DateTimeException) { throw IllegalArgumentException(e) } + +private val isoFormat by lazy { + DateTimeFormatterBuilder().parseCaseInsensitive().appendOffsetId().toFormatter() +} +private val isoBasicFormat by lazy { + DateTimeFormatterBuilder().parseCaseInsensitive().appendOffset("+HHmmss", "Z").toFormatter() +} +private val fourDigitsFormat by lazy { + DateTimeFormatterBuilder().parseCaseInsensitive().appendOffset("+HHMM", "+0000").toFormatter() +} + +private fun parseWithFormat(input: CharSequence, format: DateTimeFormatter) = try { + format.parse(input, ZoneOffset::from).let(::UtcOffset) +} catch (e: DateTimeException) { + throw DateTimeFormatException(e) +} diff --git a/core/jvm/test/InstantParsing.kt b/core/jvm/test/InstantParsing.kt new file mode 100644 index 000000000..4c29b81e9 --- /dev/null +++ b/core/jvm/test/InstantParsing.kt @@ -0,0 +1,33 @@ +package kotlinx.datetime + +import kotlinx.datetime.format.* +import kotlin.test.* + +class InstantParsing { + @Test + fun testParsingInvalidInstants() { + fun parseInstantLikeJavaDoes(input: String): Instant = + DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET.parse(input).apply { + when { + hour == 24 && minute == 0 && second == 0 && nanosecond == 0 -> { + setDate(toLocalDate().plus(1, DateTimeUnit.DAY)) + hour = 0 + } + hour == 23 && minute == 59 && second == 60 -> second = 59 + } + }.toInstantUsingOffset() + fun formatTwoDigits(i: Int) = if (i < 10) "0$i" else "$i" + for (hour in 23..25) { + for (minute in listOf(0..5, 58..62).flatten()) { + for (second in listOf(0..5, 58..62).flatten()) { + val input = "2020-03-16T${hour}:${formatTwoDigits(minute)}:${formatTwoDigits(second)}Z" + assertEquals( + runCatching { java.time.Instant.parse(input) }.getOrNull(), + runCatching { parseInstantLikeJavaDoes(input).toJavaInstant() }.getOrNull() + ) + } + } + } + } + +} diff --git a/core/jvm/test/UnicodeFormatTest.kt b/core/jvm/test/UnicodeFormatTest.kt new file mode 100644 index 000000000..cf51533d4 --- /dev/null +++ b/core/jvm/test/UnicodeFormatTest.kt @@ -0,0 +1,301 @@ +/* + * 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.test + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import java.text.ParsePosition +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.* +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +@OptIn(FormatStringsInDatetimeFormats::class) +class UnicodeFormatTest { + + @Test + fun testTop100UnicodeFormats() { + val nonLocalizedPatterns = listOf( + "yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss", "HH:mm", "HH:mm:ss", "dd/MM/yyyy", "yyyyMMdd", "dd.MM.yyyy", + "yyyy-MM-dd HH:mm", "yyyy", "dd-MM-yyyy", "yyyyMMddHHmmss", "yyyy-MM-dd HH:mm:ss.SSS", "yyyy/MM/dd", + "yyyy-MM-dd'T'HH:mm:ss", "MM/dd/yyyy", "yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + "HH:mm:ss.SSS", "dd.MM.yyyy HH:mm", "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM", "dd/MM/yyyy HH:mm:ss", + "M/d/yyyy", "yyyy-MM-dd HH:mm:ss.S", "HH", "dd-MM-yyyy HH:mm:ss", "dd/MM/yyyy HH:mm", "dd", + "yyyy-MM-dd'T'HH:mm:ssZ", "yyyy.MM.dd", "HHmmss", + "dd.MM.yyyy HH:mm:ss", "MM", "yyyy-MM-dd HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyMMddHHmmss", "MM/dd/yyyy HH:mm:ss", + "yyyy-MM-dd-HH-mm-ss", "yyyyMM", "yyyyMMddHHmm", "H:mm", + "dd-MM-yyyy HH:mm", "yyyyMMdd_HHmmss", "yyyy-MM-dd'T'HH:mm:ss.SSSX", "MM-dd-yyyy", + "yyyy-MM-dd_HH-mm-ss", "mm", "dd/MM/yy", "ddMMyy", + "uuuu-MM-dd", "dd.MM.yy", "yyyy-MM-dd'T'HH:mm", "yyyyMMdd-HHmmss", + "uuuu-MM-dd'T'HH:mm:ss", "yyyy MM dd", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "uuuu-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ssXXX", "yyyy-M-d", "d", "yyMMdd", "yyyyMMddHH", + "HHmm", "MM/dd/yy", + "yyyy_MM_dd_HH_mm_ss", "yyyy-MM-d 'at' HH:mm ", "yyyy:MM:dd HH:mm:ss", + "yyyy年MM月dd日 HH:mm:ss", "yyyy年MM月dd日", "dd.MM.yyyy. HH:mm:ss", "ss", "ddMMyyyy", + "yyyyMMdd'T'HHmmss'Z'", "yyyyMMdd'T'HHmmss", "yyyy-MM-dd'T'HH:mm:ssX", + "yyyy-MM-dd'T'HH:mm:ss[.SSS]X" // not in top 100, but interesting, as it contains an optional section + ) + val localizedPatterns = listOf( + "MMMM", "hh:mm a", "h:mm a", "dd MMMM yyyy", "dd MMM yyyy", "yyyy-MM-dd hh:mm:ss", "d MMMM yyyy", "MMM", + "MMM dd, yyyy", "dd-MMM-yyyy", "d MMM yyyy", "MMM yyyy", "MMMM yyyy", "EEE", "EEEE", "hh:mm:ss a", + "d MMM uuuu HH:mm:ss ", "MMMM d, yyyy", "MMMM dd, yyyy", "yyyy-MM-dd HH:mm:ss z", "hh:mm", "MMM dd", + ) + val unsupportedPatterns = listOf( + "YYYY-MM-dd", + ) + // "yyyyMMddHHmmssSSS" is also in the top-100 list, but parsing it fails on Java + for (pattern in unsupportedPatterns) { + assertFailsWith { + DateTimeComponents.Format { + byUnicodePattern(pattern) + } + } + } + for (pattern in localizedPatterns) { + val error = assertFailsWith { + DateTimeComponents.Format { + byUnicodePattern(pattern) + } + } + assertContains(error.message!!, "locale-dependent") + } + for (pattern in nonLocalizedPatterns) { + checkPattern(pattern) + } + } + + private fun checkPattern(pattern: String) { + val unicodeFormat = UnicodeFormat.parse(pattern) + val directives = directivesInFormat(unicodeFormat) + val dates = when { + directives.any { + it is UnicodeFormat.Directive.DateBased.Year && it.formatLength == 2 + || it is UnicodeFormat.Directive.DateBased.YearOfEra && it.formatLength == 2 + } -> interestingDates21stCentury + + directives.any { it is UnicodeFormat.Directive.DateBased.YearOfEra } -> interestingDatesPositive + directives.any { it is UnicodeFormat.Directive.DateBased } -> interestingDates + else -> listOf(LocalDate(1970, 1, 1)) + } + val times = when { + directives.any { it is UnicodeFormat.Directive.TimeBased.WithSubsecondPrecision } -> interestingTimes + directives.any { it is UnicodeFormat.Directive.TimeBased.WithSecondPrecision } -> + interestingTimesWithZeroNanoseconds + + directives.any { it is UnicodeFormat.Directive.TimeBased } -> interestingTimesWithZeroSeconds + else -> listOf(LocalTime(0, 0)) + } + val offsets = when { + directives.any { it is UnicodeFormat.Directive.OffsetBased && it.outputSeconds() != WhenToOutput.NEVER } -> + interestingOffsets + + directives.any { it is UnicodeFormat.Directive.OffsetBased && it.outputMinutes() != WhenToOutput.NEVER } -> + interestingOffsetsWithZeroSeconds + + else -> listOf(UtcOffset.ZERO) + } + val zones = when { + directives.any { it is UnicodeFormat.Directive.ZoneBased } -> TimeZone.availableZoneIds + else -> setOf("Europe/Berlin") + } + val format = DateTimeComponents.Format { byUnicodePattern(pattern) } + val javaFormat = DateTimeFormatter.ofPattern(pattern) + for (date in dates) { + for (time in times) { + for (offset in offsets) { + for (zone in zones) { + try { + val components = DateTimeComponents().apply { + setDate(date); setTime(time); setOffset(offset); timeZoneId = zone + } + val stringFromKotlin = format.format(components) + val stringFromJava = javaFormat.format(components.temporalAccessor()) + val parsePosition = ParsePosition(0) + val parsed = javaFormat.parseUnresolved(stringFromKotlin, parsePosition) + ?: throw IllegalStateException("$parsePosition on $stringFromKotlin") + val componentsFromJava = parsed.query(dateTimeComponentsTemporalQuery) + // the string produced by Kotlin is parsable by Java: + assertEquals(stringFromKotlin, format.format(componentsFromJava)) + val newStringFromJava = javaFormat.format(parsed) + assertEquals(stringFromJava, newStringFromJava) + // the string produced by Java is the same as the one by Kotlin: + assertEquals(stringFromKotlin, stringFromJava) + val componentsFromKotlin = format.parse(stringFromKotlin) + // the string produced by either Java or Kotlin is parsable by Kotlin: + assertEquals(stringFromKotlin, format.format(componentsFromKotlin)) + } catch (e: Exception) { + throw AssertionError("On $date, $time, $offset, $zone, with pattern $pattern", e) + } + } + } + } + } + } +} + +private fun DateTimeComponents.temporalAccessor() = object : TemporalAccessor { + override fun isSupported(field: TemporalField?): Boolean = true + + override fun getLong(field: TemporalField?): Long { + if (field === ChronoField.OFFSET_SECONDS) { + return toUtcOffset().totalSeconds.toLong() + } else { + return toLocalDateTime().toJavaLocalDateTime().atZone(ZoneId.of(timeZoneId)).getLong(field) + } + } + +} + +private val dateTimeComponentsTemporalQuery = TemporalQuery { accessor -> + DateTimeComponents().apply { + for ((field, setter) in listOf Unit>>( + ChronoField.YEAR_OF_ERA to { year = it }, + ChronoField.YEAR to { year = it }, + ChronoField.MONTH_OF_YEAR to { monthNumber = it }, + ChronoField.DAY_OF_MONTH to { dayOfMonth = it }, + ChronoField.DAY_OF_WEEK to { dayOfWeek = DayOfWeek(it) }, + ChronoField.AMPM_OF_DAY to { amPm = if (it == 1) AmPmMarker.PM else AmPmMarker.AM }, + ChronoField.CLOCK_HOUR_OF_AMPM to { hourOfAmPm = it }, + ChronoField.HOUR_OF_DAY to { hour = it }, + ChronoField.MINUTE_OF_HOUR to { minute = it }, + ChronoField.SECOND_OF_MINUTE to { second = it }, + ChronoField.NANO_OF_SECOND to { nanosecond = it }, + ChronoField.OFFSET_SECONDS to { setOffset(UtcOffset(seconds = it)) }, + )) { + if (accessor.isSupported(field)) { + setter(accessor[field]) + } + } + timeZoneId = accessor.query(TemporalQueries.zoneId())?.id + } +} + +internal fun directivesInFormat(format: UnicodeFormat): List = when (format) { + is UnicodeFormat.Directive -> listOf(format) + is UnicodeFormat.Sequence -> format.formats.flatMapTo(mutableListOf()) { directivesInFormat(it) } + is UnicodeFormat.OptionalGroup -> directivesInFormat(format.format) + is UnicodeFormat.StringLiteral -> listOf() +} + +val interestingDates: List = listOf( + LocalDate(2008, 7, 5), + LocalDate(2007, 12, 31), + LocalDate(1980, 12, 31), + LocalDate(999, 11, 30), + LocalDate(-1, 1, 2), + LocalDate(9999, 10, 31), + LocalDate(-9999, 9, 30), + LocalDate(10000, 8, 1), + LocalDate(-10000, 7, 1), + LocalDate(123456, 6, 1), + LocalDate(-123456, 5, 1), +) + +val interestingDatesPositive: List = listOf( + LocalDate(2008, 7, 5), + LocalDate(2007, 12, 31), + LocalDate(1980, 12, 31), + LocalDate(999, 11, 30), + LocalDate(9999, 10, 31), + LocalDate(10000, 8, 1), + LocalDate(123456, 6, 1), +) + +val interestingDates21stCentury: List = listOf( + LocalDate(2008, 7, 5), + LocalDate(2007, 12, 31), + LocalDate(2099, 1, 2), + LocalDate(2034, 11, 30), +) + +val interestingTimes: List = listOf( + LocalTime(0, 0, 0, 0), + LocalTime(1, 0, 0, 0), + LocalTime(23, 0, 0, 0), + LocalTime(0, 1, 0, 0), + LocalTime(12, 30, 0, 0), + LocalTime(23, 59, 0, 0), + LocalTime(0, 0, 1, 0), + LocalTime(0, 0, 59, 0), + LocalTime(0, 0, 0, 100000000), + LocalTime(0, 0, 0, 10000000), + LocalTime(0, 0, 0, 1000000), + LocalTime(0, 0, 0, 100000), + LocalTime(0, 0, 0, 10000), + LocalTime(0, 0, 0, 1000), + LocalTime(0, 0, 0, 100), + LocalTime(0, 0, 0, 10), + LocalTime(0, 0, 0, 1), + LocalTime(0, 0, 0, 999999999), + LocalTime(0, 0, 0, 998900000), + LocalTime(0, 0, 0, 99999999), + LocalTime(0, 0, 0, 9999999), + LocalTime(0, 0, 0, 999999), + LocalTime(0, 0, 0, 99999), + LocalTime(0, 0, 0, 9999), + LocalTime(0, 0, 0, 999), + LocalTime(0, 0, 0, 99), + LocalTime(0, 0, 0, 9), +) + +val interestingTimesWithZeroNanoseconds: List = listOf( + LocalTime(0, 0, 0, 0), + LocalTime(1, 0, 0, 0), + LocalTime(23, 0, 0, 0), + LocalTime(0, 1, 0, 0), + LocalTime(12, 30, 0, 0), + LocalTime(23, 59, 0, 0), + LocalTime(0, 0, 1, 0), + LocalTime(0, 0, 59, 0), +) + +val interestingTimesWithZeroSeconds: List = listOf( + LocalTime(0, 0, 0, 0), + LocalTime(1, 0, 0, 0), + LocalTime(23, 0, 0, 0), + LocalTime(0, 1, 0, 0), + LocalTime(12, 30, 0, 0), + LocalTime(23, 59, 0, 0), +) + +val interestingOffsets: List = listOf( + UtcOffset(-18), + UtcOffset(-17, -59, -58), + UtcOffset(-4, -3, -2), + UtcOffset(0, 0, -1), + UtcOffset(0, -1, 0), + UtcOffset(0, -1, -1), + UtcOffset(-1, 0, 0), + UtcOffset(-1, 0, -1), + UtcOffset(-1, -1, 0), + UtcOffset(-1, -1, -1), + UtcOffset(0, 0, 0), + UtcOffset(0, 1, 0), + UtcOffset(0, 1, 1), + UtcOffset(1, 0, 0), + UtcOffset(1, 0, 1), + UtcOffset(1, 1, 0), + UtcOffset(1, 1, 1), + UtcOffset(4, 3, 2), + UtcOffset(17, 59, 58), + UtcOffset(18), +) + +val interestingOffsetsWithZeroSeconds: List = listOf( + UtcOffset(-18), + UtcOffset(0, -1, 0), + UtcOffset(-1, 0, 0), + UtcOffset(-1, -1, 0), + UtcOffset(0, 0, 0), + UtcOffset(0, 1, 0), + UtcOffset(1, 0, 0), + UtcOffset(1, 1, 0), + UtcOffset(18), +) diff --git a/core/native/src/Instant.kt b/core/native/src/Instant.kt index 3b21d33e0..d041e9228 100644 --- a/core/native/src/Instant.kt +++ b/core/native/src/Instant.kt @@ -8,10 +8,10 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* import kotlinx.datetime.serializers.InstantIso8601Serializer import kotlinx.serialization.Serializable -import kotlin.math.* import kotlin.time.* import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.seconds @@ -26,95 +26,6 @@ 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) - } - } - /** * The minimum supported epoch second. */ @@ -125,7 +36,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 @@ -187,7 +98,7 @@ public actual class Instant internal constructor(public actual val epochSeconds: (epochSeconds xor (epochSeconds ushr 32)).toInt() + 51 * nanosecondsOfSecond // org.threeten.bp.format.DateTimeFormatterBuilder.InstantPrinterParser#print - actual override fun toString(): String = toStringWithOffset(UtcOffset.ZERO) + actual override fun toString(): String = format(ISO_DATE_TIME_OFFSET_WITH_TRAILING_ZEROS) public actual companion object { internal actual val MIN = Instant(MIN_SECOND, 0) @@ -228,8 +139,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(input: CharSequence, format: DateTimeFormat): Instant = try { + format.parse(input).toInstantUsingOffset() + } catch (e: IllegalArgumentException) { + throw DateTimeFormatException("Failed to parse an instant from '$input'", e) + } public actual val DISTANT_PAST: Instant = fromEpochSeconds(DISTANT_PAST_SECONDS, 999_999_999) @@ -331,65 +245,26 @@ 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)) - } - } - } +private val ISO_DATE_TIME_OFFSET_WITH_TRAILING_ZEROS = DateTimeComponents.Format { + date(ISO_DATE) + alternativeParsing({ + char('t') + }) { + char('T') } - //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)) - } - } + hour() + char(':') + minute() + char(':') + second() + optional { + char('.') + secondFractionInternal(1, 9, FractionalSecondDirective.GROUP_BY_THREE) } - buf.append(offset) - return buf.toString() + isoOffset( + zOnZero = true, + useSeparator = true, + outputMinute = WhenToOutput.IF_NONZERO, + outputSecond = WhenToOutput.IF_NONZERO + ) } diff --git a/core/native/src/LocalDate.kt b/core/native/src/LocalDate.kt index e08fa6297..92cfcd7bb 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(input: CharSequence, format: DateTimeFormat): LocalDate = format.parse(input) // org.threeten.bp.LocalDate#toEpochDay public actual fun fromEpochDays(epochDays: Int): LocalDate { @@ -102,46 +93,27 @@ public actual class LocalDate actual constructor(public actual val year: Int, pu internal const val MIN_EPOCH_DAY = -365961662 internal const val MAX_EPOCH_DAY = 364522971 + + @Suppress("FunctionName") + public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat = + LocalDateFormat.build(block) } - // 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 + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_DATE + + public actual val ISO_BASIC: DateTimeFormat = ISO_DATE_BASIC } - // 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) + // org.threeten.bp.LocalDate#toEpochDay + 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 +178,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(Formats.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..c62ef1154 100644 --- a/core/native/src/LocalDateTime.kt +++ b/core/native/src/LocalDateTime.kt @@ -8,29 +8,28 @@ 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(input: CharSequence, format: DateTimeFormat): LocalDateTime = + format.parse(input) internal actual val MIN: LocalDateTime = LocalDateTime(LocalDate.MIN, LocalTime.MIN) internal actual val MAX: LocalDateTime = LocalDateTime(LocalDate.MAX, LocalTime.MAX) + + @Suppress("FunctionName") + public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat = + LocalDateTimeFormat.build(builder) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat = ISO_DATETIME } public actual constructor(year: Int, monthNumber: Int, dayOfMonth: Int, hour: Int, minute: Int, second: Int, nanosecond: Int) : @@ -68,7 +67,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(ISO_DATETIME_OPTIONAL_SECONDS_TRAILING_ZEROS) // org.threeten.bp.chrono.ChronoLocalDateTime#toEpochSecond internal fun toEpochSecond(offset: UtcOffset): Long { @@ -127,3 +126,11 @@ internal fun LocalDateTime.plusSeconds(seconds: Int): LocalDateTime val newTime: LocalTime = if (newNanoOfDay == currentNanoOfDay) time else LocalTime.ofNanoOfDay(newNanoOfDay) return LocalDateTime(date.plusDays(totalDays.toInt()), newTime) } + +private val ISO_DATETIME_OPTIONAL_SECONDS_TRAILING_ZEROS by lazy { + LocalDateTimeFormat.build { + date(ISO_DATE) + alternativeParsing({ char('t') }) { char('T') } + time(ISO_TIME_OPTIONAL_SECONDS_TRAILING_ZEROS) + } +} diff --git a/core/native/src/LocalTime.kt b/core/native/src/LocalTime.kt index e12711333..b22b34c77 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(input: CharSequence, format: DateTimeFormat): LocalTime = format.parse(input) public actual fun fromSecondOfDay(secondOfDay: Int): LocalTime = ofSecondOfDay(secondOfDay, 0) @@ -102,6 +74,14 @@ public actual class LocalTime actual constructor( internal actual val MIN: LocalTime = LocalTime(0, 0, 0, 0) internal actual val MAX: LocalTime = LocalTime(23, 59, 59, NANOS_PER_ONE - 1) + + @Suppress("FunctionName") + public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat = + LocalTimeFormat.build(builder) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_TIME } // Several times faster than using `compareBy` @@ -146,36 +126,25 @@ 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(ISO_TIME_OPTIONAL_SECONDS_TRAILING_ZEROS) override fun equals(other: Any?): Boolean = other is LocalTime && this.compareTo(other) == 0 } + +internal val ISO_TIME_OPTIONAL_SECONDS_TRAILING_ZEROS by lazy { + LocalTimeFormat.build { + hour() + char(':') + minute() + optional { + char(':') + second() + optional { + char('.') + secondFractionInternal(1, 9, FractionalSecondDirective.GROUP_BY_THREE) + } + } + } +} diff --git a/core/native/src/Parser.kt b/core/native/src/Parser.kt deleted file mode 100644 index 5007c31db..000000000 --- a/core/native/src/Parser.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2019-2020 JetBrains s.r.o. - * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file. - */ - -package kotlinx.datetime - -internal typealias Parser = (String, Int) -> Pair - -private fun parseException(message: String, position: Int) = - DateTimeFormatException("Parse error at char $position: $message") - -internal enum class SignStyle { - NO_SIGN, - EXCEEDS_PAD -} - -internal fun Parser.map(transform: (T) -> S): Parser = { str, pos -> - val (pos1, t) = this(str, pos) - Pair(pos1, transform(t)) -} - -internal fun Parser.chain(other: Parser): Parser> = { str, pos -> - val (pos1, t) = this(str, pos) - val (pos2, s) = other(str, pos1) - Pair(pos2, Pair(t, s)) -} - -internal fun Parser.chainIgnoring(other: Parser): Parser = - chain(other).map { (t, _) -> t } - -internal fun Parser.chainSkipping(other: Parser): Parser = - chain(other).map { (_, s) -> s } - -internal val eofParser: Parser = { str, pos -> - if (str.length > pos) { - throw parseException("extraneous input", pos) - } - Pair(pos, Unit) -} - -internal fun Parser.parse(str: String): T = - chainIgnoring(eofParser)(str, 0).second - -internal fun digitSpanParser(minLength: Int, maxLength: Int, sign: SignStyle): Parser = { str, pos -> - var spanLength = 0 - // index of the position after the potential sign - val hasSign = str.length > pos && (str[pos] == '-' || str[pos] == '+') - val pos1 = if (hasSign) { pos + 1 } else { pos } - for (i in pos1 until str.length) { - if (str[i].isDigit()) { - spanLength += 1 - } else { - break - } - } - if (spanLength < minLength) { - if (spanLength == 0) { - throw parseException("number expected", pos1) - } else { - throw parseException("expected at least $minLength digits", pos1 + spanLength - 1) - } - } - if (spanLength > maxLength) { - throw parseException("expected at most $maxLength digits", pos1 + maxLength) - } - when (sign) { - SignStyle.NO_SIGN -> if (hasSign) throw parseException("unexpected number sign", pos) - SignStyle.EXCEEDS_PAD -> if (hasSign && str[pos] == '+' && spanLength == minLength) { - throw parseException("unexpected sign, as the field only has $spanLength numbers", pos) - } else if (!hasSign && spanLength > minLength) { - throw parseException("expected a sign, since the field has more than $minLength numbers", pos) - } - } - Pair(pos1 + spanLength, IntRange(pos, pos1 + spanLength - 1)) -} - -internal fun intParser(minDigits: Int, maxDigits: Int, sign: SignStyle = SignStyle.NO_SIGN): Parser = { str, pos -> - val (pos1, intRange) = digitSpanParser(minDigits, maxDigits, sign)(str, pos) - val result = if (intRange.isEmpty()) { - 0 - } else { - str.substring(intRange).toInt() - } - Pair(pos1, result) -} - -internal fun fractionParser(minDigits: Int, maxDigits: Int, denominatorDigits: Int): Parser = { str, pos -> - require(denominatorDigits <= maxDigits) - val (pos1, intRange) = digitSpanParser(minDigits, maxDigits, SignStyle.NO_SIGN)(str, pos) - if (intRange.isEmpty()) { - Pair(pos1, 0) - } else { - val nominator = str.substring(intRange).toInt() - val digitsParsed = intRange.last - intRange.first - var result = nominator - for (i in digitsParsed until denominatorDigits - 1) { - result *= 10 - } - Pair(pos1, result) - } -} - -internal fun Parser.or(other: Parser): Parser = { str, pos -> - try { - this(str, pos) - } catch (e: DateTimeFormatException) { - other(str, pos) - } -} - -internal fun optional(parser: Parser): Parser = parser.or { _, pos -> Pair(pos, null) } - -internal fun concreteCharParser(requiredChar: Char): Parser = { str, pos -> - if (str.length <= pos) { - throw parseException("unexpected end of string", pos) - } - if (str[pos] != requiredChar) { - throw parseException("expected char '$requiredChar', got '${str[pos]}", pos) - } - Pair(pos + 1, requiredChar) -} diff --git a/core/native/src/TimeZone.kt b/core/native/src/TimeZone.kt index 681716d74..9fa0646f2 100644 --- a/core/native/src/TimeZone.kt +++ b/core/native/src/TimeZone.kt @@ -8,6 +8,7 @@ package kotlinx.datetime +import kotlinx.datetime.format.* import kotlinx.datetime.internal.* import kotlinx.datetime.serializers.* import kotlinx.serialization.Serializable @@ -34,7 +35,7 @@ public actual open class TimeZone internal constructor() { } try { if (zoneId.startsWith("+") || zoneId.startsWith("-")) { - return UtcOffset.parse(zoneId).asTimeZone() + return UtcOffset.parse(zoneId, lenientOffsetFormat).asTimeZone() } if (zoneId == "UTC" || zoneId == "GMT" || zoneId == "UT") { return FixedOffsetTimeZone(UtcOffset.ZERO, zoneId) @@ -43,14 +44,14 @@ public actual open class TimeZone internal constructor() { zoneId.startsWith("UTC-") || zoneId.startsWith("GMT-") ) { val prefix = zoneId.take(3) - val offset = UtcOffset.parse(zoneId.substring(3)) + val offset = UtcOffset.parse(zoneId.substring(3), lenientOffsetFormat) return when (offset.totalSeconds) { 0 -> FixedOffsetTimeZone(offset, prefix) else -> FixedOffsetTimeZone(offset, "$prefix$offset") } } if (zoneId.startsWith("UT+") || zoneId.startsWith("UT-")) { - val offset = UtcOffset.parse(zoneId.substring(2)) + val offset = UtcOffset.parse(zoneId.substring(2), lenientOffsetFormat) return when (offset.totalSeconds) { 0 -> FixedOffsetTimeZone(offset, "UT") else -> FixedOffsetTimeZone(offset, "UT$offset") @@ -159,3 +160,26 @@ public actual fun LocalDateTime.toInstant(offset: UtcOffset): Instant = public actual fun LocalDate.atStartOfDayIn(timeZone: TimeZone): Instant = timeZone.atStartOfDay(this) + +private val lenientOffsetFormat = UtcOffsetFormat.build { + alternativeParsing( + { + offsetHours(Padding.NONE) + }, + { + isoOffset( + zOnZero = false, + useSeparator = false, + outputMinute = WhenToOutput.IF_NONZERO, + outputSecond = WhenToOutput.IF_NONZERO + ) + } + ) { + isoOffset( + zOnZero = true, + useSeparator = true, + outputMinute = WhenToOutput.ALWAYS, + outputSecond = WhenToOutput.IF_NONZERO + ) + } +} diff --git a/core/native/src/UtcOffset.kt b/core/native/src/UtcOffset.kt index 7f904cd5a..0c93e05f5 100644 --- a/core/native/src/UtcOffset.kt +++ b/core/native/src/UtcOffset.kt @@ -6,79 +6,26 @@ package kotlinx.datetime import kotlinx.datetime.internal.* +import kotlinx.datetime.format.* import kotlinx.datetime.serializers.UtcOffsetSerializer import kotlinx.serialization.Serializable import kotlin.math.abs -import kotlin.native.concurrent.ThreadLocal @Serializable(with = UtcOffsetSerializer::class) public actual class UtcOffset private constructor(public actual val totalSeconds: Int) { - private val id: String = zoneIdByOffset(totalSeconds) override fun hashCode(): Int = totalSeconds override fun equals(other: Any?): Boolean = other is UtcOffset && this.totalSeconds == other.totalSeconds - override fun toString(): String = id + actual override fun toString(): String = format(Formats.ISO) public actual companion object { 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(input: CharSequence, format: DateTimeFormat): UtcOffset = format.parse(input) private fun validateTotal(totalSeconds: Int) { - if (totalSeconds !in -18 * SECONDS_PER_HOUR .. 18 * SECONDS_PER_HOUR) { + if (totalSeconds !in -18 * SECONDS_PER_HOUR..18 * SECONDS_PER_HOUR) { throw IllegalArgumentException("Total seconds value is out of range: $totalSeconds") } } @@ -86,8 +33,10 @@ public actual class UtcOffset private constructor(public actual val totalSeconds // org.threeten.bp.ZoneOffset#validate private fun validate(hours: Int, minutes: Int, seconds: Int) { if (hours < -18 || hours > 18) { - throw IllegalArgumentException("Zone offset hours not in valid range: value " + hours + - " is not in the range -18 to 18") + throw IllegalArgumentException( + "Zone offset hours not in valid range: value " + hours + + " is not in the range -18 to 18" + ) } if (hours > 0) { if (minutes < 0 || seconds < 0) { @@ -101,12 +50,16 @@ public actual class UtcOffset private constructor(public actual val totalSeconds throw IllegalArgumentException("Zone offset minutes and seconds must have the same sign") } if (abs(minutes) > 59) { - throw IllegalArgumentException("Zone offset minutes not in valid range: abs(value) " + - abs(minutes) + " is not in the range 0 to 59") + throw IllegalArgumentException( + "Zone offset minutes not in valid range: abs(value) " + + abs(minutes) + " is not in the range 0 to 59" + ) } if (abs(seconds) > 59) { - throw IllegalArgumentException("Zone offset seconds not in valid range: abs(value) " + - abs(seconds) + " is not in the range 0 to 59") + throw IllegalArgumentException( + "Zone offset seconds not in valid range: abs(value) " + + abs(seconds) + " is not in the range 0 to 59" + ) } if (abs(hours) == 18 && (abs(minutes) > 0 || abs(seconds) > 0)) { throw IllegalArgumentException("Utc offset not in valid range: -18:00 to +18:00") @@ -130,33 +83,31 @@ public actual class UtcOffset private constructor(public actual val totalSeconds } } - // 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') - } + @Suppress("FunctionName") + public actual fun Format(block: DateTimeFormatBuilder.WithUtcOffset.() -> Unit): DateTimeFormat = + UtcOffsetFormat.build(block) + } + + public actual object Formats { + public actual val ISO: DateTimeFormat get() = ISO_OFFSET + public actual val ISO_BASIC: DateTimeFormat get() = ISO_OFFSET_BASIC + public actual val FOUR_DIGITS: DateTimeFormat get() = FOUR_DIGIT_OFFSET } + } -@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 = when { hours != null -> UtcOffset.ofHoursMinutesSeconds(hours, minutes ?: 0, seconds ?: 0) + minutes != null -> UtcOffset.ofHoursMinutesSeconds(minutes / MINUTES_PER_HOUR, minutes % MINUTES_PER_HOUR, seconds ?: 0) + else -> { UtcOffset.ofSeconds(seconds ?: 0) } } - diff --git a/core/native/src/internal/dateCalculations.kt b/core/native/src/internal/dateCalculations.kt deleted file mode 100644 index f185be183..000000000 --- a/core/native/src/internal/dateCalculations.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 - -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) { - "Z" - } else { - val absTotalSeconds: Int = abs(totalSeconds) - val buf = StringBuilder() - val absHours: Int = absTotalSeconds / SECONDS_PER_HOUR - val absMinutes: Int = absTotalSeconds / SECONDS_PER_MINUTE % MINUTES_PER_HOUR - buf.append(if (totalSeconds < 0) "-" else "+") - .append(if (absHours < 10) "0" else "").append(absHours) - .append(if (absMinutes < 10) ":0" else ":").append(absMinutes) - val absSeconds: Int = absTotalSeconds % SECONDS_PER_MINUTE - if (absSeconds != 0) { - buf.append(if (absSeconds < 10) ":0" else ":").append(absSeconds) - } - buf.toString() - } -} 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/ThreeTenBpTimeZoneTest.kt b/core/native/test/ThreeTenBpTimeZoneTest.kt index 66bc6a2ff..95ee1cf81 100644 --- a/core/native/test/ThreeTenBpTimeZoneTest.kt +++ b/core/native/test/ThreeTenBpTimeZoneTest.kt @@ -9,6 +9,7 @@ package kotlinx.datetime.test import kotlinx.datetime.* +import kotlinx.datetime.format.* import kotlin.test.* @@ -19,14 +20,12 @@ class ThreeTenBpTimeZoneTest { @Test fun utcIsCached() { - val values = arrayOf( - "Z", "+0", - "+00", "+0000", "+00:00", "+000000", "+00:00:00", - "-00", "-0000", "-00:00", "-000000", "-00:00:00") + val values = arrayOf("Z", "+00:00", "+00:00:00", "-00:00", "-00:00:00") for (v in values) { val test = UtcOffset.parse(v) assertSame(test, UtcOffset.ZERO) } + assertSame(UtcOffset.parse("-0", UtcOffset.Format { offsetHours(padding = Padding.NONE) }), UtcOffset.ZERO) } @Test diff --git a/gradle.properties b/gradle.properties index 8f7cb7bb7..8050a2848 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,6 +8,7 @@ versionSuffix=SNAPSHOT defaultKotlinVersion=1.9.21 dokkaVersion=1.9.10 serializationVersion=1.6.2 +benchmarksVersion=0.7.2 java.mainToolchainVersion=8 java.modularToolchainVersion=11 diff --git a/license/README.md b/license/README.md index d5b5d4ad8..d55b4875f 100644 --- a/license/README.md +++ b/license/README.md @@ -2,6 +2,10 @@ The Apache 2 license (given in full in [LICENSE.txt](../LICENSE.txt)) applies to by JetBrains s.r.o. and contributors. The following sections of the repository contain third-party code, to which different licenses may apply: +- Path: `core/common/src/internal/dateCalculations.kt` + - Origin: implementation of date/time calculations is based on ThreeTen backport project. + - License: BSD 3-Clause ([license/thirdparty/threetenbp_license.txt][threetenbp]) + - Path: `core/nativeMain/src` - Origin: implementation of date/time entities is based on ThreeTen backport project. - License: BSD 3-Clause ([license/thirdparty/threetenbp_license.txt][threetenbp]) @@ -13,7 +17,7 @@ may apply: - Path: `core/commonTest/src` - Origin: Some tests are derived from tests of ThreeTen backport project - License: BSD 3-Clause ([license/thirdparty/threetenbp_license.txt][threetenbp]) - + - Path: `thirdparty/date` - Origin: https://github.com/HowardHinnant/date library - License: MIT ([license/thirdparty/cppdate_license.txt](thirdparty/cppdate_license.txt)) @@ -22,6 +26,6 @@ may apply: - Origin: time zone name mappings for Windows are generated from https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml - License: Unicode ([license/thirdparty/unicode_license.txt](thirdparty/unicode_license.txt)) - - -[threetenbp]: thirdparty/threetenbp_license.txt \ No newline at end of file + + +[threetenbp]: thirdparty/threetenbp_license.txt diff --git a/serialization/common/test/UtcOffsetSerializationTest.kt b/serialization/common/test/UtcOffsetSerializationTest.kt index 93d474f3b..2504d6fb6 100644 --- a/serialization/common/test/UtcOffsetSerializationTest.kt +++ b/serialization/common/test/UtcOffsetSerializationTest.kt @@ -15,11 +15,10 @@ import kotlin.test.* class UtcOffsetSerializationTest { private fun testSerializationAsPrimitive(serializer: KSerializer) { - val offset2h = UtcOffset.parse("+2") + val offset2h = UtcOffset(hours = 2) assertEquals("\"+02:00\"", Json.encodeToString(serializer, offset2h)) assertEquals(offset2h, Json.decodeFromString(serializer, "\"+02:00\"")) - assertEquals(offset2h, Json.decodeFromString(serializer, "\"+02\"")) - assertEquals(offset2h, Json.decodeFromString(serializer, "\"+2\"")) + assertEquals(offset2h, Json.decodeFromString(serializer, "\"+02:00:00\"")) assertFailsWith { Json.decodeFromString(serializer, "\"UTC+02:00\"") // not an offset @@ -36,4 +35,4 @@ class UtcOffsetSerializationTest { testSerializationAsPrimitive(UtcOffsetSerializer) testSerializationAsPrimitive(UtcOffset.serializer()) } -} \ No newline at end of file +} diff --git a/settings.gradle.kts b/settings.gradle.kts index baf237b83..c5845a6ee 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,8 +5,10 @@ pluginManagement { gradlePluginPortal() } val dokkaVersion: String by settings + val benchmarksVersion: String by settings plugins { id("org.jetbrains.dokka") version dokkaVersion + id("me.champeau.jmh") version benchmarksVersion } } @@ -16,3 +18,5 @@ include(":core") project(":core").name = "kotlinx-datetime" include(":serialization") project(":serialization").name = "kotlinx-datetime-serialization" +include(":benchmarks") +