Skip to main content

fluent_datetime/
lib.rs

1//! # International datetimes in Fluent translations
2//!
3//! fluent-datetime uses [ICU4X], in particular [`icu_datetime`] and
4//! [`icu_calendar`], to format datetimes internationally within
5//! a [Fluent] translation.
6//!
7//! [Fluent]: https://projectfluent.org/
8//! [ICU4X]: https://github.com/unicode-org/icu4x
9//!
10//! # Example
11//!
12//! This example uses [`fluent_bundle`] directly.
13//!
14//! You may prefer to use less verbose integrations; in which case the
15//! [`bundle.add_datetime_support()`](BundleExt::add_datetime_support)
16//! line is the only one you need.
17//!
18//! ```rust
19//! use fluent::fluent_args;
20//! use fluent_bundle::{FluentBundle, FluentResource};
21//! use fluent_datetime::{BundleExt, FluentDateTime};
22//! use fluent_datetime::length;
23//! use icu_calendar::Iso;
24//! use icu_time::DateTime;
25//! use unic_langid::LanguageIdentifier;
26//!
27//! // Create a FluentBundle
28//! let langid_en: LanguageIdentifier = "en-US".parse()?;
29//! let mut bundle = FluentBundle::new(vec![langid_en]);
30//!
31//! // Register the DATETIME function
32//! bundle.add_datetime_support();
33//!
34//! // Add a FluentResource to the bundle
35//! let ftl_string = r#"
36//! today-is = Today is {$date}
37//! today-is-fulldate = Today is {DATETIME($date, dateStyle: "full")}
38//! now-is-time = Now is {DATETIME($date, timeStyle: "medium")}
39//! now-is-longtime = Now is {DATETIME($date, timeStyle: "long")}
40//! now-is-datetime = Now is {DATETIME($date, dateStyle: "full", timeStyle: "short")}
41//! "#
42//! .to_string();
43//!
44//! let res = FluentResource::try_new(ftl_string)
45//!     .expect("Failed to parse an FTL string.");
46//! bundle
47//!     .add_resource(res)
48//!     .expect("Failed to add FTL resources to the bundle.");
49//!
50//! // Create an ICU DateTime
51//! let datetime = DateTime::try_from_str("1989-11-09 23:30", Iso)
52//!     .expect("Failed to create ICU DateTime");
53//!
54//! // Convert to FluentDateTime
55//! let mut datetime = FluentDateTime::from(datetime);
56//!
57//! // Format some messages with date arguments
58//! let mut errors = vec![];
59//!
60//! assert_eq!(
61//!     bundle.format_pattern(
62//!         &bundle.get_message("today-is").unwrap().value().unwrap(),
63//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
64//!     "Today is \u{2068}11/9/89\u{2069}"
65//! );
66//!
67//! assert_eq!(
68//!     bundle.format_pattern(
69//!         &bundle.get_message("today-is-fulldate").unwrap().value().unwrap(),
70//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
71//!     "Today is \u{2068}Thursday, November 9, 1989\u{2069}"
72//! );
73//!
74//! assert_eq!(
75//!     bundle.format_pattern(
76//!         &bundle.get_message("now-is-time").unwrap().value().unwrap(),
77//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
78//!     "Now is \u{2068}11:30:00\u{202f}PM\u{2069}"
79//! );
80//!
81//! assert!(
82//!     bundle.format_pattern(
83//!         &bundle.get_message("now-is-longtime").unwrap().value().unwrap(),
84//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors).starts_with(
85//!             "Now is \u{2068}11:30:00\u{202f}PM ")
86//! );
87//!
88//! assert_eq!(
89//!     bundle.format_pattern(
90//!         &bundle.get_message("now-is-datetime").unwrap().value().unwrap(),
91//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
92//!     "Now is \u{2068}Thursday, November 9, 1989 at 11:30\u{202f}PM\u{2069}"
93//! );
94//!
95//! // Set FluentDateTime.options in code rather than in translation data
96//! // This is useful because it sets presentation options that are
97//! // shared between all locales
98//! datetime.options.set_date_style(Some(length::Date::Full));
99//! assert_eq!(
100//!     bundle.format_pattern(
101//!         &bundle.get_message("today-is").unwrap().value().unwrap(),
102//!         Some(&fluent_args!("date" => datetime)), &mut errors),
103//!     "Today is \u{2068}Thursday, November 9, 1989\u{2069}"
104//! );
105//!
106//! assert!(errors.is_empty());
107//!
108//! # // I would like to use the ? operator, but Fluent and ICU error types don't implement the std Error trait…
109//! # Ok::<(), Box<dyn std::error::Error>>(())
110//! ```
111#![forbid(unsafe_code)]
112#![warn(missing_docs)]
113use std::borrow::Cow;
114use std::mem::discriminant;
115use std::sync::LazyLock;
116
117use fluent_bundle::bundle::FluentBundle;
118use fluent_bundle::types::FluentType;
119use fluent_bundle::{FluentArgs, FluentError, FluentValue};
120
121use icu_calendar::{Gregorian, Iso};
122use icu_datetime::fieldsets;
123use icu_time::{DateTime, ZonedDateTime};
124
125pub mod length;
126
127fn val_as_str<'a>(val: &'a FluentValue) -> Option<&'a str> {
128    if let FluentValue::String(str) = val {
129        Some(str)
130    } else {
131        None
132    }
133}
134
135/// Options for formatting a DateTime
136#[derive(Debug, Clone, PartialEq)]
137pub struct FluentDateTimeOptions {
138    // See AnyCalendarKind::new if we want to expose explicit calendar choice
139    //calendar: Option<icu_calendar::AnyCalendarKind>,
140    // We don't handle icu_datetime per-component settings atm, it is experimental
141    // and length is expressive enough so far
142    length: length::Bag,
143}
144
145impl Default for FluentDateTimeOptions {
146    /// Defaults to showing a short date
147    ///
148    /// The intent is to emulate [Intl.DateTimeFormat] behavior:
149    /// > The default value for each date-time component option is undefined,
150    /// > but if all component properties are undefined, then year, month, and day default
151    /// > to "numeric". If any of the date-time component options is specified, then
152    /// > dateStyle and timeStyle must be undefined.
153    ///
154    /// In terms of the current Rust implementation:
155    ///
156    /// The default value for each date-time style option is None, but if both
157    /// are unset, we display the date only, using the `length::Date::Short`
158    /// style.
159    ///
160    /// [Intl.DateTimeFormat]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
161    fn default() -> Self {
162        Self {
163            length: length::Bag::empty(),
164        }
165    }
166}
167
168impl FluentDateTimeOptions {
169    /// Set a date style, from verbose to compact
170    ///
171    /// See [`length::Date`].
172    pub fn set_date_style(&mut self, style: Option<length::Date>) {
173        self.length.date = style;
174    }
175
176    /// Set a time style, from verbose to compact
177    ///
178    /// See [`length::Time`].
179    pub fn set_time_style(&mut self, style: Option<length::Time>) {
180        self.length.time = style;
181    }
182
183    fn make_formatter(
184        &self,
185        langid: icu_locale_core::LanguageIdentifier,
186    ) -> Result<DateTimeFormatter, icu_datetime::DateTimeFormatterLoadError> {
187        let fsb = self.length.to_fieldset_builder();
188        let formatter_prefs = langid.into();
189        // build_().unwrap(): If we set any incompatible options, it's a bug
190        Ok(if fsb.zone_style.is_some() {
191            DateTimeFormatter::WithZone(icu_datetime::DateTimeFormatter::try_new(
192                formatter_prefs,
193                fsb.build_composite().unwrap(),
194            )?)
195        } else {
196            DateTimeFormatter::NoZone(icu_datetime::DateTimeFormatter::try_new(
197                formatter_prefs,
198                fsb.build_composite_datetime().unwrap(),
199            )?)
200        })
201    }
202
203    fn merge_args(&mut self, other: &FluentArgs) -> Result<(), ()> {
204        // TODO set an err state on self to match fluent-js behaviour
205        for (k, v) in other.iter() {
206            match k {
207                "dateStyle" => {
208                    self.length.date = Some(match val_as_str(v).ok_or(())? {
209                        "full" => length::Date::Full,
210                        "long" => length::Date::Long,
211                        "medium" => length::Date::Medium,
212                        "short" => length::Date::Short,
213                        _ => return Err(()),
214                    });
215                }
216                "timeStyle" => {
217                    self.length.time = Some(match val_as_str(v).ok_or(())? {
218                        "full" => length::Time::Full,
219                        "long" => length::Time::Long,
220                        "medium" => length::Time::Medium,
221                        "short" => length::Time::Short,
222                        _ => return Err(()),
223                    });
224                }
225                _ => (), // Ignore with no warning
226            }
227        }
228        Ok(())
229    }
230}
231
232impl std::hash::Hash for FluentDateTimeOptions {
233    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
234        // We could also use serde… or send a simple PR to have derive(Hash) upstream
235        //self.calendar.hash(state);
236        self.length.date.map(|e| discriminant(&e)).hash(state);
237        self.length.time.map(|e| discriminant(&e)).hash(state);
238    }
239}
240
241impl Eq for FluentDateTimeOptions {}
242
243/// An ICU [`DateTime`](icu_time::DateTime) with attached formatting options
244///
245/// Construct from an [`icu_time::DateTime`] using From / Into.
246///
247/// Convert to a [`FluentValue`] with From / Into.
248///
249/// See [`FluentDateTimeOptions`] and [`FluentDateTimeOptions::default`].
250///
251///```
252/// use icu_time::DateTime;
253/// use icu_calendar::Iso;
254/// use fluent_datetime::FluentDateTime;
255///
256/// let datetime = DateTime::try_from_str("1989-11-09 23:30", Iso)
257///     .expect("Failed to create ICU DateTime");
258///
259/// let datetime = FluentDateTime::from(datetime);
260// ```
261#[derive(Debug, Clone, PartialEq)]
262pub struct FluentDateTime {
263    // Iso seemed like a natural default, but [AnyCalendarKind::new]
264    // loads Gregorian in almost all cases.  Differences have to do with eras:
265    // proleptic Gregorian has BCE / CE and no year zero, Iso has just one continuous era,
266    // containing year zero (astronomical year numbering)
267    // OTOH, DateTime<Gregorian> does not implement PartialEq and with Iso it does
268
269    // long/full timeStyles will use zone info, forcing us into a ZonedDateTime
270    // On the other hand, [DateTimeFormat.format] explicitly rejects Temporal.ZonedDateTime
271    // https://github.com/tc39/proposal-temporal/blob/514c656854e5ceab4932cfc23ace0f84ca1f6431/meetings/agenda-minutes-2023-03-16.md#zoneddatetime-in-intldatetimeformatformat-2479
272    //
273    // JS Date doesn't carry a time zone, and formatting is implicitly done in
274    // the local time zone.
275    // Temporal.Now.timeZoneId()
276    // new Intl.DateTimeFormat().resolvedOptions().timeZone
277    //
278    // https://tc39.es/ecma402/#sec-createdatetimeformat
279    // Which initializes an internal TimeZone field
280    //
281    // So now we need a dependency on the system time zone.
282    // jiff rolls its own (and is lighter on dependencies and build time;
283    // see windows-sys vs windows-core).  It also handles the TZ env var.
284    // Most other crates (chrono, temporal_rs) depend on iana-time-zone
285    // to figure out the system time zone.
286    //
287    // TZ=Europe/Paris deno eval --unstable-temporal --print 'Temporal.Now.timeZoneId()'
288    // TZ=Europe/Berlin deno eval --print 'new Intl.DateTimeFormat("en-US", {dateStyle: "long", timeStyle: "long"}).format(new Date(1989, 11, 9))'
289    //
290    // DateTimeFormat.format: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
291    value: DateTime<Iso>,
292    /// Options for rendering
293    pub options: FluentDateTimeOptions,
294}
295
296impl FluentType for FluentDateTime {
297    fn duplicate(&self) -> Box<dyn FluentType + Send> {
298        // Basically Clone
299        Box::new(self.clone())
300    }
301
302    fn as_string(&self, intls: &intl_memoizer::IntlLangMemoizer) -> Cow<'static, str> {
303        intls
304            .with_try_get::<DateTimeFormatter, _, _>(self.options.clone(), |dtf| {
305                dtf.format(&self.value).to_string()
306            })
307            .unwrap_or_default()
308            .into()
309    }
310
311    fn as_string_threadsafe(
312        &self,
313        intls: &intl_memoizer::concurrent::IntlLangMemoizer,
314    ) -> Cow<'static, str> {
315        // Maybe don't try to cache formatters in this case, the traits don't work out
316        let lang = intls
317            .with_try_get::<GimmeTheLocale, _, _>((), |gimme| gimme.0.clone())
318            .expect("Infallible");
319        let Some(langid): Option<icu_locale_core::LanguageIdentifier> =
320            lang.to_string().parse().ok()
321        else {
322            return "".into();
323        };
324        let Ok(dtf) = self.options.make_formatter(langid) else {
325            return "".into();
326        };
327        dtf.format(&self.value).to_string().into()
328    }
329}
330
331impl From<DateTime<Gregorian>> for FluentDateTime {
332    fn from(value: DateTime<Gregorian>) -> Self {
333        // Not using ConvertCalendar because it would introduce DateTime<Ref<AnyCalendar>> and we don't need ref indirection
334        Self {
335            value: DateTime {
336                date: value.date.to_iso(),
337                time: value.time,
338            },
339            options: Default::default(),
340        }
341    }
342}
343
344impl From<DateTime<Iso>> for FluentDateTime {
345    fn from(value: DateTime<Iso>) -> Self {
346        Self {
347            value,
348            options: Default::default(),
349        }
350    }
351}
352
353impl From<FluentDateTime> for FluentValue<'static> {
354    fn from(value: FluentDateTime) -> Self {
355        Self::Custom(Box::new(value))
356    }
357}
358
359static SYSTEM_TZ: LazyLock<jiff::tz::TimeZone> = LazyLock::new(|| jiff::tz::TimeZone::system());
360
361fn clamp_datetime_for_jiff(dt: &DateTime<Iso>) -> Cow<'_, DateTime<Iso>> {
362    if dt.time.second < 60u8.try_into().unwrap() {
363        Cow::Borrowed(dt)
364    } else {
365        let mut dt = dt.clone();
366        dt.time.second = 59u8.try_into().unwrap();
367        dt.time.subsecond = 999_999_999u32.try_into().unwrap();
368        Cow::Owned(dt)
369    }
370}
371
372fn naive_datetime_to_system(
373    dt: &DateTime<Iso>,
374) -> ZonedDateTime<Iso, icu_time::TimeZoneInfo<icu_time::zone::models::AtTime>> {
375    // There are ambiguities and invalidities to handle during DST transitions
376    // naive datetimes that don't in fact exist (winter -> summer)
377    // naive datetimes that can refer to two instants (summer -> winter)
378    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime#ambiguity_and_gaps_from_local_time_to_utc_time
379    // When constructing a ZonedDateTime from a local time, the behavior for
380    // ambiguity and gaps is configurable via the disambiguation option:
381    //
382    // earlier
383    //
384    // If there are two possible instants, choose the earlier one. If there is
385    // a gap, go back by the gap duration.
386    //
387    // later
388    //
389    // If there are two possible instants, choose the later one. If there is a
390    // gap, go forward by the gap duration.
391    //
392    // compatible (default)
393    //
394    // Same behavior as Date: use later for gaps and earlier for ambiguities.
395    // If there are two possible instants, choose the earlier one.
396    // If there is a gap, go forward by the gap duration.
397    //
398    // There are also offset ambiguities, which we don't have to worry about because
399    // naive datetimes are offset free.
400    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime#offset_ambiguity
401    // timeStyles long and full map to SpecificShort and SpecificLong,
402    // but they still need the full AtTime model
403    //
404    // jiff to_zoned uses the "compatible" strategy so we can just do the conversion
405    // on the jiff side
406
407    // Errors can come from
408    // https://docs.rs/jiff/latest/jiff/civil/struct.Date.html#method.new
409    // https://docs.rs/jiff/latest/jiff/civil/struct.Time.html#method.new
410    // and are due to invalid ranges
411    // I find it dodgy that jiff doesn't clamp leap seconds here; we'll do it ourselves
412    let jdt = jiff_icu::ConvertTryInto::<jiff::civil::DateTime>::convert_try_into(
413        *clamp_datetime_for_jiff(dt),
414    )
415    .unwrap();
416    jiff_icu::ConvertInto::convert_into(&jdt.to_zoned(SYSTEM_TZ.to_owned()).unwrap())
417}
418
419// With this, we won't necessarily need to build a zoned DateTime at format time
420// It's okay to use general enums; building is module-local and LLVM should be
421// able to keep track of constructed variants
422enum DateTimeFormatter {
423    WithZone(
424        icu_datetime::DateTimeFormatter<fieldsets::enums::CompositeFieldSet>,
425        //icu_datetime::DateTimeFormatter<fieldsets::Combo<fieldsets::enums::CompositeDateTimeFieldSet, fieldsets::enums::ZoneFieldSet>>
426    ),
427    NoZone(icu_datetime::DateTimeFormatter<fieldsets::enums::CompositeDateTimeFieldSet>),
428}
429
430impl DateTimeFormatter {
431    fn format(&self, dt: &DateTime<Iso>) -> icu_datetime::FormattedDateTime<'_> {
432        match self {
433            Self::WithZone(dtf) => dtf.format(&naive_datetime_to_system(dt)),
434            Self::NoZone(dtf) => dtf.format(dt),
435        }
436    }
437}
438
439impl intl_memoizer::Memoizable for DateTimeFormatter {
440    type Args = FluentDateTimeOptions;
441
442    type Error = ();
443
444    fn construct(
445        lang: unic_langid::LanguageIdentifier,
446        args: Self::Args,
447    ) -> Result<Self, Self::Error>
448    where
449        Self: std::marker::Sized,
450    {
451        // Convert LanguageIdentifier from unic_langid to icu
452        let langid: icu_locale_core::LanguageIdentifier =
453            lang.to_string().parse().map_err(|_| ())?;
454        args.make_formatter(langid).map_err(|_| ())
455    }
456}
457
458/// Working around that intl_memoizer API, because IntlLangMemoizer doesn't
459/// expose the language it is caching
460///
461/// This would be a trivial addition but it isn't maintained these days.
462struct GimmeTheLocale(unic_langid::LanguageIdentifier);
463
464impl intl_memoizer::Memoizable for GimmeTheLocale {
465    type Args = ();
466    type Error = std::convert::Infallible;
467
468    fn construct(lang: unic_langid::LanguageIdentifier, _args: ()) -> Result<Self, Self::Error>
469    where
470        Self: std::marker::Sized,
471    {
472        Ok(Self(lang))
473    }
474}
475
476/// A Fluent function for formatted datetimes
477///
478/// Normally you would register this using
479/// [`BundleExt::add_datetime_support`]; you would not use it directly.
480///
481/// However, some frameworks like [l10n](https://lib.rs/crates/l10n)
482/// require functions to be set up like this:
483///
484/// ```ignore
485/// l10n::init!({
486///     functions: { "DATETIME": fluent_datetime::DATETIME }
487/// });
488/// ```
489///
490/// # Usage
491///
492/// ```fluent
493/// today-is = Today is {$date}
494/// today-is-fulldate = Today is {DATETIME($date, dateStyle: "full")}
495/// now-is-time = Now is {DATETIME($date, timeStyle: "medium")}
496/// now-is-datetime = Now is {DATETIME($date, dateStyle: "full", timeStyle: "short")}
497/// ````
498///
499/// See [`DATETIME` in the Fluent guide][datetime-fluent]
500/// and [the `Intl.DateTimeFormat` constructor][Intl.DateTimeFormat]
501/// from [ECMA 402] for how to use this inside a Fluent document.
502///
503/// We currently implement only a subset of the formatting options:
504/// * `dateStyle`
505/// * `timeStyle`
506///
507/// Unknown options and extra positional arguments are ignored, unknown values
508/// of known options cause the date to be returned as-is.
509///
510/// [datetime-fluent]: https://projectfluent.org/fluent/guide/functions.html#datetime
511/// [Intl.DateTimeFormat]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
512/// [ECMA 402]: https://tc39.es/ecma402/#sec-createdatetimeformat
513// Known implementations of Intl.DateTimeFormat.DateTimeFormat().  All use ICU.
514// https://searchfox.org/firefox-main/source/js/src/builtin/intl/DateTimeFormat.js (MPL-2.0)
515// https://searchfox.org/firefox-main/source/js/src/builtin/intl/DateTimeFormat.cpp
516// https://chromium.googlesource.com/v8/v8/+/main/src/objects/js-date-time-format.cc (BSD-3-Clause)
517// https://github.com/WebKit/webkit/blob/main/Source/JavaScriptCore/runtime/IntlDateTimeFormat.cpp (BSD-2-Clause)
518// deno eval --print 'new Intl.DateTimeFormat("en-US", {dateStyle: "full", timeStyle: "full"}).format(new Date())'
519// https://github.com/LadybirdBrowser/ladybird/blob/master/Libraries/LibJS/Runtime/Intl/DateTimeFormatConstructor.cpp (BSD-2-Clause)
520// https://github.com/formatjs/formatjs/tree/main/packages/intl-datetimeformat (MIT)
521// https://github.com/formatjs/formatjs/blob/main/packages/intl-datetimeformat/src/abstract/InitializeDateTimeFormat.ts
522// https://github.com/google/rust_icu/blob/main/rust_icu_ecma402/src/datetimeformat.rs (Apache-2.0, ICU4C)
523//   does new_with_pattern but never calls new_with_styles, does not handle dateStyle/timeStyle
524// https://github.com/unicode-org/icu4x/tree/main/ffi/ecma402 (Unicode-3.0; mostly a placeholder, does not impl DateTimeFormat)
525// https://codeberg.org/kiesel-js/kiesel/src/branch/main/src/builtins/intl/date_time_format.zig
526// https://github.com/boa-dev/boa/tree/main/core/engine/src/builtins/intl/date_time_format
527// Boa looks reasonable, could be extracted
528// Except it prints in UTC, not local, and does not respect long/full timeStyles.  Test with:
529// cargo run -p boa_cli -- -e 'new Intl.DateTimeFormat("en-US", {dateStyle: "full", timeStyle: "full"}).format(new Date())'
530//
531// styles map to an UDateFormatStyle in ICU4C;
532// I don't understand how ICU4X has reduced the number of styles (removed full, kept only short medium long)
533// https://unicode-org.github.io/icu-docs/apidoc/dev/icu4c/udat_8h.html#adb4c5a95efb888d04d38db7b3efff0c5
534// Explanation of the API change and mapping here:
535// https://github.com/unicode-org/icu4x/issues/7523#issuecomment-3820793161
536#[allow(non_snake_case)]
537pub fn DATETIME<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
538    match positional.first() {
539        Some(FluentValue::Custom(cus)) => {
540            if let Some(dt) = cus.as_any().downcast_ref::<FluentDateTime>() {
541                let mut dt = dt.clone();
542                let Ok(()) = dt.options.merge_args(named) else {
543                    return FluentValue::Error;
544                };
545                FluentValue::Custom(Box::new(dt))
546            } else {
547                FluentValue::Error
548            }
549        }
550        // https://github.com/projectfluent/fluent/wiki/Error-Handling
551        // argues for graceful recovery (think lingering trauma from XUL DTD
552        // errors)
553        _ => FluentValue::Error,
554    }
555}
556
557/// Extension trait to register DateTime support on [`FluentBundle`]
558///
559/// [`FluentDateTime`] values are rendered automatically, but you need to call
560/// [`BundleExt::add_datetime_support`] at bundle creation time when using
561/// the [`DATETIME`] function inside FTL resources.
562pub trait BundleExt {
563    /// Registers the [`DATETIME`] function
564    ///
565    /// Call this on a [`FluentBundle`].
566    ///
567    fn add_datetime_support(&mut self) -> Result<(), FluentError>;
568}
569
570impl<R, M> BundleExt for FluentBundle<R, M> {
571    fn add_datetime_support(&mut self) -> Result<(), FluentError> {
572        self.add_function("DATETIME", DATETIME)?;
573        //self.set_formatter(Some(datetime_formatter));
574        Ok(())
575    }
576}