From a7fc946480683074d866490dc75c110688ffdd8e Mon Sep 17 00:00:00 2001 From: Kevin Ness <46825870+nekevss@users.noreply.github.com> Date: Sat, 10 Aug 2024 13:15:36 -0500 Subject: [PATCH] Implement `MonthCode`, `PartialDate`, and `Date::with` (#89) So the title basically says it all as far as implementation. That being said, there's a lot of interpretation going into this that I'd like feedback on if possible, and it could be the right direction or it could be the wrong direction (although I'm leaning the former over the latter). The main culprit is basically [PrepareTemporalFields](https://tc39.es/proposal-temporal/#sec-temporal-preparetemporalfields). Background for discussion/sanity check: Up until recently, I basically thought it would be an implementation detail on the engine/interpreter side, but the thing that always bugged me was the `requiredFields` parameter, being either a List or PARTIAL. We could probably do that to specification, but we might be providing something like `Some(Vec::default())` as an argument, and it basically just felt clunky. After the recent `TemporalFields` update, I went to implement the `TemporalFields` portion of the `toX` abstract ops in Boa and realized that PARTIAL is never called in the `toX` operations, and it's actually exclusively called in `with` methods. We already have a sort of precedence for partials with `PartialDuration`. There's some benefits to this: we can have a with method on the native rust side, ideally the complexity that exists in `PrepareTemporalFields` can be made a bit easier to reason about. Potential negatives: we might end up deviating from the specification as far as the order of when errors are thrown and observability (TBD...potentially a total non-issue) and this is probably opening up a can of worms around what would be the ideal API for a `PartialDate`, `PartialDateTime`, and `PartialTime`. That all being said, I think the benefits do most likely outweigh any negatives, and it would be really cool to have `with` method implementations. I'm just not entirely sure around the API. Also, there's an addition of a `MonthCode` enum to make `From for TemporalFields` implementations easier. --- src/components/calendar.rs | 35 ++++++++-- src/components/date.rs | 136 ++++++++++++++++++++++++++++++++++++- src/components/mod.rs | 92 ++++++++++++++++++++++++- src/fields.rs | 135 +++++++++++++++++++----------------- 4 files changed, 325 insertions(+), 73 deletions(-) diff --git a/src/components/calendar.rs b/src/components/calendar.rs index 338a92c4..0afe13d5 100644 --- a/src/components/calendar.rs +++ b/src/components/calendar.rs @@ -10,7 +10,7 @@ use crate::{ duration::{DateDuration, TimeDuration}, Date, DateTime, Duration, MonthDay, YearMonth, }, - fields::{TemporalFieldKey, TemporalFields}, + fields::{FieldMap, TemporalFields}, iso::{IsoDate, IsoDateSlots}, options::{ArithmeticOverflow, TemporalUnit}, TemporalError, TemporalResult, @@ -310,6 +310,10 @@ impl Calendar { let month_code = MonthCode( fields .month_code + .map(|mc| { + TinyAsciiStr::from_bytes(mc.as_str().as_bytes()) + .expect("MonthCode as_str is always valid.") + }) .ok_or(TemporalError::range().with_message("No MonthCode provided."))?, ); // NOTE: This might preemptively throw as `ICU4X` does not support constraining. @@ -373,6 +377,10 @@ impl Calendar { let month_code = MonthCode( fields .month_code + .map(|mc| { + TinyAsciiStr::from_bytes(mc.as_str().as_bytes()) + .expect("MonthCode as_str is always valid.") + }) .ok_or(TemporalError::range().with_message("No MonthCode provided."))?, ); @@ -626,10 +634,22 @@ impl Calendar { } /// Provides field keys to be ignored depending on the calendar. - pub fn field_keys_to_ignore( - &self, - _keys: &[TemporalFieldKey], - ) -> TemporalResult> { + pub fn field_keys_to_ignore(&self, keys: FieldMap) -> TemporalResult { + let mut ignored_keys = FieldMap::empty(); + if self.is_iso() { + // NOTE: It is okay for ignored keys to have duplicates? + for key in keys.iter() { + ignored_keys.set(key, true); + if key == FieldMap::MONTH { + ignored_keys.set(FieldMap::MONTH_CODE, true); + } else if key == FieldMap::MONTH_CODE { + ignored_keys.set(FieldMap::MONTH, true); + } + } + + return Ok(ignored_keys); + } + // TODO: Research and implement the appropriate KeysToIgnore for all `BuiltinCalendars.` Err(TemporalError::range().with_message("FieldKeysToIgnore is not yet implemented.")) } @@ -677,8 +697,9 @@ impl From for Calendar { #[cfg(test)] mod tests { + use crate::{components::Date, iso::IsoDate, options::TemporalUnit}; - use super::*; + use super::Calendar; #[test] fn date_until_largest_year() { @@ -925,7 +946,7 @@ mod tests { ), ]; - let calendar = Calendar::from_str("iso8601").unwrap(); + let calendar = Calendar::default(); for test in tests { let first = Date::new_unchecked( diff --git a/src/components/date.rs b/src/components/date.rs index 7755a02a..39c3e8c4 100644 --- a/src/components/date.rs +++ b/src/components/date.rs @@ -21,9 +21,49 @@ use std::str::FromStr; use super::{ duration::{normalized::NormalizedDurationRecord, TimeDuration}, - MonthDay, Time, YearMonth, + MonthCode, MonthDay, Time, YearMonth, }; +// TODO: PrepareTemporalFields expects a type error to be thrown when all partial fields are None/undefined. +/// A partial Date that may or may not be complete. +#[derive(Debug, Default, Clone, Copy)] +pub struct PartialDate { + pub(crate) year: Option, + pub(crate) month: Option, + pub(crate) month_code: Option, + pub(crate) day: Option, + pub(crate) era: Option>, + pub(crate) era_year: Option, +} + +impl PartialDate { + /// Create a new `PartialDate` + pub fn new( + year: Option, + month: Option, + month_code: Option, + day: Option, + era: Option>, + era_year: Option, + ) -> TemporalResult { + if !(day.is_some() + && (month.is_some() || month_code.is_some()) + && (year.is_some() || (era.is_some() && era_year.is_some()))) + { + return Err(TemporalError::r#type() + .with_message("A partial date must have at least one defined field.")); + } + Ok(Self { + year, + month, + month_code, + day, + era, + era_year, + }) + } +} + /// The native Rust implementation of `Temporal.PlainDate`. #[non_exhaustive] #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -210,6 +250,28 @@ impl Date { Ok(Self::new_unchecked(iso, calendar)) } + /// Creates a date time with values from a `PartialDate`. + pub fn with( + &self, + partial: PartialDate, + overflow: Option, + ) -> TemporalResult { + // 6. Let fieldsResult be ? PrepareCalendarFieldsAndFieldNames(calendarRec, temporalDate, « "day", "month", "monthCode", "year" »). + let fields = TemporalFields::from(self); + // 7. Let partialDate be ? PrepareTemporalFields(temporalDateLike, fieldsResult.[[FieldNames]], partial). + let partial_fields = TemporalFields::from(partial); + + // 8. Let fields be ? CalendarMergeFields(calendarRec, fieldsResult.[[Fields]], partialDate). + let mut merge_result = fields.merge_fields(&partial_fields, self.calendar())?; + + // 9. Set fields to ? PrepareTemporalFields(fields, fieldsResult.[[FieldNames]], «»). + // 10. Return ? CalendarDateFromFields(calendarRec, fields, resolvedOptions). + self.calendar.date_from_fields( + &mut merge_result, + overflow.unwrap_or(ArithmeticOverflow::Constrain), + ) + } + /// Creates a new `Date` from the current `Date` and the provided calendar. pub fn with_calendar(&self, calendar: Calendar) -> TemporalResult { Self::new( @@ -396,7 +458,7 @@ impl Date { /// Converts the current `Date` into a `YearMonth` #[inline] pub fn to_year_month(&self) -> TemporalResult { - let mut fields: TemporalFields = self.iso_date().into(); + let mut fields: TemporalFields = self.into(); self.get_calendar() .year_month_from_fields(&mut fields, ArithmeticOverflow::Constrain) } @@ -404,7 +466,7 @@ impl Date { /// Converts the current `Date` into a `MonthDay` #[inline] pub fn to_month_day(&self) -> TemporalResult { - let mut fields: TemporalFields = self.iso_date().into(); + let mut fields: TemporalFields = self.into(); self.get_calendar() .month_day_from_fields(&mut fields, ArithmeticOverflow::Constrain) } @@ -577,6 +639,74 @@ mod tests { assert_eq!(result.days(), 9719.0,); } + #[test] + fn basic_date_with() { + let base = Date::new( + 1976, + 11, + 18, + Calendar::default(), + ArithmeticOverflow::Constrain, + ) + .unwrap(); + + // Year + let partial = PartialDate { + year: Some(2019), + ..Default::default() + }; + let with_year = base.with(partial, None).unwrap(); + assert_eq!(with_year.year().unwrap(), 2019); + assert_eq!(with_year.month().unwrap(), 11); + assert_eq!( + with_year.month_code().unwrap(), + TinyAsciiStr::<4>::from_str("M11").unwrap() + ); + assert_eq!(with_year.day().unwrap(), 18); + + // Month + let partial = PartialDate { + month: Some(5), + ..Default::default() + }; + let with_month = base.with(partial, None).unwrap(); + assert_eq!(with_month.year().unwrap(), 1976); + assert_eq!(with_month.month().unwrap(), 5); + assert_eq!( + with_month.month_code().unwrap(), + TinyAsciiStr::<4>::from_str("M05").unwrap() + ); + assert_eq!(with_month.day().unwrap(), 18); + + // Month Code + let partial = PartialDate { + month_code: Some(MonthCode::Five), + ..Default::default() + }; + let with_mc = base.with(partial, None).unwrap(); + assert_eq!(with_mc.year().unwrap(), 1976); + assert_eq!(with_mc.month().unwrap(), 5); + assert_eq!( + with_mc.month_code().unwrap(), + TinyAsciiStr::<4>::from_str("M05").unwrap() + ); + assert_eq!(with_mc.day().unwrap(), 18); + + // Day + let partial = PartialDate { + day: Some(17), + ..Default::default() + }; + let with_day = base.with(partial, None).unwrap(); + assert_eq!(with_day.year().unwrap(), 1976); + assert_eq!(with_day.month().unwrap(), 11); + assert_eq!( + with_day.month_code().unwrap(), + TinyAsciiStr::<4>::from_str("M11").unwrap() + ); + assert_eq!(with_day.day().unwrap(), 17); + } + // test262/test/built-ins/Temporal/Calendar/prototype/month/argument-string-invalid.js #[test] fn invalid_strings() { diff --git a/src/components/mod.rs b/src/components/mod.rs index b8daea0d..3e43207e 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -28,8 +28,10 @@ mod time; mod year_month; mod zoneddatetime; +use std::str::FromStr; + #[doc(inline)] -pub use date::Date; +pub use date::{Date, PartialDate}; #[doc(inline)] pub use datetime::DateTime; #[doc(inline)] @@ -45,3 +47,91 @@ pub use year_month::YearMonth; pub use year_month::YearMonthFields; #[doc(inline)] pub use zoneddatetime::ZonedDateTime; + +use crate::TemporalError; + +// TODO: Update to account for https://tc39.es/proposal-intl-era-monthcode/ +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] +pub enum MonthCode { + One = 1, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, + Nine, + Ten, + Eleven, + Twelve, + Thirteen, +} + +impl MonthCode { + pub fn as_str(&self) -> &str { + match self { + Self::One => "M01", + Self::Two => "M02", + Self::Three => "M03", + Self::Four => "M04", + Self::Five => "M05", + Self::Six => "M06", + Self::Seven => "M07", + Self::Eight => "M08", + Self::Nine => "M09", + Self::Ten => "M10", + Self::Eleven => "M11", + Self::Twelve => "M12", + Self::Thirteen => "M13", + } + } +} + +impl FromStr for MonthCode { + type Err = TemporalError; + fn from_str(s: &str) -> Result { + match s { + "M01" => Ok(Self::One), + "M02" => Ok(Self::Two), + "M03" => Ok(Self::Three), + "M04" => Ok(Self::Four), + "M05" => Ok(Self::Five), + "M06" => Ok(Self::Six), + "M07" => Ok(Self::Seven), + "M08" => Ok(Self::Eight), + "M09" => Ok(Self::Nine), + "M10" => Ok(Self::Ten), + "M11" => Ok(Self::Eleven), + "M12" => Ok(Self::Twelve), + "M13" => Ok(Self::Thirteen), + _ => { + Err(TemporalError::range() + .with_message("monthCode is not within the valid values.")) + } + } + } +} + +impl TryFrom for MonthCode { + type Error = TemporalError; + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::One), + 2 => Ok(Self::Two), + 3 => Ok(Self::Three), + 4 => Ok(Self::Four), + 5 => Ok(Self::Five), + 6 => Ok(Self::Six), + 7 => Ok(Self::Seven), + 8 => Ok(Self::Eight), + 9 => Ok(Self::Nine), + 10 => Ok(Self::Ten), + 11 => Ok(Self::Eleven), + 12 => Ok(Self::Twelve), + 13 => Ok(Self::Thirteen), + _ => Err(TemporalError::range().with_message("Invalid MonthCode value.")), + } + } +} diff --git a/src/fields.rs b/src/fields.rs index 35f26f6a..0a26ca9f 100644 --- a/src/fields.rs +++ b/src/fields.rs @@ -4,9 +4,8 @@ use core::fmt; use std::str::FromStr; use crate::{ - components::{calendar::Calendar, YearMonthFields}, + components::{calendar::Calendar, Date, MonthCode, PartialDate, YearMonthFields}, error::TemporalError, - iso::IsoDate, TemporalResult, }; @@ -210,7 +209,7 @@ pub struct TemporalFields { bit_map: FieldMap, pub(crate) year: Option, pub(crate) month: Option, - pub(crate) month_code: Option>, + pub(crate) month_code: Option, pub(crate) day: Option, hour: i32, minute: i32, @@ -260,7 +259,8 @@ impl TemporalFields { TemporalFieldKey::Year => Some(TemporalFieldValue::Integer(self.year)), TemporalFieldKey::Month => Some(TemporalFieldValue::Integer(self.month)), TemporalFieldKey::MonthCode => Some(TemporalFieldValue::String( - self.month_code.map_or(String::default(), |s| s.to_string()), + self.month_code + .map_or(String::default(), |s| s.as_str().to_owned()), )), TemporalFieldKey::Day => Some(TemporalFieldValue::Integer(self.day)), TemporalFieldKey::Hour => Some(TemporalFieldValue::from(self.hour)), @@ -312,10 +312,7 @@ impl TemporalFields { TemporalError::r#type().with_message("Invalid type for temporal field.") ); }; - self.month_code = Some( - TinyAsciiStr::<4>::from_str(&value) - .map_err(|_| TemporalError::general("Invalid MonthCode id."))?, - ); + self.month_code = Some(MonthCode::from_str(&value)?); } TemporalFieldKey::Day => { let TemporalFieldValue::Integer(value) = value else { @@ -436,9 +433,9 @@ impl TemporalFields { // MonthCode is present and needs to be resolved. - let month_code_integer = month_code_to_integer(mc)?; + let month_code_int: i32 = (mc as u8).into(); - if self.month.is_some() && self.month != Some(month_code_integer) { + if self.month.is_some() && self.month != Some(month_code_int) { return Err( TemporalError::range().with_message("month and monthCode cannot be resolved.") ); @@ -446,7 +443,7 @@ impl TemporalFields { self.insert( TemporalFieldKey::Month, - TemporalFieldValue::from(month_code_integer), + TemporalFieldValue::from(month_code_int), )?; Ok(()) @@ -455,39 +452,84 @@ impl TemporalFields { // TODO: Determine if this should be moved to `Calendar`. /// Merges two `TemporalFields` depending on the calendar. #[inline] - pub fn merge_fields(&self, other: &Self, calendar: Calendar) -> TemporalResult { - let add_keys = other.keys().collect::>(); - let overridden_keys = calendar.field_keys_to_ignore(&add_keys)?; + pub fn merge_fields(&self, other: &Self, calendar: &Calendar) -> TemporalResult { + let overridden_keys = calendar.field_keys_to_ignore(other.bit_map)?; let mut result = Self::default(); - for key in self.keys() { - let value = if overridden_keys.contains(&key) { - other.get(key) + for key in self.bit_map.iter() { + let value = if overridden_keys.contains(key) { + other.get(key.try_into()?) } else { - self.get(key) + self.get(key.try_into()?) }; - let Some(value) = value else { - return Err(TemporalError::general( - "Nonexistent TemporalFieldKey used when merging fields.", - )); + if let Some(value) = value { + result.insert(key.try_into()?, value)?; }; - - result.insert(key, value)?; } Ok(result) } } -impl From for TemporalFields { - fn from(value: IsoDate) -> Self { +impl From<&Date> for TemporalFields { + fn from(value: &Date) -> Self { + Self { + bit_map: FieldMap::YEAR | FieldMap::MONTH | FieldMap::MONTH_CODE | FieldMap::DAY, + year: Some(value.iso.year), + month: Some(value.iso.month.into()), + month_code: Some( + MonthCode::try_from(value.iso.month).expect("Date must always have a valid month."), + ), + day: Some(value.iso.day.into()), + ..Default::default() + } + } +} + +impl From for TemporalFields { + fn from(value: PartialDate) -> Self { + let mut bit_map = FieldMap::empty(); + if value.year.is_some() { + bit_map.set(FieldMap::YEAR, true) + }; + if value.month.is_some() { + bit_map.set(FieldMap::MONTH, true) + }; + if value.month_code.is_some() { + bit_map.set(FieldMap::MONTH_CODE, true) + }; + if value.day.is_some() { + bit_map.set(FieldMap::DAY, true) + }; + if value.era.is_some() { + bit_map.set(FieldMap::ERA, true) + } + if value.era_year.is_some() { + bit_map.set(FieldMap::ERA_YEAR, true) + } + Self { - bit_map: FieldMap::YEAR | FieldMap::MONTH | FieldMap::DAY, - year: Some(value.year), - month: Some(value.month.into()), - day: Some(value.day.into()), + bit_map, + year: value.year, + month: value.month, + month_code: value.month_code, + day: value.day, + era: value.era, + era_year: value.era_year, + ..Default::default() + } + } +} + +// Conversion to `TemporalFields` +impl From for TemporalFields { + fn from(value: YearMonthFields) -> Self { + TemporalFields { + bit_map: FieldMap::YEAR | FieldMap::MONTH, + year: Some(value.0), + month: Some(value.1.into()), ..Default::default() } } @@ -536,7 +578,7 @@ impl Iterator for Values<'_> { FieldMap::MONTH_CODE => Some(TemporalFieldValue::String( self.fields .month_code - .map_or(String::default(), |s| s.to_string()), + .map_or(String::default(), |s| s.as_str().to_owned()), )), FieldMap::DAY => Some(TemporalFieldValue::Integer(self.fields.day)), FieldMap::HOUR => Some(TemporalFieldValue::from(self.fields.hour)), @@ -563,34 +605,3 @@ impl Iterator for Values<'_> { } } } - -fn month_code_to_integer(mc: TinyAsciiStr<4>) -> TemporalResult { - match mc.as_str() { - "M01" => Ok(1), - "M02" => Ok(2), - "M03" => Ok(3), - "M04" => Ok(4), - "M05" => Ok(5), - "M06" => Ok(6), - "M07" => Ok(7), - "M08" => Ok(8), - "M09" => Ok(9), - "M10" => Ok(10), - "M11" => Ok(11), - "M12" => Ok(12), - "M13" => Ok(13), - _ => Err(TemporalError::range().with_message("monthCode is not within the valid values.")), - } -} - -// Conversion to `TemporalFields` -impl From for TemporalFields { - fn from(value: YearMonthFields) -> Self { - TemporalFields { - bit_map: FieldMap::YEAR | FieldMap::MONTH, - year: Some(value.0), - month: Some(value.1.into()), - ..Default::default() - } - } -}