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 icu_calendar::DateTime;
23//! use icu_datetime::options::length;
24//! use unic_langid::LanguageIdentifier;
25//!
26//! // Create a FluentBundle
27//! let langid_en: LanguageIdentifier = "en-US".parse()?;
28//! let mut bundle = FluentBundle::new(vec![langid_en]);
29//!
30//! // Register the DATETIME function
31//! bundle.add_datetime_support();
32//!
33//! // Add a FluentResource to the bundle
34//! let ftl_string = r#"
35//! today-is = Today is {$date}
36//! today-is-fulldate = Today is {DATETIME($date, dateStyle: "full")}
37//! now-is-time = Now is {DATETIME($date, timeStyle: "medium")}
38//! now-is-datetime = Now is {DATETIME($date, dateStyle: "full", timeStyle: "short")}
39//! "#
40//! .to_string();
41//!
42//! let res = FluentResource::try_new(ftl_string)
43//!     .expect("Failed to parse an FTL string.");
44//! bundle
45//!     .add_resource(res)
46//!     .expect("Failed to add FTL resources to the bundle.");
47//!
48//! // Create an ICU DateTime
49//! let datetime = DateTime::try_new_iso_datetime(1989, 11, 9, 23, 30, 0)
50//!     .expect("Failed to create ICU DateTime");
51//!
52//! // Convert to FluentDateTime
53//! let mut datetime = FluentDateTime::from(datetime);
54//!
55//! // Format some messages with date arguments
56//! let mut errors = vec![];
57//!
58//! assert_eq!(
59//!     bundle.format_pattern(
60//!         &bundle.get_message("today-is").unwrap().value().unwrap(),
61//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
62//!     "Today is \u{2068}11/9/89\u{2069}"
63//! );
64//!
65//! assert_eq!(
66//!     bundle.format_pattern(
67//!         &bundle.get_message("today-is-fulldate").unwrap().value().unwrap(),
68//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
69//!     "Today is \u{2068}Thursday, November 9, 1989\u{2069}"
70//! );
71//!
72//! assert_eq!(
73//!     bundle.format_pattern(
74//!         &bundle.get_message("now-is-time").unwrap().value().unwrap(),
75//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
76//!     "Now is \u{2068}11:30:00\u{202f}PM\u{2069}"
77//! );
78//!
79//! assert_eq!(
80//!     bundle.format_pattern(
81//!         &bundle.get_message("now-is-datetime").unwrap().value().unwrap(),
82//!         Some(&fluent_args!("date" => datetime.clone())), &mut errors),
83//!     "Now is \u{2068}Thursday, November 9, 1989, 11:30\u{202f}PM\u{2069}"
84//! );
85//!
86//! // Set FluentDateTime.options in code rather than in translation data
87//! // This is useful because it sets presentation options that are
88//! // shared between all locales
89//! datetime.options.set_date_style(Some(length::Date::Full));
90//! assert_eq!(
91//!     bundle.format_pattern(
92//!         &bundle.get_message("today-is").unwrap().value().unwrap(),
93//!         Some(&fluent_args!("date" => datetime)), &mut errors),
94//!     "Today is \u{2068}Thursday, November 9, 1989\u{2069}"
95//! );
96//!
97//! assert!(errors.is_empty());
98//!
99//! # // I would like to use the ? operator, but Fluent and ICU error types don't implement the std Error trait…
100//! # Ok::<(), Box<dyn std::error::Error>>(())
101//! ```
102#![forbid(unsafe_code)]
103#![warn(missing_docs)]
104use std::borrow::Cow;
105use std::mem::discriminant;
106
107use fluent_bundle::bundle::FluentBundle;
108use fluent_bundle::types::FluentType;
109use fluent_bundle::{FluentArgs, FluentError, FluentValue};
110
111use icu_calendar::{Gregorian, Iso};
112use icu_datetime::options::length;
113
114fn val_as_str<'a>(val: &'a FluentValue) -> Option<&'a str> {
115    if let FluentValue::String(str) = val {
116        Some(str)
117    } else {
118        None
119    }
120}
121
122/// Options for formatting a DateTime
123#[derive(Debug, Clone, PartialEq)]
124pub struct FluentDateTimeOptions {
125    // This calendar arg makes loading provider data and memoizing formatters harder
126    // In particular, the AnyCalendarKind logic (in
127    // AnyCalendarKind::from_data_locale_with_fallback) that defaults to
128    // Gregorian for most calendars, except for the thai locale (Buddhist),
129    // isn't exposed.  So we would have to build the formatter and then decide
130    // if it is the correct one for the calendar we want.
131    //calendar: Option<icu_calendar::AnyCalendarKind>,
132    // We don't handle icu_datetime per-component settings atm, it is experimental
133    // and length is expressive enough so far
134    length: length::Bag,
135}
136
137impl Default for FluentDateTimeOptions {
138    /// Defaults to showing a short date
139    ///
140    /// The intent is to emulate [Intl.DateTimeFormat] behavior:
141    /// > The default value for each date-time component option is undefined,
142    /// > but if all component properties are undefined, then year, month, and day default
143    /// > to "numeric". If any of the date-time component options is specified, then
144    /// > dateStyle and timeStyle must be undefined.
145    ///
146    /// In terms of the current Rust implementation:
147    ///
148    /// The default value for each date-time style option is None, but if both
149    /// are unset, we display the date only, using the `length::Date::Short`
150    /// style.
151    ///
152    /// [Intl.DateTimeFormat]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
153    fn default() -> Self {
154        Self {
155            length: length::Bag::empty(),
156        }
157    }
158}
159
160impl FluentDateTimeOptions {
161    /// Set a date style, from verbose to compact
162    ///
163    /// See [`icu_datetime::options::length::Date`].
164    pub fn set_date_style(&mut self, style: Option<length::Date>) {
165        self.length.date = style;
166    }
167
168    /// Set a time style, from verbose to compact
169    ///
170    /// See [`icu_datetime::options::length::Time`].
171    pub fn set_time_style(&mut self, style: Option<length::Time>) {
172        self.length.time = style;
173    }
174
175    fn make_formatter(
176        &self,
177        locale: &icu_provider::DataLocale,
178    ) -> Result<DateTimeFormatter, icu_datetime::DateTimeError> {
179        let mut length = self.length;
180        if length == length::Bag::empty() {
181            length = length::Bag::from_date_style(length::Date::Short);
182        }
183        Ok(DateTimeFormatter(icu_datetime::DateTimeFormatter::try_new(
184            locale,
185            length.into(),
186        )?))
187    }
188
189    fn merge_args(&mut self, other: &FluentArgs) -> Result<(), ()> {
190        // TODO set an err state on self to match fluent-js behaviour
191        for (k, v) in other.iter() {
192            match k {
193                "dateStyle" => {
194                    self.length.date = Some(match val_as_str(v).ok_or(())? {
195                        "full" => length::Date::Full,
196                        "long" => length::Date::Long,
197                        "medium" => length::Date::Medium,
198                        "short" => length::Date::Short,
199                        _ => return Err(()),
200                    });
201                }
202                "timeStyle" => {
203                    self.length.time = Some(match val_as_str(v).ok_or(())? {
204                        "full" => length::Time::Full,
205                        "long" => length::Time::Long,
206                        "medium" => length::Time::Medium,
207                        "short" => length::Time::Short,
208                        _ => return Err(()),
209                    });
210                }
211                _ => (), // Ignore with no warning
212            }
213        }
214        Ok(())
215    }
216}
217
218impl std::hash::Hash for FluentDateTimeOptions {
219    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
220        // We could also use serde… or send a simple PR to have derive(Hash) upstream
221        //self.calendar.hash(state);
222        self.length.date.map(|e| discriminant(&e)).hash(state);
223        self.length.time.map(|e| discriminant(&e)).hash(state);
224    }
225}
226
227impl Eq for FluentDateTimeOptions {}
228
229/// An ICU [`DateTime`](icu_calendar::DateTime) with attached formatting options
230///
231/// Construct from an [`icu_calendar::DateTime`] using From / Into.
232///
233/// Convert to a [`FluentValue`] with From / Into.
234///
235/// See [`FluentDateTimeOptions`] and [`FluentDateTimeOptions::default`].
236///
237///```
238/// use icu_calendar::DateTime;
239/// use fluent_datetime::FluentDateTime;
240///
241/// let datetime = DateTime::try_new_iso_datetime(1989, 11, 9, 23, 30, 0)
242///     .expect("Failed to create ICU DateTime");
243///
244/// let datetime = FluentDateTime::from(datetime);
245// ```
246#[derive(Debug, Clone, PartialEq)]
247pub struct FluentDateTime {
248    // Iso seemed like a natural default, but [AnyCalendarKind::from_data_locale_with_fallback]
249    // loads Gregorian in almost all cases.  Differences have to do with eras:
250    // proleptic Gregorian has BCE / CE and no year zero, iso has just the one era and a year zero
251    value: icu_calendar::DateTime<Gregorian>,
252    /// Options for rendering
253    pub options: FluentDateTimeOptions,
254}
255
256impl FluentType for FluentDateTime {
257    fn duplicate(&self) -> Box<dyn FluentType + Send> {
258        // Basically Clone
259        Box::new(self.clone())
260    }
261
262    fn as_string(&self, intls: &intl_memoizer::IntlLangMemoizer) -> Cow<'static, str> {
263        intls
264            .with_try_get::<DateTimeFormatter, _, _>(self.options.clone(), |dtf| {
265                dtf.0
266                    .format_to_string(&self.value.to_any())
267                    .unwrap_or_default()
268            })
269            .unwrap_or_default()
270            .into()
271    }
272
273    fn as_string_threadsafe(
274        &self,
275        intls: &intl_memoizer::concurrent::IntlLangMemoizer,
276    ) -> Cow<'static, str> {
277        // Maybe don't try to cache formatters in this case, the traits don't work out
278        let lang = intls
279            .with_try_get::<GimmeTheLocale, _, _>((), |gimme| gimme.0.clone())
280            .expect("Infallible");
281        let Some(langid): Option<icu_locid::LanguageIdentifier> = lang.to_string().parse().ok()
282        else {
283            return "".into();
284        };
285        let Ok(dtf) = self.options.make_formatter(&langid.into()) else {
286            return "".into();
287        };
288        dtf.0
289            .format_to_string(&self.value.to_any())
290            .unwrap_or_default()
291            .into()
292    }
293}
294
295impl From<icu_calendar::DateTime<Gregorian>> for FluentDateTime {
296    fn from(value: icu_calendar::DateTime<Gregorian>) -> Self {
297        Self {
298            value,
299            options: Default::default(),
300        }
301    }
302}
303
304impl From<icu_calendar::DateTime<Iso>> for FluentDateTime {
305    fn from(value: icu_calendar::DateTime<Iso>) -> Self {
306        Self {
307            value: value.to_calendar(Gregorian),
308            options: Default::default(),
309        }
310    }
311}
312
313impl From<FluentDateTime> for FluentValue<'static> {
314    fn from(value: FluentDateTime) -> Self {
315        Self::Custom(Box::new(value))
316    }
317}
318
319struct DateTimeFormatter(icu_datetime::DateTimeFormatter);
320
321impl intl_memoizer::Memoizable for DateTimeFormatter {
322    type Args = FluentDateTimeOptions;
323
324    type Error = ();
325
326    fn construct(
327        lang: unic_langid::LanguageIdentifier,
328        args: Self::Args,
329    ) -> Result<Self, Self::Error>
330    where
331        Self: std::marker::Sized,
332    {
333        // Convert LanguageIdentifier from unic_langid to icu_locid
334        let langid: icu_locid::LanguageIdentifier = lang.to_string().parse().map_err(|_| ())?;
335        args.make_formatter(&langid.into()).map_err(|_| ())
336    }
337}
338
339/// Working around that intl_memoizer API, because IntlLangMemoizer doesn't
340/// expose the language it is caching
341///
342/// This would be a trivial addition but it isn't maintained these days.
343struct GimmeTheLocale(unic_langid::LanguageIdentifier);
344
345impl intl_memoizer::Memoizable for GimmeTheLocale {
346    type Args = ();
347    type Error = std::convert::Infallible;
348
349    fn construct(lang: unic_langid::LanguageIdentifier, _args: ()) -> Result<Self, Self::Error>
350    where
351        Self: std::marker::Sized,
352    {
353        Ok(Self(lang))
354    }
355}
356
357/// A Fluent function for formatted datetimes
358///
359/// Normally you would register this using
360/// [`BundleExt::add_datetime_support`]; you would not use it directly.
361///
362/// However, some frameworks like [l10n](https://lib.rs/crates/l10n)
363/// require functions to be set up like this:
364///
365/// ```ignore
366/// l10n::init!({
367///     functions: { "DATETIME": fluent_datetime::DATETIME }
368/// });
369/// ```
370///
371/// # Usage
372///
373/// ```fluent
374/// today-is = Today is {$date}
375/// today-is-fulldate = Today is {DATETIME($date, dateStyle: "full")}
376/// now-is-time = Now is {DATETIME($date, timeStyle: "medium")}
377/// now-is-datetime = Now is {DATETIME($date, dateStyle: "full", timeStyle: "short")}
378/// ````
379///
380/// See [`DATETIME` in the Fluent guide][datetime-fluent]
381/// and [the `Intl.DateTimeFormat` constructor][Intl.DateTimeFormat]
382/// from [ECMA 402] for how to use this inside a Fluent document.
383///
384/// We currently implement only a subset of the formatting options:
385/// * `dateStyle`
386/// * `timeStyle`
387///
388/// Unknown options and extra positional arguments are ignored, unknown values
389/// of known options cause the date to be returned as-is.
390///
391/// [datetime-fluent]: https://projectfluent.org/fluent/guide/functions.html#datetime
392/// [Intl.DateTimeFormat]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
393/// [ECMA 402]: https://tc39.es/ecma402/#sec-createdatetimeformat
394#[allow(non_snake_case)]
395pub fn DATETIME<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
396    match positional.get(0) {
397        Some(FluentValue::Custom(cus)) => {
398            if let Some(dt) = cus.as_any().downcast_ref::<FluentDateTime>() {
399                let mut dt = dt.clone();
400                let Ok(()) = dt.options.merge_args(named) else {
401                    return FluentValue::Error;
402                };
403                FluentValue::Custom(Box::new(dt))
404            } else {
405                FluentValue::Error
406            }
407        }
408        // https://github.com/projectfluent/fluent/wiki/Error-Handling
409        // argues for graceful recovery (think lingering trauma from XUL DTD
410        // errors)
411        _ => FluentValue::Error,
412    }
413}
414
415/// Extension trait to register DateTime support on [`FluentBundle`]
416///
417/// [`FluentDateTime`] values are rendered automatically, but you need to call
418/// [`BundleExt::add_datetime_support`] at bundle creation time when using
419/// the [`DATETIME`] function inside FTL resources.
420pub trait BundleExt {
421    /// Registers the [`DATETIME`] function
422    ///
423    /// Call this on a [`FluentBundle`].
424    ///
425    fn add_datetime_support(&mut self) -> Result<(), FluentError>;
426}
427
428impl<R, M> BundleExt for FluentBundle<R, M> {
429    fn add_datetime_support(&mut self) -> Result<(), FluentError> {
430        self.add_function("DATETIME", DATETIME)?;
431        //self.set_formatter(Some(datetime_formatter));
432        Ok(())
433    }
434}