From f5b2b0317294644fbd71986da90fbb1253983f51 Mon Sep 17 00:00:00 2001 From: bezmuth Date: Tue, 5 Aug 2025 17:31:34 +0100 Subject: [PATCH 1/3] Add TZ="timezone" support --- src/items/builder.rs | 20 +++++++++++++++++++- src/items/conversion_timezone.rs | 13 +++++++++++++ src/items/mod.rs | 8 ++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/items/conversion_timezone.rs diff --git a/src/items/builder.rs b/src/items/builder.rs index f11d94c..909f285 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -17,6 +17,7 @@ pub(crate) struct DateTimeBuilder { date: Option, time: Option, weekday: Option, + conversion_timezone: Option, timezone: Option, relative: Vec, } @@ -106,11 +107,22 @@ impl DateTimeBuilder { if self.timestamp.is_some() { return Err("timestamp cannot be combined with other date/time items"); } - self.relative.push(relative); Ok(self) } + pub(super) fn set_conversion_timezone( + mut self, + conversion_timezone: time::Offset, + ) -> Result { + if self.conversion_timezone.is_some() { + Err("TZ= cannot appear more than once") + } else { + self.conversion_timezone = Some(conversion_timezone); + Ok(self) + } + } + /// Sets a pure number that can be interpreted as either a year or time /// depending on the current state of the builder. /// @@ -315,6 +327,12 @@ impl DateTimeBuilder { dt = with_timezone_restore(offset, dt)?; } + if let Some(mut offset) = self.conversion_timezone { + // Reuse with_timezone_restore with a swapped offset + offset.negative = !offset.negative; + dt = with_timezone_restore(offset, dt)?; + } + Some(dt) } } diff --git a/src/items/conversion_timezone.rs b/src/items/conversion_timezone.rs new file mode 100644 index 0000000..4dec947 --- /dev/null +++ b/src/items/conversion_timezone.rs @@ -0,0 +1,13 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use winnow::{ModalResult, Parser}; + +use super::time; + +pub(crate) fn parse(input: &mut &str) -> ModalResult { + let _ = "tz=\"".parse_next(input)?; + let tz = time::timezone(input); + let _ = "\" ".parse_next(input)?; + return tz +} diff --git a/src/items/mod.rs b/src/items/mod.rs index 49eae08..f25cc2f 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -30,6 +30,7 @@ // date and time items mod combined; +mod conversion_timezone; mod date; mod epoch; mod pure; @@ -58,6 +59,7 @@ use crate::ParseDateTimeError; #[derive(PartialEq, Debug)] pub(crate) enum Item { + ConversionTimeZone(FixedOffset), Timestamp(epoch::Timestamp), DateTime(combined::DateTime), Date(date::Date), @@ -214,6 +216,11 @@ fn parse_items(input: &mut &str) -> ModalResult { loop { match parse_item.parse_next(input) { Ok(item) => match item { + Item::ConversionTimeZone(tz) => { + builder = builder + .set_conversion_timezone(tz) + .map_err(|e| expect_error(input, e))?; + } Item::Timestamp(ts) => { builder = builder .set_timestamp(ts) @@ -269,6 +276,7 @@ fn parse_item(input: &mut &str) -> ModalResult { trace( "parse_item", alt(( + conversion_timezone::parse.map(Item::ConversionTimeZone), combined::parse.map(Item::DateTime), date::parse.map(Item::Date), time::parse.map(Item::Time), From 8422111fb565d8841212021ec4c38333ce1ee5c9 Mon Sep 17 00:00:00 2001 From: bezmuth Date: Mon, 11 Aug 2025 14:08:10 +0100 Subject: [PATCH 2/3] Add named timezone support --- Cargo.lock | 112 +++++++++++++++++++++++++++++++ Cargo.toml | 1 + fuzz/Cargo.lock | 2 +- src/items/builder.rs | 14 ++-- src/items/conversion_timezone.rs | 26 +++++-- tests/date.rs | 26 +++++++ 6 files changed, 167 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b462c6..5da0e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,30 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", + "uncased", +] + +[[package]] +name = "chrono-tz-build" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d8d1efd5109b9c1cd3b7966bd071cdfb53bb6eb0b22a473a68c2f70a11a1eb" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", + "phf_shared", + "uncased", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -77,6 +101,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "futures-core" version = "0.3.31" @@ -209,17 +239,78 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "parse-zoneinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c406c9e2aa74554e662d2c2ee11cd3e73756988800be7e6f5eddb16fed4699" + [[package]] name = "parse_datetime" version = "0.11.0" dependencies = [ "chrono", + "chrono-tz", "num-traits", "regex", "rstest", "winnow", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbdcb6f01d193b17f0b9c3360fa7e0e620991b193ff08702f78b3ce365d7e61" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", + "uncased", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", + "uncased", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -350,6 +441,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -384,12 +481,27 @@ dependencies = [ "winnow", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasm-bindgen" version = "0.2.100" diff --git a/Cargo.toml b/Cargo.toml index b76ffd2..64298ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ regex = "1.10.4" chrono = { version="0.4.38", default-features=false, features=["std", "alloc", "clock"] } winnow = "0.7.10" num-traits = "0.2.19" +chrono-tz = { version = "0.10.4", features=["case-insensitive"] } [dev-dependencies] rstest = "0.26" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 3152fe6..6b70666 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -175,7 +175,7 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.10.0" +version = "0.11.0" dependencies = [ "chrono", "num-traits", diff --git a/src/items/builder.rs b/src/items/builder.rs index 909f285..35e8f12 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -113,7 +113,7 @@ impl DateTimeBuilder { pub(super) fn set_conversion_timezone( mut self, - conversion_timezone: time::Offset, + conversion_timezone: FixedOffset, ) -> Result { if self.conversion_timezone.is_some() { Err("TZ= cannot appear more than once") @@ -324,13 +324,12 @@ impl DateTimeBuilder { } if let Some(offset) = self.timezone { - dt = with_timezone_restore(offset, dt)?; + let offset = chrono::FixedOffset::try_from(offset).ok(); + dt = with_timezone_restore(offset?, dt)?; } - if let Some(mut offset) = self.conversion_timezone { - // Reuse with_timezone_restore with a swapped offset - offset.negative = !offset.negative; - dt = with_timezone_restore(offset, dt)?; + if let Some(conversion_timezone) = self.conversion_timezone { + dt = with_timezone_restore(conversion_timezone, dt)?; } Some(dt) @@ -357,10 +356,9 @@ fn new_date( /// Restores year, month, day, etc after applying the timezone /// returns None if timezone overflows the date fn with_timezone_restore( - offset: timezone::Offset, + offset: FixedOffset, at: DateTime, ) -> Option> { - let offset: FixedOffset = chrono::FixedOffset::try_from(offset).ok()?; let copy = at; let x = at .with_timezone(&offset) diff --git a/src/items/conversion_timezone.rs b/src/items/conversion_timezone.rs index 4dec947..5b7d529 100644 --- a/src/items/conversion_timezone.rs +++ b/src/items/conversion_timezone.rs @@ -1,13 +1,29 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use winnow::{ModalResult, Parser}; +use chrono::{FixedOffset, NaiveDate, TimeZone}; +use chrono_tz::Tz; +use winnow::{combinator::peek, error::ErrMode, token::take_while, ModalResult, Parser}; -use super::time; +use super::{primitive::ctx_err, timezone}; -pub(crate) fn parse(input: &mut &str) -> ModalResult { +pub(crate) fn parse(input: &mut &str) -> ModalResult { let _ = "tz=\"".parse_next(input)?; - let tz = time::timezone(input); + let mut tz_name = take_while(1.., |character| character != '\"').parse_next(input)?; let _ = "\" ".parse_next(input)?; - return tz + + // Try and use the built in timezone system first before trying to use the + // chrono_tz library which can handle named timezones + if let Ok(mut offset) = peek(timezone::parse).parse_next(&mut tz_name) { + offset.negative = !offset.negative; + offset + .try_into() + .map_err(|_| ErrMode::Cut(ctx_err("Invalid timezone"))) + } else { + let conv: Tz = chrono_tz::Tz::from_str_insensitive(tz_name) + .map_err(|_| ErrMode::Cut(ctx_err("Invalid timezone")))?; + Ok(chrono::Offset::fix( + &conv.offset_from_utc_date(&NaiveDate::default()), + )) + } } diff --git a/tests/date.rs b/tests/date.rs index 5653564..e189282 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -93,3 +93,29 @@ fn test_date_omitting_year(#[case] input: &str, #[case] year: u32, #[case] expec let now = DateTime::parse_from_rfc3339(&format!("{year}-06-01T00:00:00+00:00")).unwrap(); check_relative(now, input, expected); } + +#[rstest] +#[case::convert_timezone_ahead(r#"TZ="UTC+1" 2022-12-14"#, 2022, "2022-12-14 01:00:00+00:00")] +#[case::convert_timezone_behind(r#"TZ="UTC-1" 2022-12-14"#, 2022, "2022-12-13 23:00:00+00:00")] +#[case::convert_timezone_ahead_minutes( + r#"TZ="UTC+1:30" 2022-12-14"#, + 2022, + "2022-12-14 01:30:00+00:00" +)] +#[case::convert_timezone_behind_minutes( + r#"TZ="UTC-1:30" 2022-12-14"#, + 2022, + "2022-12-13 22:30:00+00:00" +)] +#[case::convert_named_timezone( + r#"TZ="America/New_York" 2022-12-14 12:00"#, + 2022, + "2022-12-14 17:00:00+00:00" +)] +fn test_conversion_date(#[case] input: &str, #[case] year: u32, #[case] expected: &str) { + use chrono::DateTime; + use common::check_relative; + + let now = DateTime::parse_from_rfc3339(&format!("{year}-06-01T00:00:00+00:00")).unwrap(); + check_relative(now, input, expected); +} From 50bf621728274a16021b41cac7d1111a5696e778 Mon Sep 17 00:00:00 2001 From: bezmuth Date: Mon, 11 Aug 2025 14:13:16 +0100 Subject: [PATCH 3/3] Cargo fmt --- src/items/builder.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/items/builder.rs b/src/items/builder.rs index 35e8f12..872b08c 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -100,17 +100,6 @@ impl DateTimeBuilder { Ok(self) } - pub(super) fn push_relative( - mut self, - relative: relative::Relative, - ) -> Result { - if self.timestamp.is_some() { - return Err("timestamp cannot be combined with other date/time items"); - } - self.relative.push(relative); - Ok(self) - } - pub(super) fn set_conversion_timezone( mut self, conversion_timezone: FixedOffset, @@ -123,6 +112,17 @@ impl DateTimeBuilder { } } + pub(super) fn push_relative( + mut self, + relative: relative::Relative, + ) -> Result { + if self.timestamp.is_some() { + return Err("timestamp cannot be combined with other date/time items"); + } + self.relative.push(relative); + Ok(self) + } + /// Sets a pure number that can be interpreted as either a year or time /// depending on the current state of the builder. ///