use crate::{
Context, JsArgs, JsData, JsExpect, JsResult, JsString, JsValue, NativeFunction,
builtins::{
BuiltInBuilder, BuiltInConstructor, BuiltInObject, IntrinsicObject,
date::utils::{
date_from_time, hour_from_time, min_from_time, month_from_time, ms_from_time,
sec_from_time, time_clip, year_from_time,
},
intl::{
Service,
date_time_format::options::{DateStyle, FormatMatcher, FormatOptions, TimeStyle},
locale::{canonicalize_locale_list, filter_locales, resolve_locale},
options::{IntlOptions, coerce_options_to_object},
},
options::get_option,
},
context::intrinsics::{Intrinsics, StandardConstructor, StandardConstructors},
error::JsNativeError,
js_error, js_string,
object::{
FunctionObjectBuilder, JsFunction, JsObject, ObjectInitializer,
internal_methods::get_prototype_from_constructor,
},
property::{Attribute, PropertyDescriptor},
realm::Realm,
string::StaticJsStrings,
};
use boa_gc::{Finalize, Trace};
use icu_calendar::{Iso, preferences::CalendarAlgorithm};
use icu_datetime::{
DateTimeFormatter, DateTimeFormatterPreferences,
fieldsets::{
builder::{DateFields, FieldSetBuilder},
enums::CompositeFieldSet,
},
input::{Date, DateTime, Time, TimeZone, UtcOffset},
options::{Length, TimePrecision},
preferences::HourCycle as IcuHourCycle,
};
use icu_decimal::preferences::NumberingSystem;
use icu_decimal::provider::DecimalSymbolsV1;
use icu_locale::{Locale, extensions::unicode::Value};
use icu_time::{
TimeZoneInfo, ZonedDateTime,
zone::{IanaParser, models::Base},
};
use timezone_provider::provider::TimeZoneId;
mod options;
#[cfg(all(test, feature = "intl_bundled"))]
mod tests;
#[derive(Debug, Clone)]
pub(crate) enum FormatTimeZone {
UtcOffset(UtcOffset),
Identifier((TimeZone, TimeZoneId)),
}
impl FormatTimeZone {
pub(crate) fn to_time_zone_info(&self) -> TimeZoneInfo<Base> {
match self {
Self::Identifier((tz, _)) => tz.without_offset(),
Self::UtcOffset(utc_offset) => TimeZone::UNKNOWN.with_offset(Some(*utc_offset)),
}
}
}
#[derive(Debug, Clone, Trace, Finalize, JsData)]
#[boa_gc(unsafe_empty_trace)] #[allow(dead_code)]
pub(crate) struct DateTimeFormat {
locale: Locale,
calendar_algorithm: Option<CalendarAlgorithm>, numbering_system: Option<NumberingSystem>,
hour_cycle: Option<IcuHourCycle>,
date_style: Option<DateStyle>,
time_style: Option<TimeStyle>,
time_zone: FormatTimeZone,
fieldset: CompositeFieldSet,
formatter: DateTimeFormatter<CompositeFieldSet>,
bound_format: Option<JsFunction>,
resolved_options: Option<JsObject>,
}
impl Service for DateTimeFormat {
type LangMarker = DecimalSymbolsV1;
type Preferences = DateTimeFormatterPreferences;
}
impl IntrinsicObject for DateTimeFormat {
fn init(realm: &Realm) {
use crate::JsSymbol;
let get_format = BuiltInBuilder::callable(realm, Self::get_format)
.name(js_string!("get format"))
.build();
BuiltInBuilder::from_standard_constructor::<Self>(realm)
.static_method(
Self::supported_locales_of,
js_string!("supportedLocalesOf"),
1,
)
.accessor(
js_string!("format"),
Some(get_format),
None,
Attribute::CONFIGURABLE,
)
.method(Self::resolved_options, js_string!("resolvedOptions"), 0)
.property(
JsSymbol::to_string_tag(),
js_string!("Intl.DateTimeFormat"),
Attribute::READONLY | Attribute::NON_ENUMERABLE | Attribute::CONFIGURABLE,
)
.build();
}
fn get(intrinsics: &Intrinsics) -> JsObject {
Self::STANDARD_CONSTRUCTOR(intrinsics.constructors()).constructor()
}
}
impl BuiltInObject for DateTimeFormat {
const NAME: JsString = StaticJsStrings::DATE_TIME_FORMAT;
}
impl BuiltInConstructor for DateTimeFormat {
const CONSTRUCTOR_ARGUMENTS: usize = 0;
const PROTOTYPE_STORAGE_SLOTS: usize = 4;
const CONSTRUCTOR_STORAGE_SLOTS: usize = 1;
const STANDARD_CONSTRUCTOR: fn(&StandardConstructors) -> &StandardConstructor =
StandardConstructors::date_time_format;
fn constructor(
new_target: &JsValue,
args: &[JsValue],
context: &mut Context,
) -> JsResult<JsValue> {
let new_target_inner = &if new_target.is_undefined() {
context
.active_function_object()
.unwrap_or_else(|| {
context
.intrinsics()
.constructors()
.date_time_format()
.constructor()
})
.into()
} else {
new_target.clone()
};
let locales = args.get_or_undefined(0);
let options = args.get_or_undefined(1);
let dtf = create_date_time_format(
locales,
options,
FormatType::Any,
FormatDefaults::Date,
context,
)?;
let prototype = get_prototype_from_constructor(
new_target_inner,
StandardConstructors::date_time_format,
context,
)?;
let date_time_format = JsObject::from_proto_and_data(prototype, dtf);
let this = context.vm.stack.get_this(context.vm.frame());
let Some(this_obj) = this.as_object() else {
return Ok(date_time_format.into());
};
let constructor = context
.intrinsics()
.constructors()
.date_time_format()
.constructor();
if new_target.is_undefined()
&& JsValue::ordinary_has_instance(&constructor.into(), &this, context)?
{
let fallback_symbol = context
.intrinsics()
.objects()
.intl()
.borrow()
.data()
.fallback_symbol();
this_obj.define_property_or_throw(
fallback_symbol,
PropertyDescriptor::builder()
.value(date_time_format)
.writable(false)
.enumerable(false)
.configurable(false),
context,
)?;
Ok(this)
} else {
Ok(date_time_format.into())
}
}
}
impl DateTimeFormat {
fn get_format(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
let dtf_object = unwrap_date_time_format(this, context)?;
let dtf_clone = dtf_object.clone();
let mut dtf = dtf_object.borrow_mut();
if let Some(bound_format) = &dtf.data_mut().bound_format.clone() {
Ok(bound_format.clone().into())
} else {
let bound_format = FunctionObjectBuilder::new(
context.realm(),
NativeFunction::from_copy_closure_with_captures(
|_, args, dtf, context| {
let date = args.get_or_undefined(0);
let x = if date.is_undefined() {
context.clock().system_time_millis() as f64
} else {
date.to_number(context)?
};
let x = time_clip(x);
if x.is_nan() {
return Err(js_error!(RangeError: "formatted date cannot be NaN"));
}
let result = format_timestamp_with_dtf(dtf.borrow().data(), x, context)?;
Ok(JsValue::from(result))
},
dtf_clone,
),
)
.length(1)
.build();
dtf.data_mut().bound_format = Some(bound_format.clone());
Ok(bound_format.into())
}
}
fn supported_locales_of(
_: &JsValue,
args: &[JsValue],
context: &mut Context,
) -> JsResult<JsValue> {
let locales = args.get_or_undefined(0);
let options = args.get_or_undefined(1);
let requested_locales = canonicalize_locale_list(locales, context)?;
filter_locales::<Self>(requested_locales, options, context).map(JsValue::from)
}
fn resolved_options(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
let dtf_object = unwrap_date_time_format(this, context)?;
if let Some(cached) = dtf_object.borrow().data().resolved_options.clone() {
return Ok(cached.into());
}
let result = {
let dtf = dtf_object.borrow();
let dtf = dtf.data();
let mut options = ObjectInitializer::new(context);
options.property(
js_string!("locale"),
js_string!(dtf.locale.to_string()),
Attribute::all(),
);
if let Some(ca) = &dtf.calendar_algorithm {
options.property(
js_string!("calendar"),
js_string!(ca.as_str()),
Attribute::all(),
);
}
if let Some(nu) = &dtf.numbering_system {
options.property(
js_string!("numberingSystem"),
js_string!(nu.as_str()),
Attribute::all(),
);
}
let time_zone_str = match &dtf.time_zone {
FormatTimeZone::UtcOffset(offset) => {
let seconds = offset.to_seconds();
let hours = seconds / 3600;
let minutes = (seconds.abs() % 3600) / 60;
format!("{hours:+03}:{minutes:02}")
}
FormatTimeZone::Identifier((tz, _id)) => tz.to_string(),
};
options.property(
js_string!("timeZone"),
js_string!(time_zone_str),
Attribute::all(),
);
if let Some(hc) = &dtf.hour_cycle {
options.property(
js_string!("hourCycle"),
js_string!(hc.as_str()),
Attribute::all(),
);
let hour12 = matches!(hc, IcuHourCycle::H11 | IcuHourCycle::H12);
options.property(js_string!("hour12"), hour12, Attribute::all());
}
if let Some(ds) = dtf.date_style {
let ds_str = match ds {
DateStyle::Full => "full",
DateStyle::Long => "long",
DateStyle::Medium => "medium",
DateStyle::Short => "short",
};
options.property(
js_string!("dateStyle"),
js_string!(ds_str),
Attribute::all(),
);
}
if let Some(ts) = dtf.time_style {
let ts_str = match ts {
TimeStyle::Full => "full",
TimeStyle::Long => "long",
TimeStyle::Medium => "medium",
TimeStyle::Short => "short",
};
options.property(
js_string!("timeStyle"),
js_string!(ts_str),
Attribute::all(),
);
}
options.build()
};
dtf_object.borrow_mut().data_mut().resolved_options = Some(result.clone());
Ok(result.into())
}
}
struct ToLocalTime {
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
subsecond: u32,
}
impl ToLocalTime {
pub(crate) fn from_local_epoch_milliseconds(local_millis: f64) -> JsResult<Self> {
let x = time_clip(local_millis);
if x.is_nan() {
return Err(js_error!(RangeError: "formattable time value cannot be NaN"));
}
let epoch_nanoseconds = (x * 1_000_000f64) as i128;
let t = epoch_nanoseconds.div_euclid(1_000_000) as f64;
let year = year_from_time(t);
let month = month_from_time(t);
let day = date_from_time(t);
let hour = hour_from_time(t);
let minute = min_from_time(t);
let second = sec_from_time(t);
let ms = u32::from(ms_from_time(t));
let month = month + 1;
Ok(Self {
year,
month,
day,
hour,
minute,
second,
subsecond: ms * 1_000_000,
})
}
pub(crate) fn to_formattable_datetime(&self) -> JsResult<DateTime<Iso>> {
Ok(DateTime {
date: Date::try_new_iso(self.year, self.month, self.day)
.ok()
.js_expect("TimeClip ensures valid range.")?,
time: Time::try_new(self.hour, self.minute, self.second, self.subsecond)
.ok()
.js_expect("valid values")?,
})
}
}
pub(crate) fn create_date_time_format(
locales: &JsValue,
options: &JsValue,
date_time_format_type: FormatType,
defaults: FormatDefaults,
context: &mut Context,
) -> JsResult<DateTimeFormat> {
let requested_locales = canonicalize_locale_list(locales, context)?;
let options = coerce_options_to_object(options, context)?;
let matcher = get_option(&options, js_string!("localeMatcher"), context)?.unwrap_or_default();
let mut preferences = DateTimeFormatterPreferences::default();
preferences.calendar_algorithm =
get_option::<Value>(&options, js_string!("calendar"), context)?
.map(|ca| CalendarAlgorithm::try_from(&ca))
.transpose()
.map_err(|_icu4x_error| js_error!(RangeError: "unknown calendar algorithm"))?;
preferences.numbering_system =
get_option::<Value>(&options, js_string!("numberingSystem"), context)?
.map(NumberingSystem::try_from)
.transpose()
.map_err(|_icu4x_error| js_error!(RangeError: "unknown numbering system"))?;
let hour_12 = get_option::<bool>(&options, js_string!("hour12"), context)?;
preferences.hour_cycle =
get_option::<options::HourCycle>(&options, js_string!("hourCycle"), context)?
.map(|hc| {
if hour_12.is_some() {
Ok(None)
} else {
IcuHourCycle::try_from(hc).map(Some)
}
})
.transpose()
.map_err(|_icu4x_error| js_error!(RangeError: "unknown hour cycle"))?
.flatten();
let mut intl_options = IntlOptions {
matcher,
preferences,
};
let resolved_locale = resolve_locale::<DateTimeFormat>(
requested_locales,
&mut intl_options,
context.intl_provider(),
)?;
if intl_options.preferences.calendar_algorithm.is_none() {
intl_options.preferences.calendar_algorithm = CalendarAlgorithm::try_from(
&Value::try_from_str("gregory").expect("'gregory' is a valid BCP 47 value"),
)
.ok();
}
if intl_options.preferences.numbering_system.is_none() {
intl_options.preferences.numbering_system = NumberingSystem::try_from(
Value::try_from_str("latn").expect("'latn' is a valid BCP 47 value"),
)
.ok();
}
if intl_options.preferences.hour_cycle.is_none() {
intl_options.preferences.hour_cycle = IcuHourCycle::try_from(
&Value::try_from_str("h12").expect("'h12' is a valid BCP 47 value"),
)
.ok();
}
let time_zone = options.get(js_string!("timeZone"), context)?;
let time_zone = if time_zone.is_undefined() {
JsString::from("Etc/UTC")
} else {
time_zone.to_string(context)?
};
let time_zone_string = time_zone.to_std_string_escaped();
let time_zone = if let Ok(utc_offset) = UtcOffset::try_from_str(&time_zone_string) {
FormatTimeZone::UtcOffset(utc_offset)
} else {
let parser =
IanaParser::try_new_with_buffer_provider(context.intl_provider().erased_provider())
.map_err(|_| {
JsNativeError::error().with_message("Failed to init time zone data provider")
})?;
let time_zone = parser.as_borrowed().parse(&time_zone_string);
let time_zone_id = context
.timezone_provider()
.get(time_zone_string.as_bytes())
.map_err(|_| {
JsNativeError::range()
.with_message(format!("{time_zone_string:#?} was not a valid time zone."))
})?;
FormatTimeZone::Identifier((time_zone, time_zone_id))
};
let mut format_options = FormatOptions::try_init(&options, preferences.hour_cycle, context)?;
let format_matcher =
get_option::<FormatMatcher>(&options, js_string!("formatMatcher"), context)?
.unwrap_or(FormatMatcher::BestFit);
let date_style = get_option::<DateStyle>(&options, js_string!("dateStyle"), context)?;
let time_style = get_option::<TimeStyle>(&options, js_string!("timeStyle"), context)?;
let fieldset = if date_style.is_some() || time_style.is_some() {
if format_options.has_explicit_format_components() {
return Err(
js_error!(TypeError: "cannot have explicit format components when timeStyle or dateStyle is defined"),
);
}
if date_time_format_type == FormatType::Date && time_style.is_some() {
return Err(
js_error!(TypeError: "timeStyle cannot be defined for a date DateTimeFormat"),
);
}
if date_time_format_type == FormatType::Time && date_style.is_some() {
return Err(
js_error!(TypeError: "dateStyle cannot be defined for a time DateTimeFormat"),
);
}
date_time_style_format(date_style, time_style)?
} else {
let needs_defaults = format_options.check_dtf_type(date_time_format_type);
if needs_defaults && defaults != FormatDefaults::Time {
format_options.set_date_defaults();
}
if needs_defaults && defaults != FormatDefaults::Date {
format_options.set_time_defaults();
}
match format_matcher {
FormatMatcher::Basic | FormatMatcher::BestFit => {
best_fit_date_time_format(&format_options)?
}
}
};
let formatter = DateTimeFormatter::try_new_with_buffer_provider(
context.intl_provider().erased_provider(),
resolved_locale.clone().into(),
fieldset,
)
.map_err(|e| JsNativeError::range().with_message(format!("failed to load formatter: {e}")))?;
Ok(DateTimeFormat {
locale: resolved_locale,
calendar_algorithm: intl_options.preferences.calendar_algorithm,
numbering_system: intl_options.preferences.numbering_system,
hour_cycle: intl_options.preferences.hour_cycle,
date_style,
time_style,
time_zone,
fieldset,
formatter,
bound_format: None,
resolved_options: None,
})
}
fn format_timestamp_with_dtf(
dtf: &DateTimeFormat,
timestamp: f64,
context: &mut Context,
) -> JsResult<JsString> {
let epoch_ns = timestamp as i128 * 1_000_000;
let time_zone = &dtf.time_zone;
let time_zone_offset_seconds = match time_zone {
FormatTimeZone::UtcOffset(offset) => offset.to_seconds(),
FormatTimeZone::Identifier((_, time_zone_id)) => {
let offset_seconds = context
.timezone_provider()
.transition_nanoseconds_for_utc_epoch_nanoseconds(*time_zone_id, epoch_ns)
.map_err(
|_e| js_error!(RangeError: "unable to determine transition nanoseconds"),
)?;
offset_seconds.0 as i32
}
};
let tz = timestamp + f64::from(time_zone_offset_seconds * 1_000);
let fields = ToLocalTime::from_local_epoch_milliseconds(tz)?;
let dt = fields.to_formattable_datetime()?;
let tz_info = time_zone.to_time_zone_info();
let tz_info_at_time = tz_info.at_date_time(dt);
let zdt = ZonedDateTime {
date: dt.date,
time: dt.time,
zone: tz_info_at_time,
};
let result = dtf.formatter.format(&zdt).to_string();
Ok(JsString::from(result))
}
fn date_time_style_format(
date_style: Option<DateStyle>,
time_style: Option<TimeStyle>,
) -> JsResult<CompositeFieldSet> {
let mut builder = FieldSetBuilder::default();
builder.length = match date_style {
Some(DateStyle::Full | DateStyle::Long) => Some(Length::Long),
Some(DateStyle::Medium) => Some(Length::Medium),
Some(DateStyle::Short) => Some(Length::Short),
None => match time_style {
Some(TimeStyle::Full | TimeStyle::Long) => Some(Length::Long),
Some(TimeStyle::Medium) => Some(Length::Medium),
Some(TimeStyle::Short) => Some(Length::Short),
None => return Err(js_error!(TypeError: "dateStyle or timeStyle must be defined")),
},
};
builder.date_fields = match date_style {
Some(DateStyle::Full) => Some(DateFields::YMDE),
Some(DateStyle::Long | DateStyle::Medium | DateStyle::Short) => Some(DateFields::YMD),
None => None, };
builder.time_precision = match time_style {
Some(TimeStyle::Full | TimeStyle::Long | TimeStyle::Medium) => Some(TimePrecision::Second),
Some(TimeStyle::Short) => Some(TimePrecision::Minute),
None => None, };
builder
.build_composite()
.map_err(|e| JsNativeError::range().with_message(e.to_string()).into())
}
fn best_fit_date_time_format(format_options: &FormatOptions) -> JsResult<CompositeFieldSet> {
let mut builder = FieldSetBuilder::default();
builder.length = format_options.to_length();
builder.date_fields = format_options.to_date_fields();
builder.time_precision = format_options.to_time_fields();
builder.zone_style = format_options.to_zone_style();
builder
.build_composite()
.map_err(|e| JsNativeError::range().with_message(e.to_string()).into())
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum FormatType {
Date,
Time,
Any,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum FormatDefaults {
Date,
Time,
All,
}
fn unwrap_date_time_format(
dtf: &JsValue,
context: &mut Context,
) -> JsResult<JsObject<DateTimeFormat>> {
let dtf_o = dtf.as_object().ok_or_else(|| {
JsNativeError::typ()
.with_message("value was not an initialized `Intl.DateTimeFormat` object")
})?;
if let Ok(dtf) = dtf_o.clone().downcast::<DateTimeFormat>() {
return Ok(dtf);
}
let constructor = context
.intrinsics()
.constructors()
.date_time_format()
.constructor();
if JsValue::ordinary_has_instance(&constructor.into(), dtf, context)? {
let fallback_symbol = context
.intrinsics()
.objects()
.intl()
.borrow()
.data()
.fallback_symbol();
if let Some(dtf) = dtf_o
.get(fallback_symbol, context)?
.as_object()
.and_then(|o| o.downcast::<DateTimeFormat>().ok())
{
return Ok(dtf);
}
}
Err(JsNativeError::typ()
.with_message("object was not an initialized `Intl.DateTimeFormat` object")
.into())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn format_date_time_locale(
locales: &JsValue,
options: &JsValue,
format_type: FormatType,
defaults: FormatDefaults,
timestamp: f64,
context: &mut Context,
) -> JsResult<JsValue> {
let options = coerce_options_to_object(options, context)?;
let options_value = options.into();
let dtf = create_date_time_format(locales, &options_value, format_type, defaults, context)?;
let x = time_clip(timestamp);
if x.is_nan() {
return Err(js_error!(RangeError: "formatted date cannot be NaN"));
}
let result = format_timestamp_with_dtf(&dtf, x, context)?;
Ok(JsValue::from(result))
}