diff --git a/core/common/src/format/DateTimeFormatBuilder.kt b/core/common/src/format/DateTimeFormatBuilder.kt index d77021f5..192ccf4a 100644 --- a/core/common/src/format/DateTimeFormatBuilder.kt +++ b/core/common/src/format/DateTimeFormatBuilder.kt @@ -100,6 +100,13 @@ public sealed interface DateTimeFormatBuilder { */ public fun day(padding: Padding = Padding.ZERO) + /** + * A day-of-month ordinal. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.dayOrdinal + */ + public fun dayOrdinal(names: DayOrdinalNames) + /** @suppress */ @Deprecated("Use 'day' instead", ReplaceWith("day(padding = padding)")) public fun dayOfMonth(padding: Padding = Padding.ZERO) { day(padding) } diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt index c472ea19..54897526 100644 --- a/core/common/src/format/LocalDateFormat.kt +++ b/core/common/src/format/LocalDateFormat.kt @@ -210,6 +210,72 @@ private class DayDirective(private val padding: Padding) : override fun hashCode(): Int = padding.hashCode() } +private class OrdinalDayDirective( + private val names: DayOrdinalNames = DayOrdinalNames.ENGLISH, +) : NamedUnsignedIntFieldFormatDirective(DateFields.day, names.names, "dayOrdinalName") { + + override val builderRepresentation: String + get() = + "${DateTimeFormatBuilder.WithDate::day.name}(${names.toKotlinCode()})" + + override fun equals(other: Any?): Boolean = other is OrdinalDayDirective && names.names == other.names.names + override fun hashCode(): Int = names.names.hashCode() +} + + +/** + * A description of how the names of ordinal days are formatted. + * + * Instances of this class are typically used as arguments to [DateTimeFormatBuilder.WithDate.dayOrdinal]. + * + * A predefined instance is available as [ENGLISH]. + * You can also create custom instances using the constructor. + * + * An [IllegalArgumentException] will be thrown if the list does not contain exactly 31 elements. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.usage + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.customNames + */ +public class DayOrdinalNames( + /** + * A list of the names of ordinal days from 1st to 31st. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.names + */ + public val names: List +) { + init { + /** + * The list must contain exactly 31 elements, one for each day of the month. + * The names are expected to be in the order from 1st to 31st. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.invalidListSize + */ + require(names.size == 31) { + "Day ordinal names must contain exactly 31 elements, one for each day of the month" + } + } + + public companion object { + /** The English default: "1st", "2nd", "3rd", … */ + public val ENGLISH: DayOrdinalNames = DayOrdinalNames(List(31) { i -> + val d = i + 1 + when { + d % 100 in 11..13 -> "${d}th" + d % 10 == 1 -> "${d}st" + d % 10 == 2 -> "${d}nd" + d % 10 == 3 -> "${d}rd" + else -> "${d}th" + } + }) + } +} + +private fun DayOrdinalNames.toKotlinCode(): String = when (this.names) { + DayOrdinalNames.ENGLISH.names -> "DayOrdinalNames.${DayOrdinalNames.Companion::ENGLISH.name}" + else -> names.joinToString(", ", "DayOrdinalNames(", ")", transform = String::toKotlinCode) +} + private class DayOfYearDirective(private val padding: Padding) : UnsignedIntFieldFormatDirective( DateFields.dayOfYear, @@ -271,6 +337,10 @@ internal interface AbstractWithDateBuilder : AbstractWithYearMonthBuilder, DateT addFormatStructureForDate(structure) } + override fun dayOrdinal(names: DayOrdinalNames) { + addFormatStructureForDate(BasicFormatStructure(OrdinalDayDirective(names))) + } + override fun day(padding: Padding) = addFormatStructureForDate(BasicFormatStructure(DayDirective(padding))) diff --git a/core/common/test/format/LocalDateFormatTest.kt b/core/common/test/format/LocalDateFormatTest.kt index 3666cb50..78159eb0 100644 --- a/core/common/test/format/LocalDateFormatTest.kt +++ b/core/common/test/format/LocalDateFormatTest.kt @@ -258,6 +258,38 @@ class LocalDateFormatTest { } } + @Test + fun testDayOrdinalFormatting() { + val format = LocalDate.Format { + dayOrdinal(DayOrdinalNames.ENGLISH); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + val testCases = listOf( + LocalDate(2021, 1, 1) to "1st Jan", + LocalDate(2021, 1, 2) to "2nd Jan", + LocalDate(2021, 1, 3) to "3rd Jan", + LocalDate(2021, 1, 4) to "4th Jan", + LocalDate(2021, 1, 11) to "11th Jan", + LocalDate(2021, 1, 21) to "21st Jan", + LocalDate(2021, 1, 22) to "22nd Jan", + LocalDate(2021, 1, 23) to "23rd Jan", + LocalDate(2021, 1, 31) to "31st Jan" + ) + for ((date, expected) in testCases) { + assertEquals(expected, format.format(date), "Failed for $date") + } + } + + @Test + fun testDayOrdinalCustomNames() { + val customNames = DayOrdinalNames(List(31) { i -> "${i + 1}th" }) + val format = LocalDate.Format { + dayOrdinal(customNames); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + assertEquals("1th Jan", format.format(LocalDate(2021, 1, 1))) + assertEquals("2th Jan", format.format(LocalDate(2021, 1, 2))) + assertEquals("31th Jan", format.format(LocalDate(2021, 1, 31))) + } + private fun test(strings: Map>>, format: DateTimeFormat) { for ((date, stringsForDate) in strings) { val (canonicalString, otherStrings) = stringsForDate diff --git a/core/common/test/samples/format/LocalDateFormatSamples.kt b/core/common/test/samples/format/LocalDateFormatSamples.kt index 0a03c98c..91efbd04 100644 --- a/core/common/test/samples/format/LocalDateFormatSamples.kt +++ b/core/common/test/samples/format/LocalDateFormatSamples.kt @@ -25,6 +25,22 @@ class LocalDateFormatSamples { check(spacePaddedDays.format(LocalDate(2021, 1, 31)) == "31/01/2021") } + @Test + fun ordinalDay() { + // Using ordinal day with the default English‑suffix formatter + val defaultOrdinalDays = LocalDate.Format { + dayOrdinal(DayOrdinalNames.ENGLISH); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + check(defaultOrdinalDays.format(LocalDate(2021, 1, 1)) == "1st Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 2)) == "2nd Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 3)) == "3rd Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 4)) == "4th Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 11)) == "11th Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 21)) == "21st Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 22)) == "22nd Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 31)) == "31st Jan") + } + @Test fun dayOfWeek() { // Using strings for day-of-week names in a custom format @@ -118,4 +134,64 @@ class LocalDateFormatSamples { check(format.format(LocalDate(2021, 1, 13)) == "2021-01-13, Wed") } } + + class DayOrdinalNamesSamples { + @Test + fun usage() { + // Using ordinal day with the default English‑suffix formatter + val format = LocalDate.Format { + dayOrdinal(DayOrdinalNames.ENGLISH); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + check(format.format(LocalDate(2021, 1, 13)) == "2021-01-13, Wed") + } + + @Test + fun customNames() { + // Using ordinal day with a custom formatter that always falls back to "th" + val customOrdinalDays = LocalDate.Format { + dayOrdinal( + names = DayOrdinalNames( + List(31) { + val d = it + 1 + when (d) { + 1 -> "1st" + 2 -> "2nd" + 3 -> "3rd" + else -> "${d}th" + } + } + )); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + check(customOrdinalDays.format(LocalDate(2021, 1, 1)) == "1st Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 2)) == "2nd Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 3)) == "3rd Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 22)) == "22th Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 31)) == "31th Jan") + } + + @Test + fun names() { + // Obtaining the list of day of week names + check( + DayOrdinalNames.ENGLISH.names == listOf( + "1st", "2nd", "3rd", "4th", "5th", "6th", "7th", + "8th", "9th", "10th", "11th", "12th", "13th", + "14th", "15th", "16th", "17th", "18th", "19th", + "20th", "21st", "22nd", "23rd", "24th", "25th", + "26th", "27th", "28th", "29th", "30th", "31st" + ) + ) + } + + @Test + fun invalidListSize() { + // Attempting to create a DayOrdinalNames with an invalid list size + try { + DayOrdinalNames(listOf("1st", "2nd", "3rd")) // only 3 names, should throw + check(false) // should not reach here + } catch (e: Throwable) { + check(e is IllegalArgumentException) + } + } + } }