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,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.
198205fn 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.
214230fn 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>
251279mod 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