diff --git a/crates/temporal/src/builtins/compiled/zoned_date_time.rs b/crates/temporal/src/builtins/compiled/zoned_date_time.rs index 4a8e4dd19..36c30d05b 100644 --- a/crates/temporal/src/builtins/compiled/zoned_date_time.rs +++ b/crates/temporal/src/builtins/compiled/zoned_date_time.rs @@ -214,11 +214,27 @@ impl ZonedDateTime { ) -> TemporalResult { ZonedDateTime::from_utf8_with_provider(source, disambiguation, offset_option, &*TZ_PROVIDER) } + + /// Attempts to parse and create a `ZonedDateTime` from a UTF-16 encoded IXDTF string. + /// + /// Enable with the `compiled_data` feature flag. + pub fn from_utf16( + source: &[u16], + disambiguation: Disambiguation, + offset_option: OffsetDisambiguation, + ) -> TemporalResult { + ZonedDateTime::from_utf16_with_provider( + source, + disambiguation, + offset_option, + &*TZ_PROVIDER, + ) + } /// Attempts to parse and create a `ZonedDateTime` from an IXDTF formatted [`&str`]. /// /// Enable with the `compiled_data` feature flag. pub fn from_parsed( - parsed: crate::parsed_intermediates::ParsedZonedDateTime, + parsed: crate::parsers::intermediates::ParsedZonedDateTime, disambiguation: Disambiguation, offset_option: OffsetDisambiguation, ) -> TemporalResult { diff --git a/crates/temporal/src/builtins/core/calendar.rs b/crates/temporal/src/builtins/core/calendar.rs index 58c22f865..e7129de18 100644 --- a/crates/temporal/src/builtins/core/calendar.rs +++ b/crates/temporal/src/builtins/core/calendar.rs @@ -10,7 +10,7 @@ use crate::{ error::ErrorMessage, iso::IsoDate, options::{Overflow, Unit}, - parsers::parse_allowed_calendar_formats, + parsers::TemporalEncoding, TemporalError, TemporalResult, }; use core::str::FromStr; @@ -179,6 +179,12 @@ impl Calendar { Ok(Self::new(kind)) } + /// Returns a `Calendar` from a slice of UTF-16 encoded code units. + pub fn try_from_utf16(code_units: &[u16]) -> TemporalResult { + let ascii = crate::parsers::Utf16::ascii_identifier(code_units)?; + Self::try_from_utf8(&ascii) + } + /// Returns a `Calendar` from the a slice of UTF-8 encoded bytes. pub(crate) fn try_kind_from_utf8(bytes: &[u8]) -> TemporalResult { // TODO: Determine the best way to handle "julian" here. @@ -207,11 +213,7 @@ impl FromStr for Calendar { // 13.34 ParseTemporalCalendarString ( string ) fn from_str(s: &str) -> Result { - match parse_allowed_calendar_formats(s.as_bytes()) { - Some([]) => Ok(Calendar::ISO), - Some(result) => Calendar::try_from_utf8(result), - None => Calendar::try_from_utf8(s.as_bytes()), - } + crate::parsers::TemporalParser::from_utf8(s.as_bytes()).parse_calendar() } } @@ -795,6 +797,19 @@ mod tests { let _err = Calendar::from_str(cal_str).unwrap_err(); } + #[test] + fn try_from_utf16_multi_segment_id() { + // Regression test: `islamic-umalqura` is two dash-joined BCP-47 subtags + // and exceeds the 8-ASCII-char bound of a single subtag. + let cal_str = "islamic-umalqura"; + let utf16: alloc::vec::Vec = cal_str.encode_utf16().collect(); + assert_eq!( + Calendar::try_from_utf8(cal_str.as_bytes()), + Calendar::try_from_utf16(&utf16) + ); + assert!(Calendar::try_from_utf16(&utf16).is_ok()); + } + #[test] fn date_until_largest_year() { // tests format: (Date one, PlainDate two, Duration result) diff --git a/crates/temporal/src/builtins/core/duration.rs b/crates/temporal/src/builtins/core/duration.rs index a49f84ced..218aa6790 100644 --- a/crates/temporal/src/builtins/core/duration.rs +++ b/crates/temporal/src/builtins/core/duration.rs @@ -8,6 +8,7 @@ use crate::{ Overflow, RelativeTo, ResolvedRoundingOptions, RoundingIncrement, RoundingOptions, ToStringRoundingOptions, Unit, UnitGroup, }, + parsers::TemporalParser, parsers::{FormattableDateDuration, FormattableDuration, FormattableTimeDuration, Precision}, primitive::FiniteF64, provider::TimeZoneProvider, @@ -16,7 +17,9 @@ use crate::{ use alloc::string::String; use core::{cmp::Ordering, str::FromStr}; use ixdtf::{ - encoding::Utf8, parsers::IsoDurationParser, records::Fraction, records::TimeDurationRecord, + encoding::{Utf16, Utf8}, + records::Fraction, + records::TimeDurationRecord, }; use normalized::InternalDurationRecord; use num_traits::Euclid; @@ -714,8 +717,17 @@ impl Duration { // Converts a UTF-8 encoded string into a `Duration`. pub fn from_utf8(s: &[u8]) -> TemporalResult { - let parse_record = IsoDurationParser::::from_utf8(s).parse()?; + TemporalParser::::from_utf8(s).parse_duration() + } + + /// Converts a UTF-16 encoded string into a `Duration`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + TemporalParser::::from_utf16(s).parse_duration() + } + pub(crate) fn from_duration_record( + parse_record: ixdtf::records::DurationParseRecord, + ) -> TemporalResult { fn fraction_to_unadjusted_ns(fraction: Option) -> Result { if let Some(fraction) = fraction { fraction.to_nanoseconds().ok_or( diff --git a/crates/temporal/src/builtins/core/instant.rs b/crates/temporal/src/builtins/core/instant.rs index 542ce0951..e0eb07b37 100644 --- a/crates/temporal/src/builtins/core/instant.rs +++ b/crates/temporal/src/builtins/core/instant.rs @@ -11,7 +11,7 @@ use crate::{ DifferenceOperation, DifferenceSettings, DisplayOffset, ResolvedRoundingOptions, RoundingOptions, ToStringRoundingOptions, Unit, UnitGroup, }, - parsers::{parse_instant, IxdtfStringBuilder}, + parsers::IxdtfStringBuilder, provider::TimeZoneProvider, rounding::IncrementRounder, unix_time::EpochNanoseconds, @@ -296,8 +296,17 @@ impl Instant { // Converts a UTF-8 encoded string into a `Instant`. pub fn from_utf8(s: &[u8]) -> TemporalResult { - let ixdtf_record = parse_instant(s)?; + crate::parsers::TemporalParser::from_utf8(s).parse_instant() + } + + /// Converts a UTF-16 encoded string into a `Instant`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + crate::parsers::TemporalParser::from_utf16(s).parse_instant() + } + pub(crate) fn from_parsed_record( + ixdtf_record: crate::parsers::ParsedInstantRecord, + ) -> TemporalResult { // Find the offset let ns_offset = match ixdtf_record.offset { UtcOffsetRecordOrZ::Offset(offset) => { @@ -505,6 +514,16 @@ mod tests { assert!(Instant::try_new(min_minus_one).is_err()); } + #[test] + fn from_utf16_matches_from_utf8() { + let s = "2024-07-19T15:43:12.5Z"; + let utf16: alloc::vec::Vec = s.encode_utf16().collect(); + assert_eq!( + Instant::from_utf8(s.as_bytes()), + Instant::from_utf16(&utf16) + ); + } + #[test] fn max_min_epoch_millseconds() { // Assert the casting is valid. diff --git a/crates/temporal/src/builtins/core/plain_date.rs b/crates/temporal/src/builtins/core/plain_date.rs index 15be81bd2..0cd1c0d5d 100644 --- a/crates/temporal/src/builtins/core/plain_date.rs +++ b/crates/temporal/src/builtins/core/plain_date.rs @@ -1,7 +1,7 @@ //! This module implements `PlainDate` and any directly related algorithms. use crate::error::ErrorMessage; -use crate::parsed_intermediates::ParsedDate; +use crate::parsers::intermediates::ParsedDate; use crate::{ builtins::{ calendar::{CalendarFields, YearMonthCalendarFields}, @@ -426,6 +426,13 @@ impl PlainDate { Self::from_parsed(parsed) } + /// Converts a UTF-16 encoded string into a `PlainDate`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + let parsed = ParsedDate::from_utf16(s)?; + + Self::from_parsed(parsed) + } + /// Creates a `PlainDate` from a [`ParsedDate`]. pub fn from_parsed(parsed: ParsedDate) -> TemporalResult { Self::try_new( @@ -757,6 +764,16 @@ mod tests { ); } + #[test] + fn from_utf16_matches_from_utf8() { + let s = "2024-07-19[u-ca=hebrew]"; + let utf16: alloc::vec::Vec = s.encode_utf16().collect(); + assert_eq!( + PlainDate::from_utf8(s.as_bytes()), + PlainDate::from_utf16(&utf16) + ); + } + #[test] fn simple_date_add() { let base = PlainDate::from_str("1976-11-18").unwrap(); diff --git a/crates/temporal/src/builtins/core/plain_date_time.rs b/crates/temporal/src/builtins/core/plain_date_time.rs index 548e46c74..cda6eca49 100644 --- a/crates/temporal/src/builtins/core/plain_date_time.rs +++ b/crates/temporal/src/builtins/core/plain_date_time.rs @@ -5,7 +5,7 @@ use super::{ ZonedDateTime, }; use crate::error::ErrorMessage; -use crate::parsed_intermediates::ParsedDateTime; +use crate::parsers::intermediates::ParsedDateTime; use crate::{ builtins::{ calendar::CalendarFields, @@ -597,6 +597,12 @@ impl PlainDateTime { Self::from_parsed(parsed) } + /// Converts a UTF-16 encoded string into a `PlainDateTime`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + let parsed = ParsedDateTime::from_utf16(s)?; + Self::from_parsed(parsed) + } + pub fn from_parsed(parsed: ParsedDateTime) -> TemporalResult { let date = IsoDate::new_with_overflow( parsed.date.record.year, diff --git a/crates/temporal/src/builtins/core/plain_month_day.rs b/crates/temporal/src/builtins/core/plain_month_day.rs index 198dd96e2..03ffd11d0 100644 --- a/crates/temporal/src/builtins/core/plain_month_day.rs +++ b/crates/temporal/src/builtins/core/plain_month_day.rs @@ -8,8 +8,9 @@ use crate::{ error::ErrorMessage, iso::{IsoDate, IsoDateTime, IsoTime}, options::{Disambiguation, DisplayCalendar, Overflow}, - parsed_intermediates::ParsedDate, - parsers::{FormattableCalendar, FormattableDate, FormattableMonthDay}, + parsers::{ + intermediates::ParsedDate, FormattableCalendar, FormattableDate, FormattableMonthDay, + }, provider::TimeZoneProvider, unix_time::EpochNanoseconds, Calendar, MonthCode, TemporalError, TemporalResult, TimeZone, @@ -215,6 +216,12 @@ impl PlainMonthDay { Self::from_parsed(parsed) } + /// Converts a UTF-16 encoded string into a `PlainMonthDay`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + let parsed = ParsedDate::month_day_from_utf16(s)?; + Self::from_parsed(parsed) + } + // Converts a ParsedDate into a `PlainMonthDay`. // // Be sure to parse this using [`ParsedDate::month_day_from_utf8()`]~ diff --git a/crates/temporal/src/builtins/core/plain_time.rs b/crates/temporal/src/builtins/core/plain_time.rs index d7e78a3aa..03ede3683 100644 --- a/crates/temporal/src/builtins/core/plain_time.rs +++ b/crates/temporal/src/builtins/core/plain_time.rs @@ -12,7 +12,7 @@ use crate::{ DifferenceOperation, DifferenceSettings, Overflow, ResolvedRoundingOptions, RoundingOptions, ToStringRoundingOptions, Unit, UnitGroup, }, - parsers::{parse_time, IxdtfStringBuilder}, + parsers::IxdtfStringBuilder, unix_time::EpochNanoseconds, TemporalError, TemporalResult, }; @@ -410,9 +410,12 @@ impl PlainTime { // Converts a UTF-8 encoded string into a `PlainTime`. pub fn from_utf8(s: &[u8]) -> TemporalResult { - let result = parse_time(s)?; - let iso = IsoTime::from_time_record(result)?; - Ok(Self::new_unchecked(iso)) + crate::parsers::TemporalParser::from_utf8(s).parse_time() + } + + /// Converts a UTF-16 encoded string into a `PlainTime`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + crate::parsers::TemporalParser::from_utf16(s).parse_time() } /// Creates a new `PlainTime` using the current `PlainTime` fields as a fallback. diff --git a/crates/temporal/src/builtins/core/plain_year_month.rs b/crates/temporal/src/builtins/core/plain_year_month.rs index c2e3ec63b..1f72ed19f 100644 --- a/crates/temporal/src/builtins/core/plain_year_month.rs +++ b/crates/temporal/src/builtins/core/plain_year_month.rs @@ -13,8 +13,9 @@ use crate::{ DifferenceOperation, DifferenceSettings, Disambiguation, DisplayCalendar, Overflow, ResolvedRoundingOptions, RoundingIncrement, Unit, UnitGroup, }, - parsed_intermediates::ParsedDate, - parsers::{FormattableCalendar, FormattableDate, FormattableYearMonth}, + parsers::{ + intermediates::ParsedDate, FormattableCalendar, FormattableDate, FormattableYearMonth, + }, provider::{NeverProvider, TimeZoneProvider}, unix_time::EpochNanoseconds, Calendar, MonthCode, TemporalError, TemporalResult, TimeZone, @@ -419,6 +420,12 @@ impl PlainYearMonth { Self::from_parsed(parsed) } + /// Converts a UTF-16 encoded string into a `PlainYearMonth`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + let parsed = ParsedDate::year_month_from_utf16(s)?; + Self::from_parsed(parsed) + } + /// Converts a ParsedDate into a `PlainYearMonth`. /// /// Be sure to parse this using [`ParsedDate::year_month_from_utf8()`]~ diff --git a/crates/temporal/src/builtins/core/time_zone.rs b/crates/temporal/src/builtins/core/time_zone.rs index 8a2e40df8..74801539b 100644 --- a/crates/temporal/src/builtins/core/time_zone.rs +++ b/crates/temporal/src/builtins/core/time_zone.rs @@ -2,16 +2,12 @@ use alloc::string::String; -use ixdtf::encoding::Utf8; -use ixdtf::{ - parsers::TimeZoneParser, - records::{MinutePrecisionOffset, TimeZoneRecord, UtcOffsetRecord}, -}; +use ixdtf::records::{MinutePrecisionOffset, TimeZoneRecord, UtcOffsetRecord}; use num_traits::ToPrimitive; use crate::error::ErrorMessage; use crate::parsers::{ - parse_allowed_timezone_formats, parse_identifier, FormattableOffset, FormattableTime, Precision, + FormattableOffset, FormattableTime, Precision, TemporalEncoding, TemporalParser, Utf16, Utf8, }; use crate::provider::{CandidateEpochNanoseconds, TimeZoneId, TimeZoneProvider}; use crate::Sign; @@ -75,9 +71,14 @@ impl UtcOffset { } } + /// Converts a UTF-8 encoded string into a `UtcOffset`. pub fn from_utf8(source: &[u8]) -> TemporalResult { - let record = TimeZoneParser::from_utf8(source).parse_offset()?; - Self::from_ixdtf_record(record) + TemporalParser::::from_utf8(source).parse_utc_offset() + } + + /// Converts a UTF-16 encoded string into a `UtcOffset`. + pub fn from_utf16(source: &[u16]) -> TemporalResult { + TemporalParser::::from_utf16(source).parse_utc_offset() } #[allow(clippy::inherent_to_string)] @@ -170,12 +171,15 @@ pub enum TimeZone { impl TimeZone { // Create a `TimeZone` from an ixdtf `TimeZoneRecord`. #[inline] - pub(crate) fn from_time_zone_record( - record: TimeZoneRecord, + pub(crate) fn from_time_zone_record( + record: TimeZoneRecord, provider: &(impl TimeZoneProvider + ?Sized), ) -> TemporalResult { let timezone = match record { - TimeZoneRecord::Name(name) => TimeZone::IanaIdentifier(provider.get(name)?), + TimeZoneRecord::Name(name) => { + let ascii = T::ascii_identifier(name)?; + TimeZone::IanaIdentifier(provider.get(&ascii)?) + } TimeZoneRecord::Offset(offset_record) => { let offset = UtcOffset::from_ixdtf_minute_record(offset_record); TimeZone::UtcOffset(offset) @@ -192,13 +196,23 @@ impl TimeZone { identifier: &str, provider: &(impl TimeZoneProvider + ?Sized), ) -> TemporalResult { - parse_identifier(identifier).map(|tz| match tz { - TimeZoneRecord::Name(name) => Ok(TimeZone::IanaIdentifier(provider.get(name)?)), - TimeZoneRecord::Offset(minute_precision_offset) => Ok(TimeZone::UtcOffset( - UtcOffset::from_ixdtf_minute_record(minute_precision_offset), - )), - _ => Err(TemporalError::range().with_message("Invalid TimeZone Identifier")), - })? + Self::try_from_identifier_encoded::(identifier.as_bytes(), provider) + } + + /// Parses a `TimeZone` from a provided UTF-16 identifier. + pub fn try_from_identifier_utf16_with_provider( + identifier: &[u16], + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + Self::try_from_identifier_encoded::(identifier, provider) + } + + fn try_from_identifier_encoded( + identifier: &[T::CodeUnit], + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + TemporalParser::::from_encoded(identifier) + .parse_time_zone_identifier_with_provider(provider) } #[cfg(feature = "compiled_data")] @@ -212,10 +226,28 @@ impl TimeZone { src: &str, provider: &(impl TimeZoneProvider + ?Sized), ) -> TemporalResult { - if let Ok(timezone) = Self::try_from_identifier_str_with_provider(src, provider) { + Self::try_from_encoded::(src.as_bytes(), provider) + } + + /// Parse a `TimeZone` from a UTF-16 encoded string. + /// + /// This is the equivalent to [`ParseTemporalTimeZoneString`](https://tc39.es/proposal-temporal/#sec-temporal-parsetemporaltimezonestring) + pub fn try_from_utf16_with_provider( + src: &[u16], + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + Self::try_from_encoded::(src, provider) + } + + fn try_from_encoded( + src: &[T::CodeUnit], + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + if let Ok(timezone) = Self::try_from_identifier_encoded::(src, provider) { return Ok(timezone); } - parse_allowed_timezone_formats(src, provider) + TemporalParser::::from_encoded(src) + .parse_time_zone_with_provider(provider) .ok_or_else(|| TemporalError::range().with_message("Not a valid time zone string")) } @@ -224,6 +256,11 @@ impl TimeZone { Self::try_from_str_with_provider(src, &*crate::builtins::TZ_PROVIDER) } + #[cfg(feature = "compiled_data")] + pub fn try_from_utf16(src: &[u16]) -> TemporalResult { + Self::try_from_utf16_with_provider(src, &*crate::builtins::TZ_PROVIDER) + } + /// Returns the current `TimeZoneSlot`'s identifier. pub fn identifier_with_provider( &self, @@ -614,6 +651,18 @@ mod tests { assert_eq!(tz.identifier().unwrap(), src); } + #[test] + #[cfg(feature = "compiled_data")] + fn from_utf16_matches_from_str() { + for src in ["+09:30", "America/New_York", "Asia/Kolkata"] { + let utf16: alloc::vec::Vec = src.encode_utf16().collect(); + assert_eq!( + TimeZone::try_from_str(src), + TimeZone::try_from_utf16(&utf16) + ); + } + } + #[test] #[cfg(feature = "compiled_data")] fn canonicalize_equals() { diff --git a/crates/temporal/src/builtins/core/zoned_date_time.rs b/crates/temporal/src/builtins/core/zoned_date_time.rs index 417d83259..65716c34e 100644 --- a/crates/temporal/src/builtins/core/zoned_date_time.rs +++ b/crates/temporal/src/builtins/core/zoned_date_time.rs @@ -23,8 +23,10 @@ use crate::{ DisplayTimeZone, OffsetDisambiguation, Overflow, ResolvedRoundingOptions, RoundingIncrement, RoundingMode, RoundingOptions, ToStringRoundingOptions, Unit, UnitGroup, }, - parsed_intermediates::ParsedZonedDateTime, - parsers::{FormattableOffset, FormattableTime, IxdtfStringBuilder, Precision}, + parsers::{ + intermediates::ParsedZonedDateTime, FormattableOffset, FormattableTime, IxdtfStringBuilder, + Precision, + }, partial::PartialTime, primitive::FiniteF64, provider::{TimeZoneProvider, TransitionDirection, UtcOffsetSeconds}, @@ -1421,6 +1423,18 @@ impl ZonedDateTime { Self::from_parsed_with_provider(parsed, disambiguation, offset_option, provider) } + /// Attempts to parse and create a `ZonedDateTime` from a UTF-16 encoded IXDTF string. + pub fn from_utf16_with_provider( + source: &[u16], + disambiguation: Disambiguation, + offset_option: OffsetDisambiguation, + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + let parsed = ParsedZonedDateTime::from_utf16_with_provider(source, provider)?; + + Self::from_parsed_with_provider(parsed, disambiguation, offset_option, provider) + } + pub fn from_parsed_with_provider( parsed: ParsedZonedDateTime, disambiguation: Disambiguation, diff --git a/crates/temporal/src/error.rs b/crates/temporal/src/error.rs index b9c2955cd..773912c8b 100644 --- a/crates/temporal/src/error.rs +++ b/crates/temporal/src/error.rs @@ -210,7 +210,6 @@ pub(crate) enum ErrorMessage { EmptyFieldsIsInvalid, // Parsing - ParserNeedsDate, FractionalTimeMoreThanNineDigits, // Other @@ -260,7 +259,6 @@ impl ErrorMessage { Self::TzMismatch => "Timezones must be the same if unit is a day unit.", Self::EmptyFieldsIsInvalid => "fields cannot be empty", - Self::ParserNeedsDate => "Could not find a valid DateRecord node during parsing.", Self::FractionalTimeMoreThanNineDigits => "Fractional time exceeds nine digits.", Self::OffsetNeedsDisambiguation => { "Offsets could not be determined without disambiguation" diff --git a/crates/temporal/src/lib.rs b/crates/temporal/src/lib.rs index 36b1db8b2..0f23e785f 100644 --- a/crates/temporal/src/lib.rs +++ b/crates/temporal/src/lib.rs @@ -319,6 +319,7 @@ pub mod partial { }; } +#[deprecated(since = "0.2.7", note = "Use `parsers::intermediates` instead")] pub mod parsed_intermediates; // TODO: Potentially bikeshed how `EpochNanoseconds` should be exported. diff --git a/crates/temporal/src/options/relative_to.rs b/crates/temporal/src/options/relative_to.rs index 771d656aa..f51a4d4ae 100644 --- a/crates/temporal/src/options/relative_to.rs +++ b/crates/temporal/src/options/relative_to.rs @@ -4,9 +4,9 @@ use crate::builtins::core::zoned_date_time::interpret_isodatetime_offset; use crate::builtins::core::{calendar::Calendar, time_zone::TimeZone, PlainDate, ZonedDateTime}; use crate::iso::{IsoDate, IsoTime}; use crate::options::{Disambiguation, OffsetDisambiguation, Overflow}; -use crate::parsers::{parse_date_time, parse_zoned_date_time}; +use crate::parsers::{parse_date_time_record, parse_zoned_date_time_record, Utf8}; use crate::provider::TimeZoneProvider; -use crate::{TemporalResult, TemporalUnwrap}; +use crate::TemporalResult; use ixdtf::records::UtcOffsetRecordOrZ; @@ -40,35 +40,45 @@ impl RelativeTo { source: &str, provider: &(impl TimeZoneProvider + ?Sized), ) -> TemporalResult { - // b. Let result be ? ParseISODateTime(value, « TemporalDateTimeString[+Zoned], TemporalDateTimeString[~Zoned] »). + // b. Let result be ? ParseISODateTime(value, « TemporalDateTimeString[+Zoned], TemporalDateTimeString[~Zoned] »). let bytes = source.as_bytes(); - let result = parse_date_time(bytes).or_else(|_| parse_zoned_date_time(bytes))?; - - let Some(annotation) = result.tz else { - let date_record = result.date.temporal_unwrap()?; + let (date, time, offset, tz, calendar) = match parse_date_time_record::(bytes) { + Ok(record) => ( + record.date, + record.time, + record.offset, + record.tz, + record.calendar, + ), + Err(_) => { + let record = parse_zoned_date_time_record::(bytes)?; + ( + record.date, + record.time, + record.offset, + Some(record.tz), + record.calendar, + ) + } + }; - let calendar = result - .calendar + let Some(annotation) = tz else { + let parsed_calendar = calendar .map(Calendar::try_from_utf8) .transpose()? .unwrap_or_default(); - return Ok(PlainDate::try_new( - date_record.year, - date_record.month, - date_record.day, - calendar, - )? - .into()); + return Ok( + PlainDate::try_new(date.year, date.month, date.day, parsed_calendar)?.into(), + ); }; // iv. Set matchBehaviour to match-minutes. let mut match_minutes = true; - let time_zone = TimeZone::from_time_zone_record(annotation.tz, provider)?; + let time_zone = TimeZone::from_time_zone_record::(annotation.tz, provider)?; - let (offset_nanos, is_exact) = result - .offset + let (offset_nanos, is_exact) = offset .map(|record| { let UtcOffsetRecordOrZ::Offset(offset) = record else { return (None, true); @@ -95,17 +105,14 @@ impl RelativeTo { }) .unwrap_or((None, false)); - let calendar = result - .calendar + let calendar = calendar .map(Calendar::try_from_utf8) .transpose()? .unwrap_or_default(); - let time = result.time.map(IsoTime::from_time_record).transpose()?; + let time = time.map(IsoTime::from_time_record).transpose()?; - let date = result.date.temporal_unwrap()?; let iso = IsoDate::new_with_overflow(date.year, date.month, date.day, Overflow::Constrain)?; - let epoch_ns = interpret_isodatetime_offset( iso, time, diff --git a/crates/temporal/src/parsed_intermediates.rs b/crates/temporal/src/parsed_intermediates.rs index 6b1c46f0d..757a5aa50 100644 --- a/crates/temporal/src/parsed_intermediates.rs +++ b/crates/temporal/src/parsed_intermediates.rs @@ -1,244 +1,22 @@ -//! Parsed intermediate types +//! Deprecated aliases for the parsed intermediate types. //! -//! These are types which have been *parsed* from an IXDTF string, extracting -//! calendar/time zone/etc information, but have not yet been validated. -//! -//! They are primarily useful for clients implementing the Temporal specification -//! since the specification performs observable operations -//! between the parse and validate steps. - -use crate::error::ErrorMessage; -use crate::error::TemporalError; -use crate::iso::IsoTime; -use crate::parsers; -use crate::provider::TimeZoneProvider; -use crate::Calendar; -use crate::TemporalResult; -use crate::TemporalUnwrap; -use crate::TimeZone; -use crate::UtcOffset; -use icu_calendar::AnyCalendarKind; -use ixdtf::records::DateRecord; -use ixdtf::records::UtcOffsetRecordOrZ; - -fn extract_kind(calendar: Option<&[u8]>) -> TemporalResult { - Ok(calendar - .map(Calendar::try_kind_from_utf8) - // Note that this will successfully parse AnyCalendarKind::HijriSimulatedMecca - // However, Calendar::new will immediately turn it into an ISO calendar, - // so we don't need to do anything here. - .transpose()? - .unwrap_or(AnyCalendarKind::Iso)) -} - -/// A parsed-but-not-validated date -#[derive(Copy, Clone, Debug)] -pub struct ParsedDate { - pub(crate) record: DateRecord, - pub(crate) calendar: AnyCalendarKind, -} - -impl ParsedDate { - /// Converts a UTF-8 encoded string into a `ParsedDate`. - pub fn from_utf8(s: &[u8]) -> TemporalResult { - let parse_record = parsers::parse_date_time(s)?; - - let calendar = extract_kind(parse_record.calendar)?; - - // Assertion: PlainDate must exist on a DateTime parse. - let record = parse_record.date.temporal_unwrap()?; - - // TODO: Potentially, remove this check in favor of type guarantee of a - // Temporal parser. - // NOTE: The check here is to confirm that the time component is - // valid. - let _time = parse_record - .time - .map(IsoTime::from_time_record) - .transpose()? - .unwrap_or_default(); - - Ok(Self { record, calendar }) - } - /// Converts a UTF-8 encoded YearMonth string into a `ParsedDate`. - pub fn year_month_from_utf8(s: &[u8]) -> TemporalResult { - let parse_record = parsers::parse_year_month(s)?; - - let calendar = extract_kind(parse_record.calendar)?; - - // Assertion: PlainDate must exist on a DateTime parse. - let record = parse_record.date.temporal_unwrap()?; - - // TODO: Potentially, remove this check in favor of type guarantee of a - // Temporal parser. - // NOTE: The check here is to confirm that the time component is - // valid. - let _time = parse_record - .time - .map(IsoTime::from_time_record) - .transpose()? - .unwrap_or_default(); - - Ok(Self { record, calendar }) - } - - /// Converts a UTF-8 encoded MonthDay string into a `ParsedDate`. - pub fn month_day_from_utf8(s: &[u8]) -> TemporalResult { - let parse_record = parsers::parse_month_day(s)?; - - let calendar = extract_kind(parse_record.calendar)?; - - // Assertion: PlainDate must exist on a DateTime parse. - let record = parse_record.date.temporal_unwrap()?; - - // TODO: Potentially, remove this check in favor of type guarantee of a - // Temporal parser. - // NOTE: The check here is to confirm that the time component is - // valid. - let _time = parse_record - .time - .map(IsoTime::from_time_record) - .transpose()? - .unwrap_or_default(); - - Ok(Self { record, calendar }) - } -} - -/// A parsed-but-not-validated datetime -#[derive(Copy, Clone, Debug)] -pub struct ParsedDateTime { - pub(crate) date: ParsedDate, - pub(crate) time: IsoTime, -} - -impl ParsedDateTime { - /// Converts a UTF-8 encoded string into a `ParsedDateTime`. - pub fn from_utf8(s: &[u8]) -> TemporalResult { - let parse_record = parsers::parse_date_time(s)?; - - let calendar = extract_kind(parse_record.calendar)?; - - let time = parse_record - .time - .map(IsoTime::from_time_record) - .transpose()? - .unwrap_or_default(); - - let record = parse_record.date.temporal_unwrap()?; - Ok(Self { - date: ParsedDate { record, calendar }, - time, - }) - } -} - -/// A parsed-but-not-validated zoned datetime -#[derive(Clone, Debug)] -pub struct ParsedZonedDateTime { - pub(crate) date: ParsedDate, - /// None time is START-OF-DAY - pub(crate) time: Option, - /// Whether or not the string has a UTC designator (`Z`) - /// - /// Incompatible with having an offset (you can still have a offset-format timezone) - pub(crate) has_utc_designator: bool, - /// Whether or not to allow offsets rounded to the minute - /// - /// (Typically only needs to be set when parsing, can be false otherwise) - pub(crate) match_minutes: bool, - /// An optional offset string - pub(crate) offset: Option, - /// The time zone - pub(crate) timezone: TimeZone, -} - -impl ParsedZonedDateTime { - /// Converts a UTF-8 encoded string into a `ParsedZonedDateTime`, using compiled data - #[cfg(feature = "compiled_data")] - pub fn from_utf8(source: &[u8]) -> TemporalResult { - Self::from_utf8_with_provider(source, &*crate::builtins::TZ_PROVIDER) - } - - /// Converts a UTF-8 encoded string into a `ParsedZonedDateTime`. - pub fn from_utf8_with_provider( - source: &[u8], - provider: &(impl TimeZoneProvider + ?Sized), - ) -> TemporalResult { - // Steps from the parse bits of of ToZonedDateTime - - // 3. Let matchBehaviour be match-minutes. - let mut match_minutes = true; - - // b. Let result be ? ParseISODateTime(item, « TemporalDateTimeString[+Zoned] »). - let parse_result = parsers::parse_zoned_date_time(source)?; - - // c. Let annotation be result.[[TimeZone]].[[TimeZoneAnnotation]]. - // d. Assert: annotation is not empty. - // NOTE (nekevss): `parse_zoned_date_time` guarantees that this value exists. - let annotation = parse_result.tz.temporal_unwrap()?; - - // e. Let timeZone be ? ToTemporalTimeZoneIdentifier(annotation). - let time_zone = TimeZone::from_time_zone_record(annotation.tz, provider)?; - - // f. Let offsetString be result.[[TimeZone]].[[OffsetString]]. - let (offset, has_utc_designator) = match parse_result.offset { - // g. If result.[[TimeZone]].[[Z]] is true, then - // i. Set hasUTCDesignator to true. - Some(UtcOffsetRecordOrZ::Z) => (None, true), - Some(UtcOffsetRecordOrZ::Offset(offset)) => { - if offset.second().is_some() { - // iii. If offsetParseResult contains more than one MinuteSecond Parse Node, set matchBehaviour to match-exactly. - match_minutes = false; - } - (Some(UtcOffset::from_ixdtf_record(offset)?), false) - } - None => (None, false), - }; - - // h. Let calendar be result.[[Calendar]]. - // i. If calendar is empty, set calendar to "iso8601". - // j. Set calendar to ? CanonicalizeCalendar(calendar). - let calendar = extract_kind(parse_result.calendar)?; - - let Some(date) = parse_result.date else { - return Err(TemporalError::range().with_enum(ErrorMessage::ParserNeedsDate)); - }; - - let time = parse_result - .time - .map(IsoTime::from_time_record) - .transpose()?; - - Ok(Self { - date: ParsedDate { - record: date, - calendar, - }, - time, - has_utc_designator, - match_minutes, - offset, - timezone: time_zone, - }) - } -} - -#[test] -fn test_parsed_intermediate_invalid_dates() { - // https://tc39.es/proposal-temporal/#sec-temporal-iso8601grammar-static-semantics-early-errors - // - // We need to error on invalid date/times *before* producing a ParsedFoo, since this is observable - assert!(ParsedDate::from_utf8(b"2025-02-29").is_err()); - assert!(ParsedDate::from_utf8(b"2025-02-28").is_ok()); - assert!(ParsedDate::year_month_from_utf8(b"202513").is_err()); - assert!(ParsedDate::year_month_from_utf8(b"202512").is_ok()); - assert!(ParsedDate::month_day_from_utf8(b"1000").is_err()); - assert!(ParsedDate::month_day_from_utf8(b"1001").is_ok()); - assert!(ParsedDateTime::from_utf8(b"2025-02-28T25:69:69").is_err()); - assert!(ParsedDateTime::from_utf8(b"2025-02-28T23:59:59").is_ok()); - #[cfg(feature = "compiled_data")] - assert!(ParsedZonedDateTime::from_utf8(b"2025-02-29T00:00:00Z[utc]").is_err()); - #[cfg(feature = "compiled_data")] - assert!(ParsedZonedDateTime::from_utf8(b"2025-02-28T00:00:00Z[utc]").is_ok()); -} +//! These types moved to [`crate::parsers::intermediates`], where `TemporalParser` +//! produces them. This module remains only as a compatibility shim. + +#[deprecated( + since = "0.2.7", + note = "Use `parsers::intermediates::ParsedDate` instead" +)] +pub type ParsedDate = crate::parsers::intermediates::ParsedDate; + +#[deprecated( + since = "0.2.7", + note = "Use `parsers::intermediates::ParsedDateTime` instead" +)] +pub type ParsedDateTime = crate::parsers::intermediates::ParsedDateTime; + +#[deprecated( + since = "0.2.7", + note = "Use `parsers::intermediates::ParsedZonedDateTime` instead" +)] +pub type ParsedZonedDateTime = crate::parsers::intermediates::ParsedZonedDateTime; diff --git a/crates/temporal/src/parsers.rs b/crates/temporal/src/parsers.rs index f8eb981e6..cf7aabc3e 100644 --- a/crates/temporal/src/parsers.rs +++ b/crates/temporal/src/parsers.rs @@ -4,22 +4,26 @@ use crate::{ error::ErrorMessage, iso::{IsoDate, IsoTime}, options::{DisplayCalendar, DisplayOffset, DisplayTimeZone}, - Sign, TemporalError, TemporalResult, + parsers::intermediates::{ParsedDate, ParsedDateTime, ParsedZonedDateTime}, + provider::TimeZoneProvider, + Calendar, Duration, Instant, PlainTime, Sign, TemporalError, TemporalResult, TemporalUnwrap, + TimeZone, }; +use alloc::{borrow::Cow, vec::Vec}; +use icu_calendar::AnyCalendarKind; +pub use ixdtf::encoding::{EncodingType, Utf16, Utf8}; use ixdtf::{ - encoding::Utf8, - parsers::IxdtfParser, + parsers::{IsoDurationParser, IxdtfParser, TimeZoneParser}, records::{ - Annotation, DateRecord, FullPrecisionOffset, IxdtfParseRecord, TimeRecord, UtcOffsetRecord, - UtcOffsetRecordOrZ, + Annotation, DateRecord, FullPrecisionOffset, TimeRecord, TimeZoneAnnotation, + TimeZoneRecord, UtcOffsetRecord, UtcOffsetRecordOrZ, }, }; use writeable::{impl_display_with_writeable, LengthHint, Writeable}; +pub mod intermediates; mod time_zone; -pub(crate) use time_zone::{parse_allowed_timezone_formats, parse_identifier}; - // TODO: Move `Writeable` functionality to `ixdtf` crate #[derive(Debug, Default)] @@ -651,8 +655,101 @@ fn checked_write_u64_with_suffix( sink.write_char(suffix) } -// TODO: Determine if these should be separate structs, i.e. TemporalDateTimeParser/TemporalInstantParser, or -// maybe on global `TemporalParser` around `IxdtfParser` that handles the Temporal idiosyncracies. +mod sealed { + /// Prevents outside implementations of [`super::TemporalEncoding`]. + pub trait Sealed {} + + impl Sealed for super::Utf8 {} + impl Sealed for super::Utf16 {} +} + +/// Extends `ixdtf`'s [`EncodingType`] with ASCII normalization for calendar/time-zone +/// identifiers. Sealed: implemented only for [`Utf8`] and [`Utf16`]. +/// +/// Normalization happens at the edge that needs UTF-8 bytes, not during parsing — +/// parse records keep identifiers in the source encoding. +pub trait TemporalEncoding: EncodingType + sealed::Sealed { + /// Normalizes an ASCII identifier slice (calendar id, IANA name) to UTF-8. + /// Zero-copy for [`Utf8`]. + #[doc(hidden)] + fn ascii_identifier(source: &[Self::CodeUnit]) -> TemporalResult>; + + /// Case-insensitive ASCII comparison without allocating; used for calendar-id + /// literal checks (e.g. `"iso8601"`) during parsing. + #[doc(hidden)] + fn eq_ascii_ignore_case(source: &[Self::CodeUnit], ascii: &[u8]) -> bool { + source.len() == ascii.len() + && (0..source.len()).all(|i| { + let Some(expected) = ascii.get(i) else { + return false; + }; + Self::get_ascii(source, i) + .ok() + .flatten() + .is_some_and(|byte| byte.eq_ignore_ascii_case(expected)) + }) + } +} + +impl TemporalEncoding for Utf8 { + fn ascii_identifier(source: &[u8]) -> TemporalResult> { + Ok(Cow::Borrowed(source)) + } +} + +impl TemporalEncoding for Utf16 { + fn ascii_identifier(source: &[u16]) -> TemporalResult> { + let mut buf = Vec::with_capacity(source.len()); + for i in 0..source.len() { + let byte = Utf16::get_ascii(source, i)?.ok_or_else(TemporalError::assert)?; + buf.push(byte); + } + Ok(Cow::Owned(buf)) + } +} + +/// A parsed-but-not-yet-normalized IXDTF record, generic over source encoding. +/// +/// Calendar/time-zone identifiers stay in the source encoding; normalize via +/// [`TemporalEncoding::ascii_identifier`] only where actually needed. +/// +/// `date`/`tz` are `Option` since this is the shared shape for every +/// [`ParseVariant`] (bare `Time` has no date). Use [`DateParseRecord`] or +/// [`ZonedDateParseRecord`] where the guarantee is known instead of re-deriving it. +#[derive(Debug, PartialEq)] +pub(crate) struct TemporalParseRecord<'a, T: EncodingType> { + pub(crate) date: Option, + pub(crate) time: Option, + pub(crate) offset: Option, + pub(crate) tz: Option>, + pub(crate) calendar: Option<&'a [T::CodeUnit]>, +} + +/// A parsed IXDTF record for a grammar production that always carries a date +/// (`TemporalDateTimeString`, `TemporalYearMonthString`, `TemporalMonthDayString`). +/// +/// Unlike [`TemporalParseRecord`], `date` is guaranteed present. +#[derive(Debug, PartialEq)] +pub(crate) struct DateParseRecord<'a, T: EncodingType> { + pub(crate) date: DateRecord, + pub(crate) time: Option, + pub(crate) offset: Option, + pub(crate) tz: Option>, + pub(crate) calendar: Option<&'a [T::CodeUnit]>, +} + +/// A parsed IXDTF record for `TemporalZonedDateTimeString`, which additionally +/// guarantees a time zone annotation is present (`parse_zoned_date_time_record` rejects +/// strings without one before ever constructing this type). +#[derive(Debug, PartialEq)] +pub(crate) struct ZonedDateParseRecord<'a, T: EncodingType> { + pub(crate) date: DateRecord, + pub(crate) time: Option, + pub(crate) offset: Option, + pub(crate) tz: TimeZoneAnnotation<'a, T>, + pub(crate) calendar: Option<&'a [T::CodeUnit]>, +} + #[derive(PartialEq)] enum ParseVariant { YearMonth, @@ -662,22 +759,25 @@ enum ParseVariant { } #[inline] -fn parse_ixdtf(source: &[u8], variant: ParseVariant) -> TemporalResult> { - fn cast_handler<'a>( - _: &mut IxdtfParser<'a, Utf8>, - handler: impl FnMut(Annotation<'a, Utf8>) -> Option>, - ) -> impl FnMut(Annotation<'a, Utf8>) -> Option> { +fn parse_ixdtf( + source: &[T::CodeUnit], + variant: ParseVariant, +) -> TemporalResult> { + fn cast_handler<'a, T: EncodingType>( + _: &mut IxdtfParser<'a, T>, + handler: impl FnMut(Annotation<'a, T>) -> Option>, + ) -> impl FnMut(Annotation<'a, T>) -> Option> { handler } - let mut first_calendar: Option> = None; + let mut first_calendar: Option> = None; let mut critical_duplicate_calendar = false; - let mut parser = IxdtfParser::from_utf8(source); + let mut parser = IxdtfParser::::new(source); - let handler = cast_handler(&mut parser, |annotation: Annotation| { - if annotation.key == "u-ca".as_bytes() { - match first_calendar { - Some(ref cal) => { + let handler = cast_handler(&mut parser, |annotation: Annotation| { + if T::check_calendar_key(annotation.key) { + match &first_calendar { + Some(cal) => { if cal.critical || annotation.critical { critical_duplicate_calendar = true } @@ -691,7 +791,7 @@ fn parse_ixdtf(source: &[u8], variant: ParseVariant) -> TemporalResult parser.parse_year_month_with_annotation_handler(handler), ParseVariant::MonthDay => parser.parse_month_day_with_annotation_handler(handler), ParseVariant::DateTime => parser.parse_with_annotation_handler(handler), @@ -713,7 +813,8 @@ fn parse_ixdtf(source: &[u8], variant: ParseVariant) -> TemporalResult TemporalResult TemporalResult TemporalResult> { - let record = parse_ixdtf(source, ParseVariant::DateTime)?; +pub(crate) fn parse_date_time_record( + source: &[T::CodeUnit], +) -> TemporalResult> { + let record = parse_ixdtf::(source, ParseVariant::DateTime)?; if record.offset == Some(UtcOffsetRecordOrZ::Z) { return Err(TemporalError::range() .with_message("UTC designator is not valid for DateTime parsing.")); } - Ok(record) + Ok(DateParseRecord { + date: record.date.temporal_unwrap()?, + time: record.time, + offset: record.offset, + tz: record.tz, + calendar: record.calendar, + }) } #[inline] -pub(crate) fn parse_zoned_date_time(source: &[u8]) -> TemporalResult> { - let record = parse_ixdtf(source, ParseVariant::DateTime)?; +pub(crate) fn parse_zoned_date_time_record( + source: &[T::CodeUnit], +) -> TemporalResult> { + let record = parse_ixdtf::(source, ParseVariant::DateTime)?; // TODO: Support rejecting subminute precision in time zone annootations - if record.tz.is_none() { + let Some(tz) = record.tz else { return Err(TemporalError::range() .with_message("Time zone annotation is required for parsing a zoned date time.")); - } + }; - Ok(record) + Ok(ZonedDateParseRecord { + date: record.date.temporal_unwrap()?, + time: record.time, + offset: record.offset, + tz, + calendar: record.calendar, + }) } -pub(crate) struct IxdtfParseInstantRecord { +/// The components of a parsed `TemporalInstantString`. +/// +/// Unlike a `TemporalDateTimeString`, every component is required. +pub(crate) struct ParsedInstantRecord { pub(crate) date: DateRecord, pub(crate) time: TimeRecord, pub(crate) offset: UtcOffsetRecordOrZ, @@ -807,10 +933,12 @@ pub(crate) struct IxdtfParseInstantRecord { /// A utility function for parsing an `Instant` string #[inline] -pub(crate) fn parse_instant(source: &[u8]) -> TemporalResult { - let record = parse_ixdtf(source, ParseVariant::DateTime)?; +pub(crate) fn parse_instant_record( + source: &[T::CodeUnit], +) -> TemporalResult { + let record = parse_ixdtf::(source, ParseVariant::DateTime)?; - let IxdtfParseRecord { + let TemporalParseRecord { date: Some(date), time: Some(time), offset: Some(offset), @@ -822,15 +950,15 @@ pub(crate) fn parse_instant(source: &[u8]) -> TemporalResult, -) -> TemporalResult> { +fn check_offset_no_z( + record: TemporalParseRecord<'_, T>, +) -> TemporalResult> { if record.offset == Some(UtcOffsetRecordOrZ::Z) { return Err(TemporalError::range() .with_message("UTC designator is not valid for plain date/time parsing.")); @@ -841,40 +969,51 @@ fn check_offset_no_z( /// A utility function for parsing a `YearMonth` string #[inline] -pub(crate) fn parse_year_month(source: &[u8]) -> TemporalResult> { - let ym_record = parse_ixdtf(source, ParseVariant::YearMonth); +pub(crate) fn parse_year_month_record( + source: &[T::CodeUnit], +) -> TemporalResult> { + let ym_record = parse_ixdtf::(source, ParseVariant::YearMonth).and_then(check_offset_no_z); - let Err(e) = ym_record else { - return ym_record.and_then(check_offset_no_z); + let record = match ym_record { + Ok(record) => record, + // Format and return the error from parsing YearMonth. + Err(e) => return parse_date_time_record::(source).map_err(|_| e), }; - let dt_parse = parse_date_time(source); - - match dt_parse { - Ok(dt) => check_offset_no_z(dt), - // Format and return the error from parsing YearMonth. - _ => Err(e), - } + Ok(DateParseRecord { + date: record.date.temporal_unwrap()?, + time: record.time, + offset: record.offset, + tz: record.tz, + calendar: record.calendar, + }) } /// A utilty function for parsing a `MonthDay` String. -pub(crate) fn parse_month_day(source: &[u8]) -> TemporalResult> { - let md_record = parse_ixdtf(source, ParseVariant::MonthDay); - let Err(e) = md_record else { - return md_record.and_then(check_offset_no_z); - }; +pub(crate) fn parse_month_day_record( + source: &[T::CodeUnit], +) -> TemporalResult> { + let md_record = parse_ixdtf::(source, ParseVariant::MonthDay).and_then(check_offset_no_z); - let dt_parse = parse_date_time(source); - - match dt_parse { - Ok(dt) => check_offset_no_z(dt), + let record = match md_record { + Ok(record) => record, // Format and return the error from parsing MonthDay. - _ => Err(e), - } + Err(e) => return parse_date_time_record::(source).map_err(|_| e), + }; + + Ok(DateParseRecord { + date: record.date.temporal_unwrap()?, + time: record.time, + offset: record.offset, + tz: record.tz, + calendar: record.calendar, + }) } -// Ensures that an IxdtfParseRecord was parsed with [~Zoned][+TimeRequired] -fn check_time_record(record: IxdtfParseRecord) -> TemporalResult { +// Ensures that a TemporalParseRecord was parsed with [~Zoned][+TimeRequired] +fn check_time_record( + record: TemporalParseRecord<'_, T>, +) -> TemporalResult { // Handle [~Zoned] let record = check_offset_no_z(record)?; // Handle [+TimeRequired] @@ -886,39 +1025,345 @@ fn check_time_record(record: IxdtfParseRecord) -> TemporalResult TemporalResult { - let time_record = parse_ixdtf(source, ParseVariant::Time); +pub(crate) fn parse_time_record( + source: &[T::CodeUnit], +) -> TemporalResult { + let time_record = parse_ixdtf::(source, ParseVariant::Time); let Err(e) = time_record else { return time_record.and_then(check_time_record); }; - let dt_parse = parse_date_time(source); - - match dt_parse { - Ok(dt) => check_time_record(dt), - // Format and return the error from parsing MonthDay. + match parse_date_time_record::(source) { + // `parse_date_time_record` already rejects a `Z` offset, so only the + // [+TimeRequired] check remains here. + Ok(dt) => dt.time.ok_or_else(|| { + TemporalError::range() + .with_message("PlainTime can only be parsed from strings with a time component.") + }), + // Format and return the error from parsing Time. _ => Err(e), } } -/// Consider this API to be unstable: it is used internally by temporal_capi but -/// will likely be replaced with a proper TemporalParser API at some point. +/// Finds a calendar annotation across the Temporal grammar productions that may +/// carry one. +/// +/// `None`: no calendar-bearing production matched. `Some(None)`: matched, no `u-ca` +/// annotation. `Some(Some(id))`: matched with an explicit calendar identifier. #[inline] -pub fn parse_allowed_calendar_formats(s: &[u8]) -> Option<&[u8]> { - if let Ok(r) = parse_ixdtf(s, ParseVariant::DateTime).map(|r| r.calendar) { - return Some(r.unwrap_or(&[])); - } else if let Ok(r) = IxdtfParser::from_utf8(s).parse_time().map(|r| r.calendar) { - return Some(r.unwrap_or(&[])); - } else if let Ok(r) = parse_ixdtf(s, ParseVariant::YearMonth).map(|r| r.calendar) { - return Some(r.unwrap_or(&[])); - } else if let Ok(r) = parse_ixdtf(s, ParseVariant::MonthDay).map(|r| r.calendar) { - return Some(r.unwrap_or(&[])); +fn parse_allowed_calendar_formats( + s: &[T::CodeUnit], +) -> Option> { + for variant in [ + ParseVariant::DateTime, + ParseVariant::Time, + ParseVariant::YearMonth, + ParseVariant::MonthDay, + ] { + if let Ok(r) = parse_ixdtf::(s, variant) { + return Some(r.calendar); + } } None } -// TODO: ParseTimeZoneString, ParseZonedDateTimeString +/// Converts a parsed time component to an [`IsoTime`], defaulting to midnight when +/// absent. Errors if the component is present but invalid. +fn iso_time_or_midnight(time: Option) -> TemporalResult { + Ok(time + .map(IsoTime::from_time_record) + .transpose()? + .unwrap_or_default()) +} + +fn calendar_kind( + calendar: Option<&[T::CodeUnit]>, +) -> TemporalResult { + let Some(calendar) = calendar else { + return Ok(AnyCalendarKind::Iso); + }; + let ascii = T::ascii_identifier(calendar)?; + // HijriSimulatedMecca parses fine here; Calendar::new turns it into ISO. + Calendar::try_kind_from_utf8(&ascii) +} + +/// A parser for Temporal strings, generic over the source encoding. +/// +/// This is the entry point for callers that need the parse step separated from the +/// resolution step. The Temporal specification performs observable operations (JS +/// option getters) between the two, so an engine must be able to parse a string into +/// an intermediate, run those operations, and only then construct the final value. +/// +/// The date and date/time methods return the intermediates in [`intermediates`], +/// which are consumed by the matching `Foo::from_parsed*` constructors. The +/// remaining methods return `ixdtf` records directly. +/// +/// Identifiers in returned records stay in the source encoding; normalize them with +/// [`TemporalEncoding::ascii_identifier`] at the point of use. +/// +/// ``` +/// use temporal::parsers::TemporalParser; +/// use temporal::PlainDate; +/// +/// let parsed = TemporalParser::from_utf8(b"2024-07-19[u-ca=hebrew]").parse_date()?; +/// // An engine would run observable option getters here. +/// let date = PlainDate::from_parsed(parsed)?; +/// assert_eq!(date.calendar().identifier(), "hebrew"); +/// # Ok::<(), temporal::TemporalError>(()) +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct TemporalParser<'a, T: TemporalEncoding> { + source: &'a [T::CodeUnit], +} + +impl<'a> TemporalParser<'a, Utf8> { + /// Creates a parser over a UTF-8 encoded source. + #[must_use] + pub fn from_utf8(source: &'a [u8]) -> Self { + Self::from_encoded(source) + } +} + +impl<'a> TemporalParser<'a, Utf16> { + /// Creates a parser over a UTF-16 encoded source. + #[must_use] + pub fn from_utf16(source: &'a [u16]) -> Self { + Self::from_encoded(source) + } +} + +impl<'a, T: TemporalEncoding> TemporalParser<'a, T> { + /// Creates a parser over a source in any supported encoding. + #[must_use] + pub fn from_encoded(source: &'a [T::CodeUnit]) -> Self { + Self { source } + } + + /// Parses a `TemporalDateTimeString` into a [`ParsedDate`]. + #[must_use = "parsing returns the parsed intermediate"] + pub fn parse_date(&self) -> TemporalResult { + let record = parse_date_time_record::(self.source)?; + let calendar = calendar_kind::(record.calendar)?; + // Time is discarded, but must still be validated here (observable). + iso_time_or_midnight(record.time)?; + + Ok(ParsedDate { + record: record.date, + calendar, + }) + } + + /// Parses a `TemporalYearMonthString` into a [`ParsedDate`]. + #[must_use = "parsing returns the parsed intermediate"] + pub fn parse_year_month(&self) -> TemporalResult { + let record = parse_year_month_record::(self.source)?; + let calendar = calendar_kind::(record.calendar)?; + iso_time_or_midnight(record.time)?; + + Ok(ParsedDate { + record: record.date, + calendar, + }) + } + + /// Parses a `TemporalMonthDayString` into a [`ParsedDate`]. + #[must_use = "parsing returns the parsed intermediate"] + pub fn parse_month_day(&self) -> TemporalResult { + let record = parse_month_day_record::(self.source)?; + let calendar = calendar_kind::(record.calendar)?; + iso_time_or_midnight(record.time)?; + + Ok(ParsedDate { + record: record.date, + calendar, + }) + } + + /// Parses a `TemporalDateTimeString` into a [`ParsedDateTime`]. + #[must_use = "parsing returns the parsed intermediate"] + pub fn parse_date_time(&self) -> TemporalResult { + let record = parse_date_time_record::(self.source)?; + let calendar = calendar_kind::(record.calendar)?; + let time = iso_time_or_midnight(record.time)?; + + Ok(ParsedDateTime { + date: ParsedDate { + record: record.date, + calendar, + }, + time, + }) + } + + /// Parses a `TemporalZonedDateTimeString` into a [`ParsedZonedDateTime`], using + /// compiled time zone data. + #[cfg(feature = "compiled_data")] + #[must_use = "parsing returns the parsed intermediate"] + pub fn parse_zoned_date_time(&self) -> TemporalResult { + self.parse_zoned_date_time_with_provider(&*crate::builtins::TZ_PROVIDER) + } + + /// Parses a `TemporalZonedDateTimeString` into a [`ParsedZonedDateTime`]. + #[must_use = "parsing returns the parsed intermediate"] + pub fn parse_zoned_date_time_with_provider( + &self, + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + // Steps from the parse bits of ToZonedDateTime + + // 3. Let matchBehaviour be match-minutes. + let mut match_minutes = true; + + // b. Let result be ? ParseISODateTime(item, « TemporalDateTimeString[+Zoned] »). + let record = parse_zoned_date_time_record::(self.source)?; + + // c-e. Resolve the (guaranteed present) time zone annotation. + let time_zone = TimeZone::from_time_zone_record::(record.tz.tz, provider)?; + + // f-g. Resolve the offset, tracking whether `Z` was used. + let (offset, has_utc_designator) = match record.offset { + Some(UtcOffsetRecordOrZ::Z) => (None, true), + Some(UtcOffsetRecordOrZ::Offset(offset)) => { + // iii. More than one MinuteSecond node means match-exactly. + if offset.second().is_some() { + match_minutes = false; + } + (Some(crate::UtcOffset::from_ixdtf_record(offset)?), false) + } + None => (None, false), + }; + + // h-j. Resolve the calendar, defaulting to ISO. + let calendar = calendar_kind::(record.calendar)?; + + let time = record.time.map(IsoTime::from_time_record).transpose()?; + + Ok(ParsedZonedDateTime { + date: ParsedDate { + record: record.date, + calendar, + }, + time, + has_utc_designator, + match_minutes, + offset, + timezone: time_zone, + }) + } + + /// Parses a `TemporalInstantString` into an [`Instant`]. + /// + /// `ToTemporalInstant` runs no observable operations after parsing, so the + /// result is validated here rather than handed back as an intermediate. + #[must_use = "parsing returns the parsed instant"] + pub fn parse_instant(&self) -> TemporalResult { + Instant::from_parsed_record(parse_instant_record::(self.source)?) + } + + /// Parses a `TemporalTimeString` into a [`PlainTime`]. + /// + /// `ToTemporalTime` reads the overflow option after parsing but discards it, and + /// `CreateTemporalTime` is infallible, so the result is validated here. + #[must_use = "parsing returns the parsed time"] + pub fn parse_time(&self) -> TemporalResult { + let record = parse_time_record::(self.source)?; + Ok(PlainTime::new_unchecked(IsoTime::from_time_record(record)?)) + } + + /// Parses a `TemporalDurationString` into a [`Duration`]. + #[must_use = "parsing returns the parsed duration"] + pub fn parse_duration(&self) -> TemporalResult { + Duration::from_duration_record(IsoDurationParser::::new(self.source).parse()?) + } + + /// Parses a `UTCOffset` into a [`UtcOffset`], per `ParseDateTimeUTCOffset`. + #[must_use = "parsing returns the parsed offset"] + pub fn parse_utc_offset(&self) -> TemporalResult { + crate::UtcOffset::from_ixdtf_record(TimeZoneParser::::new(self.source).parse_offset()?) + } + + /// Parses a `TimeZoneIdentifier` (IANA name or offset) into a [`TimeZone`], using + /// compiled time zone data. + #[cfg(feature = "compiled_data")] + #[must_use = "parsing returns the parsed time zone"] + pub fn parse_time_zone_identifier(&self) -> TemporalResult { + self.parse_time_zone_identifier_with_provider(&*crate::builtins::TZ_PROVIDER) + } + + /// Parses a `TimeZoneIdentifier` (IANA name or offset) into a [`TimeZone`]. + #[must_use = "parsing returns the parsed time zone"] + pub fn parse_time_zone_identifier_with_provider( + &self, + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + let invalid = || TemporalError::range().with_message("Invalid TimeZone Identifier"); + match TimeZoneParser::::new(self.source) + .parse_identifier() + .map_err(|_| invalid())? + { + TimeZoneRecord::Name(name) => Ok(TimeZone::IanaIdentifier( + provider.get(&T::ascii_identifier(name)?)?, + )), + TimeZoneRecord::Offset(offset) => Ok(TimeZone::UtcOffset( + crate::UtcOffset::from_ixdtf_minute_record(offset), + )), + _ => Err(invalid()), + } + } + + /// Resolves a [`TimeZone`] from any Temporal production that may carry one, using + /// compiled time zone data. + /// + /// This is the fallback half of `ParseTemporalTimeZoneString`, used after a bare + /// identifier parse fails. + #[cfg(feature = "compiled_data")] + #[must_use = "parsing returns the resolved time zone"] + pub fn parse_time_zone(&self) -> Option { + self.parse_time_zone_with_provider(&*crate::builtins::TZ_PROVIDER) + } + + /// Resolves a [`TimeZone`] from any Temporal production that may carry one. + /// + /// This is the fallback half of `ParseTemporalTimeZoneString`, used after a bare + /// identifier parse fails. + #[must_use = "parsing returns the resolved time zone"] + pub fn parse_time_zone_with_provider( + &self, + provider: &(impl TimeZoneProvider + ?Sized), + ) -> Option { + time_zone::parse_allowed_timezone_formats::(self.source, provider) + } + + /// Parses a `TemporalCalendarString`, returning the identifier it names. + /// + /// `None` means the source named no calendar and so denotes `iso8601`. + /// + /// Per `ParseTemporalCalendarString`, the identifier is syntactically valid but + /// "not necessarily an existing one" — canonicalization is a separate step. Use + /// [`Self::parse_calendar`] unless you canonicalize differently from [`Calendar`]. + #[must_use = "parsing returns the parsed identifier"] + pub fn parse_calendar_identifier(&self) -> Option<&'a [T::CodeUnit]> { + match parse_allowed_calendar_formats::(self.source) { + // A dated production matched; its annotation, if any, is the identifier. + Some(annotation) => annotation, + // Nothing matched, so the whole source is a bare identifier. + None => Some(self.source), + } + } + + /// Parses a `TemporalCalendarString` into a [`Calendar`]. + /// + /// Every call site canonicalizes the parsed identifier immediately, with no + /// observable operation in between, so that happens here. + #[must_use = "parsing returns the parsed calendar"] + pub fn parse_calendar(&self) -> TemporalResult { + match self.parse_calendar_identifier() { + Some(id) => Calendar::try_from_utf8(&T::ascii_identifier(id)?), + None => Ok(Calendar::ISO), + } + } +} #[cfg(test)] mod tests { @@ -1062,3 +1507,82 @@ mod tests { assert_writeable_eq!(date, "-010000-12-08"); } } + +#[cfg(test)] +mod utf16_tests { + //! Proves the generic parse functions are genuinely encoding-generic: UTF-8 + //! and UTF-16 encodings of the same source string parse to identical records. + use super::{parse_date_time_record, TemporalEncoding, Utf16, Utf8}; + use alloc::vec::Vec; + + fn utf16(s: &str) -> Vec { + s.encode_utf16().collect() + } + + #[test] + fn date_time_parity() { + let s = "2024-07-19T15:43:12.5[America/New_York][u-ca=iso8601]"; + let utf8 = parse_date_time_record::(s.as_bytes()).unwrap(); + let wide = utf16(s); + let utf16_record = parse_date_time_record::(&wide).unwrap(); + + assert_eq!(utf8.date, utf16_record.date); + assert_eq!(utf8.time, utf16_record.time); + assert_eq!(utf8.offset, utf16_record.offset); + assert_eq!(utf8.calendar, Some(b"iso8601".as_slice())); + let utf16_calendar = Utf16::ascii_identifier(utf16_record.calendar.unwrap()).unwrap(); + assert_eq!(&*utf16_calendar, b"iso8601".as_slice()); + assert!(utf8.tz.is_some()); + assert!(utf16_record.tz.is_some()); + } + + #[test] + fn multi_segment_calendar_id_parity() { + // `islamic-umalqura` is two dash-joined BCP-47 subtags, over 8 chars total. + let s = "2024-07-19[u-ca=islamic-umalqura]"; + let utf8 = parse_date_time_record::(s.as_bytes()).unwrap(); + let wide = utf16(s); + let utf16_record = parse_date_time_record::(&wide).unwrap(); + + assert_eq!(utf8.calendar, Some(b"islamic-umalqura".as_slice())); + let cal = utf16_record.calendar.unwrap(); + let utf16_calendar = Utf16::ascii_identifier(cal).unwrap(); + assert_eq!(&*utf16_calendar, b"islamic-umalqura".as_slice()); + } + + #[test] + fn calendar_identifier_parity() { + use super::TemporalParser; + + // `None` denotes an absent annotation, i.e. an implicit `iso8601`. + for (s, expected) in [ + ("2024-07", None), + ("--07-19", None), + ("T15:43:12", None), + ("2024-07-19[u-ca=hebrew]", Some(b"hebrew".as_slice())), + // No dated production matches, so the whole source is the identifier. + ("hebrew", Some(b"hebrew".as_slice())), + ] { + let expected = expected.map(<[u8]>::to_vec); + let wide = utf16(s); + + let utf8 = TemporalParser::::from_utf8(s.as_bytes()) + .parse_calendar_identifier() + .map(<[u8]>::to_vec); + let utf16_identifier = TemporalParser::::from_utf16(&wide) + .parse_calendar_identifier() + .map(|id| Utf16::ascii_identifier(id).unwrap().into_owned()); + + assert_eq!(utf8, expected, "{s}"); + assert_eq!(utf16_identifier, expected, "{s}"); + } + } + + #[test] + fn invalid_input_errors_identically() { + let s = "2025-02-29"; // not a leap year + let wide = utf16(s); + assert!(parse_date_time_record::(s.as_bytes()).is_err()); + assert!(parse_date_time_record::(&wide).is_err()); + } +} diff --git a/crates/temporal/src/parsers/intermediates.rs b/crates/temporal/src/parsers/intermediates.rs new file mode 100644 index 000000000..3d7ab41d7 --- /dev/null +++ b/crates/temporal/src/parsers/intermediates.rs @@ -0,0 +1,184 @@ +//! Parsed-but-not-validated intermediate records. +//! +//! The Temporal specification performs observable operations between the parse +//! and validate steps, so parsing must yield these intermediates rather than a +//! finished builtin. `TemporalParser` produces them, applying the resolution +//! steps (calendar canonicalization, time zone resolution, early time validity) +//! in the order the specification requires. + +use super::{TemporalParser, Utf16, Utf8}; +use crate::iso::IsoTime; +use crate::provider::TimeZoneProvider; +use crate::TemporalResult; +use crate::TimeZone; +use crate::UtcOffset; +use icu_calendar::AnyCalendarKind; +use ixdtf::records::DateRecord; + +/// A parsed-but-not-validated date +#[derive(Copy, Clone, Debug)] +pub struct ParsedDate { + pub(crate) record: DateRecord, + pub(crate) calendar: AnyCalendarKind, +} + +impl ParsedDate { + /// Converts a UTF-8 encoded string into a `ParsedDate`. + pub fn from_utf8(s: &[u8]) -> TemporalResult { + TemporalParser::::from_utf8(s).parse_date() + } + + /// Converts a UTF-16 encoded string into a `ParsedDate`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + TemporalParser::::from_utf16(s).parse_date() + } + + /// Converts a UTF-8 encoded YearMonth string into a `ParsedDate`. + pub fn year_month_from_utf8(s: &[u8]) -> TemporalResult { + TemporalParser::::from_utf8(s).parse_year_month() + } + + /// Converts a UTF-16 encoded YearMonth string into a `ParsedDate`. + pub fn year_month_from_utf16(s: &[u16]) -> TemporalResult { + TemporalParser::::from_utf16(s).parse_year_month() + } + + /// Converts a UTF-8 encoded MonthDay string into a `ParsedDate`. + pub fn month_day_from_utf8(s: &[u8]) -> TemporalResult { + TemporalParser::::from_utf8(s).parse_month_day() + } + + /// Converts a UTF-16 encoded MonthDay string into a `ParsedDate`. + pub fn month_day_from_utf16(s: &[u16]) -> TemporalResult { + TemporalParser::::from_utf16(s).parse_month_day() + } +} + +/// A parsed-but-not-validated datetime +#[derive(Copy, Clone, Debug)] +pub struct ParsedDateTime { + pub(crate) date: ParsedDate, + pub(crate) time: IsoTime, +} + +impl ParsedDateTime { + /// Converts a UTF-8 encoded string into a `ParsedDateTime`. + pub fn from_utf8(s: &[u8]) -> TemporalResult { + TemporalParser::::from_utf8(s).parse_date_time() + } + + /// Converts a UTF-16 encoded string into a `ParsedDateTime`. + pub fn from_utf16(s: &[u16]) -> TemporalResult { + TemporalParser::::from_utf16(s).parse_date_time() + } +} + +/// A parsed-but-not-validated zoned datetime +#[derive(Clone, Debug)] +pub struct ParsedZonedDateTime { + pub(crate) date: ParsedDate, + /// None time is START-OF-DAY + pub(crate) time: Option, + /// Whether or not the string has a UTC designator (`Z`) + /// + /// Incompatible with having an offset (you can still have a offset-format timezone) + pub(crate) has_utc_designator: bool, + /// Whether or not to allow offsets rounded to the minute + /// + /// (Typically only needs to be set when parsing, can be false otherwise) + pub(crate) match_minutes: bool, + /// An optional offset string + pub(crate) offset: Option, + /// The time zone + pub(crate) timezone: TimeZone, +} + +impl ParsedZonedDateTime { + /// Converts a UTF-8 encoded string into a `ParsedZonedDateTime`, using compiled data + #[cfg(feature = "compiled_data")] + pub fn from_utf8(source: &[u8]) -> TemporalResult { + Self::from_utf8_with_provider(source, &*crate::builtins::TZ_PROVIDER) + } + + /// Converts a UTF-16 encoded string into a `ParsedZonedDateTime`, using compiled data + #[cfg(feature = "compiled_data")] + pub fn from_utf16(source: &[u16]) -> TemporalResult { + Self::from_utf16_with_provider(source, &*crate::builtins::TZ_PROVIDER) + } + + /// Converts a UTF-8 encoded string into a `ParsedZonedDateTime`. + pub fn from_utf8_with_provider( + source: &[u8], + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + TemporalParser::::from_utf8(source).parse_zoned_date_time_with_provider(provider) + } + + /// Converts a UTF-16 encoded string into a `ParsedZonedDateTime`. + pub fn from_utf16_with_provider( + source: &[u16], + provider: &(impl TimeZoneProvider + ?Sized), + ) -> TemporalResult { + TemporalParser::::from_utf16(source).parse_zoned_date_time_with_provider(provider) + } +} + +#[test] +fn test_parsed_intermediate_invalid_dates() { + // https://tc39.es/proposal-temporal/#sec-temporal-iso8601grammar-static-semantics-early-errors + // + // We need to error on invalid date/times *before* producing a ParsedFoo, since this is observable + assert!(ParsedDate::from_utf8(b"2025-02-29").is_err()); + assert!(ParsedDate::from_utf8(b"2025-02-28").is_ok()); + assert!(ParsedDate::year_month_from_utf8(b"202513").is_err()); + assert!(ParsedDate::year_month_from_utf8(b"202512").is_ok()); + assert!(ParsedDate::month_day_from_utf8(b"1000").is_err()); + assert!(ParsedDate::month_day_from_utf8(b"1001").is_ok()); + assert!(ParsedDateTime::from_utf8(b"2025-02-28T25:69:69").is_err()); + assert!(ParsedDateTime::from_utf8(b"2025-02-28T23:59:59").is_ok()); +} + +#[test] +#[cfg(feature = "compiled_data")] +fn test_parsed_intermediate_invalid_zoned_dates() { + assert!(ParsedZonedDateTime::from_utf8(b"2025-02-29T00:00:00Z[utc]").is_err()); + assert!(ParsedZonedDateTime::from_utf8(b"2025-02-28T00:00:00Z[utc]").is_ok()); +} + +// Pins the resolution order inside the parse step, which is observable: +// +// V8 runs the option getters (`GetTemporalDisambiguationOption` etc.) *between* +// `ParsedFoo::from_*` and `Foo::from_parsed*`, so reordering these resolutions +// changes which error a JS program sees. +#[test] +fn test_resolution_order_within_parse() { + use crate::TemporalError; + + // Calendar is resolved before the time component is validated. + assert_eq!( + ParsedDateTime::from_utf8(b"2025-01-01T00:00:00.1234567891[u-ca=bogus]").unwrap_err(), + TemporalError::range().with_message("unknown calendar") + ); +} + +#[test] +#[cfg(feature = "compiled_data")] +fn test_zoned_resolution_order_within_parse() { + use crate::error::ErrorMessage; + use crate::TemporalError; + + // Time zone is resolved before the calendar. + assert_eq!( + ParsedZonedDateTime::from_utf8(b"2025-01-01T00:00:00[Bogus/Zone][u-ca=bogus]").unwrap_err(), + TemporalError::range().with_message("Unknown time zone identifier") + ); + + // Per ParseDateTimeUTCOffset, a >9-digit offset fraction is a grammar error, + // so it is rejected while parsing (step b), ahead of time zone resolution + // (steps c-e). + assert_eq!( + ParsedZonedDateTime::from_utf8(b"2025-01-01T00:00:00+05:00:00.1234567891[Bogus/Zone]") + .unwrap_err(), + TemporalError::range().with_enum(ErrorMessage::FractionalTimeMoreThanNineDigits) + ); +} diff --git a/crates/temporal/src/parsers/time_zone.rs b/crates/temporal/src/parsers/time_zone.rs index 248ce0bbb..b7525ddbe 100644 --- a/crates/temporal/src/parsers/time_zone.rs +++ b/crates/temporal/src/parsers/time_zone.rs @@ -1,42 +1,29 @@ -use ixdtf::{ - encoding::Utf8, - parsers::{IxdtfParser, TimeZoneParser}, - records::{TimeZoneRecord, UtcOffsetRecord, UtcOffsetRecordOrZ}, -}; +use ixdtf::records::{UtcOffsetRecord, UtcOffsetRecordOrZ}; use crate::provider::TimeZoneProvider; -use crate::{builtins::time_zone::UtcOffset, TemporalError, TemporalResult, TimeZone}; +use crate::{builtins::time_zone::UtcOffset, TimeZone}; -use super::{parse_ixdtf, ParseVariant}; +use super::{parse_ixdtf, ParseVariant, TemporalEncoding}; #[inline] -pub(crate) fn parse_allowed_timezone_formats( - s: &str, +pub(crate) fn parse_allowed_timezone_formats( + s: &[T::CodeUnit], provider: &(impl TimeZoneProvider + ?Sized), ) -> Option { - let (offset, annotation) = if let Ok((offset, annotation)) = - parse_ixdtf(s.as_bytes(), ParseVariant::DateTime).map(|r| (r.offset, r.tz)) - { - (offset, annotation) - } else if let Ok((offset, annotation)) = IxdtfParser::from_str(s) - .parse_time() - .map(|r| (r.offset, r.tz)) - { - (offset, annotation) - } else if let Ok((offset, annotation)) = - parse_ixdtf(s.as_bytes(), ParseVariant::YearMonth).map(|r| (r.offset, r.tz)) - { - (offset, annotation) - } else if let Ok((offset, annotation)) = - parse_ixdtf(s.as_bytes(), ParseVariant::MonthDay).map(|r| (r.offset, r.tz)) - { - (offset, annotation) + let (offset, annotation) = if let Ok(r) = parse_ixdtf::(s, ParseVariant::DateTime) { + (r.offset, r.tz) + } else if let Ok(r) = parse_ixdtf::(s, ParseVariant::Time) { + (r.offset, r.tz) + } else if let Ok(r) = parse_ixdtf::(s, ParseVariant::YearMonth) { + (r.offset, r.tz) + } else if let Ok(r) = parse_ixdtf::(s, ParseVariant::MonthDay) { + (r.offset, r.tz) } else { return None; }; if let Some(annotation) = annotation { - return TimeZone::from_time_zone_record(annotation.tz, provider).ok(); + return TimeZone::from_time_zone_record::(annotation.tz, provider).ok(); }; if let Some(offset) = offset { @@ -56,11 +43,3 @@ pub(crate) fn parse_allowed_timezone_formats( None } - -#[inline] -pub(crate) fn parse_identifier(source: &str) -> TemporalResult> { - let mut parser = TimeZoneParser::from_str(source); - parser.parse_identifier().or(Err( - TemporalError::range().with_message("Invalid TimeZone Identifier") - )) -} diff --git a/crates/temporal_capi/src/calendar.rs b/crates/temporal_capi/src/calendar.rs index 5e432aa00..8140ec50d 100644 --- a/crates/temporal_capi/src/calendar.rs +++ b/crates/temporal_capi/src/calendar.rs @@ -41,10 +41,13 @@ pub mod ffi { // https://tc39.es/proposal-temporal/#sec-temporal-parsetemporalcalendarstring pub fn parse_temporal_calendar_string(s: &DiplomatStr) -> Option { - match temporal::parsers::parse_allowed_calendar_formats(s) { - Some([]) => Some(AnyCalendarKind::Iso), - Some(result) => Self::get_for_str(result), - None => Self::get_for_str(s), + use temporal::parsers::{TemporalParser, Utf8}; + + // Not `parse_calendar`: `Calendar` maps the calendars Temporal does not + // support onto ISO, which would hide them from `get_for_str`. + match TemporalParser::::from_utf8(s).parse_calendar_identifier() { + Some(identifier) => Self::get_for_str(identifier), + None => Some(AnyCalendarKind::Iso), } } } diff --git a/crates/temporal_capi/src/plain_date.rs b/crates/temporal_capi/src/plain_date.rs index 060f291c4..c4512ec54 100644 --- a/crates/temporal_capi/src/plain_date.rs +++ b/crates/temporal_capi/src/plain_date.rs @@ -42,11 +42,11 @@ pub mod ffi { } #[diplomat::opaque] - pub struct ParsedDate(pub(crate) temporal::parsed_intermediates::ParsedDate); + pub struct ParsedDate(pub(crate) temporal::parsers::intermediates::ParsedDate); impl ParsedDate { pub fn from_utf8(s: &DiplomatStr) -> Result, TemporalError> { - temporal::parsed_intermediates::ParsedDate::from_utf8(s) + temporal::parsers::intermediates::ParsedDate::from_utf8(s) .map(|x| Box::new(ParsedDate(x))) .map_err(Into::::into) } @@ -54,13 +54,13 @@ pub mod ffi { // TODO(#275) This should not need to convert let s = String::from_utf16(s).map_err(|_| temporal::TemporalError::range())?; - temporal::parsed_intermediates::ParsedDate::from_utf8(s.as_bytes()) + temporal::parsers::intermediates::ParsedDate::from_utf8(s.as_bytes()) .map(|x| Box::new(ParsedDate(x))) .map_err(Into::::into) } pub fn year_month_from_utf8(s: &DiplomatStr) -> Result, TemporalError> { - temporal::parsed_intermediates::ParsedDate::year_month_from_utf8(s) + temporal::parsers::intermediates::ParsedDate::year_month_from_utf8(s) .map(|x| Box::new(ParsedDate(x))) .map_err(Into::::into) } @@ -68,13 +68,13 @@ pub mod ffi { // TODO(#275) This should not need to convert let s = String::from_utf16(s).map_err(|_| temporal::TemporalError::range())?; - temporal::parsed_intermediates::ParsedDate::year_month_from_utf8(s.as_bytes()) + temporal::parsers::intermediates::ParsedDate::year_month_from_utf8(s.as_bytes()) .map(|x| Box::new(ParsedDate(x))) .map_err(Into::::into) } pub fn month_day_from_utf8(s: &DiplomatStr) -> Result, TemporalError> { - temporal::parsed_intermediates::ParsedDate::month_day_from_utf8(s) + temporal::parsers::intermediates::ParsedDate::month_day_from_utf8(s) .map(|x| Box::new(ParsedDate(x))) .map_err(Into::::into) } @@ -82,7 +82,7 @@ pub mod ffi { // TODO(#275) This should not need to convert let s = String::from_utf16(s).map_err(|_| temporal::TemporalError::range())?; - temporal::parsed_intermediates::ParsedDate::month_day_from_utf8(s.as_bytes()) + temporal::parsers::intermediates::ParsedDate::month_day_from_utf8(s.as_bytes()) .map(|x| Box::new(ParsedDate(x))) .map_err(Into::::into) } diff --git a/crates/temporal_capi/src/plain_date_time.rs b/crates/temporal_capi/src/plain_date_time.rs index 1cfe4c22a..5d59054d2 100644 --- a/crates/temporal_capi/src/plain_date_time.rs +++ b/crates/temporal_capi/src/plain_date_time.rs @@ -35,11 +35,11 @@ pub mod ffi { } #[diplomat::opaque] - pub struct ParsedDateTime(temporal::parsed_intermediates::ParsedDateTime); + pub struct ParsedDateTime(temporal::parsers::intermediates::ParsedDateTime); impl ParsedDateTime { pub fn from_utf8(s: &DiplomatStr) -> Result, TemporalError> { - temporal::parsed_intermediates::ParsedDateTime::from_utf8(s) + temporal::parsers::intermediates::ParsedDateTime::from_utf8(s) .map(|x| Box::new(ParsedDateTime(x))) .map_err(Into::::into) } @@ -47,7 +47,7 @@ pub mod ffi { // TODO(#275) This should not need to convert let s = String::from_utf16(s).map_err(|_| temporal::TemporalError::range())?; - temporal::parsed_intermediates::ParsedDateTime::from_utf8(s.as_bytes()) + temporal::parsers::intermediates::ParsedDateTime::from_utf8(s.as_bytes()) .map(|x| Box::new(ParsedDateTime(x))) .map_err(Into::::into) } diff --git a/crates/temporal_capi/src/zoned_date_time.rs b/crates/temporal_capi/src/zoned_date_time.rs index 9530bea94..8802be205 100644 --- a/crates/temporal_capi/src/zoned_date_time.rs +++ b/crates/temporal_capi/src/zoned_date_time.rs @@ -95,7 +95,7 @@ pub mod ffi { } #[diplomat::opaque] - pub struct ParsedZonedDateTime(temporal::parsed_intermediates::ParsedZonedDateTime); + pub struct ParsedZonedDateTime(temporal::parsers::intermediates::ParsedZonedDateTime); impl ParsedZonedDateTime { #[cfg(feature = "compiled_data")] @@ -107,7 +107,7 @@ pub mod ffi { p: &Provider<'p>, ) -> Result, TemporalError> { with_provider!(p, |p| { - temporal::parsed_intermediates::ParsedZonedDateTime::from_utf8_with_provider(s, p) + temporal::parsers::intermediates::ParsedZonedDateTime::from_utf8_with_provider(s, p) }) .map(|x| Box::new(ParsedZonedDateTime(x))) .map_err(Into::::into) @@ -125,7 +125,7 @@ pub mod ffi { let s = String::from_utf16(s).map_err(|_| temporal::TemporalError::range())?; with_provider!(p, |p| { - temporal::parsed_intermediates::ParsedZonedDateTime::from_utf8_with_provider( + temporal::parsers::intermediates::ParsedZonedDateTime::from_utf8_with_provider( s.as_bytes(), p, )