Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions src/items/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub(crate) struct DateTimeBuilder {
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
offset: Option<offset::Offset>,
timezone: Option<jiff::tz::TimeZone>,
relative: Vec<relative::Relative>,
}

Expand All @@ -33,6 +34,20 @@ impl DateTimeBuilder {
self
}

/// Sets the timezone rule for the builder.
///
/// By default, the builder uses the time zone rules indicated by the `TZ`
/// environment variable, or the system default rules if `TZ` is not set.
/// This method allows overriding the time zone rules.
fn set_timezone(mut self, tz: jiff::tz::TimeZone) -> Result<Self, &'static str> {
if self.timezone.is_some() {
return Err("timezone rule cannot appear more than once");
}

self.timezone = Some(tz);
Ok(self)
}

/// Sets a timestamp value. Timestamp values are exclusive to other date/time
/// items (date, time, weekday, timezone, relative adjustments).
pub(super) fn set_timestamp(mut self, ts: epoch::Timestamp) -> Result<Self, &'static str> {
Expand All @@ -51,7 +66,7 @@ impl DateTimeBuilder {
Ok(self)
}

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

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

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

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

pub(super) fn push_relative(
mut self,
relative: relative::Relative,
) -> Result<Self, &'static str> {
fn push_relative(mut self, relative: relative::Relative) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
}
Expand All @@ -117,7 +129,7 @@ impl DateTimeBuilder {
/// If a date is already set but lacks a year, the number is interpreted as
/// a year. Otherwise, it's interpreted as a time in HHMM, HMM, HH, or H
/// format.
pub(super) fn set_pure(mut self, pure: String) -> Result<Self, &'static str> {
fn set_pure(mut self, pure: String) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
}
Expand Down Expand Up @@ -149,7 +161,11 @@ impl DateTimeBuilder {
}

pub(super) fn build(self) -> Result<Zoned, error::Error> {
let base = self.base.unwrap_or(Zoned::now());
let base = self.base.unwrap_or(if let Some(tz) = &self.timezone {
jiff::Timestamp::now().to_zoned(tz.clone())
} else {
Zoned::now()
});

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

// If any of the following items are set, we truncate the time portion
// of the base date to zero; otherwise, we use the base date as is.
let mut dt = if self.timestamp.is_none()
&& self.date.is_none()
let mut dt = if self.date.is_none()
&& self.time.is_none()
&& self.weekday.is_none()
&& self.offset.is_none()
&& self.timezone.is_none()
{
base
} else {
Expand Down Expand Up @@ -264,6 +280,7 @@ impl TryFrom<Vec<Item>> for DateTimeBuilder {
Item::Weekday(weekday) => builder.set_weekday(weekday)?,
Item::Offset(offset) => builder.set_offset(offset)?,
Item::Relative(rel) => builder.push_relative(rel)?,
Item::TimeZone(tz) => builder.set_timezone(tz)?,
Item::Pure(pure) => builder.set_pure(pure)?,
}
}
Expand Down
86 changes: 66 additions & 20 deletions src/items/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
//! - [`pure`]
//! - [`relative`]
//! - [`time`]
//! - [`timezone`]
//! - [`weekday`]
//! - [`year`]

Expand All @@ -36,6 +37,7 @@ mod offset;
mod pure;
mod relative;
mod time;
mod timezone;
mod weekday;
mod year;

Expand Down Expand Up @@ -67,14 +69,14 @@ enum Item {
Weekday(weekday::Weekday),
Relative(relative::Relative),
Offset(offset::Offset),
TimeZone(jiff::tz::TimeZone),
Pure(String),
}

/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the given base date and time.
pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(base: Zoned, input: S) -> Result<Zoned, Error> {
let input = input.as_ref().to_ascii_lowercase();
match parse(&mut input.as_str()) {
match parse(&mut input.as_ref()) {
Ok(builder) => builder.set_base(base).build(),
Err(e) => Err(e.into()),
}
Expand All @@ -83,8 +85,7 @@ pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(base: Zoned, input: S) -> Res
/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the current local date and time.
pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, Error> {
let input = input.as_ref().to_ascii_lowercase();
match parse(&mut input.as_str()) {
match parse(&mut input.as_ref()) {
Ok(builder) => builder.build(), // the builder uses current local date and time if no base is given.
Err(e) => Err(e.into()),
}
Expand All @@ -95,12 +96,14 @@ pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, E
/// Grammar:
///
/// ```ebnf
/// spec = timestamp | items ;
/// spec = [ tz_rule ] ( timestamp | items ) ;
///
/// tz_rule = "TZ=" , "\"" , ( posix_tz | iana_tz ) , "\"" ;
///
/// timestamp = "@" , float ;
///
/// items = item , { item } ;
/// item = datetime | date | time | relative | weekday | timezone | pure ;
/// item = datetime | date | time | relative | weekday | offset | pure ;
///
/// datetime = date , [ "t" | whitespace ] , iso_time ;
///
Expand Down Expand Up @@ -179,7 +182,7 @@ pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, E
/// | "saturday" | "sat" | "sat."
/// | "sunday" | "sun" | "sun." ;
///
/// timezone = named_zone , [ time_offset ] ;
/// offset = named_zone , [ time_offset ] ;
///
/// pure = { digit }
///
Expand All @@ -189,35 +192,60 @@ fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
trace("parse", alt((parse_timestamp, parse_items))).parse_next(input)
}

/// Parse a timestamp.
/// Parse a standalone epoch timestamp (e.g., `@1758724019`).
///
/// GNU `date` specifies that a timestamp item is *complete* and *must not* be
/// combined with any other date/time item.
///
/// From the GNU docs:
/// Notes:
///
/// > (Timestamp) Such a number cannot be combined with any other date item, as
/// > it specifies a complete timestamp.
/// - If a timezone rule (`TZ="..."`) appears at the beginning of the input, it
/// has no effect on the epoch value. We intentionally parse and ignore it.
/// - Trailing input (aside from optional whitespaces) is rejected.
fn parse_timestamp(input: &mut &str) -> ModalResult<DateTimeBuilder> {
// Parse and ignore an optional leading timezone rule.
let _ = timezone::parse(input);

trace(
"parse_timestamp",
// Expect exactly one timestamp and then EOF (allowing trailing spaces).
terminated(epoch::parse.map(Item::Timestamp), preceded(space, eof)),
)
.verify_map(|ts: Item| {
if let Item::Timestamp(ts) = ts {
DateTimeBuilder::new().set_timestamp(ts).ok()
} else {
None
}
.verify_map(|item: Item| match item {
Item::Timestamp(ts) => DateTimeBuilder::new().set_timestamp(ts).ok(),
_ => None,
})
.parse_next(input)
}

/// Parse a sequence of items.
/// Parse a sequence of date/time items, honoring an optional leading TZ rule.
///
/// Notes:
///
/// - If a timezone rule (`TZ="..."`) appears at the beginning of the input,
/// parse it first. The timezone rule is case-sensitive.
/// - After the optional timezone rule is parsed, we convert the input to
/// lowercase to allow case-insensitive parsing of the remaining items.
/// - Trailing input (aside from optional whitespaces) is rejected.
fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
let (items, _): (Vec<Item>, _) = trace(
// Parse and consume an optional leading timezone rule.
let tz = timezone::parse(input).map(Item::TimeZone);

// Convert input to lowercase for case-insensitive parsing.
let lower = input.to_ascii_lowercase();
let input = &mut lower.as_str();

let (mut items, _): (Vec<Item>, _) = trace(
"parse_items",
// Parse zero or more items until EOF (allowing trailing spaces).
repeat_till(0.., parse_item, preceded(space, eof)),
)
.parse_next(input)?;

if let Ok(tz) = tz {
items.push(tz);
}

items.try_into().map_err(|e| expect_error(input, e))
}

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

use super::{parse, DateTimeBuilder};
use super::*;

fn at_date(builder: DateTimeBuilder, base: Zoned) -> Zoned {
builder.set_base(base).build().unwrap()
Expand Down Expand Up @@ -527,4 +555,22 @@ mod tests {
assert_eq!(result.hour(), 1);
assert_eq!(result.minute(), 0);
}

#[test]
fn timezone_rule() {
let parse_build = |mut s| parse(&mut s).unwrap().build().unwrap();

for (input, expected) in [
(
r#"TZ="Europe/Paris" 2025-01-02"#,
"2025-01-02 00:00:00[Europe/Paris]".parse().unwrap(),
),
(
r#"TZ="Europe/Paris" 2025-01-02 03:04:05"#,
"2025-01-02 03:04:05[Europe/Paris]".parse().unwrap(),
),
] {
assert_eq!(parse_build(input), expected, "{input}");
}
}
}
Loading
Loading