Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement parsing and formatting days-of-year #417

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions core/common/src/format/DateTimeComponents.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -604,4 +608,15 @@ private class TwoDigitNumber(private val reference: KMutableProperty0<Int?>) {
}
}

private class ThreeDigitNumber(private val reference: KMutableProperty0<Int?>) {
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()
7 changes: 7 additions & 0 deletions core/common/src/format/DateTimeFormatBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
68 changes: 59 additions & 9 deletions core/common/src/format/LocalDateFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,15 @@ internal interface DateFieldContainer {
var monthNumber: Int?
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)
}

/**
Expand All @@ -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<IncompleteLocalDate> {
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(
Expand All @@ -241,16 +269,19 @@ 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
year.hashCode() + monthNumber.hashCode() + dayOfMonth.hashCode() + isoDayOfWeek.hashCode() +
dayOfYear.hashCode()

override fun toString(): String =
"${year ?: "??"}-${monthNumber ?: "??"}-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"})"
Expand Down Expand Up @@ -375,6 +406,22 @@ private class DayDirective(private val padding: Padding) :
override fun hashCode(): Int = padding.hashCode()
}

private class DayOfYearDirective(private val padding: Padding) :
UnsignedIntFieldFormatDirective<DateFieldContainer>(
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<DateFieldContainer>(DateFields.isoDayOfWeek, names.names, "dayOfWeekName") {

Expand Down Expand Up @@ -432,6 +479,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<LocalDate>) = when (format) {
is LocalDateFormat -> addFormatStructureForDate(format.actualFormat)
Expand Down
8 changes: 7 additions & 1 deletion core/common/src/format/Unicode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
21 changes: 21 additions & 0 deletions core/common/test/format/LocalDateFormatTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,27 @@ class LocalDateFormatTest {
test(dates, LocalDate.Formats.ISO_BASIC)
}

@Test
fun testDayOfYear() {
val dates = buildMap<LocalDate, Pair<String, Set<String>>> {
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 {
Expand Down
23 changes: 23 additions & 0 deletions core/common/test/samples/format/DateTimeComponentsSamples.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -162,6 +184,7 @@ class DateTimeComponentsSamples {
check(parsedDate.month == Month.JANUARY)
check(parsedDate.dayOfMonth == 2)
check(parsedDate.dayOfWeek == null)
check(parsedDate.dayOfYear == null)
}

@Test
Expand Down
10 changes: 10 additions & 0 deletions core/common/test/samples/format/LocalDateFormatSamples.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion core/jvm/test/UnicodeFormatTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 },
Expand Down