8 Commits

Author SHA1 Message Date
Daniel Hofstetter eb610870e1 Merge pull request #235 from cakebaker/bump_version_to_0_13_0
chore: bump version to `0.13.0`
2025-09-26 14:20:54 +02:00
Daniel Hofstetter 9db7a54926 chore: bump version to 0.13.0 2025-09-26 09:18:35 +02:00
Daniel Hofstetter e43652c0ee Merge pull request #232 from yuankunzhang/tz-rule
add support for `TZ="timezone"` date spec
2025-09-25 16:03:41 +02:00
Daniel Hofstetter d9c2ed2b11 Merge pull request #234 from uutils/renovate/regex-1.x-lockfile
fix(deps): update rust crate regex to v1.11.3
2025-09-25 14:32:35 +02:00
renovate[bot] 6a16e61ab1 fix(deps): update rust crate regex to v1.11.3 2025-09-25 12:26:47 +00:00
yuankunzhang f0c499593e feat: add support for TZ="timezone" date spec 2025-09-25 00:09:54 +08:00
Daniel Hofstetter 5453d6777a Merge pull request #231 from yuankunzhang/rename-timezone-to-offset
refactor: rename the timezone module to offset
2025-09-23 17:51:28 +02:00
yuankunzhang 0c09d5bd17 refactor: rename the timezone module to offset 2025-09-23 22:57:59 +08:00
7 changed files with 889 additions and 486 deletions
Generated
+5 -5
View File
@@ -158,7 +158,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"jiff",
"num-traits",
@@ -223,9 +223,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.2"
version = "1.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
dependencies = [
"aho-corasick",
"memchr",
@@ -235,9 +235,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
dependencies = [
"aho-corasick",
"memchr",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a DateTime"
version = "0.12.0"
version = "0.13.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
+40 -23
View File
@@ -3,7 +3,7 @@
use jiff::{civil, Span, Zoned};
use super::{date, epoch, error, relative, time, timezone, weekday, year, Item};
use super::{date, epoch, error, offset, relative, time, weekday, year, Item};
/// The builder is used to construct a DateTime object from various components.
/// The parser creates a `DateTimeBuilder` object with the parsed components,
@@ -17,7 +17,8 @@ pub(crate) struct DateTimeBuilder {
date: Option<date::Date>,
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
timezone: Option<timezone::Offset>,
offset: Option<offset::Offset>,
timezone: Option<jiff::tz::TimeZone>,
relative: Vec<relative::Relative>,
}
@@ -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> {
@@ -41,7 +56,7 @@ impl DateTimeBuilder {
} else if self.date.is_some()
|| self.time.is_some()
|| self.weekday.is_some()
|| self.timezone.is_some()
|| self.offset.is_some()
|| !self.relative.is_empty()
{
return Err("timestamp cannot be combined with other date/time items");
@@ -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() {
@@ -62,12 +77,12 @@ 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() {
return Err("time cannot appear more than once");
} else if self.timezone.is_some() && time.offset.is_some() {
} else if self.offset.is_some() && time.offset.is_some() {
return Err("time offset and timezone are mutually exclusive");
}
@@ -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() {
@@ -86,23 +101,20 @@ impl DateTimeBuilder {
Ok(self)
}
pub(super) fn set_timezone(mut self, timezone: timezone::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.timezone.is_some() {
return Err("timezone cannot appear more than once");
} else if self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() {
return Err("time offset and timezone are mutually exclusive");
} else if self.offset.is_some()
|| self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some()
{
return Err("time offset cannot appear more than once");
}
self.timezone = Some(timezone);
self.offset = Some(timezone);
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");
}
@@ -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");
}
@@ -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 {
@@ -158,10 +174,10 @@ 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
@@ -239,7 +255,7 @@ impl DateTimeBuilder {
})?;
}
if let Some(offset) = self.timezone {
if let Some(offset) = self.offset {
let (offset, hour_adjustment) = offset.normalize();
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
@@ -262,8 +278,9 @@ impl TryFrom<Vec<Item>> for DateTimeBuilder {
Item::Date(d) => builder.set_date(d)?,
Item::Time(t) => builder.set_time(t)?,
Item::Weekday(weekday) => builder.set_weekday(weekday)?,
Item::TimeZone(tz) => builder.set_timezone(tz)?,
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)?,
}
}
+69 -23
View File
@@ -21,6 +21,7 @@
//! - [`combined`]
//! - [`date`]
//! - [`epoch`]
//! - [`offset`]
//! - [`pure`]
//! - [`relative`]
//! - [`time`]
@@ -32,6 +33,7 @@
mod combined;
mod date;
mod epoch;
mod offset;
mod pure;
mod relative;
mod time;
@@ -66,15 +68,15 @@ enum Item {
Time(time::Time),
Weekday(weekday::Weekday),
Relative(relative::Relative),
TimeZone(timezone::Offset),
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()),
}
@@ -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()),
}
@@ -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 ;
///
@@ -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 }
///
@@ -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`).
///
/// From the GNU docs:
/// GNU `date` specifies that a timestamp item is *complete* and *must not* be
/// combined with any other date/time item.
///
/// > (Timestamp) Such a number cannot be combined with any other date item, as
/// > it specifies a complete timestamp.
/// Notes:
///
/// - 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))
}
@@ -231,7 +259,7 @@ fn parse_item(input: &mut &str) -> ModalResult<Item> {
time::parse.map(Item::Time),
relative::parse.map(Item::Relative),
weekday::parse.map(Item::Weekday),
timezone::parse.map(Item::TimeZone),
offset::parse.map(Item::Offset),
pure::parse.map(Item::Pure),
)),
)
@@ -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()
@@ -369,7 +397,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("timezone cannot appear more than once"));
.contains("time offset cannot appear more than once"));
let result = parse(&mut "2025-05-19 abcdef");
assert!(result.is_err());
@@ -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}");
}
}
}
+468
View File
@@ -0,0 +1,468 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Parse an offset item.
//!
//! From the GNU docs:
//!
//! > A “time zone item” specifies an international time zone, indicated by a
//! > small set of letters, e.g., UTC or Z for Coordinated Universal Time.
//! > Any included periods are ignored. By following a non-daylight-saving
//! > time zone by the string DST in a separate word (that is, separated by
//! > some white space), the corresponding daylight saving time zone may be
//! > specified. Alternatively, a non-daylight-saving time zone can be
//! > followed by a time zone correction, to add the two values. This is
//! > normally done only for UTC; for example, UTC+05:30 is equivalent to
//! > +05:30.
//! >
//! > Time zone items other than UTC and Z are obsolescent and are not
//! > recommended, because they are ambiguous; for example, EST has a
//! > different meaning in Australia than in the United States, and A has
//! > different meaning as a military time zone than as an obsolete RFC 822
//! > time zone. Instead, it's better to use unambiguous numeric time zone
//! > corrections like -0500, as described in the previous section.
//! >
//! > If neither a time zone item nor a time zone correction is supplied,
//! > timestamps are interpreted using the rules of the default time zone.
use std::fmt::Display;
use winnow::{
combinator::{alt, peek},
error::{ContextError, ErrMode},
stream::{AsChar, Stream},
token::take_while,
ModalResult, Parser,
};
use super::{
primitive::{colon, ctx_err, dec_uint, dec_uint_str, plus_or_minus, s},
relative,
};
/// Represents a time zone offset from UTC.
///
/// This struct is used to represent a time zone offset in hours and minutes,
/// with a boolean indicating whether the offset is negative (i.e., west of
/// UTC).
#[derive(PartialEq, Debug, Clone, Default)]
pub(super) struct Offset {
negative: bool,
hours: u8,
minutes: u8,
}
impl Offset {
/// Merge two timezone offsets.
///
/// Note: when parsing an offset from a string (e.g., "+08:00"), the hours
/// and minutes are validated to ensure they fall within valid bounds. In
/// contrast, merging two offsets does not perform such validation. This
/// behavior is intentional to match GNU date.
fn merge(self, offset: Offset) -> Offset {
fn combine(a: u16, neg_a: bool, b: u16, neg_b: bool) -> (u16, bool) {
if neg_a == neg_b {
(a + b, neg_a)
} else if a > b {
(a - b, neg_a)
} else {
(b - a, neg_b)
}
}
let (total_minutes, negative) = combine(
(self.hours as u16) * 60 + (self.minutes as u16),
self.negative,
(offset.hours as u16) * 60 + (offset.minutes as u16),
offset.negative,
);
let hours = (total_minutes / 60) as u8;
let minutes = (total_minutes % 60) as u8;
Offset {
negative,
hours,
minutes,
}
}
/// Normalize the offset so that the hour field is within the accepted range.
///
/// - If the hour field is less than 24, or exactly 24 with a zero minute,
/// the offset is already normalized, and the function returns the offset
/// itself along with a zero hour adjustment.
/// - Otherwise, the hour field is reduced to 23 while preserving the minute
/// field, and the function returns the normalized offset along with the
/// hour adjustment needed to reach the original offset.
pub(super) fn normalize(self) -> (Offset, i8) {
if self.hours < 24 || (self.hours == 24 && self.minutes == 0) {
return (self, 0);
}
let hour_adjustment = (self.hours as i8 - 23) * if self.negative { 1 } else { -1 };
(
Offset {
negative: self.negative,
hours: 23,
minutes: self.minutes,
},
hour_adjustment,
)
}
}
impl TryFrom<(bool, u8, u8)> for Offset {
type Error = &'static str;
fn try_from((negative, hours, minutes): (bool, u8, u8)) -> Result<Self, Self::Error> {
if hours > 24 {
return Err("timezone hour must be between 0 and 24");
}
if minutes > 60 || (hours == 24 && minutes != 0) {
return Err("timezone minute must be between 0 and 60");
}
Ok(Offset {
negative,
hours,
minutes,
})
}
}
impl TryFrom<&Offset> for jiff::tz::TimeZone {
type Error = &'static str;
fn try_from(
Offset {
negative,
hours,
minutes,
}: &Offset,
) -> Result<Self, Self::Error> {
let secs = (*hours as i32) * 3600 + (*minutes as i32) * 60;
let secs = if *negative { -secs } else { secs };
let offset = jiff::tz::Offset::from_seconds(secs).map_err(|_| "offset is invalid")?;
Ok(jiff::tz::TimeZone::fixed(offset))
}
}
impl Display for Offset {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(
fmt,
"{}{:02}:{:02}",
if self.negative { "-" } else { "+" },
self.hours,
self.minutes
)
}
}
pub(super) fn parse(input: &mut &str) -> ModalResult<Offset> {
timezone_name_offset.parse_next(input)
}
/// Parse a timezone starting with `+` or `-`.
pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
// Strings like "+8 years" are ambiguous, they can either be parsed as a
// timezone offset "+8" and a relative time "years", or just a relative time
// "+8 years". GNU date parses them the second way, so we do the same here.
//
// Return early if the input can be parsed as a relative time.
if peek(relative::parse).parse_next(input).is_ok() {
return Err(ErrMode::Backtrack(ContextError::new()));
}
alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input)
}
/// Parse a timezone by name, with an optional numeric offset appended.
fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
/// I'm assuming there are no timezone abbreviations with more
/// than 6 charactres
const MAX_TZ_SIZE: usize = 6;
let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?;
let tz = timezone_name_to_offset(nextword)?;
// Strings like "UTC +8 years" are ambiguous, they can either be parsed as
// "UTC+8" and "years", or "UTC" and "+8 years". GNU date parses them the
// second way, so we do the same here.
//
// Only process if the input cannot be parsed as a relative time.
if peek(relative::parse).parse_next(input).is_err() {
let start = input.checkpoint();
if let Ok(other_tz) = timezone_offset.parse_next(input) {
let new_tz = tz.merge(other_tz);
return Ok(new_tz);
};
input.reset(&start);
}
Ok(tz)
}
/// Parse a timezone offset with a colon separating hours and minutes, e.g.,
/// `+08:00`, `+8:00`, `+8:0`.
fn timezone_offset_colon(input: &mut &str) -> ModalResult<Offset> {
(plus_or_minus, s(dec_uint), s(colon), s(dec_uint))
.parse_next(input)
.and_then(|(sign, hours, _, minutes)| {
(sign == '-', hours, minutes)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
})
}
/// Parse a timezone offset without colon, e.g., `+0800`, `+800`, `+08`, `+8`.
fn timezone_offset_colonless(input: &mut &str) -> ModalResult<Offset> {
(plus_or_minus, s(dec_uint_str))
.verify_map(|(sign, s)| {
// GNU date accepts numeric offset strings with leading zeroes. For
// example, `+000000110` is valid. In such cases, the string is
// truncated to the last four characters. Thus, `+000000110` becomes
// `+0110` (note that one leading zero is kept).
let s = if s.len() > 4 && s.trim_start_matches('0').len() <= 4 {
&s[s.len() - 4..]
} else {
s
};
// Hour and minute values are dependent on the length of the string.
// For example:
//
// - "5" -> 05:00
// - "05" -> 05:00
// - "530" -> 05:30 (the minute is the last two characters here)
// - "0530"-> 05:30
// - "0000530" -> 05:30
let (h_str, m_str) = match s.len() {
1 | 2 => (s, "0"),
3 => s.split_at(1),
4 => s.split_at(2),
_ => return None,
};
let hours = h_str.parse::<u8>().ok()?;
let minutes = m_str.parse::<u8>().ok()?;
Some((sign, hours, minutes))
})
.parse_next(input)
.and_then(|(sign, hours, minutes)| {
(sign == '-', hours, minutes)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
})
}
/// Named timezone list.
///
/// The full list of timezones can be extracted from
/// https://www.timeanddate.com/time/zones/. GNU date only supports a subset of
/// these. We support the same subset as GNU date.
fn timezone_name_to_offset(input: &str) -> ModalResult<Offset> {
let mut offset_str = match input {
"z" => Ok("+0"),
"y" => Ok("-12"),
"x" => Ok("-11"),
"wet" => Ok("+0"),
"west" => Ok("+1"),
"wat" => Ok("+1"),
"w" => Ok("-10"),
"v" => Ok("-9"),
"utc" => Ok("+0"),
"u" => Ok("-8"),
"t" => Ok("-7"),
"sst" => Ok("-11"),
"sgt" => Ok("+8"),
"sast" => Ok("+2"),
"s" => Ok("-6"),
"r" => Ok("-5"),
"q" => Ok("-4"),
"pst" => Ok("-8"),
"pdt" => Ok("-7"),
"p" => Ok("-3"),
"o" => Ok("-2"),
"nzst" => Ok("+12"),
"nzdt" => Ok("+13"),
"nst" => Ok("-3:30"),
"ndt" => Ok("-2:30"),
"n" => Ok("-1"),
"mst" => Ok("-7"),
"msk" => Ok("+3"),
"msd" => Ok("+4"),
"mdt" => Ok("-6"),
"m" => Ok("+12"),
"l" => Ok("+11"),
"k" => Ok("+10"),
"jst" => Ok("+9"),
"ist" => Ok("+5:30"),
"i" => Ok("+9"),
"hst" => Ok("-10"),
"h" => Ok("+8"),
"gst" => Ok("+4"),
"gmt" => Ok("+0"),
"g" => Ok("+7"),
"f" => Ok("+6"),
"est" => Ok("-5"),
"eet" => Ok("+2"),
"eest" => Ok("+3"),
"edt" => Ok("-4"),
"eat" => Ok("+3"),
"e" => Ok("+5"),
"d" => Ok("+4"),
"cst" => Ok("-6"),
"clt" => Ok("-4"),
"clst" => Ok("-3"),
"cet" => Ok("+1"),
"cest" => Ok("+2"),
"cdt" => Ok("-5"),
"cat" => Ok("+2"),
"c" => Ok("+3"),
"bst" => Ok("+6"),
"brt" => Ok("-3"),
"brst" => Ok("-2"),
"b" => Ok("+2"),
"ast" => Ok("-3"),
"art" => Ok("-3"),
"akst" => Ok("-9"),
"akdt" => Ok("-8"),
"adt" => Ok("+4"),
"a" => Ok("+1"),
_ => Err(ErrMode::Backtrack(ContextError::new())),
}?;
timezone_offset(&mut offset_str)
}
#[cfg(test)]
mod tests {
use super::*;
fn off(negative: bool, hours: u8, minutes: u8) -> Offset {
Offset {
negative,
hours,
minutes,
}
}
#[test]
fn timezone_offset_with_colon() {
for (input, expected) in [
("+00:00", off(false, 0, 0)), // UTC
("-00:00", off(true, 0, 0)), // UTC
("+01:00", off(false, 1, 0)), // positive offset
("-06:00", off(true, 6, 0)), // negative offset
("+05:30", off(false, 5, 30)), // positive offset with non-zero minutes
("-03:30", off(true, 3, 30)), // negative offset with non-zero minutes
("- 06:00", off(true, 6, 0)), // space after sign
("- 06 : 00", off(true, 6, 0)), // space around colon
("+5:3", off(false, 5, 3)), // single-digit hours and single-digit minutes
("+5:03", off(false, 5, 3)), // single-digit hours
("+05:3", off(false, 5, 3)), // single-digit minutes
("+00005:00030", off(false, 5, 30)), // leading zeroes in hours and minutes
("+00:00abc", off(false, 0, 0)), // space separator can be ignored if immediately followed by alphas (GNU date behavior)
] {
let mut s = input;
assert_eq!(timezone_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"+25:00", // invalid: hours > 24
"-23:61", // invalid: minutes > 60
"+24:01", // invalid: minutes > 0 when hours == 24
] {
let mut s = input;
assert!(timezone_offset(&mut s).is_err(), "{input}");
}
}
#[test]
fn timezone_offset_without_colon() {
for (input, expected) in [
("+0000", off(false, 0, 0)), // UTC
("-0000", off(true, 0, 0)), // UTC
("+0100", off(false, 1, 0)), // positive offset
("-0600", off(true, 6, 0)), // negative offset
("+0530", off(false, 5, 30)), // positive offset with non-zero minutes
("-0330", off(true, 3, 30)), // negative offset with non-zero minutes
("- 0330", off(true, 3, 30)), // space after sign
("+530", off(false, 5, 30)), // single-digit hours
("+05", off(false, 5, 0)), // double-digit hours and no minutes
("+5", off(false, 5, 0)), // single-digit hours and no minutes
("+00000530", off(false, 5, 30)), // leading zeroes
("+0000abc", off(false, 0, 0)), // space separator can be ignored if immediately followed by alphas (GNU date behavior)
] {
let mut s = input;
assert_eq!(timezone_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"+2500", // invalid: hours > 24
"-2361", // invalid: minutes > 60
"+2401", // invalid: minutes > 0 when hours == 24
"+23 days", // invalid: ambiguous with relative time parsing
] {
let mut s = input;
assert!(timezone_offset(&mut s).is_err(), "{input}");
}
}
#[test]
fn timezone_name_without_offset() {
for (input, expected) in [
("utc", off(false, 0, 0)), // UTC
("gmt", off(false, 0, 0)), // UTC
("z", off(false, 0, 0)), // UTC
("west", off(false, 1, 0)), // positive offset
("cst", off(true, 6, 0)), // negative offset
("ist", off(false, 5, 30)), // positive offset with non-zero minutes
("nst", off(true, 3, 30)), // negative offset with non-zero minutes
("z123", off(false, 0, 0)), // space separator can be ignored if immediately followed by digits (GNU date behavior)
] {
let mut s = input;
assert_eq!(timezone_name_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"abc", // invalid: non-existent timezone
"utcabc", // invalid: non-existent timezone
] {
let mut s = input;
assert!(timezone_name_offset(&mut s).is_err(), "{input}");
}
}
#[test]
fn timezone_name_with_offset() {
for (input, expected) in [
("utc+5:30", off(false, 5, 30)), // UTC with possitive offset
("utc-5:30", off(true, 5, 30)), // UTC with negative offset
("utc +5:30", off(false, 5, 30)), // space after timezone name
("utc + 5 : 30", off(false, 5, 30)), // spaces
("a+5:30", off(false, 6, 30)), // merge two positive offsets (a=+1)
("a-5:30", off(true, 4, 30)), // merge positive and negative offsets (a=+1)
("n-5:30", off(true, 6, 30)), // merge two negative offsets (n=-1)
("n+5:30", off(false, 4, 30)), // merge negative and positive offsets (n=-1)
("m+24", off(false, 36, 0)), // maximum possible positive offset (m=+12)
("y-24", off(true, 36, 0)), // maximum possible negative offset (y=-12)
] {
let mut s = input;
assert_eq!(timezone_name_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"abc+08:00", // invalid: non-existent timezone
"utc+25", // invalid: invalid offset
"utc+23 days", // invalid: ambiguous with relative time parsing
] {
let mut s = input;
assert!(
timezone_name_offset(&mut s).is_err() || !s.is_empty(),
"{input}"
);
}
}
}
+1 -1
View File
@@ -45,8 +45,8 @@ use winnow::{
use super::{
epoch::sec_and_nsec,
offset::{timezone_offset, Offset},
primitive::{colon, ctx_err, dec_uint, s},
timezone::{timezone_offset, Offset},
};
#[derive(PartialEq, Clone, Debug, Default)]
+305 -433
View File
File diff suppressed because it is too large Load Diff