icu_datetime/pattern/formatter.rs
1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use super::names::RawDateTimeNamesBorrowed;
6use super::pattern::DateTimePatternBorrowed;
7use crate::format::datetime::try_write_pattern_items;
8use crate::format::DateTimeInputUnchecked;
9use crate::pattern::FormattedDateTimePatternError;
10use crate::scaffold::*;
11use crate::scaffold::{
12 AllInputMarkers, DateInputMarkers, DateTimeMarkers, InFixedCalendar, TimeMarkers,
13 TypedDateDataMarkers, ZoneMarkers,
14};
15use core::fmt;
16use core::marker::PhantomData;
17use writeable::TryWriteable;
18
19/// A formatter for a specific [`DateTimePattern`].
20///
21/// ❗ This type forgoes most internationalization functionality of the datetime crate.
22/// It assumes that the pattern is already localized for the customer's locale. Most clients
23/// should use [`DateTimeFormatter`] instead of directly formatting with patterns.
24///
25/// Create one of these via factory methods on [`FixedCalendarDateTimeNames`].
26///
27/// [`DateTimePattern`]: super::DateTimePattern
28/// [`FixedCalendarDateTimeNames`]: super::FixedCalendarDateTimeNames
29/// [`DateTimeFormatter`]: crate::DateTimeFormatter
30#[derive(Debug, Copy, Clone)]
31pub struct DateTimePatternFormatter<'a, C: CldrCalendar, FSet> {
32 inner: RawDateTimePatternFormatter<'a>,
33 _calendar: PhantomData<C>,
34 _marker: PhantomData<FSet>,
35}
36
37#[derive(Debug, Copy, Clone)]
38pub(crate) struct RawDateTimePatternFormatter<'a> {
39 pattern: DateTimePatternBorrowed<'a>,
40 names: RawDateTimeNamesBorrowed<'a>,
41}
42
43impl<'a, C: CldrCalendar, FSet> DateTimePatternFormatter<'a, C, FSet> {
44 pub(crate) fn new(
45 pattern: DateTimePatternBorrowed<'a>,
46 names: RawDateTimeNamesBorrowed<'a>,
47 ) -> Self {
48 Self {
49 inner: RawDateTimePatternFormatter { pattern, names },
50 _calendar: PhantomData,
51 _marker: PhantomData,
52 }
53 }
54}
55
56impl<'a, C: CldrCalendar, FSet: DateTimeMarkers> DateTimePatternFormatter<'a, C, FSet>
57where
58 FSet::D: TypedDateDataMarkers<C> + DateInputMarkers,
59 FSet::T: TimeMarkers,
60 FSet::Z: ZoneMarkers,
61{
62 /// Formats a date and time of day with a custom date/time pattern.
63 ///
64 /// # Examples
65 ///
66 /// Format a date:
67 ///
68 /// ```
69 /// use icu::calendar::Gregorian;
70 /// use icu::datetime::fieldsets::enums::DateFieldSet;
71 /// use icu::datetime::input::Date;
72 /// use icu::datetime::pattern::DateTimePattern;
73 /// use icu::datetime::pattern::FixedCalendarDateTimeNames;
74 /// use icu::datetime::pattern::MonthNameLength;
75 /// use icu::datetime::pattern::YearNameLength;
76 /// use icu::locale::locale;
77 /// use writeable::TryWriteable;
78 ///
79 /// // Create an instance that can format wide month and era names:
80 /// let mut names: FixedCalendarDateTimeNames<Gregorian, DateFieldSet> =
81 /// FixedCalendarDateTimeNames::try_new(locale!("en-GB").into()).unwrap();
82 /// names
83 /// .include_month_names(MonthNameLength::Wide)
84 /// .unwrap()
85 /// .include_year_names(YearNameLength::Wide)
86 /// .unwrap();
87 ///
88 /// // Create a pattern from a pattern string:
89 /// let pattern_str = "'The date is:' MMMM d, y GGGG";
90 /// let pattern: DateTimePattern = pattern_str.parse().unwrap();
91 ///
92 /// // Test it with some different dates:
93 /// // Note: extended year -50 is year 51 BCE
94 /// let date_bce = Date::try_new_gregorian(-50, 3, 15).unwrap();
95 /// let date_ce = Date::try_new_gregorian(1700, 11, 20).unwrap();
96 ///
97 /// let formatter = names.with_pattern_unchecked(&pattern);
98 ///
99 /// assert_eq!(
100 /// formatter.format(&date_bce).try_write_to_string().unwrap(),
101 /// "The date is: March 15, 51 Before Christ"
102 /// );
103 /// assert_eq!(
104 /// formatter.format(&date_ce).try_write_to_string().unwrap(),
105 /// "The date is: November 20, 1700 Anno Domini"
106 /// );
107 /// ```
108 ///
109 /// Format a time:
110 ///
111 /// ```
112 /// use icu::calendar::Gregorian;
113 /// use icu::datetime::fieldsets::enums::TimeFieldSet;
114 /// use icu::datetime::input::Time;
115 /// use icu::datetime::pattern::DateTimePattern;
116 /// use icu::datetime::pattern::DayPeriodNameLength;
117 /// use icu::datetime::pattern::FixedCalendarDateTimeNames;
118 /// use icu::locale::locale;
119 /// use writeable::TryWriteable;
120 ///
121 /// // Create an instance that can format abbreviated day periods:
122 /// let mut names: FixedCalendarDateTimeNames<Gregorian, TimeFieldSet> =
123 /// FixedCalendarDateTimeNames::try_new(locale!("en-US").into()).unwrap();
124 /// names
125 /// .include_day_period_names(DayPeriodNameLength::Abbreviated)
126 /// .unwrap();
127 ///
128 /// // Create a pattern from a pattern string:
129 /// let pattern_str = "'The time is:' h:mm b";
130 /// let pattern: DateTimePattern = pattern_str.parse().unwrap();
131 ///
132 /// // Test it with different times of day:
133 /// let time_am = Time::try_new(11, 4, 14, 0).unwrap();
134 /// let time_pm = Time::try_new(13, 41, 28, 0).unwrap();
135 /// let time_noon = Time::try_new(12, 0, 0, 0).unwrap();
136 /// let time_midnight = Time::try_new(0, 0, 0, 0).unwrap();
137 ///
138 /// let formatter = names.with_pattern_unchecked(&pattern);
139 ///
140 /// assert_eq!(
141 /// formatter.format(&time_am).try_write_to_string().unwrap(),
142 /// "The time is: 11:04 AM"
143 /// );
144 /// assert_eq!(
145 /// formatter.format(&time_pm).try_write_to_string().unwrap(),
146 /// "The time is: 1:41 PM"
147 /// );
148 /// assert_eq!(
149 /// formatter.format(&time_noon).try_write_to_string().unwrap(),
150 /// "The time is: 12:00 noon"
151 /// );
152 /// assert_eq!(
153 /// formatter
154 /// .format(&time_midnight)
155 /// .try_write_to_string()
156 /// .unwrap(),
157 /// "The time is: 12:00 midnight"
158 /// );
159 /// ```
160 ///
161 /// Format a time zone:
162 ///
163 /// ```
164 /// use icu::calendar::Gregorian;
165 /// use icu::datetime::fieldsets::enums::ZoneFieldSet;
166 /// use icu::datetime::input::ZonedDateTime;
167 /// use icu::datetime::pattern::DateTimePattern;
168 /// use icu::datetime::pattern::FixedCalendarDateTimeNames;
169 /// use icu::locale::locale;
170 /// use icu::time::zone::IanaParser;
171 /// use writeable::TryWriteable;
172 ///
173 /// let mut london_winter = ZonedDateTime::try_strict_from_str(
174 /// "2024-01-01T00:00:00+00:00[Europe/London]",
175 /// Gregorian,
176 /// IanaParser::new(),
177 /// )
178 /// .unwrap();
179 /// let mut london_summer = ZonedDateTime::try_strict_from_str(
180 /// "2024-07-01T00:00:00+01:00[Europe/London]",
181 /// Gregorian,
182 /// IanaParser::new(),
183 /// )
184 /// .unwrap();
185 ///
186 /// let mut names =
187 /// FixedCalendarDateTimeNames::<Gregorian, ZoneFieldSet>::try_new(
188 /// locale!("en-GB").into(),
189 /// )
190 /// .unwrap();
191 ///
192 /// names.include_time_zone_essentials().unwrap();
193 /// names.include_time_zone_specific_short_names().unwrap();
194 ///
195 /// // Create a pattern with symbol `z`:
196 /// let pattern_str = "'Your time zone is:' z";
197 /// let pattern: DateTimePattern = pattern_str.parse().unwrap();
198 ///
199 /// let formatter = names.with_pattern_unchecked(&pattern);
200 ///
201 /// assert_eq!(
202 /// formatter
203 /// .format(&london_winter)
204 /// .try_write_to_string()
205 /// .unwrap(),
206 /// "Your time zone is: GMT",
207 /// );
208 /// assert_eq!(
209 /// formatter
210 /// .format(&london_summer)
211 /// .try_write_to_string()
212 /// .unwrap(),
213 /// "Your time zone is: BST",
214 /// );
215 /// ```
216 pub fn format<I>(&self, datetime: &I) -> FormattedDateTimePattern<'a>
217 where
218 I: ?Sized + InFixedCalendar<C> + AllInputMarkers<FSet>,
219 {
220 FormattedDateTimePattern {
221 pattern: self.inner.pattern,
222 input: DateTimeInputUnchecked::extract_from_neo_input::<FSet::D, FSet::T, FSet::Z, I>(
223 datetime,
224 ),
225 names: self.inner.names,
226 }
227 }
228}
229
230/// A pattern that has been interpolated and implements [`TryWriteable`].
231#[derive(Debug)]
232pub struct FormattedDateTimePattern<'a> {
233 pattern: DateTimePatternBorrowed<'a>,
234 input: DateTimeInputUnchecked,
235 names: RawDateTimeNamesBorrowed<'a>,
236}
237
238impl TryWriteable for FormattedDateTimePattern<'_> {
239 type Error = FormattedDateTimePatternError;
240 fn try_write_to_parts<S: writeable::PartsWrite + ?Sized>(
241 &self,
242 sink: &mut S,
243 ) -> Result<Result<(), Self::Error>, fmt::Error> {
244 try_write_pattern_items(
245 self.pattern.0.as_borrowed().metadata,
246 self.pattern.0.as_borrowed().items.iter(),
247 &self.input,
248 &self.names,
249 self.names.decimal_formatter,
250 sink,
251 )
252 }
253
254 // TODO(#489): Implement writeable_length_hint
255}
256
257#[cfg(test)]
258#[cfg(feature = "compiled_data")]
259mod tests {
260 use super::super::*;
261 use icu_calendar::{Date, Gregorian};
262 use icu_locale_core::locale;
263 use icu_time::{DateTime, Time};
264 use writeable::assert_try_writeable_eq;
265
266 #[test]
267 fn test_basic_pattern_formatting() {
268 let locale = locale!("en").into();
269 let mut names: FixedCalendarDateTimeNames<Gregorian> =
270 FixedCalendarDateTimeNames::try_new(locale).unwrap();
271 names
272 .load_month_names(&crate::provider::Baked, MonthNameLength::Wide)
273 .unwrap()
274 .load_weekday_names(&crate::provider::Baked, WeekdayNameLength::Abbreviated)
275 .unwrap()
276 .load_year_names(&crate::provider::Baked, YearNameLength::Narrow)
277 .unwrap()
278 .load_day_period_names(&crate::provider::Baked, DayPeriodNameLength::Abbreviated)
279 .unwrap();
280 let pattern: DateTimePattern = "'It is' E, MMMM d, y GGGGG 'at' hh:mm a'!'"
281 .parse()
282 .unwrap();
283 let datetime = DateTime {
284 date: Date::try_new_gregorian(2023, 10, 25).unwrap(),
285 time: Time::try_new(15, 0, 55, 0).unwrap(),
286 };
287 let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
288
289 assert_try_writeable_eq!(
290 formatted_pattern,
291 "It is Wed, October 25, 2023 A at 03:00 PM!",
292 Ok(()),
293 );
294 }
295
296 #[test]
297 fn test_era_coverage() {
298 let locale = locale!("uk").into();
299 #[derive(Debug)]
300 struct TestCase {
301 pattern: &'static str,
302 length: YearNameLength,
303 expected: &'static str,
304 }
305 let cases = [
306 TestCase {
307 pattern: "<G>",
308 length: YearNameLength::Abbreviated,
309 expected: "<н. е.>",
310 },
311 TestCase {
312 pattern: "<GG>",
313 length: YearNameLength::Abbreviated,
314 expected: "<н. е.>",
315 },
316 TestCase {
317 pattern: "<GGG>",
318 length: YearNameLength::Abbreviated,
319 expected: "<н. е.>",
320 },
321 TestCase {
322 pattern: "<GGGG>",
323 length: YearNameLength::Wide,
324 expected: "<нашої ери>",
325 },
326 TestCase {
327 pattern: "<GGGGG>",
328 length: YearNameLength::Narrow,
329 expected: "<н.е.>",
330 },
331 ];
332 for cas in cases {
333 let TestCase {
334 pattern,
335 length,
336 expected,
337 } = cas;
338 let mut names: FixedCalendarDateTimeNames<Gregorian> =
339 FixedCalendarDateTimeNames::try_new(locale).unwrap();
340 names
341 .load_year_names(&crate::provider::Baked, length)
342 .unwrap();
343 let pattern: DateTimePattern = pattern.parse().unwrap();
344 let datetime = DateTime {
345 date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
346 time: Time::try_new(13, 41, 28, 0).unwrap(),
347 };
348 let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
349
350 assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
351 }
352 }
353
354 #[test]
355 fn test_month_coverage() {
356 // Ukrainian has different values for format and standalone
357 let locale = locale!("uk").into();
358 #[derive(Debug)]
359 struct TestCase {
360 pattern: &'static str,
361 length: MonthNameLength,
362 expected: &'static str,
363 }
364 let cases = [
365 // 'M' and 'MM' are numeric
366 TestCase {
367 pattern: "<MMM>",
368 length: MonthNameLength::Abbreviated,
369 expected: "<лист.>",
370 },
371 TestCase {
372 pattern: "<MMMM>",
373 length: MonthNameLength::Wide,
374 expected: "<листопада>",
375 },
376 TestCase {
377 pattern: "<MMMMM>",
378 length: MonthNameLength::Narrow,
379 expected: "<л>",
380 },
381 // 'L' and 'LL' are numeric
382 TestCase {
383 pattern: "<LLL>",
384 length: MonthNameLength::StandaloneAbbreviated,
385 expected: "<лист.>",
386 },
387 TestCase {
388 pattern: "<LLLL>",
389 length: MonthNameLength::StandaloneWide,
390 expected: "<листопад>",
391 },
392 TestCase {
393 pattern: "<LLLLL>",
394 length: MonthNameLength::StandaloneNarrow,
395 expected: "<Л>",
396 },
397 ];
398 for cas in cases {
399 let TestCase {
400 pattern,
401 length,
402 expected,
403 } = cas;
404 let mut names: FixedCalendarDateTimeNames<Gregorian> =
405 FixedCalendarDateTimeNames::try_new(locale).unwrap();
406 names
407 .load_month_names(&crate::provider::Baked, length)
408 .unwrap();
409 let pattern: DateTimePattern = pattern.parse().unwrap();
410 let datetime = DateTime {
411 date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
412 time: Time::try_new(13, 41, 28, 0).unwrap(),
413 };
414 let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
415
416 assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
417 }
418 }
419
420 #[test]
421 fn test_weekday_coverage() {
422 let locale = locale!("uk").into();
423 #[derive(Debug)]
424 struct TestCase {
425 pattern: &'static str,
426 length: WeekdayNameLength,
427 expected: &'static str,
428 }
429 let cases = [
430 TestCase {
431 pattern: "<E>",
432 length: WeekdayNameLength::Abbreviated,
433 expected: "<пт>",
434 },
435 TestCase {
436 pattern: "<EE>",
437 length: WeekdayNameLength::Abbreviated,
438 expected: "<пт>",
439 },
440 TestCase {
441 pattern: "<EEE>",
442 length: WeekdayNameLength::Abbreviated,
443 expected: "<пт>",
444 },
445 TestCase {
446 pattern: "<EEEE>",
447 length: WeekdayNameLength::Wide,
448 expected: "<пʼятниця>",
449 },
450 TestCase {
451 pattern: "<EEEEE>",
452 length: WeekdayNameLength::Narrow,
453 expected: "<П>",
454 },
455 TestCase {
456 pattern: "<EEEEEE>",
457 length: WeekdayNameLength::Short,
458 expected: "<пт>",
459 },
460 // 'e' and 'ee' are numeric
461 TestCase {
462 pattern: "<eee>",
463 length: WeekdayNameLength::Abbreviated,
464 expected: "<пт>",
465 },
466 TestCase {
467 pattern: "<eeee>",
468 length: WeekdayNameLength::Wide,
469 expected: "<пʼятниця>",
470 },
471 TestCase {
472 pattern: "<eeeee>",
473 length: WeekdayNameLength::Narrow,
474 expected: "<П>",
475 },
476 TestCase {
477 pattern: "<eeeeee>",
478 length: WeekdayNameLength::Short,
479 expected: "<пт>",
480 },
481 // 'c' and 'cc' are numeric
482 TestCase {
483 pattern: "<ccc>",
484 length: WeekdayNameLength::StandaloneAbbreviated,
485 expected: "<пт>",
486 },
487 TestCase {
488 pattern: "<cccc>",
489 length: WeekdayNameLength::StandaloneWide,
490 expected: "<пʼятниця>",
491 },
492 TestCase {
493 pattern: "<ccccc>",
494 length: WeekdayNameLength::StandaloneNarrow,
495 expected: "<П>",
496 },
497 TestCase {
498 pattern: "<cccccc>",
499 length: WeekdayNameLength::StandaloneShort,
500 expected: "<пт>",
501 },
502 ];
503 for cas in cases {
504 let TestCase {
505 pattern,
506 length,
507 expected,
508 } = cas;
509 let mut names: FixedCalendarDateTimeNames<Gregorian> =
510 FixedCalendarDateTimeNames::try_new(locale).unwrap();
511 names
512 .load_weekday_names(&crate::provider::Baked, length)
513 .unwrap();
514 let pattern: DateTimePattern = pattern.parse().unwrap();
515 let datetime = DateTime {
516 date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
517 time: Time::try_new(13, 41, 28, 0).unwrap(),
518 };
519 let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
520
521 assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
522 }
523 }
524
525 #[test]
526 fn test_dayperiod_coverage() {
527 // Thai has different values for different lengths of day periods
528 // TODO(#487): Support flexible day periods, too
529 let locale = locale!("th").into();
530 #[derive(Debug)]
531 struct TestCase {
532 pattern: &'static str,
533 length: DayPeriodNameLength,
534 expected: &'static str,
535 }
536 let cases = [
537 TestCase {
538 pattern: "<a>",
539 length: DayPeriodNameLength::Abbreviated,
540 expected: "<PM>",
541 },
542 TestCase {
543 pattern: "<aa>",
544 length: DayPeriodNameLength::Abbreviated,
545 expected: "<PM>",
546 },
547 TestCase {
548 pattern: "<aaa>",
549 length: DayPeriodNameLength::Abbreviated,
550 expected: "<PM>",
551 },
552 TestCase {
553 pattern: "<aaaa>",
554 length: DayPeriodNameLength::Wide,
555 expected: "<หลังเที่ยง>",
556 },
557 TestCase {
558 pattern: "<aaaaa>",
559 length: DayPeriodNameLength::Narrow,
560 expected: "<p>",
561 },
562 TestCase {
563 pattern: "<b>",
564 length: DayPeriodNameLength::Abbreviated,
565 expected: "<PM>",
566 },
567 TestCase {
568 pattern: "<bb>",
569 length: DayPeriodNameLength::Abbreviated,
570 expected: "<PM>",
571 },
572 TestCase {
573 pattern: "<bbb>",
574 length: DayPeriodNameLength::Abbreviated,
575 expected: "<PM>",
576 },
577 TestCase {
578 pattern: "<bbbb>",
579 length: DayPeriodNameLength::Wide,
580 expected: "<หลังเที่ยง>",
581 },
582 TestCase {
583 pattern: "<bbbbb>",
584 length: DayPeriodNameLength::Narrow,
585 expected: "<p>",
586 },
587 ];
588 for cas in cases {
589 let TestCase {
590 pattern,
591 length,
592 expected,
593 } = cas;
594 let mut names: FixedCalendarDateTimeNames<Gregorian> =
595 FixedCalendarDateTimeNames::try_new(locale).unwrap();
596 names
597 .load_day_period_names(&crate::provider::Baked, length)
598 .unwrap();
599 let pattern: DateTimePattern = pattern.parse().unwrap();
600 let datetime = DateTime {
601 date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
602 time: Time::try_new(13, 41, 28, 0).unwrap(),
603 };
604 let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
605
606 assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
607 }
608 }
609}