Skip to content

Commit a360a2e

Browse files
authored
Add UtcOffset struct for PartialZonedDateTime (#207)
This PR introduces to `UtcOffset` parse for use with `PartialZonedDateTime`. The goal ultimately is providing the tools / API for [PrepareCalendarFields](https://tc39.es/proposal-temporal/#sec-temporal-preparecalendarfields) to be implemented, specifically the ToOffsetString conversion, while also better representing the general invariant of `PartialZonedDateTime`'s offset.
1 parent 2d735c1 commit a360a2e

File tree

5 files changed

+98
-96
lines changed

5 files changed

+98
-96
lines changed

src/builtins/core/timezone.rs

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
use alloc::string::String;
44
use alloc::{vec, vec::Vec};
55

6+
use ixdtf::parsers::records::{TimeZoneRecord, UtcOffsetRecord};
67
use num_traits::ToPrimitive;
78

89
use crate::builtins::core::duration::DateDuration;
910
use crate::parsers::{
10-
parse_allowed_timezone_formats, parse_identifier, FormattableOffset, FormattableTime, Precision,
11+
parse_allowed_timezone_formats, parse_identifier, parse_offset, FormattableOffset,
12+
FormattableTime, Precision,
1113
};
1214
use crate::provider::{TimeZoneOffset, TimeZoneProvider};
1315
use crate::{
@@ -21,20 +23,83 @@ use crate::{Calendar, Sign};
2123

2224
const NS_IN_HOUR: i128 = 60 * 60 * 1000 * 1000 * 1000;
2325

26+
/// A UTC time zone offset stored in minutes
27+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28+
pub struct UtcOffset(pub(crate) i16);
29+
30+
impl UtcOffset {
31+
pub(crate) fn from_ixdtf_record(record: UtcOffsetRecord) -> Self {
32+
// NOTE: ixdtf parser restricts minute/second to 0..=60
33+
let minutes = i16::from(record.hour) * 60 + record.minute as i16;
34+
Self(minutes * i16::from(record.sign as i8))
35+
}
36+
37+
pub fn to_string(&self) -> TemporalResult<String> {
38+
let sign = if self.0 < 0 {
39+
Sign::Negative
40+
} else {
41+
Sign::Positive
42+
};
43+
let hour = (self.0.abs() / 60) as u8;
44+
let minute = (self.0.abs() % 60) as u8;
45+
let formattable_offset = FormattableOffset {
46+
sign,
47+
time: FormattableTime {
48+
hour,
49+
minute,
50+
second: 0,
51+
nanosecond: 0,
52+
precision: Precision::Minute,
53+
include_sep: true,
54+
},
55+
};
56+
Ok(formattable_offset.to_string())
57+
}
58+
}
59+
60+
impl core::str::FromStr for UtcOffset {
61+
type Err = TemporalError;
62+
fn from_str(s: &str) -> Result<Self, Self::Err> {
63+
let mut cursor = s.chars().peekable();
64+
match parse_offset(&mut cursor)? {
65+
Some(offset) => Ok(Self(offset)),
66+
None => Err(TemporalError::range().with_message("Invalid offset")),
67+
}
68+
}
69+
}
70+
2471
// TODO: Potentially migrate to Cow<'a, str>
2572
// TODO: There may be an argument to have Offset minutes be a (Cow<'a, str>,, i16) to
2673
// prevent allocations / writing, TBD
27-
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
74+
#[derive(Debug, Clone, PartialEq, Eq)]
2875
pub enum TimeZone {
2976
IanaIdentifier(String),
30-
OffsetMinutes(i16),
77+
UtcOffset(UtcOffset),
3178
}
3279

3380
impl TimeZone {
81+
// Create a `TimeZone` from an ixdtf `TimeZoneRecord`.
82+
#[inline]
83+
pub(crate) fn from_time_zone_record(record: TimeZoneRecord) -> TemporalResult<Self> {
84+
let timezone = match record {
85+
TimeZoneRecord::Name(s) => {
86+
TimeZone::IanaIdentifier(String::from_utf8_lossy(s).into_owned())
87+
}
88+
TimeZoneRecord::Offset(offset_record) => {
89+
let offset = UtcOffset::from_ixdtf_record(offset_record);
90+
TimeZone::UtcOffset(offset)
91+
}
92+
// TimeZoneRecord is non_exhaustive, but all current branches are matching.
93+
_ => return Err(TemporalError::assert()),
94+
};
95+
96+
Ok(timezone)
97+
}
98+
3499
/// Parses a `TimeZone` from a provided `&str`.
35100
pub fn try_from_identifier_str(identifier: &str) -> TemporalResult<Self> {
36101
if identifier == "Z" {
37-
return Ok(TimeZone::OffsetMinutes(0));
102+
return Ok(TimeZone::UtcOffset(UtcOffset(0)));
38103
}
39104
parse_identifier(identifier)
40105
}
@@ -52,27 +117,7 @@ impl TimeZone {
52117
pub fn identifier(&self) -> TemporalResult<String> {
53118
match self {
54119
TimeZone::IanaIdentifier(s) => Ok(s.clone()),
55-
TimeZone::OffsetMinutes(m) => {
56-
let sign = if *m < 0 {
57-
Sign::Negative
58-
} else {
59-
Sign::Positive
60-
};
61-
let hour = (m.abs() / 60) as u8;
62-
let minute = (m.abs() % 60) as u8;
63-
let formattable_offset = FormattableOffset {
64-
sign,
65-
time: FormattableTime {
66-
hour,
67-
minute,
68-
second: 0,
69-
nanosecond: 0,
70-
precision: Precision::Minute,
71-
include_sep: true,
72-
},
73-
};
74-
Ok(formattable_offset.to_string())
75-
}
120+
TimeZone::UtcOffset(offset) => offset.to_string(),
76121
}
77122
}
78123
}
@@ -108,7 +153,7 @@ impl TimeZone {
108153
// 1. Let parseResult be ! ParseTimeZoneIdentifier(timeZone).
109154
match self {
110155
// 2. If parseResult.[[OffsetMinutes]] is not empty, return parseResult.[[OffsetMinutes]] × (60 × 10**9).
111-
Self::OffsetMinutes(minutes) => Ok(i128::from(*minutes) * 60_000_000_000i128),
156+
Self::UtcOffset(offset) => Ok(i128::from(offset.0) * 60_000_000_000i128),
112157
// 3. Return GetNamedTimeZoneOffsetNanoseconds(parseResult.[[Name]], epochNs).
113158
Self::IanaIdentifier(identifier) => provider
114159
.get_named_tz_offset_nanoseconds(identifier, utc_epoch)
@@ -137,7 +182,7 @@ impl TimeZone {
137182
// 1.Let parseResult be ! ParseTimeZoneIdentifier(timeZone).
138183
let possible_nanoseconds = match self {
139184
// 2. If parseResult.[[OffsetMinutes]] is not empty, then
140-
Self::OffsetMinutes(minutes) => {
185+
Self::UtcOffset(UtcOffset(minutes)) => {
141186
// a. Let balanced be
142187
// BalanceISODateTime(isoDateTime.[[ISODate]].[[Year]],
143188
// isoDateTime.[[ISODate]].[[Month]],

src/builtins/core/zoneddatetime.rs

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
33
use alloc::string::String;
44
use core::{cmp::Ordering, num::NonZeroU128};
5-
use ixdtf::parsers::records::{TimeZoneRecord, UtcOffsetRecordOrZ};
5+
use ixdtf::parsers::records::UtcOffsetRecordOrZ;
66
use tinystr::TinyAsciiStr;
77

88
use crate::{
99
builtins::core::{
1010
calendar::Calendar,
1111
duration::normalized::{NormalizedDurationRecord, NormalizedTimeDuration},
12-
timezone::TimeZone,
12+
timezone::{TimeZone, UtcOffset},
1313
Duration, Instant, PlainDate, PlainDateTime, PlainTime,
1414
},
1515
iso::{IsoDate, IsoDateTime, IsoTime},
@@ -19,9 +19,7 @@ use crate::{
1919
ResolvedRoundingOptions, RoundingIncrement, TemporalRoundingMode, TemporalUnit,
2020
ToStringRoundingOptions, UnitGroup,
2121
},
22-
parsers::{
23-
self, parse_offset, FormattableOffset, FormattableTime, IxdtfStringBuilder, Precision,
24-
},
22+
parsers::{self, FormattableOffset, FormattableTime, IxdtfStringBuilder, Precision},
2523
partial::{PartialDate, PartialTime},
2624
provider::{TimeZoneProvider, TransitionDirection},
2725
rounding::{IncrementRounder, Round},
@@ -38,7 +36,7 @@ pub struct PartialZonedDateTime {
3836
/// The `PartialTime` portion of a `PartialZonedDateTime`
3937
pub time: PartialTime,
4038
/// An optional offset string
41-
pub offset: Option<String>,
39+
pub offset: Option<UtcOffset>,
4240
/// The time zone value of a partial time zone.
4341
pub timezone: Option<TimeZone>,
4442
}
@@ -70,7 +68,7 @@ impl PartialZonedDateTime {
7068
self
7169
}
7270

73-
pub fn with_offset(mut self, offset: Option<String>) -> Self {
71+
pub const fn with_offset(mut self, offset: Option<UtcOffset>) -> Self {
7472
self.offset = offset;
7573
self
7674
}
@@ -397,15 +395,9 @@ impl ZonedDateTime {
397395
};
398396

399397
// Handle time zones
400-
let offset = partial
398+
let offset_nanos = partial
401399
.offset
402-
.map(|offset| {
403-
let mut cursor = offset.chars().peekable();
404-
parse_offset(&mut cursor)
405-
})
406-
.transpose()?;
407-
408-
let offset_nanos = offset.map(|minutes| i64::from(minutes) * 60_000_000_000);
400+
.map(|offset| i64::from(offset.0) * 60_000_000_000);
409401

410402
let timezone = partial.timezone.unwrap_or_default();
411403

@@ -938,18 +930,7 @@ impl ZonedDateTime {
938930
// NOTE (nekevss): `parse_zoned_date_time` guarantees that this value exists.
939931
let annotation = parse_result.tz.temporal_unwrap()?;
940932

941-
let timezone = match annotation.tz {
942-
TimeZoneRecord::Name(s) => {
943-
TimeZone::IanaIdentifier(String::from_utf8_lossy(s).into_owned())
944-
}
945-
TimeZoneRecord::Offset(offset_record) => {
946-
// NOTE: ixdtf parser restricts minute/second to 0..=60
947-
let minutes = i16::from(offset_record.hour) * 60 + offset_record.minute as i16;
948-
TimeZone::OffsetMinutes(minutes * i16::from(offset_record.sign as i8))
949-
}
950-
// TimeZoneRecord is non_exhaustive, but all current branches are matching.
951-
_ => return Err(TemporalError::assert()),
952-
};
933+
let timezone = TimeZone::from_time_zone_record(annotation.tz)?;
953934

954935
let (offset_nanos, is_exact) = parse_result
955936
.offset

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ pub mod time {
9797

9898
pub use crate::builtins::{
9999
calendar::{Calendar, MonthCode},
100-
core::timezone::TimeZone,
100+
core::timezone::{TimeZone, UtcOffset},
101101
DateDuration, Duration, Instant, Now, PlainDate, PlainDateTime, PlainMonthDay, PlainTime,
102102
PlainYearMonth, TimeDuration, ZonedDateTime,
103103
};

src/options/relative_to.rs

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
//! RelativeTo rounding option
22
3-
use alloc::string::String;
4-
53
use crate::builtins::core::zoneddatetime::interpret_isodatetime_offset;
64
use crate::builtins::core::{calendar::Calendar, timezone::TimeZone, PlainDate, ZonedDateTime};
75
use crate::iso::{IsoDate, IsoTime};
86
use crate::options::{ArithmeticOverflow, Disambiguation, OffsetDisambiguation};
97
use crate::parsers::parse_date_time;
108
use crate::provider::TimeZoneProvider;
11-
use crate::{TemporalError, TemporalResult, TemporalUnwrap};
9+
use crate::{TemporalResult, TemporalUnwrap};
1210

13-
use ixdtf::parsers::records::{TimeZoneRecord, UtcOffsetRecordOrZ};
11+
use ixdtf::parsers::records::UtcOffsetRecordOrZ;
1412

1513
// ==== RelativeTo Object ====
1614

@@ -62,18 +60,7 @@ impl RelativeTo {
6260
.into());
6361
};
6462

65-
let timezone = match annotation.tz {
66-
TimeZoneRecord::Name(s) => {
67-
TimeZone::IanaIdentifier(String::from_utf8_lossy(s).into_owned())
68-
}
69-
TimeZoneRecord::Offset(offset_record) => {
70-
// NOTE: ixdtf parser restricts minute/second to 0..=60
71-
let minutes = i16::from(offset_record.hour) * 60 + offset_record.minute as i16;
72-
TimeZone::OffsetMinutes(minutes * i16::from(offset_record.sign as i8))
73-
}
74-
// TimeZoneRecord is non_exhaustive, but all current branches are matching.
75-
_ => return Err(TemporalError::assert()),
76-
};
63+
let timezone = TimeZone::from_time_zone_record(annotation.tz)?;
7764

7865
let (offset_nanos, is_exact) = result
7966
.offset

src/parsers/timezone.rs

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
use alloc::borrow::ToOwned;
2-
use alloc::string::String;
32
use core::{iter::Peekable, str::Chars};
4-
use ixdtf::parsers::{
5-
records::{TimeZoneRecord, UtcOffsetRecord, UtcOffsetRecordOrZ},
6-
IxdtfParser,
7-
};
3+
use ixdtf::parsers::{records::UtcOffsetRecordOrZ, IxdtfParser};
84

9-
use crate::{TemporalError, TemporalResult, TimeZone};
5+
use crate::{builtins::timezone::UtcOffset, TemporalError, TemporalResult, TimeZone};
106

117
use super::{parse_ixdtf, ParseVariant};
128

@@ -34,45 +30,38 @@ pub(crate) fn parse_allowed_timezone_formats(s: &str) -> Option<TimeZone> {
3430
};
3531

3632
if let Some(annotation) = annotation {
37-
match annotation.tz {
38-
TimeZoneRecord::Name(s) => {
39-
let identifier = String::from_utf8_lossy(s).into_owned();
40-
return Some(TimeZone::IanaIdentifier(identifier));
41-
}
42-
TimeZoneRecord::Offset(offset) => return Some(timezone_from_offset_record(offset)),
43-
_ => {}
44-
}
33+
return TimeZone::from_time_zone_record(annotation.tz).ok();
4534
};
4635

4736
if let Some(offset) = offset {
4837
match offset {
4938
UtcOffsetRecordOrZ::Z => return Some(TimeZone::default()),
50-
UtcOffsetRecordOrZ::Offset(offset) => return Some(timezone_from_offset_record(offset)),
39+
UtcOffsetRecordOrZ::Offset(offset) => {
40+
return Some(TimeZone::UtcOffset(UtcOffset::from_ixdtf_record(offset)))
41+
}
5142
}
5243
}
5344

5445
None
5546
}
5647

57-
fn timezone_from_offset_record(record: UtcOffsetRecord) -> TimeZone {
58-
let minutes = (record.hour as i16 * 60) + record.minute as i16 + (record.second as i16 / 60);
59-
TimeZone::OffsetMinutes(minutes * record.sign as i16)
60-
}
61-
6248
#[inline]
6349
pub(crate) fn parse_identifier(source: &str) -> TemporalResult<TimeZone> {
6450
let mut cursor = source.chars().peekable();
65-
if cursor.peek().is_some_and(is_ascii_sign) {
66-
let offset_minutes = parse_offset(&mut cursor)?;
67-
return Ok(TimeZone::OffsetMinutes(offset_minutes));
51+
if let Some(offset) = parse_offset(&mut cursor)? {
52+
return Ok(TimeZone::UtcOffset(UtcOffset(offset)));
6853
} else if parse_iana_component(&mut cursor) {
6954
return Ok(TimeZone::IanaIdentifier(source.to_owned()));
7055
}
7156
Err(TemporalError::range().with_message("Invalid TimeZone Identifier"))
7257
}
7358

7459
#[inline]
75-
pub(crate) fn parse_offset(chars: &mut Peekable<Chars<'_>>) -> TemporalResult<i16> {
60+
pub(crate) fn parse_offset(chars: &mut Peekable<Chars<'_>>) -> TemporalResult<Option<i16>> {
61+
if chars.peek().is_none() || !chars.peek().is_some_and(is_ascii_sign) {
62+
return Ok(None);
63+
}
64+
7665
let sign = chars.next().map_or(1, |c| if c == '+' { 1 } else { -1 });
7766
// First offset portion
7867
let hours = parse_digit_pair(chars)?;
@@ -90,7 +79,7 @@ pub(crate) fn parse_offset(chars: &mut Peekable<Chars<'_>>) -> TemporalResult<i1
9079
None => 0,
9180
};
9281

93-
Ok((hours * 60 + minutes) * sign)
82+
Ok(Some((hours * 60 + minutes) * sign))
9483
}
9584

9685
fn parse_digit_pair(chars: &mut Peekable<Chars<'_>>) -> TemporalResult<i16> {

0 commit comments

Comments
 (0)