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, VariantOffsetsCalculator};
171    /// use writeable::TryWriteable;
172    ///
173    /// let mut london_winter = ZonedDateTime::try_full_from_str(
174    ///     "2024-01-01T00:00:00+00:00[Europe/London]",
175    ///     Gregorian,
176    ///     IanaParser::new(),
177    ///     VariantOffsetsCalculator::new(),
178    /// )
179    /// .unwrap();
180    /// let mut london_summer = ZonedDateTime::try_full_from_str(
181    ///     "2024-07-01T00:00:00+01:00[Europe/London]",
182    ///     Gregorian,
183    ///     IanaParser::new(),
184    ///     VariantOffsetsCalculator::new(),
185    /// )
186    /// .unwrap();
187    ///
188    /// let mut names =
189    ///     FixedCalendarDateTimeNames::<Gregorian, ZoneFieldSet>::try_new(
190    ///         locale!("en-GB").into(),
191    ///     )
192    ///     .unwrap();
193    ///
194    /// names.include_time_zone_essentials().unwrap();
195    /// names.include_time_zone_specific_short_names().unwrap();
196    ///
197    /// // Create a pattern with symbol `z`:
198    /// let pattern_str = "'Your time zone is:' z";
199    /// let pattern: DateTimePattern = pattern_str.parse().unwrap();
200    ///
201    /// let formatter = names.with_pattern_unchecked(&pattern);
202    ///
203    /// assert_eq!(
204    ///     formatter
205    ///         .format(&london_winter)
206    ///         .try_write_to_string()
207    ///         .unwrap(),
208    ///     "Your time zone is: GMT",
209    /// );
210    /// assert_eq!(
211    ///     formatter
212    ///         .format(&london_summer)
213    ///         .try_write_to_string()
214    ///         .unwrap(),
215    ///     "Your time zone is: BST",
216    /// );
217    /// ```
218    pub fn format<I>(&self, datetime: &I) -> FormattedDateTimePattern<'a>
219    where
220        I: ?Sized + InFixedCalendar<C> + AllInputMarkers<FSet>,
221    {
222        FormattedDateTimePattern {
223            pattern: self.inner.pattern,
224            input: DateTimeInputUnchecked::extract_from_neo_input::<FSet::D, FSet::T, FSet::Z, I>(
225                datetime,
226            ),
227            names: self.inner.names,
228        }
229    }
230}
231
232/// A pattern that has been interpolated and implements [`TryWriteable`].
233#[derive(Debug)]
234pub struct FormattedDateTimePattern<'a> {
235    pattern: DateTimePatternBorrowed<'a>,
236    input: DateTimeInputUnchecked,
237    names: RawDateTimeNamesBorrowed<'a>,
238}
239
240impl TryWriteable for FormattedDateTimePattern<'_> {
241    type Error = FormattedDateTimePatternError;
242    fn try_write_to_parts<S: writeable::PartsWrite + ?Sized>(
243        &self,
244        sink: &mut S,
245    ) -> Result<Result<(), Self::Error>, fmt::Error> {
246        try_write_pattern_items(
247            self.pattern.0.as_borrowed().metadata,
248            self.pattern.0.as_borrowed().items.iter(),
249            &self.input,
250            &self.names,
251            self.names.decimal_formatter,
252            sink,
253        )
254    }
255
256    // TODO(#489): Implement writeable_length_hint
257}
258
259#[cfg(test)]
260#[cfg(feature = "compiled_data")]
261mod tests {
262    use super::super::*;
263    use icu_calendar::{Date, Gregorian};
264    use icu_locale_core::locale;
265    use icu_time::{DateTime, Time};
266    use writeable::assert_try_writeable_eq;
267
268    #[test]
269    fn test_basic_pattern_formatting() {
270        let locale = locale!("en").into();
271        let mut names: FixedCalendarDateTimeNames<Gregorian> =
272            FixedCalendarDateTimeNames::try_new(locale).unwrap();
273        names
274            .load_month_names(&crate::provider::Baked, MonthNameLength::Wide)
275            .unwrap()
276            .load_weekday_names(&crate::provider::Baked, WeekdayNameLength::Abbreviated)
277            .unwrap()
278            .load_year_names(&crate::provider::Baked, YearNameLength::Narrow)
279            .unwrap()
280            .load_day_period_names(&crate::provider::Baked, DayPeriodNameLength::Abbreviated)
281            .unwrap();
282        let pattern: DateTimePattern = "'It is' E, MMMM d, y GGGGG 'at' hh:mm a'!'"
283            .parse()
284            .unwrap();
285        let datetime = DateTime {
286            date: Date::try_new_gregorian(2023, 10, 25).unwrap(),
287            time: Time::try_new(15, 0, 55, 0).unwrap(),
288        };
289        let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
290
291        assert_try_writeable_eq!(
292            formatted_pattern,
293            "It is Wed, October 25, 2023 A at 03:00 PM!",
294            Ok(()),
295        );
296    }
297
298    #[test]
299    fn test_era_coverage() {
300        let locale = locale!("uk").into();
301        #[derive(Debug)]
302        struct TestCase {
303            pattern: &'static str,
304            length: YearNameLength,
305            expected: &'static str,
306        }
307        let cases = [
308            TestCase {
309                pattern: "<G>",
310                length: YearNameLength::Abbreviated,
311                expected: "<н. е.>",
312            },
313            TestCase {
314                pattern: "<GG>",
315                length: YearNameLength::Abbreviated,
316                expected: "<н. е.>",
317            },
318            TestCase {
319                pattern: "<GGG>",
320                length: YearNameLength::Abbreviated,
321                expected: "<н. е.>",
322            },
323            TestCase {
324                pattern: "<GGGG>",
325                length: YearNameLength::Wide,
326                expected: "<нашої ери>",
327            },
328            TestCase {
329                pattern: "<GGGGG>",
330                length: YearNameLength::Narrow,
331                expected: "<н.е.>",
332            },
333        ];
334        for cas in cases {
335            let TestCase {
336                pattern,
337                length,
338                expected,
339            } = cas;
340            let mut names: FixedCalendarDateTimeNames<Gregorian> =
341                FixedCalendarDateTimeNames::try_new(locale).unwrap();
342            names
343                .load_year_names(&crate::provider::Baked, length)
344                .unwrap();
345            let pattern: DateTimePattern = pattern.parse().unwrap();
346            let datetime = DateTime {
347                date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
348                time: Time::try_new(13, 41, 28, 0).unwrap(),
349            };
350            let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
351
352            assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
353        }
354    }
355
356    #[test]
357    fn test_month_coverage() {
358        // Ukrainian has different values for format and standalone
359        let locale = locale!("uk").into();
360        #[derive(Debug)]
361        struct TestCase {
362            pattern: &'static str,
363            length: MonthNameLength,
364            expected: &'static str,
365        }
366        let cases = [
367            // 'M' and 'MM' are numeric
368            TestCase {
369                pattern: "<MMM>",
370                length: MonthNameLength::Abbreviated,
371                expected: "<лист.>",
372            },
373            TestCase {
374                pattern: "<MMMM>",
375                length: MonthNameLength::Wide,
376                expected: "<листопада>",
377            },
378            TestCase {
379                pattern: "<MMMMM>",
380                length: MonthNameLength::Narrow,
381                expected: "<л>",
382            },
383            // 'L' and 'LL' are numeric
384            TestCase {
385                pattern: "<LLL>",
386                length: MonthNameLength::StandaloneAbbreviated,
387                expected: "<лист.>",
388            },
389            TestCase {
390                pattern: "<LLLL>",
391                length: MonthNameLength::StandaloneWide,
392                expected: "<листопад>",
393            },
394            TestCase {
395                pattern: "<LLLLL>",
396                length: MonthNameLength::StandaloneNarrow,
397                expected: "<Л>",
398            },
399        ];
400        for cas in cases {
401            let TestCase {
402                pattern,
403                length,
404                expected,
405            } = cas;
406            let mut names: FixedCalendarDateTimeNames<Gregorian> =
407                FixedCalendarDateTimeNames::try_new(locale).unwrap();
408            names
409                .load_month_names(&crate::provider::Baked, length)
410                .unwrap();
411            let pattern: DateTimePattern = pattern.parse().unwrap();
412            let datetime = DateTime {
413                date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
414                time: Time::try_new(13, 41, 28, 0).unwrap(),
415            };
416            let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
417
418            assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
419        }
420    }
421
422    #[test]
423    fn test_weekday_coverage() {
424        let locale = locale!("uk").into();
425        #[derive(Debug)]
426        struct TestCase {
427            pattern: &'static str,
428            length: WeekdayNameLength,
429            expected: &'static str,
430        }
431        let cases = [
432            TestCase {
433                pattern: "<E>",
434                length: WeekdayNameLength::Abbreviated,
435                expected: "<пт>",
436            },
437            TestCase {
438                pattern: "<EE>",
439                length: WeekdayNameLength::Abbreviated,
440                expected: "<пт>",
441            },
442            TestCase {
443                pattern: "<EEE>",
444                length: WeekdayNameLength::Abbreviated,
445                expected: "<пт>",
446            },
447            TestCase {
448                pattern: "<EEEE>",
449                length: WeekdayNameLength::Wide,
450                expected: "<пʼятниця>",
451            },
452            TestCase {
453                pattern: "<EEEEE>",
454                length: WeekdayNameLength::Narrow,
455                expected: "<П>",
456            },
457            TestCase {
458                pattern: "<EEEEEE>",
459                length: WeekdayNameLength::Short,
460                expected: "<пт>",
461            },
462            // 'e' and 'ee' are numeric
463            TestCase {
464                pattern: "<eee>",
465                length: WeekdayNameLength::Abbreviated,
466                expected: "<пт>",
467            },
468            TestCase {
469                pattern: "<eeee>",
470                length: WeekdayNameLength::Wide,
471                expected: "<пʼятниця>",
472            },
473            TestCase {
474                pattern: "<eeeee>",
475                length: WeekdayNameLength::Narrow,
476                expected: "<П>",
477            },
478            TestCase {
479                pattern: "<eeeeee>",
480                length: WeekdayNameLength::Short,
481                expected: "<пт>",
482            },
483            // 'c' and 'cc' are numeric
484            TestCase {
485                pattern: "<ccc>",
486                length: WeekdayNameLength::StandaloneAbbreviated,
487                expected: "<пт>",
488            },
489            TestCase {
490                pattern: "<cccc>",
491                length: WeekdayNameLength::StandaloneWide,
492                expected: "<пʼятниця>",
493            },
494            TestCase {
495                pattern: "<ccccc>",
496                length: WeekdayNameLength::StandaloneNarrow,
497                expected: "<П>",
498            },
499            TestCase {
500                pattern: "<cccccc>",
501                length: WeekdayNameLength::StandaloneShort,
502                expected: "<пт>",
503            },
504        ];
505        for cas in cases {
506            let TestCase {
507                pattern,
508                length,
509                expected,
510            } = cas;
511            let mut names: FixedCalendarDateTimeNames<Gregorian> =
512                FixedCalendarDateTimeNames::try_new(locale).unwrap();
513            names
514                .load_weekday_names(&crate::provider::Baked, length)
515                .unwrap();
516            let pattern: DateTimePattern = pattern.parse().unwrap();
517            let datetime = DateTime {
518                date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
519                time: Time::try_new(13, 41, 28, 0).unwrap(),
520            };
521            let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
522
523            assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
524        }
525    }
526
527    #[test]
528    fn test_dayperiod_coverage() {
529        // Thai has different values for different lengths of day periods
530        // TODO(#487): Support flexible day periods, too
531        let locale = locale!("th").into();
532        #[derive(Debug)]
533        struct TestCase {
534            pattern: &'static str,
535            length: DayPeriodNameLength,
536            expected: &'static str,
537        }
538        let cases = [
539            TestCase {
540                pattern: "<a>",
541                length: DayPeriodNameLength::Abbreviated,
542                expected: "<PM>",
543            },
544            TestCase {
545                pattern: "<aa>",
546                length: DayPeriodNameLength::Abbreviated,
547                expected: "<PM>",
548            },
549            TestCase {
550                pattern: "<aaa>",
551                length: DayPeriodNameLength::Abbreviated,
552                expected: "<PM>",
553            },
554            TestCase {
555                pattern: "<aaaa>",
556                length: DayPeriodNameLength::Wide,
557                expected: "<หลังเที่ยง>",
558            },
559            TestCase {
560                pattern: "<aaaaa>",
561                length: DayPeriodNameLength::Narrow,
562                expected: "<p>",
563            },
564            TestCase {
565                pattern: "<b>",
566                length: DayPeriodNameLength::Abbreviated,
567                expected: "<PM>",
568            },
569            TestCase {
570                pattern: "<bb>",
571                length: DayPeriodNameLength::Abbreviated,
572                expected: "<PM>",
573            },
574            TestCase {
575                pattern: "<bbb>",
576                length: DayPeriodNameLength::Abbreviated,
577                expected: "<PM>",
578            },
579            TestCase {
580                pattern: "<bbbb>",
581                length: DayPeriodNameLength::Wide,
582                expected: "<หลังเที่ยง>",
583            },
584            TestCase {
585                pattern: "<bbbbb>",
586                length: DayPeriodNameLength::Narrow,
587                expected: "<p>",
588            },
589        ];
590        for cas in cases {
591            let TestCase {
592                pattern,
593                length,
594                expected,
595            } = cas;
596            let mut names: FixedCalendarDateTimeNames<Gregorian> =
597                FixedCalendarDateTimeNames::try_new(locale).unwrap();
598            names
599                .load_day_period_names(&crate::provider::Baked, length)
600                .unwrap();
601            let pattern: DateTimePattern = pattern.parse().unwrap();
602            let datetime = DateTime {
603                date: Date::try_new_gregorian(2023, 11, 17).unwrap(),
604                time: Time::try_new(13, 41, 28, 0).unwrap(),
605            };
606            let formatted_pattern = names.with_pattern_unchecked(&pattern).format(&datetime);
607
608            assert_try_writeable_eq!(formatted_pattern, expected, Ok(()), "{cas:?}");
609        }
610    }
611}