2525//! - [`pure`]
2626//! - [`relative`]
2727//! - [`time`]
28+ //! - [`timezone`]
2829//! - [`weekday`]
2930//! - [`year`]
3031
@@ -36,6 +37,7 @@ mod offset;
3637mod pure;
3738mod relative;
3839mod time;
40+ mod timezone;
3941mod weekday;
4042mod 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.
7578pub ( 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.
8587pub ( 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,7 +96,7 @@ 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 ) ;
99100///
100101/// timestamp = "@" , float ;
101102///
@@ -189,35 +190,58 @@ fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
189190 trace ( "parse" , alt ( ( parse_timestamp, parse_items) ) ) . parse_next ( input)
190191}
191192
192- /// Parse a timestamp.
193+ /// Parse a standalone epoch timestamp (e.g., `@1758724019`) .
193194///
194- /// From the GNU docs:
195+ /// GNU `date` specifies that a timestamp item is *complete* and *must not* be
196+ /// combined with any other date/time item.
195197///
196- /// > (Timestamp) Such a number cannot be combined with any other date item, as
197- /// > it specifies a complete timestamp.
198+ /// Notes:
199+ /// - If a timezone rule (`TZ="..."`) appears at the beginning of the input, it
200+ /// has no effect on the epoch value. We intentionally parse and ignore it.
201+ /// - Trailing input (aside from optional whitespaces) is rejected.
198202fn parse_timestamp ( input : & mut & str ) -> ModalResult < DateTimeBuilder > {
203+ // Parse and ignore an optional leading timezone rule.
204+ let _ = timezone:: parse ( input) ;
205+
199206 trace (
200207 "parse_timestamp" ,
208+ // Expect exactly one timestamp and then EOF (allowing trailing spaces).
201209 terminated ( epoch:: parse. map ( Item :: Timestamp ) , preceded ( space, eof) ) ,
202210 )
203- . verify_map ( |ts : Item | {
204- if let Item :: Timestamp ( ts) = ts {
205- DateTimeBuilder :: new ( ) . set_timestamp ( ts) . ok ( )
206- } else {
207- None
208- }
211+ . verify_map ( |item : Item | match item {
212+ Item :: Timestamp ( ts) => DateTimeBuilder :: new ( ) . set_timestamp ( ts) . ok ( ) ,
213+ _ => None ,
209214 } )
210215 . parse_next ( input)
211216}
212217
213- /// Parse a sequence of items.
218+ /// Parse a sequence of date/time items, honoring an optional leading TZ rule.
219+ ///
220+ /// Notes:
221+ /// - If a timezone rule (`TZ="..."`) appears at the beginning of the input,
222+ /// parse it first. The timezone rule is case-sensitive.
223+ /// - After the optional timezone rule is parsed, we convert the input to
224+ /// lowercase to allow case-insensitive parsing of the remaining items.
225+ /// - Trailing input (aside from optional whitespaces) is rejected.
214226fn parse_items ( input : & mut & str ) -> ModalResult < DateTimeBuilder > {
215- let ( items, _) : ( Vec < Item > , _ ) = trace (
227+ // Parse and consume an optional leading timezone rule.
228+ let tz = timezone:: parse ( input) . map ( Item :: TimeZone ) ;
229+
230+ // Convert input to lowercase for case-insensitive parsing.
231+ let lower = input. to_ascii_lowercase ( ) ;
232+ let input = & mut lower. as_str ( ) ;
233+
234+ let ( mut items, _) : ( Vec < Item > , _ ) = trace (
216235 "parse_items" ,
236+ // Parse zero or more items until EOF (allowing trailing spaces).
217237 repeat_till ( 0 .., parse_item, preceded ( space, eof) ) ,
218238 )
219239 . parse_next ( input) ?;
220240
241+ if let Ok ( tz) = tz {
242+ items. push ( tz) ;
243+ }
244+
221245 items. try_into ( ) . map_err ( |e| expect_error ( input, e) )
222246}
223247
@@ -251,7 +275,7 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode<ContextError>
251275mod tests {
252276 use jiff:: { civil:: DateTime , tz:: TimeZone , ToSpan , Zoned } ;
253277
254- use super :: { parse , DateTimeBuilder } ;
278+ use super :: * ;
255279
256280 fn at_date ( builder : DateTimeBuilder , base : Zoned ) -> Zoned {
257281 builder. set_base ( base) . build ( ) . unwrap ( )
@@ -527,4 +551,36 @@ mod tests {
527551 assert_eq ! ( result. hour( ) , 1 ) ;
528552 assert_eq ! ( result. minute( ) , 0 ) ;
529553 }
554+
555+ #[ test]
556+ fn timezone_rule ( ) {
557+ let parse_build = |mut s| parse ( & mut s) . unwrap ( ) . build ( ) . unwrap ( ) ;
558+
559+ let now = Zoned :: now ( ) ;
560+ let now_utc2 = now
561+ . date ( )
562+ . at ( 0 , 0 , 0 , 0 )
563+ . to_zoned ( TimeZone :: fixed ( jiff:: tz:: offset ( -2 ) ) )
564+ . unwrap ( ) ;
565+ let now_utc_neg2 = now
566+ . date ( )
567+ . at ( 0 , 0 , 0 , 0 )
568+ . to_zoned ( TimeZone :: fixed ( jiff:: tz:: offset ( 2 ) ) )
569+ . unwrap ( ) ;
570+
571+ for ( input, expected) in [
572+ ( r#"TZ="UTC2""# , now_utc2) ,
573+ ( r#"TZ="UTC-2""# , now_utc_neg2) ,
574+ (
575+ r#"TZ="Europe/Paris" 2025-01-02"# ,
576+ "2025-01-02 00:00:00[Europe/Paris]" . parse ( ) . unwrap ( ) ,
577+ ) ,
578+ (
579+ r#"TZ="Europe/Paris" 2025-01-02 03:04:05"# ,
580+ "2025-01-02 03:04:05[Europe/Paris]" . parse ( ) . unwrap ( ) ,
581+ ) ,
582+ ] {
583+ assert_eq ! ( parse_build( input) , expected, "{input}" ) ;
584+ }
585+ }
530586}
0 commit comments