diff --git a/README.md b/README.md index 3d9a61f6..a1822fb1 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The library provides a basic set of types for working with date and time: - `Clock` to obtain the current instant; - `LocalDateTime` to represent date and time components without a reference to the particular time zone; - `LocalDate` to represent the components of date only; +- `YearMonth` to represent only the year and month components; - `LocalTime` to represent the components of time only; - `TimeZone` and `FixedOffsetTimeZone` provide time zone information to convert between `Instant` and `LocalDateTime`; - `Month` and `DayOfWeek` enums; @@ -67,6 +68,9 @@ Here is some basic advice on how to choose which of the date-carrying types to u - Use `LocalDate` to represent the date of an event that does not have a specific time associated with it (like a birth date). +- Use `YearMonth` to represent the year and month of an event that does not have a specific day associated with it + or has a day-of-month that is inferred from the context (like a credit card expiration date). + - Use `LocalTime` to represent the time of an event that does not have a specific date associated with it. ## Operations @@ -150,6 +154,16 @@ Note, that today's date really depends on the time zone in which you're observin val knownDate = LocalDate(2020, 2, 21) ``` +### Getting year and month components + +A `YearMonth` represents a year and month without a day. You can obtain one from a `LocalDate` +by taking its `yearMonth` property. + +```kotlin +val day = LocalDate(2020, 2, 21) +val yearMonth: YearMonth = day.yearMonth +``` + ### Getting local time components A `LocalTime` represents local time without date. You can obtain one from an `Instant` @@ -273,10 +287,10 @@ collection of all datetime 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 monthDay = DateTimeComponents.Format { monthNumber(); char('/'); dayOfMonth() } + .parse("12/25") +println(monthDay.dayOfMonth) // 25 +println(monthDay.monthNumber) // 12 val dateTimeOffset = DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET .parse("2023-01-07T23:16:15.53+02:00") diff --git a/core/common/src/YearMonth.kt b/core/common/src/YearMonth.kt new file mode 100644 index 00000000..728510ab --- /dev/null +++ b/core/common/src/YearMonth.kt @@ -0,0 +1,397 @@ +/* + * 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 + +import kotlinx.datetime.format.* +import kotlinx.datetime.internal.* +import kotlinx.datetime.serializers.YearMonthIso8601Serializer +import kotlinx.serialization.Serializable + +/** + * The year-month part of [LocalDate], without the day-of-month. + * + * This class represents years and months without a reference to a particular time zone. + * As such, these objects may denote different time intervals in different time zones: for someone in Berlin, + * `2020-08` started and ended at different moments from those for someone in Tokyo. + * + * ### Arithmetic operations + * + * The arithmetic on [YearMonth] values is defined independently of the time zone (so `2020-08` plus one month + * is `2020-09` everywhere). + * + * Operations with [DateTimeUnit.MonthBased] are provided for [YearMonth]: + * - [YearMonth.plus] and [YearMonth.minus] allow expressing concepts like "two months later". + * - [YearMonth.until] and its shortcuts [YearMonth.monthsUntil] and [YearMonth.yearsUntil] + * can be used to find the number of months or years between two dates. + * + * ### Platform specifics + * + * The range of supported years is platform-dependent, but at least is enough to represent year-months of all instants + * between [Instant.DISTANT_PAST] and [Instant.DISTANT_FUTURE] in any time zone. + * + * On the JVM, + * there are `YearMonth.toJavaYearMonth()` and `java.time.YearMonth.toKotlinYearMonth()` + * extension functions to convert between `kotlinx.datetime` and `java.time` objects used for the same purpose. + * Similarly, on the Darwin platforms, there is a `YearMonth.toNSDateComponents()` extension function. + * + * ### Construction, serialization, and deserialization + * + * [YearMonth] can be constructed directly from its components using the constructor. + * See sample 1. + * + * [parse] and [toString] methods can be used to obtain a [YearMonth] from and convert it to a string in the + * ISO 8601 extended format. + * See sample 2. + * + * [parse] and [YearMonth.format] both support custom formats created with [Format] or defined in [Formats]. + * See sample 3. + * + * Additionally, there are several `kotlinx-serialization` serializers for [YearMonth]: + * - [YearMonthIso8601Serializer] for the ISO 8601 extended format. + * - [YearMonthComponentSerializer] for an object with components. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.constructorFunctionMonthNumber + * @sample kotlinx.datetime.test.samples.YearMonthSamples.simpleParsingAndFormatting + * @sample kotlinx.datetime.test.samples.YearMonthSamples.customFormat + */ +@Serializable(with = YearMonthIso8601Serializer::class) +public class YearMonth +/** + * Constructs a [YearMonth] instance from the given year-month components. + * + * The [month] component is 1-based. + * + * The supported ranges of components: + * - [year] the range is platform-dependent, but at least is enough to represent year-months of all instants between + * [Instant.DISTANT_PAST] and [Instant.DISTANT_FUTURE] in any time zone. + * - [month] `1..12` + * + * @throws IllegalArgumentException if any parameter is out of range. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.constructorFunctionMonthNumber + */ +public constructor(year: Int, month: Int) : Comparable { + /** + * Returns the year component of the year-month. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.year + */ + public val year: Int = year + + /** + * Returns the number-of-the-month (1..12) component of the year-month. + * + * Shortcut for `month.number`. + */ + public val monthNumber: Int = month + + init { + require(month in 1..12) { "Month must be in 1..12, but was $month" } + require(year in LocalDate.MIN.year..LocalDate.MAX.year) { + "Year $year is out of range: ${LocalDate.MIN.year}..${LocalDate.MAX.year}" + } + } + + /** + * Returns the month ([Month]) component of the year-month. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.month + */ + public val month: Month get() = Month(monthNumber) + + /** + * Returns the first day of the year-month. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.firstAndLastDay + */ + public val firstDay: LocalDate get() = onDay(1) + + /** + * Returns the last day of the year-month. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.firstAndLastDay + */ + public val lastDay: LocalDate get() = onDay(numberOfDays) + + /** + * Returns the number of days in the year-month. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.numberOfDays + */ + public val numberOfDays: Int get() = monthNumber.monthLength(isLeapYear(year)) + + /** + * Returns the range of days in the year-month. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.days + */ + // val days: LocalDateRange get() = firstDay..lastDay // no ranges yet + + /** + * Constructs a [YearMonth] instance from the given year-month components. + * + * The range for [year] is platform-dependent, but at least is enough to represent year-months of all instants + * between [Instant.DISTANT_PAST] and [Instant.DISTANT_FUTURE] in any time zone. + * + * @throws IllegalArgumentException if [year] is out of range. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.constructorFunction + */ + public constructor(year: Int, month: Month): this(year, month.number) + + public companion object { + /** + * A shortcut for calling [DateTimeFormat.parse]. + * + * Parses a string that represents a date and returns the parsed [YearMonth] value. + * + * If [format] is not specified, [Formats.ISO] is used. + * `2023-01` is an example of a string in this format. + * + * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [YearMonth] are exceeded. + * + * @see YearMonth.toString for formatting using the default format. + * @see YearMonth.format for formatting using a custom format. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.parsing + */ + public fun parse(input: CharSequence, format: DateTimeFormat = Formats.ISO): YearMonth = + format.parse(input) + + /** + * Creates a new format for parsing and formatting [YearMonth] values. + * + * There is a collection of predefined formats in [YearMonth.Formats]. + * + * @throws IllegalArgumentException if parsing using this format is ambiguous. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.customFormat + */ + @Suppress("FunctionName") + public fun Format(block: DateTimeFormatBuilder.WithYearMonth.() -> Unit): DateTimeFormat = + YearMonthFormat.build(block) + } + + /** + * A collection of predefined formats for parsing and formatting [YearMonth] values. + * + * [YearMonth.Formats.ISO] is a popular predefined format. + * [YearMonth.parse] and [YearMonth.toString] can be used as convenient shortcuts for it. + * + * Use [YearMonth.Format] to create a custom [kotlinx.datetime.format.DateTimeFormat] for [YearMonth] values. + */ + public object Formats { + /** + * ISO 8601 extended format, which is the format used by [YearMonth.toString] and [YearMonth.parse]. + * + * Examples of year-months in ISO 8601 format: + * - `2020-08` + * - `+12020-08` + * - `0000-08` + * - `-0001-08` + * + * See ISO-8601-1:2019, 5.2.2.2a), using the "expanded calendar year" extension from 5.2.2.3b), generalized + * to any number of digits in the year for years that fit in an [Int]. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.Formats.iso + */ + public val ISO: DateTimeFormat = YearMonthFormat.build { + year(); char('-'); monthNumber() + } + } + + /** + * Compares `this` date with the [other] year-month. + * Returns zero if this year-month represents the same month as the other (meaning they are equal to one other), + * a negative number if this year-month is earlier than the other, + * and a positive number if this year-month is later than the other. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.compareToSample + */ + override fun compareTo(other: YearMonth): Int = compareValuesBy(this, other, YearMonth::year, YearMonth::month) + + /** + * Converts this year-month to the extended ISO 8601 string representation. + * + * @see Formats.ISO for the format details. + * @see parse for the dual operation: obtaining [YearMonth] from a string. + * @see YearMonth.format for formatting using a custom format. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.toStringSample + */ + override fun toString(): String = Formats.ISO.format(this) + + /** + * @suppress + */ + override fun equals(other: Any?): Boolean = other is YearMonth && year == other.year && month == other.month + + /** + * @suppress + */ + override fun hashCode(): Int = year * 31 + month.hashCode() +} + +/** + * Formats this value using the given [format]. + * Equivalent to calling [DateTimeFormat.format] on [format] with `this`. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.formatting + */ +public fun YearMonth.format(format: DateTimeFormat): String = format.format(this) + +/** + * Returns the year-month part of this date. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.yearMonth + */ +public val LocalDate.yearMonth: YearMonth get() = YearMonth(year, month) + +/** + * Combines this year-month with the specified day-of-month into a [LocalDate] value. + * + * @sample kotlinx.datetime.test.samples.YearMonthSamples.onDay + */ +public fun YearMonth.onDay(day: Int): LocalDate = LocalDate(year, month, day) + +/** + * Returns the number of whole years between two year-months. + * + * The value is rounded toward zero. + * + * @see YearMonth.until + * @sample kotlinx.datetime.test.samples.YearMonthSamples.yearsUntil + */ +public fun YearMonth.yearsUntil(other: YearMonth): Int = + ((other.prolepticMonth - prolepticMonth) / 12L).toInt() + +/** + * Returns the number of whole months between two year-months. + * + * If the result does not fit in [Int], returns [Int.MAX_VALUE] for a positive result + * or [Int.MIN_VALUE] for a negative result. + * + * @see YearMonth.until + * @sample kotlinx.datetime.test.samples.YearMonthSamples.monthsUntil + */ +public fun YearMonth.monthsUntil(other: YearMonth): Int = + (other.prolepticMonth - prolepticMonth).clampToInt() + +/** + * Returns the whole number of the specified month-based [units][unit] between `this` and [other] year-months. + * + * The value returned is: + * - Positive or zero if this year-month is earlier than the other. + * - Negative or zero if this year-month is later than the other. + * - Zero if this date is equal to the other. + * + * The value is rounded toward zero. + * + * @see YearMonth.monthsUntil + * @see YearMonth.yearsUntil + * @sample kotlinx.datetime.test.samples.YearMonthSamples.until + */ +// TODO: returning `Long` here is inconsistent with `LocalDate.until` until https://github.com/Kotlin/kotlinx-datetime/pull/453 +public fun YearMonth.until(other: YearMonth, unit: DateTimeUnit.MonthBased): Long = + (other.prolepticMonth - prolepticMonth) / unit.months + +/** + * The [YearMonth] 12 months later. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.plusYear + */ +public fun YearMonth.plusYear(): YearMonth = plus(1, DateTimeUnit.YEAR) + +/** + * The [YearMonth] 12 months earlier. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.minusYear + */ +public fun YearMonth.minusYear(): YearMonth = minus(1, DateTimeUnit.YEAR) + +/** + * The [YearMonth] one month later. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.plusMonth + */ +public fun YearMonth.plusMonth(): YearMonth = plus(1, DateTimeUnit.MONTH) + +/** + * The [YearMonth] one month earlier. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.minusMonth + */ +public fun YearMonth.minusMonth(): YearMonth = minus(1, DateTimeUnit.MONTH) + +/** + * Returns a [YearMonth] that results from adding the [value] number of the specified [unit] to this year-month. + * + * If the [value] is positive, the returned year-month is later than this year-month. + * If the [value] is negative, the returned year-month is earlier than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.plus + */ +public fun YearMonth.plus(value: Int, unit: DateTimeUnit.MonthBased): YearMonth = + plus(value.toLong(), unit) + +/** + * Returns a [YearMonth] that results from subtracting the [value] number of the specified [unit] from this year-month. + * + * If the [value] is positive, the returned year-month is earlier than this year-month. + * If the [value] is negative, the returned year-month is later than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.minus + */ +public fun YearMonth.minus(value: Int, unit: DateTimeUnit.MonthBased): YearMonth = + minus(value.toLong(), unit) + +/** + * Returns a [YearMonth] that results from adding the [value] number of the specified [unit] to this year-month. + * + * If the [value] is positive, the returned year-month is later than this year-month. + * If the [value] is negative, the returned year-month is earlier than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.plus + */ +public fun YearMonth.plus(value: Long, unit: DateTimeUnit.MonthBased): YearMonth = try { + safeMultiply(value, unit.months.toLong()).let { monthsToAdd -> + if (monthsToAdd == 0L) { + this + } else { + YearMonth.fromProlepticMonth(safeAdd(prolepticMonth, monthsToAdd)) + } + } +} catch (e: ArithmeticException) { + throw DateTimeArithmeticException("Arithmetic overflow when adding $value of $unit to $this", e) +} catch (e: IllegalArgumentException) { + throw DateTimeArithmeticException("Boundaries of YearMonth exceeded when adding $value of $unit to $this", e) +} + +/** + * Returns a [YearMonth] that results from subtracting the [value] number of the specified [unit] from this year-month. + * + * If the [value] is positive, the returned year-month is earlier than this year-month. + * If the [value] is negative, the returned year-month is later than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [YearMonth]. + * @sample kotlinx.datetime.test.samples.YearMonthSamples.minus + */ +public fun YearMonth.minus(value: Long, unit: DateTimeUnit.MonthBased): YearMonth = + if (value != Long.MIN_VALUE) plus(-value, unit) else plus(Long.MAX_VALUE, unit).plus(1, unit) + +private val YearMonth.prolepticMonth: Long get() = year * 12L + (monthNumber - 1) + +private fun YearMonth.Companion.fromProlepticMonth(prolepticMonth: Long): YearMonth { + val year = prolepticMonth.floorDiv(12) + require(year in LocalDate.MIN.year..LocalDate.MAX.year) { + "Year $year is out of range: ${LocalDate.MIN.year}..${LocalDate.MAX.year}" + } + val month = prolepticMonth.mod(12) + 1 + println("proleptic month: ${prolepticMonth}, year: $year, month: $month") + return YearMonth(year.toInt(), month) +} diff --git a/core/common/src/format/DateTimeComponents.kt b/core/common/src/format/DateTimeComponents.kt index 436fa499..c8cf2349 100644 --- a/core/common/src/format/DateTimeComponents.kt +++ b/core/common/src/format/DateTimeComponents.kt @@ -181,9 +181,21 @@ public class DateTimeComponents internal constructor(internal val contents: Date contents.time.populateFrom(localTime) } + /** + * Writes the contents of the specified [yearMonth] to this [DateTimeComponents]. + * The [yearMonth] is written to the [year] and [monthNumber] fields. + * + * If any of the fields are already set, they will be overwritten. + * + * @sample kotlinx.datetime.test.samples.format.DateTimeComponentsSamples.yearMonth + */ + public fun setYearMonth(yearMonth: YearMonth) { + contents.date.yearMonth.populateFrom(yearMonth) + } + /** * Writes the contents of the specified [localDate] to this [DateTimeComponents]. - * The [localDate] is written to the [year], [monthNumber], [dayOfMonth], and [dayOfWeek] fields. + * The [localDate] is written to the [year], [monthNumber], [dayOfMonth], [dayOfWeek], and [dayOfYear] fields. * * If any of the fields are already set, they will be overwritten. * @@ -409,6 +421,18 @@ public class DateTimeComponents internal constructor(internal val contents: Date */ public fun toUtcOffset(): UtcOffset = contents.offset.toUtcOffset() + /** + * Builds a [YearMonth] from the fields in this [DateTimeComponents]. + * + * This method uses the following fields: + * * [year] + * * [monthNumber] + * + * @throws IllegalArgumentException if any of the fields is missing or invalid. + * @sample kotlinx.datetime.test.samples.format.DateTimeComponentsSamples.toYearMonth + */ + public fun toYearMonth(): YearMonth = contents.date.yearMonth.toYearMonth() + /** * Builds a [LocalDate] from the fields in this [DateTimeComponents]. * @@ -417,6 +441,11 @@ public class DateTimeComponents internal constructor(internal val contents: Date * * [monthNumber] * * [dayOfMonth] * + * Alternatively, the following fields can be used: + * * [year] + * * [dayOfYear] + * + * If both sets of fields are specified, they are checked for consistency. * Also, [dayOfWeek] is checked for consistency with the other fields. * * @throws IllegalArgumentException if any of the fields is missing or invalid. diff --git a/core/common/src/format/DateTimeFormat.kt b/core/common/src/format/DateTimeFormat.kt index 375c4732..501c0416 100644 --- a/core/common/src/format/DateTimeFormat.kt +++ b/core/common/src/format/DateTimeFormat.kt @@ -166,5 +166,6 @@ private val allFormatConstants: List>> by "${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), + "${DateTimeFormatBuilder.WithYearMonth::yearMonth.name}(YearMonth.Formats.ISO)" to unwrap(YearMonth.Formats.ISO), ) } diff --git a/core/common/src/format/DateTimeFormatBuilder.kt b/core/common/src/format/DateTimeFormatBuilder.kt index 932653b5..8b2b4597 100644 --- a/core/common/src/format/DateTimeFormatBuilder.kt +++ b/core/common/src/format/DateTimeFormatBuilder.kt @@ -24,9 +24,9 @@ public sealed interface DateTimeFormatBuilder { public fun chars(value: String) /** - * Functions specific to the datetime format builders containing the local-date fields. + * Functions specific to the datetime format builders containing the year and month fields. */ - public sealed interface WithDate : DateTimeFormatBuilder { + public sealed interface WithYearMonth : DateTimeFormatBuilder { /** * A year number. * @@ -35,7 +35,7 @@ public sealed interface DateTimeFormatBuilder { * For years outside this range, it's formatted as a decimal number with a leading sign, so the year 12345 * is formatted as "+12345". * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.year + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.year */ public fun year(padding: Padding = Padding.ZERO) @@ -54,7 +54,7 @@ public sealed interface DateTimeFormatBuilder { * and when given a full year number with a leading sign, it parses the full year number, * so "+1850" becomes 1850. * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.yearTwoDigits + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.yearTwoDigits */ public fun yearTwoDigits(baseYear: Int) @@ -63,17 +63,29 @@ public sealed interface DateTimeFormatBuilder { * * By default, it's padded with zeros to two digits. This can be changed by passing [padding]. * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.monthNumber + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.monthNumber */ public fun monthNumber(padding: Padding = Padding.ZERO) /** * A month name (for example, "January"). * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.monthName + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.monthName */ public fun monthName(names: MonthNames) + /** + * An existing [DateTimeFormat] for the date part. + * + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.yearMonth + */ + public fun yearMonth(format: DateTimeFormat) + } + + /** + * Functions specific to the datetime format builders containing the local-date fields. + */ + public sealed interface WithDate : WithYearMonth { /** * A day-of-month number, from 1 to 31. * diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt index 0104f8b7..6fea0ad0 100644 --- a/core/common/src/format/LocalDateFormat.kt +++ b/core/common/src/format/LocalDateFormat.kt @@ -6,99 +6,10 @@ package kotlinx.datetime.format import kotlinx.datetime.* -import kotlinx.datetime.format.MonthNames.Companion.ENGLISH_ABBREVIATED -import kotlinx.datetime.format.MonthNames.Companion.ENGLISH_FULL import kotlinx.datetime.internal.* import kotlinx.datetime.internal.format.* import kotlinx.datetime.internal.format.parser.Copyable -/** - * A description of how month names are formatted. - * - * Instances of this class are typically used as arguments to [DateTimeFormatBuilder.WithDate.monthName]. - * - * Predefined instances are available as [ENGLISH_FULL] and [ENGLISH_ABBREVIATED]. - * You can also create custom instances using the constructor. - * - * An [IllegalArgumentException] will be thrown if some month name is empty or there are duplicate names. - * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.MonthNamesSamples.usage - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.MonthNamesSamples.constructionFromList - */ -public class MonthNames( - /** - * A list of month names in order from January to December. - * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.MonthNamesSamples.names - */ - public val names: List -) { - init { - require(names.size == 12) { "Month names must contain exactly 12 elements" } - names.indices.forEach { ix -> - require(names[ix].isNotEmpty()) { "A month name can not be empty" } - for (ix2 in 0 until ix) { - require(names[ix] != names[ix2]) { - "Month names must be unique, but '${names[ix]}' was repeated" - } - } - } - } - - /** - * Create a [MonthNames] using the month names in order from January to December. - * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.MonthNamesSamples.constructionFromStrings - */ - 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 from 'January' to 'December'. - * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.MonthNamesSamples.englishFull - */ - public val ENGLISH_FULL: MonthNames = MonthNames( - listOf( - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" - ) - ) - - /** - * Shortened English month names from 'Jan' to 'Dec'. - * - * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.MonthNamesSamples.englishAbbreviated - */ - public val ENGLISH_ABBREVIATED: MonthNames = MonthNames( - listOf( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - ) - ) - } - - /** @suppress */ - override fun toString(): String = - names.joinToString(", ", "MonthNames(", ")", transform = String::toString) - - /** @suppress */ - override fun equals(other: Any?): Boolean = other is MonthNames && names == other.names - - /** @suppress */ - override fun hashCode(): Int = names.hashCode() -} - -private fun MonthNames.toKotlinCode(): String = when (this.names) { - MonthNames.ENGLISH_FULL.names -> "MonthNames.${MonthNames.Companion::ENGLISH_FULL.name}" - MonthNames.ENGLISH_ABBREVIATED.names -> "MonthNames.${MonthNames.Companion::ENGLISH_ABBREVIATED.name}" - else -> names.joinToString(", ", "MonthNames(", ")", transform = String::toKotlinCode) -} - /** * A description of how the names of weekdays are formatted. * @@ -146,7 +57,7 @@ public class DayOfWeekNames( saturday: String, sunday: String ) : - this(listOf(monday, tuesday, wednesday, thursday, friday, saturday, sunday)) + this(listOf(monday, tuesday, wednesday, thursday, friday, saturday, sunday)) public companion object { /** @@ -189,24 +100,13 @@ private fun DayOfWeekNames.toKotlinCode(): String = when (this.names) { 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? +internal interface DateFieldContainer: YearMonthFieldContainer { var dayOfMonth: Int? var isoDayOfWeek: Int? var dayOfYear: Int? } private object DateFields { - val year = GenericFieldSpec(PropertyAccessor(DateFieldContainer::year)) - 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) val dayOfYear = UnsignedFieldSpec(PropertyAccessor(DateFieldContainer::dayOfYear), minValue = 1, maxValue = 366) @@ -216,12 +116,11 @@ private object DateFields { * A [kotlinx.datetime.LocalDate], but potentially incomplete and inconsistent. */ internal class IncompleteLocalDate( - override var year: Int? = null, - override var monthNumber: Int? = null, + val yearMonth: IncompleteYearMonth = IncompleteYearMonth(), override var dayOfMonth: Int? = null, override var isoDayOfWeek: Int? = null, override var dayOfYear: Int? = null, -) : DateFieldContainer, Copyable { +) : YearMonthFieldContainer by yearMonth, DateFieldContainer, Copyable { fun toLocalDate(): LocalDate { val year = requireParsedField(year, "year") val date = when (val dayOfYear = dayOfYear) { @@ -234,20 +133,20 @@ internal class IncompleteLocalDate( if (it.year != year) { throw DateTimeFormatException( "Can not create a LocalDate from the given input: " + - "the day of year is $dayOfYear, which is not a valid day of year for the year $year" + "the day of year is $dayOfYear, which is not a valid day of year for the year $year" ) } if (monthNumber != null && it.monthNumber != monthNumber) { throw DateTimeFormatException( "Can not create a LocalDate from the given input: " + - "the day of year is $dayOfYear, which is ${it.month}, " + + "the day of year is $dayOfYear, which is ${it.month}, " + "but $monthNumber was specified as the month number" ) } if (dayOfMonth != null && it.dayOfMonth != dayOfMonth) { throw DateTimeFormatException( "Can not create a LocalDate from the given input: " + - "the day of year is $dayOfYear, which is the day ${it.dayOfMonth} of ${it.month}, " + + "the day of year is $dayOfYear, which is the day ${it.dayOfMonth} of ${it.month}, " + "but $dayOfMonth was specified as the day of month" ) } @@ -257,7 +156,7 @@ internal class IncompleteLocalDate( 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}" + "the day of week is ${DayOfWeek(it)} but the date is $date, which is a ${date.dayOfWeek}" ) } } @@ -273,125 +172,26 @@ internal class IncompleteLocalDate( } override fun copy(): IncompleteLocalDate = - IncompleteLocalDate(year, monthNumber, dayOfMonth, isoDayOfWeek, dayOfYear) + IncompleteLocalDate(yearMonth.copy(), dayOfMonth, isoDayOfWeek, dayOfYear) override fun equals(other: Any?): Boolean = - other is IncompleteLocalDate && year == other.year && monthNumber == other.monthNumber && - dayOfMonth == other.dayOfMonth && isoDayOfWeek == other.isoDayOfWeek && dayOfYear == other.dayOfYear + other is IncompleteLocalDate && yearMonth == other.yearMonth && + dayOfMonth == other.dayOfMonth && isoDayOfWeek == other.isoDayOfWeek && dayOfYear == other.dayOfYear - override fun hashCode(): Int = year.hashCode() * 923521 + - monthNumber.hashCode() * 29791 + + override fun hashCode(): Int = yearMonth.hashCode() * 29791 + dayOfMonth.hashCode() * 961 + isoDayOfWeek.hashCode() * 31 + dayOfYear.hashCode() - 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)) - ) + override fun toString(): String = when { + dayOfYear == null -> + "$yearMonth-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"})" + dayOfMonth == null && monthNumber == null -> + "(${yearMonth.year ?: "??"})-$dayOfYear (day of week is ${isoDayOfWeek ?: "??"})" + else -> "$yearMonth-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"}, day of year is $dayOfYear)" } } -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, @@ -462,20 +262,12 @@ internal class LocalDateFormat(override val actualFormat: CachedFormatStructure< } } -internal interface AbstractWithDateBuilder : DateTimeFormatBuilder.WithDate { +internal interface AbstractWithDateBuilder : AbstractWithYearMonthBuilder, 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 addFormatStructureForYearMonth(structure: FormatStructure) { + addFormatStructureForDate(structure) + } override fun dayOfMonth(padding: Padding) = addFormatStructureForDate(BasicFormatStructure(DayDirective(padding))) override fun dayOfWeek(names: DayOfWeekNames) = diff --git a/core/common/src/format/Unicode.kt b/core/common/src/format/Unicode.kt index 4f483dbd..c3862e22 100644 --- a/core/common/src/format/Unicode.kt +++ b/core/common/src/format/Unicode.kt @@ -123,6 +123,13 @@ public fun DateTimeFormatBuilder.byUnicodePattern(pattern: String) { format.addToFormat(builder) } + is UnicodeFormat.Directive.YearMonthBased -> { + require(builder is DateTimeFormatBuilder.WithYearMonth) { + "A year-month-based directive $format was used in a format builder that doesn't support year-month 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" @@ -247,17 +254,21 @@ internal sealed interface UnicodeFormat { override fun hashCode(): Int = formatLetter.hashCode() * 31 + formatLength - sealed class DateBased : Directive() { - abstract fun addToFormat(builder: DateTimeFormatBuilder.WithDate) + sealed class YearMonthBased : DateBased() { + abstract fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + val downcastedBuilder: DateTimeFormatBuilder.WithYearMonth = builder + addToFormat(downcastedBuilder) + } - class Era(override val formatLength: Int) : DateBased() { + class Era(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'G' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = localizedDirective() + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) = localizedDirective() } - class Year(override val formatLength: Int) : DateBased() { + class Year(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'u' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) { when (formatLength) { 1 -> builder.year(padding = Padding.NONE) 2 -> builder.yearTwoDigits(baseYear = 2000) @@ -268,9 +279,9 @@ internal sealed interface UnicodeFormat { } } - class YearOfEra(override val formatLength: Int) : DateBased() { + class YearOfEra(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'y' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = when (formatLength) { + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) = when (formatLength) { 1 -> builder.yearOfEra(padding = Padding.NONE) 2 -> builder.yearOfEraTwoDigits(baseYear = 2000) 3 -> unsupportedPadding(formatLength) @@ -279,32 +290,21 @@ internal sealed interface UnicodeFormat { } } - class CyclicYearName(override val formatLength: Int) : DateBased() { + class CyclicYearName(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'U' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = unsupportedDirective("cyclic-year") + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) = unsupportedDirective("cyclic-year") } // https://cldr.unicode.org/development/development-process/design-proposals/pattern-character-for-related-year - class RelatedGregorianYear(override val formatLength: Int) : DateBased() { + class RelatedGregorianYear(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'r' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) = unsupportedDirective("related-gregorian-year") } - class DayOfYear(override val formatLength: Int) : DateBased() { - override val formatLetter = 'D' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { - when (formatLength) { - 1 -> builder.dayOfYear(Padding.NONE) - 3 -> builder.dayOfYear(Padding.ZERO) - else -> unknownLength() - } - } - } - - class MonthOfYear(override val formatLength: Int) : DateBased() { + class MonthOfYear(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'M' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) { when (formatLength) { 1 -> builder.monthNumber(Padding.NONE) 2 -> builder.monthNumber(Padding.ZERO) @@ -314,9 +314,9 @@ internal sealed interface UnicodeFormat { } } - class StandaloneMonthOfYear(override val formatLength: Int) : DateBased() { + class StandaloneMonthOfYear(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'L' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) { when (formatLength) { 1 -> builder.monthNumber(Padding.NONE) 2 -> builder.monthNumber(Padding.ZERO) @@ -326,24 +326,9 @@ internal sealed interface UnicodeFormat { } } - 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() { + class QuarterOfYear(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'Q' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) { when (formatLength) { 1, 2 -> unsupportedDirective("quarter-of-year") 3, 4, 5 -> localizedDirective() @@ -352,9 +337,9 @@ internal sealed interface UnicodeFormat { } } - class StandaloneQuarterOfYear(override val formatLength: Int) : DateBased() { + class StandaloneQuarterOfYear(override val formatLength: Int) : YearMonthBased() { override val formatLetter = 'q' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + override fun addToFormat(builder: DateTimeFormatBuilder.WithYearMonth) { when (formatLength) { 1, 2 -> unsupportedDirective("standalone-quarter-of-year") 3, 4, 5 -> localizedDirective() @@ -363,6 +348,38 @@ internal sealed interface UnicodeFormat { } } + } + + sealed class DateBased : Directive() { + abstract fun addToFormat(builder: DateTimeFormatBuilder.WithDate) + + class DayOfYear(override val formatLength: Int) : DateBased() { + override val formatLetter = 'D' + override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) { + when (formatLength) { + 1 -> builder.dayOfYear(Padding.NONE) + 3 -> builder.dayOfYear(Padding.ZERO) + 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 WeekBasedYear(override val formatLength: Int) : DateBased() { override val formatLetter = 'Y' override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = @@ -401,7 +418,6 @@ internal sealed interface UnicodeFormat { override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = unsupportedDirective("day-of-week-in-month") } - } sealed class TimeBased : Directive() { @@ -582,16 +598,16 @@ internal sealed interface UnicodeFormat { 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) + 'G' -> UnicodeFormat.Directive.YearMonthBased.Era(formatLength) + 'y' -> UnicodeFormat.Directive.YearMonthBased.YearOfEra(formatLength) + 'u' -> UnicodeFormat.Directive.YearMonthBased.Year(formatLength) + 'U' -> UnicodeFormat.Directive.YearMonthBased.CyclicYearName(formatLength) + 'r' -> UnicodeFormat.Directive.YearMonthBased.RelatedGregorianYear(formatLength) + 'Q' -> UnicodeFormat.Directive.YearMonthBased.QuarterOfYear(formatLength) + 'q' -> UnicodeFormat.Directive.YearMonthBased.StandaloneQuarterOfYear(formatLength) + 'M' -> UnicodeFormat.Directive.YearMonthBased.MonthOfYear(formatLength) + 'L' -> UnicodeFormat.Directive.YearMonthBased.StandaloneMonthOfYear(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) diff --git a/core/common/src/format/YearMonthFormat.kt b/core/common/src/format/YearMonthFormat.kt new file mode 100644 index 00000000..cd35fd76 --- /dev/null +++ b/core/common/src/format/YearMonthFormat.kt @@ -0,0 +1,293 @@ +/* + * 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. + * + * Instances of this class are typically used as arguments to [DateTimeFormatBuilder.WithYearMonth.monthName]. + * + * Predefined instances are available as [ENGLISH_FULL] and [ENGLISH_ABBREVIATED]. + * You can also create custom instances using the constructor. + * + * An [IllegalArgumentException] will be thrown if some month name is empty or there are duplicate names. + * + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.MonthNamesSamples.usage + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.MonthNamesSamples.constructionFromList + */ +public class MonthNames( + /** + * A list of month names in order from January to December. + * + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.MonthNamesSamples.names + */ + public val names: List +) { + init { + require(names.size == 12) { "Month names must contain exactly 12 elements" } + names.indices.forEach { ix -> + require(names[ix].isNotEmpty()) { "A month name can not be empty" } + for (ix2 in 0 until ix) { + require(names[ix] != names[ix2]) { + "Month names must be unique, but '${names[ix]}' was repeated" + } + } + } + } + + /** + * Create a [MonthNames] using the month names in order from January to December. + * + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.MonthNamesSamples.constructionFromStrings + */ + 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 from 'January' to 'December'. + * + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.MonthNamesSamples.englishFull + */ + public val ENGLISH_FULL: MonthNames = MonthNames( + listOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ) + ) + + /** + * Shortened English month names from 'Jan' to 'Dec'. + * + * @sample kotlinx.datetime.test.samples.format.YearMonthFormatSamples.MonthNamesSamples.englishAbbreviated + */ + public val ENGLISH_ABBREVIATED: MonthNames = MonthNames( + listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + ) + ) + } + + /** @suppress */ + override fun toString(): String = + names.joinToString(", ", "MonthNames(", ")", transform = String::toString) + + /** @suppress */ + override fun equals(other: Any?): Boolean = other is MonthNames && names == other.names + + /** @suppress */ + override fun hashCode(): Int = names.hashCode() +} + +private fun MonthNames.toKotlinCode(): String = when (this.names) { + MonthNames.ENGLISH_FULL.names -> "MonthNames.${MonthNames::ENGLISH_FULL.name}" + MonthNames.ENGLISH_ABBREVIATED.names -> "MonthNames.${MonthNames::ENGLISH_ABBREVIATED.name}" + else -> names.joinToString(", ", "MonthNames(", ")", 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 YearMonthFieldContainer { + var year: Int? + var monthNumber: Int? +} + +private object YearMonthFields { + val year = GenericFieldSpec(PropertyAccessor(YearMonthFieldContainer::year)) + val month = UnsignedFieldSpec(PropertyAccessor(YearMonthFieldContainer::monthNumber), minValue = 1, maxValue = 12) +} + +internal class IncompleteYearMonth( + override var year: Int? = null, + override var monthNumber: Int? = null, +) : YearMonthFieldContainer, Copyable { + fun toYearMonth(): YearMonth { + val year = requireParsedField(year, "year") + val month = requireParsedField(monthNumber, "monthNumber") + return YearMonth(year, month) + } + + fun populateFrom(yearMonth: YearMonth) { + year = yearMonth.year + monthNumber = yearMonth.month.number + } + + override fun copy(): IncompleteYearMonth = IncompleteYearMonth(year, monthNumber) + + override fun equals(other: Any?): Boolean = + other is IncompleteYearMonth && year == other.year && monthNumber == other.monthNumber + + override fun hashCode(): Int = year.hashCode() * 31 + monthNumber.hashCode() + + override fun toString(): String = "${year ?: "??"}-${monthNumber ?: "??"}" +} + +private class YearDirective(private val padding: Padding, private val isYearOfEra: Boolean = false) : + SignedIntFieldFormatDirective( + YearMonthFields.year, + minDigits = padding.minDigits(4), + maxDigits = null, + spacePadding = padding.spaces(4), + outputPlusOnExceededWidth = 4, + ) { + override val builderRepresentation: String + get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithYearMonth::year.name}()" + else -> "${DateTimeFormatBuilder.WithYearMonth::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( + YearMonthFields.year, + digits = 2, + base = base, + ) { + override val builderRepresentation: String + get() = + "${DateTimeFormatBuilder.WithYearMonth::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.WithYearMonth.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.WithYearMonth.yearOfEra(padding: Padding) { + @Suppress("NO_ELSE_IN_WHEN") + when (this) { + is AbstractWithYearMonthBuilder -> addFormatStructureForYearMonth( + BasicFormatStructure(YearDirective(padding, isYearOfEra = true)) + ) + } +} + +/** + * A special directive for year-of-era that behaves equivalently to [DateTimeFormatBuilder.WithYearMonth.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.WithYearMonth.yearOfEraTwoDigits(baseYear: Int) { + @Suppress("NO_ELSE_IN_WHEN") + when (this) { + is AbstractWithYearMonthBuilder -> addFormatStructureForYearMonth( + BasicFormatStructure(ReducedYearDirective(baseYear, isYearOfEra = true)) + ) + } +} + +private class MonthDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + YearMonthFields.month, + minDigits = padding.minDigits(2), + spacePadding = padding.spaces(2), + ) { + override val builderRepresentation: String + get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithYearMonth::monthNumber.name}()" + else -> "${DateTimeFormatBuilder.WithYearMonth::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(YearMonthFields.month, names.names, "monthName") { + override val builderRepresentation: String + get() = + "${DateTimeFormatBuilder.WithYearMonth::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() +} + +internal class YearMonthFormat(override val actualFormat: CachedFormatStructure) : + AbstractDateTimeFormat() { + override fun intermediateFromValue(value: YearMonth): IncompleteYearMonth = + IncompleteYearMonth().apply { populateFrom(value) } + + override fun valueFromIntermediate(intermediate: IncompleteYearMonth): YearMonth = intermediate.toYearMonth() + + override val emptyIntermediate get() = emptyIncompleteYearMonth + + companion object { + fun build(block: DateTimeFormatBuilder.WithYearMonth.() -> Unit): DateTimeFormat { + val builder = Builder(AppendableFormatStructure()) + builder.block() + return YearMonthFormat(builder.build()) + } + } + + internal class Builder(override val actualBuilder: AppendableFormatStructure) : + AbstractDateTimeFormatBuilder, AbstractWithYearMonthBuilder { + + override fun addFormatStructureForYearMonth(structure: FormatStructure) = + actualBuilder.add(structure) + + override fun createEmpty(): Builder = Builder(AppendableFormatStructure()) + } +} + +internal interface AbstractWithYearMonthBuilder : DateTimeFormatBuilder.WithYearMonth { + fun addFormatStructureForYearMonth(structure: FormatStructure) + + override fun year(padding: Padding) = + addFormatStructureForYearMonth(BasicFormatStructure(YearDirective(padding))) + + override fun yearTwoDigits(baseYear: Int) = + addFormatStructureForYearMonth(BasicFormatStructure(ReducedYearDirective(baseYear))) + + override fun monthNumber(padding: Padding) = + addFormatStructureForYearMonth(BasicFormatStructure(MonthDirective(padding))) + + override fun monthName(names: MonthNames) = + addFormatStructureForYearMonth(BasicFormatStructure(MonthNameDirective(names))) + + @Suppress("NO_ELSE_IN_WHEN") + override fun yearMonth(format: DateTimeFormat) = when (format) { + is YearMonthFormat -> addFormatStructureForYearMonth(format.actualFormat) + } +} + +private val emptyIncompleteYearMonth = IncompleteYearMonth() diff --git a/core/common/src/serializers/YearMonthSerializers.kt b/core/common/src/serializers/YearMonthSerializers.kt new file mode 100644 index 00000000..6adf74b1 --- /dev/null +++ b/core/common/src/serializers/YearMonthSerializers.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2019-2023 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.serializers + +import kotlinx.datetime.YearMonth +import kotlinx.datetime.number +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * A serializer for [YearMonth] that uses the ISO 8601 representation. + * + * JSON example: `"2020-01"` + * + * @see YearMonth.parse + * @see YearMonth.toString + */ +public object YearMonthIso8601Serializer: KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("kotlinx.datetime.YearMonth", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): YearMonth = + YearMonth.parse(decoder.decodeString()) + + override fun serialize(encoder: Encoder, value: YearMonth) { + encoder.encodeString(value.toString()) + } + +} + +/** + * A serializer for [YearMonth] that represents a value as its components. + * + * JSON example: `{"year":2020,"month":12}` + */ +public object YearMonthComponentSerializer: KSerializer { + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("kotlinx.datetime.LocalDate") { + element("year") + element("month") + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): YearMonth = + decoder.decodeStructure(descriptor) { + var year: Int? = null + var month: Short? = null + loop@while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> year = decodeIntElement(descriptor, 0) + 1 -> month = decodeShortElement(descriptor, 1) + CompositeDecoder.DECODE_DONE -> break@loop // https://youtrack.jetbrains.com/issue/KT-42262 + else -> throwUnknownIndexException(index) + } + } + if (year == null) throw MissingFieldException(missingField = "year", serialName = descriptor.serialName) + if (month == null) throw MissingFieldException(missingField = "month", serialName = descriptor.serialName) + YearMonth(year, month.toInt()) + } + + override fun serialize(encoder: Encoder, value: YearMonth) { + encoder.encodeStructure(descriptor) { + encodeIntElement(descriptor, 0, value.year) + encodeShortElement(descriptor, 1, value.month.number.toShort()) + } + } + +} diff --git a/core/common/test/ReadmeTest.kt b/core/common/test/ReadmeTest.kt index 696ff151..0c7fdd00 100644 --- a/core/common/test/ReadmeTest.kt +++ b/core/common/test/ReadmeTest.kt @@ -117,10 +117,10 @@ class ReadmeTest { @Test fun testParsingAndFormattingPartialCompoundOrOutOfBoundsData() { - val yearMonth = DateTimeComponents.Format { year(); char('-'); monthNumber() } - .parse("2024-01") - assertEquals(2024, yearMonth.year) - assertEquals(1, yearMonth.monthNumber) + val monthDay = DateTimeComponents.Format { monthNumber(); char('/'); dayOfMonth() } + .parse("12/25") + assertEquals(25, monthDay.dayOfMonth) + assertEquals(12, monthDay.monthNumber) val dateTimeOffset = DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET .parse("2023-01-07T23:16:15.53+02:00") diff --git a/core/common/test/YearMonthTest.kt b/core/common/test/YearMonthTest.kt new file mode 100644 index 00000000..242f0a3c --- /dev/null +++ b/core/common/test/YearMonthTest.kt @@ -0,0 +1,188 @@ +/* + * 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 kotlin.test.* + +class YearMonthTest { + + private fun checkEquals(expected: YearMonth, actual: YearMonth) { + assertEquals(expected, actual) + assertEquals(expected.hashCode(), actual.hashCode()) + assertEquals(expected.toString(), actual.toString()) + } + + private fun checkComponents(value: YearMonth, year: Int, month: Int) { + assertEquals(year, value.year) + assertEquals(month, value.monthNumber) + assertEquals(Month(month), value.month) + + val fromComponents = YearMonth(year, month) + checkEquals(fromComponents, value) + } + + private fun checkLocalDatePart(yearMonth: YearMonth, date: LocalDate) { + checkEquals(yearMonth, date.yearMonth) + checkComponents(yearMonth, date.year, date.monthNumber) + } + + @Test + fun parseIsoString() { + fun checkParsedComponents(value: String, year: Int, month: Int) { + checkComponents(YearMonth.parse(value), year, month) + assertEquals(value, YearMonth(year, month).toString()) + } + checkParsedComponents("2019-10", 2019, 10) + checkParsedComponents("2016-02", 2016, 2) + checkParsedComponents("2017-10", 2017, 10) + assertInvalidFormat { LocalDate.parse("102017-10") } + assertInvalidFormat { LocalDate.parse("2017--10") } + assertInvalidFormat { LocalDate.parse("2017-+10") } + // this date is currently larger than the largest representable one any of the platforms: + assertInvalidFormat { LocalDate.parse("+1000000000-10") } + // threetenbp + checkParsedComponents("2008-07", 2008, 7) + checkParsedComponents("2007-12", 2007, 12) + checkParsedComponents("0999-12", 999, 12) + checkParsedComponents("-0001-01", -1, 1) + checkParsedComponents("9999-12", 9999, 12) + checkParsedComponents("-9999-12", -9999, 12) + checkParsedComponents("+10000-01", 10000, 1) + checkParsedComponents("-10000-01", -10000, 1) + checkParsedComponents("+123456-01", 123456, 1) + checkParsedComponents("-123456-01", -123456, 1) + for (i in 1..30) { + checkComponents(YearMonth.parse("+${"0".repeat(i)}2024-01"), 2024, 1) + checkComponents(YearMonth.parse("-${"0".repeat(i)}2024-01"), -2024, 1) + } + } + + @Test + fun localDatePart() { + val date = LocalDate(2016, Month.FEBRUARY, 29) + checkLocalDatePart(YearMonth(2016, 2), date) + } + + @Test + fun onDay() { + assertEquals(LocalDate(2016, 2, 29), YearMonth(2016, Month.FEBRUARY).onDay(29)) + assertFailsWith { YearMonth(2016, Month.FEBRUARY).onDay(30) } + assertFailsWith { YearMonth(2016, Month.FEBRUARY).onDay(0) } + assertFailsWith { YearMonth(2016, Month.FEBRUARY).onDay(-1) } + assertFailsWith { YearMonth(2015, Month.FEBRUARY).onDay(29) } + } + + @Test + fun addComponents() { + val start = YearMonth(2016, 2) + checkComponents(start.plus(1, DateTimeUnit.MONTH), 2016, 3) + checkComponents(start.plus(1, DateTimeUnit.YEAR), 2017, 2) + assertEquals(start, start.plus(1, DateTimeUnit.MONTH).minus(1, DateTimeUnit.MONTH)) + assertEquals(start, start.plus(3, DateTimeUnit.MONTH).minus(3, DateTimeUnit.MONTH)) + } + + @Test + fun unitsUntil() { + val data = listOf, Long, Int>>( + Triple(Pair("2012-06", "2012-06"), 0, 0), + Triple(Pair("2012-06", "2012-07"), 1, 0), + Triple(Pair("2012-06", "2013-07"), 13, 1), + Triple(Pair("-0001-01", "0001-01"), 24, 2), + Triple(Pair("-10000-01", "+10000-01"), 240000, 20000), + ) + for ((values, months, years) in data) { + val (v1, v2) = values + val start = YearMonth.parse(v1) + val end = YearMonth.parse(v2) + assertEquals(months, start.until(end, DateTimeUnit.MONTH)) + assertEquals(-months, end.until(start, DateTimeUnit.MONTH)) + assertEquals(years.toLong(), start.until(end, DateTimeUnit.YEAR)) + assertEquals(-years.toLong(), end.until(start, DateTimeUnit.YEAR)) + if (months <= Int.MAX_VALUE) { + assertEquals(months.toInt(), start.monthsUntil(end)) + assertEquals(-months.toInt(), end.monthsUntil(start)) + } + assertEquals(years, start.yearsUntil(end)) + assertEquals(-years, end.yearsUntil(start)) + } + + } + + @Test + fun unitMultiplesUntil() { + val start = YearMonth(2000, 1) + val end = YearMonth(2030, 3) + val yearsBetween = start.until(end, DateTimeUnit.MONTH * 12) + assertEquals(30, yearsBetween) + assertEquals(15, start.until(end, DateTimeUnit.MONTH * 24)) + assertEquals(10, start.until(end, DateTimeUnit.MONTH * 36)) + assertEquals(5, start.until(end, DateTimeUnit.MONTH * 72)) + assertEquals(2, start.until(end, DateTimeUnit.MONTH * 180)) + assertEquals(1, start.until(end, DateTimeUnit.MONTH * 360)) + val monthsBetween = start.until(end, DateTimeUnit.MONTH) + assertEquals(yearsBetween * 12 + 2, monthsBetween) // 362 + assertEquals(181, start.until(end, DateTimeUnit.MONTH * 2)) + assertEquals(120, start.until(end, DateTimeUnit.MONTH * 3)) + assertEquals(90, start.until(end, DateTimeUnit.MONTH * 4)) + assertEquals(72, start.until(end, DateTimeUnit.MONTH * 5)) + assertEquals(60, start.until(end, DateTimeUnit.MONTH * 6)) + } + + @Test + fun constructInvalidYearMonth() { + assertFailsWith { YearMonth(Int.MIN_VALUE, 1) } + assertFailsWith { YearMonth(2007, 0) } + assertFailsWith { YearMonth(2007, 13) } + } + + @Test + fun unitArithmeticOutOfRange() { + maxYearMonth.plus(-1, DateTimeUnit.MONTH) + minYearMonth.plus(1, DateTimeUnit.MONTH) + // Arithmetic overflow + assertArithmeticFails { maxYearMonth.plus(Long.MAX_VALUE, DateTimeUnit.YEAR) } + assertArithmeticFails { maxYearMonth.plus(Long.MAX_VALUE - 2, DateTimeUnit.YEAR) } + assertArithmeticFails { minYearMonth.plus(Long.MIN_VALUE, DateTimeUnit.YEAR) } + assertArithmeticFails { minYearMonth.plus(Long.MIN_VALUE + 2, DateTimeUnit.YEAR) } + assertArithmeticFails { minYearMonth.plus(Long.MAX_VALUE, DateTimeUnit.MONTH) } + assertArithmeticFails { maxYearMonth.plus(Long.MIN_VALUE, DateTimeUnit.MONTH) } + // Exceeding the boundaries of LocalDate + assertArithmeticFails { maxYearMonth.plus(1, DateTimeUnit.YEAR) } + assertArithmeticFails { minYearMonth.plus(-1, DateTimeUnit.YEAR) } + } + + @Test + fun monthsUntilClamping() { + val preciseDifference = minYearMonth.until(maxYearMonth, DateTimeUnit.MONTH) + // TODO: remove the condition after https://github.com/Kotlin/kotlinx-datetime/pull/453 + if (preciseDifference > Int.MAX_VALUE) { + assertEquals(Int.MAX_VALUE, minYearMonth.monthsUntil(maxYearMonth)) + assertEquals(Int.MIN_VALUE, maxYearMonth.monthsUntil(minYearMonth)) + } + } + + @Test + fun firstAndLastDay() { + fun test(year: Int, month: Int) { + val yearMonth = YearMonth(year, month) + assertEquals(LocalDate(year, month, 1), yearMonth.firstDay) + assertEquals(LocalDate(year, month, yearMonth.numberOfDays), yearMonth.lastDay) + assertEquals(yearMonth.plusMonth().firstDay, yearMonth.lastDay.plus(1, DateTimeUnit.DAY)) + } + for (month in 1..12) { + for (year in 2000..2005) { + test(year, month) + } + for (year in -2005..-2000) { + test(year, month) + } + } + } + + private val minYearMonth = LocalDate.MIN.yearMonth + private val maxYearMonth = LocalDate.MAX.yearMonth +} diff --git a/core/common/test/format/LocalDateFormatTest.kt b/core/common/test/format/LocalDateFormatTest.kt index 35f7e6d9..70570c56 100644 --- a/core/common/test/format/LocalDateFormatTest.kt +++ b/core/common/test/format/LocalDateFormatTest.kt @@ -242,15 +242,6 @@ class LocalDateFormatTest { assertEquals("2020 Jan 05", format.format(LocalDate(2020, 1, 5))) } - @Test - fun testEmptyMonthNames() { - val names = MonthNames.ENGLISH_FULL.names - for (i in 0 until 12) { - val newNames = (0 until 12).map { if (it == i) "" else names[it] } - assertFailsWith { MonthNames(newNames) } - } - } - @Test fun testEmptyDayOfWeekNames() { val names = DayOfWeekNames.ENGLISH_FULL.names @@ -260,13 +251,6 @@ class LocalDateFormatTest { } } - @Test - fun testIdenticalMonthNames() { - assertFailsWith { - MonthNames("Jan", "Jan", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") - } - } - @Test fun testIdenticalDayOfWeekNames() { assertFailsWith { diff --git a/core/common/test/format/YearMonthFormatTest.kt b/core/common/test/format/YearMonthFormatTest.kt new file mode 100644 index 00000000..dc8b1df2 --- /dev/null +++ b/core/common/test/format/YearMonthFormatTest.kt @@ -0,0 +1,84 @@ +/* + * 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.format + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlin.test.* + +class YearMonthFormatTest { + @Test + fun testIso() { + val yearMonths = buildMap>> { + put(YearMonth(2008, 7), ("2008-07" to setOf())) + put(YearMonth(2007, 12), ("2007-12" to setOf())) + put(YearMonth(999, 11), ("0999-11" to setOf())) + put(YearMonth(-1, 1), ("-0001-01" to setOf())) + put(YearMonth(9999, 10), ("9999-10" to setOf())) + put(YearMonth(-9999, 9), ("-9999-09" to setOf())) + put(YearMonth(10000, 8), ("+10000-08" to setOf())) + put(YearMonth(-10000, 7), ("-10000-07" to setOf())) + put(YearMonth(123456, 6), ("+123456-06" to setOf())) + put(YearMonth(-123456, 5), ("-123456-05" to setOf())) + } + test(yearMonths, YearMonth.Formats.ISO) + test(yearMonths, YearMonth.Format { byUnicodePattern("yyyy-MM") }) + } + + @Test + fun testIsoWithoutSeparators() { + val yearMonths = buildMap>> { + put(YearMonth(2008, 7), ("200807" to setOf())) + put(YearMonth(2007, 12), ("200712" to setOf())) + put(YearMonth(999, 11), ("099911" to setOf())) + put(YearMonth(-1, 1), ("-000101" to setOf())) + put(YearMonth(9999, 10), ("999910" to setOf())) + put(YearMonth(-9999, 9), ("-999909" to setOf())) + put(YearMonth(10000, 8), ("+1000008" to setOf())) + put(YearMonth(-10000, 7), ("-1000007" to setOf())) + put(YearMonth(123456, 6), ("+12345606" to setOf())) + put(YearMonth(-123456, 5), ("-12345605" to setOf())) + } + test(yearMonths, YearMonth.Format { year(); monthNumber() }) + test(yearMonths, YearMonth.Format { byUnicodePattern("yyyyMM") }) + } + + @Test + fun testErrorHandling() { + YearMonth.Formats.ISO.apply { + assertEquals(YearMonth(2023, 2), parse("2023-02")) + assertCanNotParse("2023-XX") + assertCanNotParse("2023-40") + } + } + + @Test + fun testEmptyMonthNames() { + val names = MonthNames.ENGLISH_FULL.names + for (i in 0 until 12) { + val newNames = (0 until 12).map { if (it == i) "" else names[it] } + assertFailsWith { MonthNames(newNames) } + } + } + + @Test + fun testIdenticalMonthNames() { + assertFailsWith { + MonthNames("Jan", "Jan", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + } + } + + private fun test(strings: Map>>, format: DateTimeFormat) { + for ((yearMonth, stringsForYearMonth) in strings) { + val (canonicalString, otherStrings) = stringsForYearMonth + assertEquals(canonicalString, format.format(yearMonth), "formatting $yearMonth with $format") + assertEquals(yearMonth, format.parse(canonicalString), "parsing '$canonicalString' with $format") + for (otherString in otherStrings) { + assertEquals(yearMonth, format.parse(otherString), "parsing '$otherString' with $format") + } + } + } +} diff --git a/core/common/test/samples/YearMonthSamples.kt b/core/common/test/samples/YearMonthSamples.kt new file mode 100644 index 00000000..b5d6e6a0 --- /dev/null +++ b/core/common/test/samples/YearMonthSamples.kt @@ -0,0 +1,214 @@ +/* + * 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.samples + +import kotlinx.datetime.* +import kotlinx.datetime.format.* +import kotlinx.datetime.onDay +import kotlin.test.* + +class YearMonthSamples { + + @Test + fun simpleParsingAndFormatting() { + // Parsing and formatting YearMonth values + check(YearMonth.parse("2023-01") == YearMonth(2023, Month.JANUARY)) + check(YearMonth(2023, Month.JANUARY).toString() == "2023-01") + } + + @Test + fun parsing() { + // Parsing YearMonth values using predefined and custom formats + check(YearMonth.parse("2024-04") == YearMonth(2024, Month.APRIL)) + val customFormat = YearMonth.Format { + monthName(MonthNames.ENGLISH_ABBREVIATED); chars(", "); year() + } + check(YearMonth.parse("Apr, 2024", customFormat) == YearMonth(2024, Month.APRIL)) + } + + @Test + fun customFormat() { + // Parsing and formatting YearMonth values using a custom format + val customFormat = YearMonth.Format { + monthName(MonthNames.ENGLISH_ABBREVIATED); chars(", "); year() + } + val yearMonth = customFormat.parse("Apr, 2024") + check(yearMonth == YearMonth(2024, Month.APRIL)) + val formatted = yearMonth.format(customFormat) + check(formatted == "Apr, 2024") + } + + @Test + fun constructorFunctionMonthNumber() { + // Constructing a YearMonth value using its constructor + val yearMonth = YearMonth(2024, 4) + check(yearMonth.year == 2024) + check(yearMonth.monthNumber == 4) + check(yearMonth.month == Month.APRIL) + } + + @Test + fun constructorFunction() { + // Constructing a YearMonth value using its constructor + val yearMonth = YearMonth(2024, Month.APRIL) + check(yearMonth.year == 2024) + check(yearMonth.month == Month.APRIL) + } + + @Test + fun year() { + // Getting the year + check(YearMonth(2024, Month.APRIL).year == 2024) + check(YearMonth(0, Month.APRIL).year == 0) + check(YearMonth(-2024, Month.APRIL).year == -2024) + } + + @Test + fun month() { + // Getting the month + for (month in Month.entries) { + check(YearMonth(2024, month).month == month) + } + } + + @Test + fun compareToSample() { + // Comparing YearMonth values + check(YearMonth(2023, 4) < YearMonth(2024, 3)) + check(YearMonth(2023, 4) < YearMonth(2023, 5)) + check(YearMonth(-1000, 4) < YearMonth(0, 4)) + } + + @Test + fun toStringSample() { + // Converting YearMonth values to strings + check(YearMonth(2024, 4).toString() == "2024-04") + check(YearMonth(12024, 4).toString() == "+12024-04") + check(YearMonth(-2024, 4).toString() == "-2024-04") + } + + @Test + fun formatting() { + // Formatting a YearMonth value using predefined and custom formats + check(YearMonth(2024, 4).toString() == "2024-04") + check(YearMonth(2024, 4).format(YearMonth.Formats.ISO) == "2024-04") + val customFormat = YearMonth.Format { + monthName(MonthNames.ENGLISH_ABBREVIATED); chars(", "); year() + } + check(YearMonth(2024, 4).format(customFormat) == "Apr, 2024") + } + + @Test + fun plusYear() { + check(YearMonth(2023, Month.JANUARY).plusYear() == YearMonth(2024, Month.JANUARY)) + } + + @Test + fun minusYear() { + check(YearMonth(2023, Month.JANUARY).minusYear() == YearMonth(2022, Month.JANUARY)) + } + + @Test + fun plusMonth() { + check(YearMonth(2023, Month.JANUARY).plusMonth() == YearMonth(2023, Month.FEBRUARY)) + } + + @Test + fun minusMonth() { + check(YearMonth(2023, Month.JANUARY).minusMonth() == YearMonth(2022, Month.DECEMBER)) + } + + @Test + fun yearMonth() { + // Getting a YearMonth value from a LocalDate + val localDate = LocalDate(2024, Month.APRIL, 13) + check(localDate.yearMonth == YearMonth(2024, Month.APRIL)) + } + + @Test + fun onDay() { + // Getting a LocalDate value from a YearMonth + val yearMonth = YearMonth(2024, Month.APRIL) + check(yearMonth.onDay(13) == LocalDate(2024, Month.APRIL, 13)) + } + + @Test + fun until() { + // Measuring the difference between two year-months in terms of the given unit + val startMonth = YearMonth(2023, Month.JANUARY) + val endMonth = YearMonth(2024, Month.APRIL) + val differenceInMonths = startMonth.until(endMonth, DateTimeUnit.MONTH) + check(differenceInMonths == 15L) + // one whole year + january, february, and march + } + + @Test + fun monthsUntil() { + // Finding how many months have passed between two year-months + val firstBillingMonth = YearMonth(2024, Month.MAY) + val today = YearMonth(2024, Month.NOVEMBER) + val billableMonths = firstBillingMonth.monthsUntil(today) + check(billableMonths == 6) + } + + @Test + fun yearsUntil() { + // Finding how many years have passed between two year-months + val firstBillingMonth = YearMonth(2016, Month.JANUARY) + val thisMonth = YearMonth(2024, Month.NOVEMBER) + val billableYears = firstBillingMonth.yearsUntil(thisMonth) + check(billableYears == 8) + } + + @Test + fun plus() { + // Adding a number of months or years to a year-month + val thisMonth = YearMonth(2024, Month.NOVEMBER) + val halfYearLater = thisMonth.plus(6, DateTimeUnit.MONTH) + check(halfYearLater == YearMonth(2025, Month.MAY)) + val twoMonthsLater = thisMonth.plus(2, DateTimeUnit.YEAR) + check(twoMonthsLater == YearMonth(2026, Month.NOVEMBER)) + } + + @Test + fun minus() { + // Subtracting a number of months or years from a year-month + val thisMonth = YearMonth(2024, Month.NOVEMBER) + val halfYearAgo = thisMonth.minus(6, DateTimeUnit.MONTH) + assertEquals(halfYearAgo, YearMonth(2024, Month.MAY)) + check(halfYearAgo == YearMonth(2024, Month.MAY)) + val twoYearsAgo = thisMonth.minus(2, DateTimeUnit.YEAR) + check(twoYearsAgo == YearMonth(2022, Month.NOVEMBER)) + } + + + @Test + fun firstAndLastDay() { + // Getting the first and last day of a year-month + val yearMonth = YearMonth(2024, Month.FEBRUARY) + check(yearMonth.firstDay == LocalDate(2024, Month.FEBRUARY, 1)) + check(yearMonth.lastDay == LocalDate(2024, Month.FEBRUARY, 29)) + } + + @Test + fun numberOfDays() { + // Determining the number of days in a year-month + check(YearMonth(2024, Month.FEBRUARY).numberOfDays == 29) + check(YearMonth(2023, Month.FEBRUARY).numberOfDays == 28) + check(YearMonth(2024, Month.APRIL).numberOfDays == 30) + } + + class Formats { + @Test + fun iso() { + // Using the extended ISO format for parsing and formatting YearMonth values + val yearMonth = YearMonth.Formats.ISO.parse("2024-04") + check(yearMonth == YearMonth(2024, Month.APRIL)) + val formatted = YearMonth.Formats.ISO.format(yearMonth) + check(formatted == "2024-04") + } + } +} diff --git a/core/common/test/samples/format/DateTimeComponentsSamples.kt b/core/common/test/samples/format/DateTimeComponentsSamples.kt index e3e81daa..9bd1a1b0 100644 --- a/core/common/test/samples/format/DateTimeComponentsSamples.kt +++ b/core/common/test/samples/format/DateTimeComponentsSamples.kt @@ -142,6 +142,24 @@ class DateTimeComponentsSamples { check(parsedWithoutDayOfWeek.dayOfWeek == null) } + @Test + fun yearMonth() { + // Formatting and parsing a year-month in complex scenarios + val format = DateTimeComponents.Format { + year(); char('-'); monthNumber() + } + val formattedYearMonth = format.format { + setYearMonth(YearMonth(2023, Month.FEBRUARY)) + check(year == 2023) + check(month == Month.FEBRUARY) + } + check(formattedYearMonth == "2023-02") + val parsedYearMonth = format.parse("2023-02") + check(parsedYearMonth.toYearMonth() == YearMonth(2023, Month.FEBRUARY)) + check(parsedYearMonth.year == 2023) + check(parsedYearMonth.month == Month.FEBRUARY) + } + @Test fun dayOfYear() { // Formatting and parsing a date with the day of the year in complex scenarios @@ -305,6 +323,15 @@ class DateTimeComponentsSamples { check(offset == UtcOffset(3, 0)) } + @Test + fun toYearMonth() { + // Obtaining a YearMonth from the parsed data + val rfc1123Input = "Sun, 06 Nov 1994 08:49:37 +0300" + val parsed = DateTimeComponents.Formats.RFC_1123.parse(rfc1123Input) + val yearMonth = parsed.toYearMonth() + check(yearMonth == YearMonth(1994, Month.NOVEMBER)) + } + @Test fun toLocalDate() { // Obtaining a LocalDate from the parsed data diff --git a/core/common/test/samples/format/LocalDateFormatSamples.kt b/core/common/test/samples/format/LocalDateFormatSamples.kt index 192ec754..0fd1ac73 100644 --- a/core/common/test/samples/format/LocalDateFormatSamples.kt +++ b/core/common/test/samples/format/LocalDateFormatSamples.kt @@ -10,58 +10,6 @@ import kotlinx.datetime.format.* import kotlin.test.* class LocalDateFormatSamples { - - @Test - fun year() { - // Using the year number in a custom format - val format = LocalDate.Format { - year(); char(' '); monthNumber(); char('/'); dayOfMonth() - } - check(format.format(LocalDate(2021, 1, 13)) == "2021 01/13") - check(format.format(LocalDate(13, 1, 13)) == "0013 01/13") - check(format.format(LocalDate(-2021, 1, 13)) == "-2021 01/13") - check(format.format(LocalDate(12021, 1, 13)) == "+12021 01/13") - } - - @Test - fun yearTwoDigits() { - // Using two-digit years in a custom format - val format = LocalDate.Format { - yearTwoDigits(baseYear = 1960); char(' '); monthNumber(); char('/'); dayOfMonth() - } - check(format.format(LocalDate(1960, 1, 13)) == "60 01/13") - check(format.format(LocalDate(2000, 1, 13)) == "00 01/13") - check(format.format(LocalDate(2021, 1, 13)) == "21 01/13") - check(format.format(LocalDate(2059, 1, 13)) == "59 01/13") - check(format.format(LocalDate(2060, 1, 13)) == "+2060 01/13") - check(format.format(LocalDate(-13, 1, 13)) == "-13 01/13") - } - - @Test - fun monthNumber() { - // Using month number with various paddings in a custom format - val zeroPaddedMonths = LocalDate.Format { - monthNumber(); char('/'); dayOfMonth(); char('/'); year() - } - check(zeroPaddedMonths.format(LocalDate(2021, 1, 13)) == "01/13/2021") - check(zeroPaddedMonths.format(LocalDate(2021, 12, 13)) == "12/13/2021") - val spacePaddedMonths = LocalDate.Format { - monthNumber(padding = Padding.SPACE); char('/'); dayOfMonth(); char('/'); year() - } - check(spacePaddedMonths.format(LocalDate(2021, 1, 13)) == " 1/13/2021") - check(spacePaddedMonths.format(LocalDate(2021, 12, 13)) == "12/13/2021") - } - - @Test - fun monthName() { - // Using strings for month names in a custom format - val format = LocalDate.Format { - monthName(MonthNames.ENGLISH_FULL); char(' '); dayOfMonth(); char('/'); year() - } - check(format.format(LocalDate(2021, 1, 13)) == "January 13/2021") - check(format.format(LocalDate(2021, 12, 13)) == "December 13/2021") - } - @Test fun dayOfMonth() { // Using day-of-month with various paddings in a custom format @@ -108,78 +56,6 @@ class LocalDateFormatSamples { check(format.format(LocalDateTime(2021, 1, 13, 14, 30)) == "2021-01-13T14:30") } - class MonthNamesSamples { - @Test - fun usage() { - // Using strings for month names in a custom format - val format = LocalDate.Format { - monthName(MonthNames.ENGLISH_ABBREVIATED) // "Jan", "Feb", ... - char(' ') - dayOfMonth() - chars(", ") - year() - } - check(format.format(LocalDate(2021, 1, 13)) == "Jan 13, 2021") - } - - @Test - fun constructionFromStrings() { - // Constructing a custom set of month names for parsing and formatting by passing 12 strings - val myMonthNames = MonthNames( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - ) - check(myMonthNames == MonthNames.ENGLISH_ABBREVIATED) // could just use the built-in one... - } - - @Test - fun constructionFromList() { - // Constructing a custom set of month names for parsing and formatting - val germanMonthNames = listOf( - "Januar", "Februar", "März", "April", "Mai", "Juni", - "Juli", "August", "September", "Oktober", "November", "Dezember" - ) - // constructing by passing a list of 12 strings - val myMonthNamesFromList = MonthNames(germanMonthNames) - check(myMonthNamesFromList.names == germanMonthNames) - } - - @Test - fun names() { - // Obtaining the list of month names - check(MonthNames.ENGLISH_ABBREVIATED.names == listOf( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - )) - } - - @Test - fun englishFull() { - // Using the built-in English month names in a custom format - val format = LocalDate.Format { - monthName(MonthNames.ENGLISH_FULL) - char(' ') - dayOfMonth() - chars(", ") - year() - } - check(format.format(LocalDate(2021, 1, 13)) == "January 13, 2021") - } - - @Test - fun englishAbbreviated() { - // Using the built-in English abbreviated month names in a custom format - val format = LocalDate.Format { - monthName(MonthNames.ENGLISH_ABBREVIATED) - char(' ') - dayOfMonth() - chars(", ") - year() - } - check(format.format(LocalDate(2021, 1, 13)) == "Jan 13, 2021") - } - } - class DayOfWeekNamesSamples { @Test fun usage() { diff --git a/core/common/test/samples/format/YearMonthFormatSamples.kt b/core/common/test/samples/format/YearMonthFormatSamples.kt new file mode 100644 index 00000000..710aaf3b --- /dev/null +++ b/core/common/test/samples/format/YearMonthFormatSamples.kt @@ -0,0 +1,154 @@ +/* + * 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.samples.format + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.YearMonth +import kotlinx.datetime.format.DayOfWeekNames +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.Padding +import kotlinx.datetime.format.alternativeParsing +import kotlinx.datetime.format.char +import kotlin.test.Test + +class YearMonthFormatSamples { + @Test + fun year() { + // Using the year number in a custom format + val format = LocalDate.Format { + year(); char(' '); monthNumber(); char('/'); dayOfMonth() + } + check(format.format(LocalDate(2021, 1, 13)) == "2021 01/13") + check(format.format(LocalDate(13, 1, 13)) == "0013 01/13") + check(format.format(LocalDate(-2021, 1, 13)) == "-2021 01/13") + check(format.format(LocalDate(12021, 1, 13)) == "+12021 01/13") + } + + @Test + fun yearTwoDigits() { + // Using two-digit years in a custom format + val format = LocalDate.Format { + yearTwoDigits(baseYear = 1960); char(' '); monthNumber(); char('/'); dayOfMonth() + } + check(format.format(LocalDate(1960, 1, 13)) == "60 01/13") + check(format.format(LocalDate(2000, 1, 13)) == "00 01/13") + check(format.format(LocalDate(2021, 1, 13)) == "21 01/13") + check(format.format(LocalDate(2059, 1, 13)) == "59 01/13") + check(format.format(LocalDate(2060, 1, 13)) == "+2060 01/13") + check(format.format(LocalDate(-13, 1, 13)) == "-13 01/13") + } + + @Test + fun monthNumber() { + // Using month number with various paddings in a custom format + val zeroPaddedMonths = LocalDate.Format { + monthNumber(); char('/'); dayOfMonth(); char('/'); year() + } + check(zeroPaddedMonths.format(LocalDate(2021, 1, 13)) == "01/13/2021") + check(zeroPaddedMonths.format(LocalDate(2021, 12, 13)) == "12/13/2021") + val spacePaddedMonths = LocalDate.Format { + monthNumber(padding = Padding.SPACE); char('/'); dayOfMonth(); char('/'); year() + } + check(spacePaddedMonths.format(LocalDate(2021, 1, 13)) == " 1/13/2021") + check(spacePaddedMonths.format(LocalDate(2021, 12, 13)) == "12/13/2021") + } + + @Test + fun monthName() { + // Using strings for month names in a custom format + val format = LocalDate.Format { + monthName(MonthNames.ENGLISH_FULL); char(' '); dayOfMonth(); char('/'); year() + } + check(format.format(LocalDate(2021, 1, 13)) == "January 13/2021") + check(format.format(LocalDate(2021, 12, 13)) == "December 13/2021") + } + + @Test + fun yearMonth() { + // Using a predefined format for a year-month in a larger custom format + val format = LocalDate.Format { + yearMonth(YearMonth.Formats.ISO) + chars(", ") + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + char(' ') + dayOfMonth() + } + check(format.format(LocalDate(2021, 1, 13)) == "2021-01, Wed 13") + } + + class MonthNamesSamples { + @Test + fun usage() { + // Using strings for month names in a custom format + val format = LocalDate.Format { + monthName(MonthNames.ENGLISH_ABBREVIATED) // "Jan", "Feb", ... + char(' ') + dayOfMonth() + chars(", ") + year() + } + check(format.format(LocalDate(2021, 1, 13)) == "Jan 13, 2021") + } + + @Test + fun constructionFromStrings() { + // Constructing a custom set of month names for parsing and formatting by passing 12 strings + val myMonthNames = MonthNames( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + ) + check(myMonthNames == MonthNames.ENGLISH_ABBREVIATED) // could just use the built-in one... + } + + @Test + fun constructionFromList() { + // Constructing a custom set of month names for parsing and formatting + val germanMonthNames = listOf( + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember" + ) + // constructing by passing a list of 12 strings + val myMonthNamesFromList = MonthNames(germanMonthNames) + check(myMonthNamesFromList.names == germanMonthNames) + } + + @Test + fun names() { + // Obtaining the list of month names + check(MonthNames.ENGLISH_ABBREVIATED.names == listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + )) + } + + @Test + fun englishFull() { + // Using the built-in English month names in a custom format + val format = LocalDate.Format { + monthName(MonthNames.ENGLISH_FULL) + char(' ') + dayOfMonth() + chars(", ") + year() + } + check(format.format(LocalDate(2021, 1, 13)) == "January 13, 2021") + } + + @Test + fun englishAbbreviated() { + // Using the built-in English abbreviated month names in a custom format + val format = LocalDate.Format { + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + dayOfMonth() + chars(", ") + year() + } + check(format.format(LocalDate(2021, 1, 13)) == "Jan 13, 2021") + } + } +} diff --git a/core/darwin/src/Converters.kt b/core/darwin/src/Converters.kt index 6ffdfe65..fbbeb9d7 100644 --- a/core/darwin/src/Converters.kt +++ b/core/darwin/src/Converters.kt @@ -80,7 +80,7 @@ public fun LocalDate.toNSDateComponents(): NSDateComponents { } /** - * Converts the given [LocalDate] to [NSDateComponents]. + * Converts the given [LocalDateTime] to [NSDateComponents]. * * Of all the fields, only the bare minimum required for uniquely identifying the date and time are set. */ @@ -92,3 +92,15 @@ public fun LocalDateTime.toNSDateComponents(): NSDateComponents { components.nanosecond = nanosecond.convert() return components } + +/** + * Converts the given [YearMonth] to [NSDateComponents]. + * + * Of all the fields, only the bare minimum required for uniquely identifying the year and month are set. + */ +public fun YearMonth.toNSDateComponents(): NSDateComponents { + val components = NSDateComponents() + components.year = year.convert() + components.month = monthNumber.convert() + return components +} diff --git a/core/darwin/test/ConvertersTest.kt b/core/darwin/test/ConvertersTest.kt index 9efe0775..c4ce58d2 100644 --- a/core/darwin/test/ConvertersTest.kt +++ b/core/darwin/test/ConvertersTest.kt @@ -103,6 +103,18 @@ class ConvertersTest { assertEqualUpToHalfMicrosecond(dateTime.toInstant(TimeZone.UTC), nsDate.toKotlinInstant()) } + @Test + fun yearMonthToNSDateComponentsTest() { + val yearMonth = YearMonth(2019, 2) + val components = yearMonth.toNSDateComponents().apply { timeZone = utc } + val nsDate = isoCalendar.dateFromComponents(components)!! + val formatter = NSDateFormatter().apply { + timeZone = utc + dateFormat = "yyyy-MM" + } + assertEquals("2019-02", formatter.stringFromDate(nsDate)) + } + @OptIn(ExperimentalForeignApi::class, UnsafeNumber::class) private fun zoneOffsetCheck(timeZone: FixedOffsetTimeZone, hours: Int, minutes: Int) { val nsTimeZone = timeZone.toNSTimeZone() diff --git a/core/jvm/src/Converters.kt b/core/jvm/src/Converters.kt index 6db8339d..597d35d2 100644 --- a/core/jvm/src/Converters.kt +++ b/core/jvm/src/Converters.kt @@ -93,3 +93,12 @@ public fun UtcOffset.toJavaZoneOffset(): java.time.ZoneOffset = this.zoneOffset */ public fun java.time.ZoneOffset.toKotlinUtcOffset(): UtcOffset = UtcOffset(this) +/** + * Converts this [kotlinx.datetime.YearMonth][YearMonth] value to a [java.time.YearMonth][java.time.YearMonth] value. + */ +public fun YearMonth.toJavaYearMonth(): java.time.YearMonth = java.time.YearMonth.of(year, month.number) + +/** + * Converts this [java.time.YearMonth][java.time.YearMonth] value to a [kotlinx.datetime.YearMonth][YearMonth] value. + */ +public fun java.time.YearMonth.toKotlinYearMonth(): YearMonth = YearMonth(year, month) diff --git a/core/jvm/test/ConvertersTest.kt b/core/jvm/test/ConvertersTest.kt index 18d75993..2ff7bffd 100644 --- a/core/jvm/test/ConvertersTest.kt +++ b/core/jvm/test/ConvertersTest.kt @@ -26,7 +26,7 @@ class ConvertersTest { assertEquals(ktInstant, jtInstant.toKotlinInstant()) assertEquals(jtInstant, ktInstant.toJavaInstant()) - assertEquals(ktInstant, jtInstant.toString().toInstant()) + assertEquals(ktInstant, jtInstant.toString().let(Instant::parse)) assertEquals(jtInstant, ktInstant.toString().let(JTInstant::parse)) } @@ -66,7 +66,7 @@ class ConvertersTest { assertEquals(ktDateTime, jtDateTime.toKotlinLocalDateTime()) assertEquals(jtDateTime, ktDateTime.toJavaLocalDateTime()) - assertEquals(ktDateTime, jtDateTime.toString().toLocalDateTime()) + assertEquals(ktDateTime, jtDateTime.toString().let(LocalDateTime::parse)) assertEquals(jtDateTime, ktDateTime.toString().let(JTLocalDateTime::parse)) } @@ -83,7 +83,7 @@ class ConvertersTest { assertEquals(ktTime, jtTime.toKotlinLocalTime()) assertEquals(jtTime, ktTime.toJavaLocalTime()) - assertEquals(ktTime, jtTime.toString().toLocalTime()) + assertEquals(ktTime, jtTime.toString().let(LocalTime::parse)) assertEquals(jtTime, ktTime.toString().let(JTLocalTime::parse)) } @@ -100,7 +100,7 @@ class ConvertersTest { assertEquals(ktDate, jtDate.toKotlinLocalDate()) assertEquals(jtDate, ktDate.toJavaLocalDate()) - assertEquals(ktDate, jtDate.toString().toLocalDate()) + assertEquals(ktDate, jtDate.toString().let(LocalDate::parse)) assertEquals(jtDate, ktDate.toString().let(JTLocalDate::parse)) } @@ -187,4 +187,22 @@ class ConvertersTest { test("+08") test("-103030") } + + @Test + fun yearMonth() { + fun test(year: Int, month: Int) { + val ktYearMonth = YearMonth(year, month) + val jtYearMonth = java.time.YearMonth.of(year, month) + + assertEquals(ktYearMonth, jtYearMonth.toKotlinYearMonth()) + assertEquals(jtYearMonth, ktYearMonth.toJavaYearMonth()) + + assertEquals(ktYearMonth, jtYearMonth.toString().let(YearMonth::parse)) + assertEquals(jtYearMonth, ktYearMonth.toString().let(java.time.YearMonth::parse)) + } + + repeat(STRESS_TEST_ITERATIONS) { + test(Random.nextInt(-10000, 10000), (1..12).random()) + } + } } diff --git a/core/jvm/test/UnicodeFormatTest.kt b/core/jvm/test/UnicodeFormatTest.kt index fe2faa18..6f650f55 100644 --- a/core/jvm/test/UnicodeFormatTest.kt +++ b/core/jvm/test/UnicodeFormatTest.kt @@ -85,12 +85,12 @@ class UnicodeFormatTest { 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 + it is UnicodeFormat.Directive.YearMonthBased.Year && it.formatLength == 2 + || it is UnicodeFormat.Directive.YearMonthBased.YearOfEra && it.formatLength == 2 } -> interestingDates21stCentury - directives.any { it is UnicodeFormat.Directive.DateBased.YearOfEra } -> interestingDatesPositive - directives.any { it is UnicodeFormat.Directive.DateBased } -> interestingDates + directives.any { it is UnicodeFormat.Directive.YearMonthBased.YearOfEra } -> interestingDatesPositive + directives.any { it is UnicodeFormat.Directive.YearMonthBased } -> interestingDates else -> listOf(LocalDate(1970, 1, 1)) } val times = when { diff --git a/serialization/common/test/YearMonthSerializationTest.kt b/serialization/common/test/YearMonthSerializationTest.kt new file mode 100644 index 00000000..31a6eca4 --- /dev/null +++ b/serialization/common/test/YearMonthSerializationTest.kt @@ -0,0 +1,70 @@ +/* + * 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.serialization.test + +import kotlinx.datetime.* +import kotlinx.datetime.serializers.* +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlin.test.* + +class YearMonthSerializationTest { + private fun iso8601Serialization(serializer: KSerializer) { + for ((yearMonth, json) in listOf( + Pair(YearMonth(2020, 12), "\"2020-12\""), + Pair(YearMonth(-2020, 1), "\"-2020-01\""), + Pair(YearMonth(2019, 10), "\"2019-10\""), + )) { + assertEquals(json, Json.encodeToString(serializer, yearMonth)) + assertEquals(yearMonth, Json.decodeFromString(serializer, json)) + } + } + + private fun componentSerialization(serializer: KSerializer) { + for ((yearMonth, json) in listOf( + Pair(YearMonth(2020, 12), "{\"year\":2020,\"month\":12}"), + Pair(YearMonth(-2020, 1), "{\"year\":-2020,\"month\":1}"), + Pair(YearMonth(2019, 10), "{\"year\":2019,\"month\":10}"), + )) { + assertEquals(json, Json.encodeToString(serializer, yearMonth)) + assertEquals(yearMonth, Json.decodeFromString(serializer, json)) + } + // all components must be present + assertFailsWith { + Json.decodeFromString(serializer, "{}") + } + assertFailsWith { + Json.decodeFromString(serializer, "{\"year\":3}") + } + assertFailsWith { + Json.decodeFromString(serializer, "{\"month\":3}") + } + // invalid values must fail to construct + assertFailsWith { + Json.decodeFromString(serializer, "{\"year\":1000000000000,\"month\":3}") + } + assertFailsWith { + Json.decodeFromString(serializer, "{\"year\":2020,\"month\":30}") + } + } + + @Test + fun testIso8601Serialization() { + iso8601Serialization(YearMonthIso8601Serializer) + } + + @Test + fun testComponentSerialization() { + componentSerialization(YearMonthComponentSerializer) + } + + @Test + fun testDefaultSerializers() { + // should be the same as the ISO 8601 + iso8601Serialization(Json.serializersModule.serializer()) + } + +}