Skip to content

Commit a96c194

Browse files
committed
Convert y to u automatically, but emit a comment about it
1 parent 24d8ae8 commit a96c194

File tree

4 files changed

+79
-17
lines changed

4 files changed

+79
-17
lines changed

core/common/src/format/LocalDateFormat.kt

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ internal class IncompleteLocalDate(
182182
"${year ?: "??"}-${monthNumber ?: "??"}-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"})"
183183
}
184184

185-
private class YearDirective(private val padding: Padding) :
185+
private class YearDirective(private val padding: Padding, private val isYearOfEra: Boolean = false) :
186186
SignedIntFieldFormatDirective<DateFieldContainer>(
187187
DateFields.year,
188188
minDigits = padding.minDigits(4),
@@ -193,22 +193,62 @@ private class YearDirective(private val padding: Padding) :
193193
override val builderRepresentation: String get() = when (padding) {
194194
Padding.ZERO -> "${DateTimeFormatBuilder.WithDate::year.name}()"
195195
else -> "${DateTimeFormatBuilder.WithDate::year.name}(${padding.toKotlinCode()})"
196+
}.let {
197+
if (isYearOfEra) { it + YEAR_OF_ERA_COMMENT } else it
196198
}
197199

198-
override fun equals(other: Any?): Boolean = other is YearDirective && padding == other.padding
199-
override fun hashCode(): Int = padding.hashCode()
200+
override fun equals(other: Any?): Boolean =
201+
other is YearDirective && padding == other.padding && isYearOfEra == other.isYearOfEra
202+
override fun hashCode(): Int = padding.hashCode() * 31 + isYearOfEra.hashCode()
200203
}
201204

202-
private class ReducedYearDirective(val base: Int) :
205+
private class ReducedYearDirective(val base: Int, private val isYearOfEra: Boolean = false) :
203206
ReducedIntFieldDirective<DateFieldContainer>(
204207
DateFields.year,
205208
digits = 2,
206209
base = base,
207210
) {
208-
override val builderRepresentation: String get() = "${DateTimeFormatBuilder.WithDate::yearTwoDigits.name}($base)"
211+
override val builderRepresentation: String get() =
212+
"${DateTimeFormatBuilder.WithDate::yearTwoDigits.name}($base)".let {
213+
if (isYearOfEra) { it + YEAR_OF_ERA_COMMENT } else it
214+
}
215+
216+
override fun equals(other: Any?): Boolean =
217+
other is ReducedYearDirective && base == other.base && isYearOfEra == other.isYearOfEra
218+
override fun hashCode(): Int = base.hashCode() * 31 + isYearOfEra.hashCode()
219+
}
220+
221+
private const val YEAR_OF_ERA_COMMENT =
222+
" /** 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. */"
223+
224+
/**
225+
* A special directive for year-of-era that behaves equivalently to [DateTimeFormatBuilder.WithDate.year].
226+
* This is the result of calling [byUnicodePattern] on a pattern that uses the ubiquitous "y" symbol.
227+
* We need a separate directive so that, when using [DateTimeFormat.formatAsKotlinBuilderDsl], we can print an
228+
* additional comment and explain that the behavior was not preserved exactly.
229+
*/
230+
internal fun DateTimeFormatBuilder.WithDate.yearOfEra(padding: Padding) {
231+
@Suppress("NO_ELSE_IN_WHEN")
232+
when (this) {
233+
is AbstractWithDateBuilder -> addFormatStructureForDate(
234+
BasicFormatStructure(YearDirective(padding, isYearOfEra = true))
235+
)
236+
}
237+
}
209238

210-
override fun equals(other: Any?): Boolean = other is ReducedYearDirective && base == other.base
211-
override fun hashCode(): Int = base.hashCode()
239+
/**
240+
* A special directive for year-of-era that behaves equivalently to [DateTimeFormatBuilder.WithDate.year].
241+
* This is the result of calling [byUnicodePattern] on a pattern that uses the ubiquitous "y" symbol.
242+
* We need a separate directive so that, when using [DateTimeFormat.formatAsKotlinBuilderDsl], we can print an
243+
* additional comment and explain that the behavior was not preserved exactly.
244+
*/
245+
internal fun DateTimeFormatBuilder.WithDate.yearOfEraTwoDigits(baseYear: Int) {
246+
@Suppress("NO_ELSE_IN_WHEN")
247+
when (this) {
248+
is AbstractWithDateBuilder -> addFormatStructureForDate(
249+
BasicFormatStructure(ReducedYearDirective(baseYear, isYearOfEra = true))
250+
)
251+
}
212252
}
213253

214254
private class MonthDirective(private val padding: Padding) :

core/common/src/format/Unicode.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,16 @@ public annotation class FormatStringsInDatetimeFormats
8686
* | `xxxx` | always | unless zero | none | `+0000` |
8787
* | `xxxxx` | always | unless zero | colon | `+00:00` |
8888
*
89-
* `y` is the most common way to represent the year, but it's not supported by [byUnicodePattern] because its
90-
* interpretation depends on which calendar is being used.
91-
* For the ISO chronology and years 0001 and later, one can obtain results that are equivalent to `yyyy` and `yy` like
92-
* this: `byUnicodePattern(pattern.replace("yyyy", "uuuu").replace("yy", "uu")`.
89+
* Additionally, because the `y` directive is very often used instead of `u`, they are taken to mean the same.
90+
* This may lead to unexpected results if the year is negative: `y` would always produce a positive number, whereas
91+
* `u` may sometimes produce a negative one. For example:
92+
* ```
93+
* LocalDate(-10, 1, 5).format { byUnicodeFormat("yyyy-MM-dd") } // -0010-01-05
94+
* LocalDate(-10, 1, 5).toJavaLocalDate().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd")) // 0011-01-05
95+
* ```
96+
*
97+
* Note that, when the format includes the era directive, [byUnicodePattern] will fail with an exception, so almost all
98+
* of the intentional usages of `y` will correctly report an error instead of behaving slightly differently.
9399
*
94100
* @throws IllegalArgumentException if the pattern is invalid or contains unsupported directives.
95101
* @throws IllegalArgumentException if the builder is incompatible with the specified directives.
@@ -260,9 +266,13 @@ internal sealed interface UnicodeFormat {
260266

261267
class YearOfEra(override val formatLength: Int) : DateBased() {
262268
override val formatLetter = 'y'
263-
override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = localizedDirective(
264-
"The locale-invariant ISO year directive '${"u".repeat(formatLength)}' can be used instead."
265-
)
269+
override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = when (formatLength) {
270+
1 -> builder.yearOfEra(padding = Padding.NONE)
271+
2 -> builder.yearOfEraTwoDigits(baseYear = 2000)
272+
3 -> unsupportedPadding(formatLength)
273+
4 -> builder.yearOfEra(padding = Padding.ZERO)
274+
else -> unsupportedPadding(formatLength)
275+
}
266276
}
267277

268278
class CyclicYearName(override val formatLength: Int) : DateBased() {

core/common/test/format/DateTimeFormatTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ class DateTimeFormatTest {
7676
""".trimIndent(), kotlinCode)
7777
}
7878

79+
/**
80+
* Check that we mention [byUnicodePattern] in the string representation of the format when the conversion is
81+
* incorrect.
82+
*/
83+
@OptIn(FormatStringsInDatetimeFormats::class)
84+
@Test
85+
fun testStringRepresentationAfterIncorrectConversion() {
86+
for (format in listOf("yyyy-MM-dd", "yy-MM-dd")) {
87+
assertContains(DateTimeFormat.formatAsKotlinBuilderDsl(
88+
DateTimeComponents.Format { byUnicodePattern(format) }
89+
), "byUnicodePattern")
90+
}
91+
}
92+
7993
@Test
8094
fun testParseStringWithNumbers() {
8195
val formats = listOf(

core/jvm/test/UnicodeFormatTest.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,7 @@ class UnicodeFormatTest {
106106
directives.any { it is UnicodeFormat.Directive.ZoneBased } -> TimeZone.availableZoneIds
107107
else -> setOf("Europe/Berlin")
108108
}
109-
val format = DateTimeComponents.Format {
110-
byUnicodePattern(pattern.replace("yyyy", "uuuu").replace("yy", "uu"))
111-
}
109+
val format = DateTimeComponents.Format { byUnicodePattern(pattern) }
112110
val javaFormat = DateTimeFormatter.ofPattern(pattern)
113111
for (date in dates) {
114112
for (time in times) {

0 commit comments

Comments
 (0)