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}