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}