diff --git a/core/common/src/format/DateTimeComponents.kt b/core/common/src/format/DateTimeComponents.kt index 61f833115..436fa4992 100644 --- a/core/common/src/format/DateTimeComponents.kt +++ b/core/common/src/format/DateTimeComponents.kt @@ -305,8 +305,12 @@ public class DateTimeComponents internal constructor(internal val contents: Date set(value) { contents.date.isoDayOfWeek = value?.isoDayNumber } - // /** Returns the day-of-year component of the date. */ - // public var dayOfYear: Int + + /** + * The day-of-year component of the date. + * @sample kotlinx.datetime.test.samples.format.DateTimeComponentsSamples.dayOfYear + */ + public var dayOfYear: Int? by ThreeDigitNumber(contents.date::dayOfYear) /** * The hour-of-day (0..23) time component. @@ -604,4 +608,15 @@ private class TwoDigitNumber(private val reference: KMutableProperty0) { } } +private class ThreeDigitNumber(private val reference: KMutableProperty0) { + operator fun getValue(thisRef: Any?, property: KProperty<*>) = reference.getValue(thisRef, property) + + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int?) { + require(value === null || value in 0..999) { + "${property.name} must be a three-digit number, got '$value'" + } + reference.setValue(thisRef, property, value) + } +} + private val emptyDateTimeComponentsContents = DateTimeComponentsContents() diff --git a/core/common/src/format/DateTimeFormatBuilder.kt b/core/common/src/format/DateTimeFormatBuilder.kt index cd98ef122..932653b5b 100644 --- a/core/common/src/format/DateTimeFormatBuilder.kt +++ b/core/common/src/format/DateTimeFormatBuilder.kt @@ -90,6 +90,13 @@ public sealed interface DateTimeFormatBuilder { */ public fun dayOfWeek(names: DayOfWeekNames) + /** + * A day-of-year number, from 1 to 366. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.dayOfYear + */ + public fun dayOfYear(padding: Padding = Padding.ZERO) + /** * An existing [DateTimeFormat] for the date part. * diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt index 2e96fe10c..bc6638e6b 100644 --- a/core/common/src/format/LocalDateFormat.kt +++ b/core/common/src/format/LocalDateFormat.kt @@ -201,6 +201,7 @@ internal interface DateFieldContainer { var monthNumber: Int? var dayOfMonth: Int? var isoDayOfWeek: Int? + var dayOfYear: Int? } private object DateFields { @@ -208,6 +209,7 @@ private object DateFields { 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) } /** @@ -217,14 +219,40 @@ internal class IncompleteLocalDate( override var year: Int? = null, override var monthNumber: Int? = null, override var dayOfMonth: Int? = null, - override var isoDayOfWeek: Int? = null + override var isoDayOfWeek: Int? = null, + override var dayOfYear: Int? = null, ) : DateFieldContainer, Copyable { fun toLocalDate(): LocalDate { - val date = LocalDate( - requireParsedField(year, "year"), - requireParsedField(monthNumber, "monthNumber"), - requireParsedField(dayOfMonth, "dayOfMonth") - ) + val year = requireParsedField(year, "year") + val date = when (val dayOfYear = dayOfYear) { + null -> LocalDate( + year, + requireParsedField(monthNumber, "monthNumber"), + requireParsedField(dayOfMonth, "dayOfMonth") + ) + else -> LocalDate(year, 1, 1).plus(dayOfYear - 1, DateTimeUnit.DAY).also { + 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" + ) + } + 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}, " + + "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}, " + + "but $dayOfMonth was specified as the day of month" + ) + } + } + } isoDayOfWeek?.let { if (it != date.dayOfWeek.isoDayNumber) { throw DateTimeFormatException( @@ -241,16 +269,21 @@ internal class IncompleteLocalDate( monthNumber = date.monthNumber dayOfMonth = date.dayOfMonth isoDayOfWeek = date.dayOfWeek.isoDayNumber + dayOfYear = date.dayOfYear } - override fun copy(): IncompleteLocalDate = IncompleteLocalDate(year, monthNumber, dayOfMonth, isoDayOfWeek) + override fun copy(): IncompleteLocalDate = + IncompleteLocalDate(year, monthNumber, dayOfMonth, isoDayOfWeek, dayOfYear) override fun equals(other: Any?): Boolean = other is IncompleteLocalDate && year == other.year && monthNumber == other.monthNumber && - dayOfMonth == other.dayOfMonth && isoDayOfWeek == other.isoDayOfWeek + dayOfMonth == other.dayOfMonth && isoDayOfWeek == other.isoDayOfWeek && dayOfYear == other.dayOfYear - override fun hashCode(): Int = - year.hashCode() * 31 + monthNumber.hashCode() * 31 + dayOfMonth.hashCode() * 31 + isoDayOfWeek.hashCode() * 31 + override fun hashCode(): Int = year.hashCode() * 923521 + + monthNumber.hashCode() * 29791 + + dayOfMonth.hashCode() * 961 + + isoDayOfWeek.hashCode() * 31 + + dayOfYear.hashCode() override fun toString(): String = "${year ?: "??"}-${monthNumber ?: "??"}-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"})" @@ -375,6 +408,22 @@ private class DayDirective(private val padding: Padding) : override fun hashCode(): Int = padding.hashCode() } +private class DayOfYearDirective(private val padding: Padding) : + UnsignedIntFieldFormatDirective( + DateFields.dayOfYear, + minDigits = padding.minDigits(3), + spacePadding = padding.spaces(3), + ) { + override val builderRepresentation: String + get() = when (padding) { + Padding.ZERO -> "${DateTimeFormatBuilder.WithDate::dayOfYear.name}()" + else -> "${DateTimeFormatBuilder.WithDate::dayOfYear.name}(${padding.toKotlinCode()})" + } + + override fun equals(other: Any?): Boolean = other is DayOfYearDirective && padding == other.padding + override fun hashCode(): Int = padding.hashCode() +} + private class DayOfWeekDirective(private val names: DayOfWeekNames) : NamedUnsignedIntFieldFormatDirective(DateFields.isoDayOfWeek, names.names, "dayOfWeekName") { @@ -432,6 +481,9 @@ internal interface AbstractWithDateBuilder : DateTimeFormatBuilder.WithDate { override fun dayOfWeek(names: DayOfWeekNames) = addFormatStructureForDate(BasicFormatStructure(DayOfWeekDirective(names))) + override fun dayOfYear(padding: Padding) = + addFormatStructureForDate(BasicFormatStructure(DayOfYearDirective(padding))) + @Suppress("NO_ELSE_IN_WHEN") override fun date(format: DateTimeFormat) = when (format) { is LocalDateFormat -> addFormatStructureForDate(format.actualFormat) diff --git a/core/common/src/format/Unicode.kt b/core/common/src/format/Unicode.kt index 573583c5d..4f483dbd1 100644 --- a/core/common/src/format/Unicode.kt +++ b/core/common/src/format/Unicode.kt @@ -293,7 +293,13 @@ internal sealed interface UnicodeFormat { class DayOfYear(override val formatLength: Int) : DateBased() { override val formatLetter = 'D' - override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = unsupportedDirective("day-of-year") + 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() { diff --git a/core/common/test/format/LocalDateFormatTest.kt b/core/common/test/format/LocalDateFormatTest.kt index d1363253b..35f7e6d90 100644 --- a/core/common/test/format/LocalDateFormatTest.kt +++ b/core/common/test/format/LocalDateFormatTest.kt @@ -209,6 +209,27 @@ class LocalDateFormatTest { test(dates, LocalDate.Formats.ISO_BASIC) } + @Test + fun testDayOfYear() { + val dates = buildMap>> { + put(LocalDate(2008, 7, 5), ("2008-187" to setOf())) + put(LocalDate(2007, 12, 31), ("2007-365" to setOf())) + put(LocalDate(999, 11, 30), ("0999-334" to setOf())) + put(LocalDate(-1, 1, 2), ("-0001-002" to setOf())) + put(LocalDate(9999, 10, 31), ("9999-304" to setOf())) + put(LocalDate(-9999, 9, 30), ("-9999-273" to setOf())) + put(LocalDate(10000, 8, 1), ("+10000-214" to setOf())) + put(LocalDate(-10000, 7, 1), ("-10000-183" to setOf())) + put(LocalDate(123456, 6, 1), ("+123456-153" to setOf())) + put(LocalDate(-123456, 5, 1), ("-123456-122" to setOf())) + } + test(dates, LocalDate.Format { + year() + char('-') + dayOfYear() + }) + } + @Test fun testDoc() { val format = LocalDate.Format { diff --git a/core/common/test/samples/format/DateTimeComponentsSamples.kt b/core/common/test/samples/format/DateTimeComponentsSamples.kt index b4c0148e8..e3e81daa9 100644 --- a/core/common/test/samples/format/DateTimeComponentsSamples.kt +++ b/core/common/test/samples/format/DateTimeComponentsSamples.kt @@ -142,6 +142,27 @@ class DateTimeComponentsSamples { check(parsedWithoutDayOfWeek.dayOfWeek == null) } + @Test + fun dayOfYear() { + // Formatting and parsing a date with the day of the year in complex scenarios + val format = DateTimeComponents.Format { + year(); dayOfYear() + } + val formattedDate = format.format { + setDate(LocalDate(2023, 2, 13)) + check(year == 2023) + check(dayOfYear == 44) + } + check(formattedDate == "2023044") + val parsedDate = format.parse("2023044") + check(parsedDate.toLocalDate() == LocalDate(2023, 2, 13)) + check(parsedDate.year == 2023) + check(parsedDate.dayOfYear == 44) + check(parsedDate.month == null) + check(parsedDate.dayOfMonth == null) + check(parsedDate.dayOfWeek == null) + } + @Test fun date() { // Formatting and parsing a date in complex scenarios @@ -154,6 +175,7 @@ class DateTimeComponentsSamples { check(month == Month.JANUARY) check(dayOfMonth == 2) check(dayOfWeek == DayOfWeek.MONDAY) + check(dayOfYear == 2) } check(formattedDate == "2023-01-02") val parsedDate = format.parse("2023-01-02") @@ -162,6 +184,7 @@ class DateTimeComponentsSamples { check(parsedDate.month == Month.JANUARY) check(parsedDate.dayOfMonth == 2) check(parsedDate.dayOfWeek == null) + check(parsedDate.dayOfYear == null) } @Test diff --git a/core/common/test/samples/format/LocalDateFormatSamples.kt b/core/common/test/samples/format/LocalDateFormatSamples.kt index 5a6d476ce..192ec7549 100644 --- a/core/common/test/samples/format/LocalDateFormatSamples.kt +++ b/core/common/test/samples/format/LocalDateFormatSamples.kt @@ -87,6 +87,16 @@ class LocalDateFormatSamples { check(format.format(LocalDate(2021, 12, 13)) == "Mon 13/12/2021") } + @Test + fun dayOfYear() { + // Using day-of-year in a custom format + val format = LocalDate.Format { + year(); dayOfYear() + } + check(format.format(LocalDate(2021, 2, 13)) == "2021044") + check(format.parse("2021044") == LocalDate(2021, 2, 13)) + } + @Test fun date() { // Using a predefined format for a date in a larger custom format diff --git a/core/jvm/test/UnicodeFormatTest.kt b/core/jvm/test/UnicodeFormatTest.kt index cf51533d4..fe2faa183 100644 --- a/core/jvm/test/UnicodeFormatTest.kt +++ b/core/jvm/test/UnicodeFormatTest.kt @@ -40,7 +40,6 @@ class UnicodeFormatTest { "yyyy_MM_dd_HH_mm_ss", "yyyy-MM-d 'at' HH:mm ", "yyyy:MM:dd HH:mm:ss", "yyyy年MM月dd日 HH:mm:ss", "yyyy年MM月dd日", "dd.MM.yyyy. HH:mm:ss", "ss", "ddMMyyyy", "yyyyMMdd'T'HHmmss'Z'", "yyyyMMdd'T'HHmmss", "yyyy-MM-dd'T'HH:mm:ssX", - "yyyy-MM-dd'T'HH:mm:ss[.SSS]X" // not in top 100, but interesting, as it contains an optional section ) val localizedPatterns = listOf( "MMMM", "hh:mm a", "h:mm a", "dd MMMM yyyy", "dd MMM yyyy", "yyyy-MM-dd hh:mm:ss", "d MMMM yyyy", "MMM", @@ -71,6 +70,16 @@ class UnicodeFormatTest { } } + @Test + fun testOptionalSection() { + checkPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]X") + } + + @Test + fun testDayOfYearFormats() { + checkPattern("yyyyDDDHHmm") + } + private fun checkPattern(pattern: String) { val unicodeFormat = UnicodeFormat.parse(pattern) val directives = directivesInFormat(unicodeFormat) @@ -160,6 +169,7 @@ private val dateTimeComponentsTemporalQuery = TemporalQuery { accessor -> ChronoField.YEAR to { year = it }, ChronoField.MONTH_OF_YEAR to { monthNumber = it }, ChronoField.DAY_OF_MONTH to { dayOfMonth = it }, + ChronoField.DAY_OF_YEAR to { dayOfYear = it }, ChronoField.DAY_OF_WEEK to { dayOfWeek = DayOfWeek(it) }, ChronoField.AMPM_OF_DAY to { amPm = if (it == 1) AmPmMarker.PM else AmPmMarker.AM }, ChronoField.CLOCK_HOUR_OF_AMPM to { hourOfAmPm = it },