Skip to content

Commit e43652c

Browse files
authored
Merge pull request #232 from yuankunzhang/tz-rule
add support for `TZ="timezone"` date spec
2 parents d9c2ed2 + f0c4995 commit e43652c

File tree

3 files changed

+435
-32
lines changed

3 files changed

+435
-32
lines changed

src/items/builder.rs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub(crate) struct DateTimeBuilder {
1818
time: Option<time::Time>,
1919
weekday: Option<weekday::Weekday>,
2020
offset: Option<offset::Offset>,
21+
timezone: Option<jiff::tz::TimeZone>,
2122
relative: Vec<relative::Relative>,
2223
}
2324

@@ -33,6 +34,20 @@ impl DateTimeBuilder {
3334
self
3435
}
3536

37+
/// Sets the timezone rule for the builder.
38+
///
39+
/// By default, the builder uses the time zone rules indicated by the `TZ`
40+
/// environment variable, or the system default rules if `TZ` is not set.
41+
/// This method allows overriding the time zone rules.
42+
fn set_timezone(mut self, tz: jiff::tz::TimeZone) -> Result<Self, &'static str> {
43+
if self.timezone.is_some() {
44+
return Err("timezone rule cannot appear more than once");
45+
}
46+
47+
self.timezone = Some(tz);
48+
Ok(self)
49+
}
50+
3651
/// Sets a timestamp value. Timestamp values are exclusive to other date/time
3752
/// items (date, time, weekday, timezone, relative adjustments).
3853
pub(super) fn set_timestamp(mut self, ts: epoch::Timestamp) -> Result<Self, &'static str> {
@@ -51,7 +66,7 @@ impl DateTimeBuilder {
5166
Ok(self)
5267
}
5368

54-
pub(super) fn set_date(mut self, date: date::Date) -> Result<Self, &'static str> {
69+
fn set_date(mut self, date: date::Date) -> Result<Self, &'static str> {
5570
if self.timestamp.is_some() {
5671
return Err("timestamp cannot be combined with other date/time items");
5772
} else if self.date.is_some() {
@@ -62,7 +77,7 @@ impl DateTimeBuilder {
6277
Ok(self)
6378
}
6479

65-
pub(super) fn set_time(mut self, time: time::Time) -> Result<Self, &'static str> {
80+
fn set_time(mut self, time: time::Time) -> Result<Self, &'static str> {
6681
if self.timestamp.is_some() {
6782
return Err("timestamp cannot be combined with other date/time items");
6883
} else if self.time.is_some() {
@@ -75,7 +90,7 @@ impl DateTimeBuilder {
7590
Ok(self)
7691
}
7792

78-
pub(super) fn set_weekday(mut self, weekday: weekday::Weekday) -> Result<Self, &'static str> {
93+
fn set_weekday(mut self, weekday: weekday::Weekday) -> Result<Self, &'static str> {
7994
if self.timestamp.is_some() {
8095
return Err("timestamp cannot be combined with other date/time items");
8196
} else if self.weekday.is_some() {
@@ -86,7 +101,7 @@ impl DateTimeBuilder {
86101
Ok(self)
87102
}
88103

89-
pub(super) fn set_offset(mut self, timezone: offset::Offset) -> Result<Self, &'static str> {
104+
fn set_offset(mut self, timezone: offset::Offset) -> Result<Self, &'static str> {
90105
if self.timestamp.is_some() {
91106
return Err("timestamp cannot be combined with other date/time items");
92107
} else if self.offset.is_some()
@@ -99,10 +114,7 @@ impl DateTimeBuilder {
99114
Ok(self)
100115
}
101116

102-
pub(super) fn push_relative(
103-
mut self,
104-
relative: relative::Relative,
105-
) -> Result<Self, &'static str> {
117+
fn push_relative(mut self, relative: relative::Relative) -> Result<Self, &'static str> {
106118
if self.timestamp.is_some() {
107119
return Err("timestamp cannot be combined with other date/time items");
108120
}
@@ -117,7 +129,7 @@ impl DateTimeBuilder {
117129
/// If a date is already set but lacks a year, the number is interpreted as
118130
/// a year. Otherwise, it's interpreted as a time in HHMM, HMM, HH, or H
119131
/// format.
120-
pub(super) fn set_pure(mut self, pure: String) -> Result<Self, &'static str> {
132+
fn set_pure(mut self, pure: String) -> Result<Self, &'static str> {
121133
if self.timestamp.is_some() {
122134
return Err("timestamp cannot be combined with other date/time items");
123135
}
@@ -149,7 +161,11 @@ impl DateTimeBuilder {
149161
}
150162

151163
pub(super) fn build(self) -> Result<Zoned, error::Error> {
152-
let base = self.base.unwrap_or(Zoned::now());
164+
let base = self.base.unwrap_or(if let Some(tz) = &self.timezone {
165+
jiff::Timestamp::now().to_zoned(tz.clone())
166+
} else {
167+
Zoned::now()
168+
});
153169

154170
// If a timestamp is set, we use it to build the `Zoned` object.
155171
if let Some(ts) = self.timestamp {
@@ -158,11 +174,11 @@ impl DateTimeBuilder {
158174

159175
// If any of the following items are set, we truncate the time portion
160176
// of the base date to zero; otherwise, we use the base date as is.
161-
let mut dt = if self.timestamp.is_none()
162-
&& self.date.is_none()
177+
let mut dt = if self.date.is_none()
163178
&& self.time.is_none()
164179
&& self.weekday.is_none()
165180
&& self.offset.is_none()
181+
&& self.timezone.is_none()
166182
{
167183
base
168184
} else {
@@ -264,6 +280,7 @@ impl TryFrom<Vec<Item>> for DateTimeBuilder {
264280
Item::Weekday(weekday) => builder.set_weekday(weekday)?,
265281
Item::Offset(offset) => builder.set_offset(offset)?,
266282
Item::Relative(rel) => builder.push_relative(rel)?,
283+
Item::TimeZone(tz) => builder.set_timezone(tz)?,
267284
Item::Pure(pure) => builder.set_pure(pure)?,
268285
}
269286
}

src/items/mod.rs

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
//! - [`pure`]
2626
//! - [`relative`]
2727
//! - [`time`]
28+
//! - [`timezone`]
2829
//! - [`weekday`]
2930
//! - [`year`]
3031
@@ -36,6 +37,7 @@ mod offset;
3637
mod pure;
3738
mod relative;
3839
mod time;
40+
mod timezone;
3941
mod weekday;
4042
mod year;
4143

@@ -67,14 +69,14 @@ enum Item {
6769
Weekday(weekday::Weekday),
6870
Relative(relative::Relative),
6971
Offset(offset::Offset),
72+
TimeZone(jiff::tz::TimeZone),
7073
Pure(String),
7174
}
7275

7376
/// Parse a date and time string and build a `Zoned` object. The parsed result
7477
/// is resolved against the given base date and time.
7578
pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(base: Zoned, input: S) -> Result<Zoned, Error> {
76-
let input = input.as_ref().to_ascii_lowercase();
77-
match parse(&mut input.as_str()) {
79+
match parse(&mut input.as_ref()) {
7880
Ok(builder) => builder.set_base(base).build(),
7981
Err(e) => Err(e.into()),
8082
}
@@ -83,8 +85,7 @@ pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(base: Zoned, input: S) -> Res
8385
/// Parse a date and time string and build a `Zoned` object. The parsed result
8486
/// is resolved against the current local date and time.
8587
pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, Error> {
86-
let input = input.as_ref().to_ascii_lowercase();
87-
match parse(&mut input.as_str()) {
88+
match parse(&mut input.as_ref()) {
8889
Ok(builder) => builder.build(), // the builder uses current local date and time if no base is given.
8990
Err(e) => Err(e.into()),
9091
}
@@ -95,12 +96,14 @@ pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, E
9596
/// Grammar:
9697
///
9798
/// ```ebnf
98-
/// spec = timestamp | items ;
99+
/// spec = [ tz_rule ] ( timestamp | items ) ;
100+
///
101+
/// tz_rule = "TZ=" , "\"" , ( posix_tz | iana_tz ) , "\"" ;
99102
///
100103
/// timestamp = "@" , float ;
101104
///
102105
/// items = item , { item } ;
103-
/// item = datetime | date | time | relative | weekday | timezone | pure ;
106+
/// item = datetime | date | time | relative | weekday | offset | pure ;
104107
///
105108
/// datetime = date , [ "t" | whitespace ] , iso_time ;
106109
///
@@ -179,7 +182,7 @@ pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, E
179182
/// | "saturday" | "sat" | "sat."
180183
/// | "sunday" | "sun" | "sun." ;
181184
///
182-
/// timezone = named_zone , [ time_offset ] ;
185+
/// offset = named_zone , [ time_offset ] ;
183186
///
184187
/// pure = { digit }
185188
///
@@ -189,35 +192,60 @@ fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
189192
trace("parse", alt((parse_timestamp, parse_items))).parse_next(input)
190193
}
191194

192-
/// Parse a timestamp.
195+
/// Parse a standalone epoch timestamp (e.g., `@1758724019`).
196+
///
197+
/// GNU `date` specifies that a timestamp item is *complete* and *must not* be
198+
/// combined with any other date/time item.
193199
///
194-
/// From the GNU docs:
200+
/// Notes:
195201
///
196-
/// > (Timestamp) Such a number cannot be combined with any other date item, as
197-
/// > it specifies a complete timestamp.
202+
/// - If a timezone rule (`TZ="..."`) appears at the beginning of the input, it
203+
/// has no effect on the epoch value. We intentionally parse and ignore it.
204+
/// - Trailing input (aside from optional whitespaces) is rejected.
198205
fn parse_timestamp(input: &mut &str) -> ModalResult<DateTimeBuilder> {
206+
// Parse and ignore an optional leading timezone rule.
207+
let _ = timezone::parse(input);
208+
199209
trace(
200210
"parse_timestamp",
211+
// Expect exactly one timestamp and then EOF (allowing trailing spaces).
201212
terminated(epoch::parse.map(Item::Timestamp), preceded(space, eof)),
202213
)
203-
.verify_map(|ts: Item| {
204-
if let Item::Timestamp(ts) = ts {
205-
DateTimeBuilder::new().set_timestamp(ts).ok()
206-
} else {
207-
None
208-
}
214+
.verify_map(|item: Item| match item {
215+
Item::Timestamp(ts) => DateTimeBuilder::new().set_timestamp(ts).ok(),
216+
_ => None,
209217
})
210218
.parse_next(input)
211219
}
212220

213-
/// Parse a sequence of items.
221+
/// Parse a sequence of date/time items, honoring an optional leading TZ rule.
222+
///
223+
/// Notes:
224+
///
225+
/// - If a timezone rule (`TZ="..."`) appears at the beginning of the input,
226+
/// parse it first. The timezone rule is case-sensitive.
227+
/// - After the optional timezone rule is parsed, we convert the input to
228+
/// lowercase to allow case-insensitive parsing of the remaining items.
229+
/// - Trailing input (aside from optional whitespaces) is rejected.
214230
fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
215-
let (items, _): (Vec<Item>, _) = trace(
231+
// Parse and consume an optional leading timezone rule.
232+
let tz = timezone::parse(input).map(Item::TimeZone);
233+
234+
// Convert input to lowercase for case-insensitive parsing.
235+
let lower = input.to_ascii_lowercase();
236+
let input = &mut lower.as_str();
237+
238+
let (mut items, _): (Vec<Item>, _) = trace(
216239
"parse_items",
240+
// Parse zero or more items until EOF (allowing trailing spaces).
217241
repeat_till(0.., parse_item, preceded(space, eof)),
218242
)
219243
.parse_next(input)?;
220244

245+
if let Ok(tz) = tz {
246+
items.push(tz);
247+
}
248+
221249
items.try_into().map_err(|e| expect_error(input, e))
222250
}
223251

@@ -251,7 +279,7 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode<ContextError>
251279
mod tests {
252280
use jiff::{civil::DateTime, tz::TimeZone, ToSpan, Zoned};
253281

254-
use super::{parse, DateTimeBuilder};
282+
use super::*;
255283

256284
fn at_date(builder: DateTimeBuilder, base: Zoned) -> Zoned {
257285
builder.set_base(base).build().unwrap()
@@ -527,4 +555,22 @@ mod tests {
527555
assert_eq!(result.hour(), 1);
528556
assert_eq!(result.minute(), 0);
529557
}
558+
559+
#[test]
560+
fn timezone_rule() {
561+
let parse_build = |mut s| parse(&mut s).unwrap().build().unwrap();
562+
563+
for (input, expected) in [
564+
(
565+
r#"TZ="Europe/Paris" 2025-01-02"#,
566+
"2025-01-02 00:00:00[Europe/Paris]".parse().unwrap(),
567+
),
568+
(
569+
r#"TZ="Europe/Paris" 2025-01-02 03:04:05"#,
570+
"2025-01-02 03:04:05[Europe/Paris]".parse().unwrap(),
571+
),
572+
] {
573+
assert_eq!(parse_build(input), expected, "{input}");
574+
}
575+
}
530576
}

0 commit comments

Comments
 (0)