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}