jiff/fmt/
rfc9557.rs

1/*!
2This module provides parsing facilities for [RFC 9557] extensions to
3[RFC 3339].
4
5This only provides internal helper routines that can be used in other parsers.
6Namely, RFC 9557 is just a backward compatible expansion to RFC 3339.
7
8The parser in this module checks for full syntactic validity of the annotation
9syntax defined in RFC 9557. However, Jiff doesn't make use of any of these
10annotations except for time zone annotations. So for example,
11`2024-05-25T13:33:00-05[America/New_York][foo=bar]` is valid, but the parser
12will only expose the `America/New_York` annotation.
13
14Note though that even for things that are ignored, validity
15and criticality are still respected. So for example,
16`2024-05-25T13:33:00-05[America/New_York][!foo=bar]` will fail to parse because
17of the `!` indicating that consumers must take action on the annotation,
18including by returning an error if it isn't supported.
19
20[RFC 3339]: https://www.rfc-editor.org/rfc/rfc3339
21[RFC 9557]: https://www.rfc-editor.org/rfc/rfc9557.html
22*/
23
24// Here's the specific part of Temporal's grammar that is implemented below
25// (which should match what's in RFC 9557):
26//
27// TimeZoneAnnotation :::
28//   [ AnnotationCriticalFlag[opt] TimeZoneIdentifier ]
29//
30// Annotations :::
31//   Annotation Annotations[opt]
32//
33// AnnotationCriticalFlag :::
34//   !
35//
36// TimeZoneIdentifier :::
37//   TimeZoneUTCOffsetName
38//   TimeZoneIANAName
39//
40// TimeZoneIANAName :::
41//   TimeZoneIANANameComponent
42//   TimeZoneIANAName / TimeZoneIANANameComponent
43//
44// TimeZoneIANANameComponent :::
45//   TZLeadingChar
46//   TimeZoneIANANameComponent TZChar
47//
48// Annotation :::
49//   [ AnnotationCriticalFlag[opt] AnnotationKey = AnnotationValue ]
50//
51// AnnotationKey :::
52//   AKeyLeadingChar
53//   AnnotationKey AKeyChar
54//
55// AnnotationValue :::
56//   AnnotationValueComponent
57//   AnnotationValueComponent - AnnotationValue
58//
59// AnnotationValueComponent :::
60//   Alpha AnnotationValueComponent[opt]
61//   DecimalDigit AnnotationValueComponent[opt]
62//
63// AKeyLeadingChar :::
64//   LowercaseAlpha
65//   _
66//
67// AKeyChar :::
68//   AKeyLeadingChar
69//   DecimalDigit
70//   -
71//
72// TZLeadingChar :::
73//   Alpha
74//   .
75//   _
76//
77// TZChar :::
78//   TZLeadingChar
79//   DecimalDigit
80//   -
81//   +
82//
83// DecimalDigit :: one of
84//   0 1 2 3 4 5 6 7 8 9
85//
86// Alpha ::: one of
87//   A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
88//     a b c d e f g h i j k l m n o p q r s t u v w x y z
89//
90// LowercaseAlpha ::: one of
91//   a b c d e f g h i j k l m n o p q r s t u v w x y z
92//
93// # N.B. This is handled by src/format/offset.rs, so we don't expand it here.
94// TimeZoneUTCOffsetName :::
95//   UTCOffsetMinutePrecision
96
97use crate::{
98    error::{fmt::rfc9557::Error as E, Error},
99    fmt::{
100        offset::{self, ParsedOffset},
101        temporal::{TimeZoneAnnotation, TimeZoneAnnotationKind},
102        Parsed,
103    },
104    util::parse,
105};
106
107/// The result of parsing RFC 9557 annotations.
108///
109/// Currently, this only provides access to a parsed time zone annotation, if
110/// present. While the parser does validate all other key/value annotations,
111/// Jiff doesn't make use of them and thus does not expose them here. They are
112/// only validated at a syntax level.
113#[derive(Debug)]
114pub(crate) struct ParsedAnnotations<'i> {
115    /// An optional time zone annotation that was extracted from the input.
116    time_zone: Option<ParsedTimeZone<'i>>,
117    // While we parse/validate them, we don't support any other annotations
118    // at time of writing. Temporal supports calendar annotations, but I'm
119    // not sure Jiff will ever go down that route.
120}
121
122impl<'i> ParsedAnnotations<'i> {
123    /// Return an empty parsed annotations.
124    pub(crate) fn none() -> ParsedAnnotations<'static> {
125        ParsedAnnotations { time_zone: None }
126    }
127
128    /// Turns this parsed time zone into a structured time zone annotation,
129    /// if an annotation was found. Otherwise, returns `Ok(None)`.
130    ///
131    /// This can return an error if the parsed offset could not be converted
132    /// to a `crate::tz::Offset`.
133    pub(crate) fn to_time_zone_annotation(
134        &self,
135    ) -> Result<Option<TimeZoneAnnotation<'i>>, Error> {
136        let Some(ref parsed) = self.time_zone else { return Ok(None) };
137        Ok(Some(parsed.to_time_zone_annotation()?))
138    }
139}
140
141impl<'i> core::fmt::Display for ParsedAnnotations<'i> {
142    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
143        if let Some(ref tz) = self.time_zone {
144            core::fmt::Display::fmt(tz, f)?;
145        }
146        Ok(())
147    }
148}
149
150/// The result of parsing a time zone annotation.
151#[derive(Debug)]
152enum ParsedTimeZone<'i> {
153    /// The name of an IANA time zone was found.
154    Named {
155        /// Whether the critical flag was seen.
156        critical: bool,
157        /// The parsed name.
158        name: &'i str,
159    },
160    /// A specific UTC numeric offset was found.
161    Offset {
162        /// Whether the critical flag was seen.
163        critical: bool,
164        /// The parsed UTC offset.
165        offset: ParsedOffset,
166    },
167}
168
169impl<'i> ParsedTimeZone<'i> {
170    /// Turns this parsed time zone into a structured time zone annotation.
171    ///
172    /// This can return an error if the parsed offset could not be converted
173    /// to a `crate::tz::Offset`.
174    ///
175    /// This also includes a flag of whether the annotation is "critical" or
176    /// not.
177    pub(crate) fn to_time_zone_annotation(
178        &self,
179    ) -> Result<TimeZoneAnnotation<'i>, Error> {
180        let (kind, critical) = match *self {
181            ParsedTimeZone::Named { name, critical } => {
182                let kind = TimeZoneAnnotationKind::from(name);
183                (kind, critical)
184            }
185            ParsedTimeZone::Offset { ref offset, critical } => {
186                let kind = TimeZoneAnnotationKind::Offset(offset.to_offset()?);
187                (kind, critical)
188            }
189        };
190        Ok(TimeZoneAnnotation { kind, critical })
191    }
192}
193
194impl<'i> core::fmt::Display for ParsedTimeZone<'i> {
195    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
196        match *self {
197            ParsedTimeZone::Named { critical, name } => {
198                f.write_str("[")?;
199                if critical {
200                    f.write_str("!")?;
201                }
202                f.write_str(name)?;
203                f.write_str("]")
204            }
205            ParsedTimeZone::Offset { critical, ref offset } => {
206                f.write_str("[")?;
207                if critical {
208                    f.write_str("!")?;
209                }
210                core::fmt::Display::fmt(offset, f)?;
211                f.write_str("]")
212            }
213        }
214    }
215}
216
217/// A parser for RFC 9557 annotations.
218#[derive(Debug)]
219pub(crate) struct Parser {
220    /// There are currently no configuration options for this parser.
221    _priv: (),
222}
223
224impl Parser {
225    /// Create a new RFC 9557 annotation parser with the default configuration.
226    pub(crate) const fn new() -> Parser {
227        Parser { _priv: () }
228    }
229
230    /// Parse RFC 9557 annotations from the start of `input`.
231    ///
232    /// This only parses annotations when `input` starts with an `[`.
233    ///
234    /// Note that the result returned only provides access to the time zone
235    /// annotation (if it was present). All other annotations are parsed and
236    /// checked for validity, but are not accessible from `ParsedAnnotations`
237    /// since Jiff does not make use of them.
238    pub(crate) fn parse<'i>(
239        &self,
240        input: &'i [u8],
241    ) -> Result<Parsed<'i, ParsedAnnotations<'i>>, Error> {
242        let Parsed { value: time_zone, mut input } =
243            self.parse_time_zone_annotation(input)?;
244        loop {
245            // We don't actually do anything with any annotation that isn't
246            // a time zone, but we do parse them to ensure validity and to
247            // be able to fail when a critical flag is set. Otherwise, we know
248            // we're done if parsing an annotation doesn't consume any input.
249            let Parsed { value: did_consume, input: unconsumed } =
250                self.parse_annotation(input)?;
251            if !did_consume {
252                break;
253            }
254            input = unconsumed;
255        }
256
257        let value = ParsedAnnotations { time_zone };
258        Ok(Parsed { value, input })
259    }
260
261    fn parse_time_zone_annotation<'i>(
262        &self,
263        mut input: &'i [u8],
264    ) -> Result<Parsed<'i, Option<ParsedTimeZone<'i>>>, Error> {
265        let unconsumed = input;
266        let Some((&first, tail)) = input.split_first() else {
267            return Ok(Parsed { value: None, input: unconsumed });
268        };
269        if first != b'[' {
270            return Ok(Parsed { value: None, input: unconsumed });
271        }
272        input = tail;
273
274        let mut critical = false;
275        if let Some(tail) = input.strip_prefix(b"!") {
276            critical = true;
277            input = tail;
278        }
279
280        // If we're starting with a `+` or `-`, then we know we MUST have a
281        // time zone offset annotation. It can't be anything else since neither
282        // an IANA annotation nor a generic key/value annotation can begin with
283        // a `+` or a `-`.
284        if input.starts_with(b"+") || input.starts_with(b"-") {
285            const P: offset::Parser =
286                offset::Parser::new().zulu(false).subminute(false);
287
288            let Parsed { value: offset, input } = P.parse(input)?;
289            let Parsed { input, .. } =
290                self.parse_tz_annotation_close(input)?;
291            let value = Some(ParsedTimeZone::Offset { critical, offset });
292            return Ok(Parsed { value, input });
293        }
294
295        // At this point, we know it's impossible to see an offset. But we
296        // could still see *either* an IANA time zone annotation or a more
297        // generic key-value annotation. We don't know yet. In the latter case,
298        // we'll eventually see an `=` sign. But since IANA time zone names
299        // represent a superset of generic keys, we just parse what we can.
300        // Once we stop, we can check for an `=`.
301        let mkiana = parse::slicer(input);
302        let Parsed { mut input, .. } =
303            self.parse_tz_annotation_iana_name(input)?;
304        // Now that we've parsed the first IANA name component, if this were
305        // actually a generic key/value annotation, the `=` *must* appear here.
306        // Otherwise, we assume we are trying to parse an IANA annotation as it
307        // is the only other possibility and likely the most common case.
308        if input.starts_with(b"=") {
309            // Pretend like we parsed nothing and let the caller try to parse
310            // a generic key/value annotation.
311            return Ok(Parsed { value: None, input: unconsumed });
312        }
313        while let Some(tail) = input.strip_prefix(b"/") {
314            input = tail;
315            let Parsed { input: unconsumed, .. } =
316                self.parse_tz_annotation_iana_name(input)?;
317            input = unconsumed;
318        }
319        // This is OK because all bytes in a IANA TZ annotation are guaranteed
320        // to be ASCII, or else we wouldn't be here. If this turns out to be
321        // a perf issue, we can do an unchecked conversion here. But I figured
322        // it would be better to start conservative.
323        let iana_name = core::str::from_utf8(mkiana(input)).expect("ASCII");
324        let time_zone =
325            Some(ParsedTimeZone::Named { critical, name: iana_name });
326        // And finally, parse the closing bracket.
327        let Parsed { input, .. } = self.parse_tz_annotation_close(input)?;
328        Ok(Parsed { value: time_zone, input })
329    }
330
331    fn parse_annotation<'i>(
332        &self,
333        mut input: &'i [u8],
334    ) -> Result<Parsed<'i, bool>, Error> {
335        let Some((&first, tail)) = input.split_first() else {
336            return Ok(Parsed { value: false, input });
337        };
338        if first != b'[' {
339            return Ok(Parsed { value: false, input });
340        }
341        input = tail;
342
343        let mut critical = false;
344        if let Some(tail) = input.strip_prefix(b"!") {
345            critical = true;
346            input = tail;
347        }
348
349        let Parsed { input, .. } = self.parse_annotation_key(input)?;
350        let Parsed { input, .. } = self.parse_annotation_separator(input)?;
351        let Parsed { input, .. } = self.parse_annotation_values(input)?;
352        let Parsed { input, .. } = self.parse_annotation_close(input)?;
353
354        // If the critical flag is set, then we automatically return an error
355        // because we don't support any non-time-zone annotations. When the
356        // critical flag isn't set, we're "permissive" and just validate that
357        // the syntax is correct (as we've already done at this point).
358        if critical {
359            return Err(Error::from(E::UnsupportedAnnotationCritical));
360        }
361
362        Ok(Parsed { value: true, input })
363    }
364
365    fn parse_tz_annotation_iana_name<'i>(
366        &self,
367        input: &'i [u8],
368    ) -> Result<Parsed<'i, &'i [u8]>, Error> {
369        let mkname = parse::slicer(input);
370        let Parsed { mut input, .. } =
371            self.parse_tz_annotation_leading_char(input)?;
372        loop {
373            let Parsed { value: did_consume, input: unconsumed } =
374                self.parse_tz_annotation_char(input);
375            if !did_consume {
376                break;
377            }
378            input = unconsumed;
379        }
380        Ok(Parsed { value: mkname(input), input })
381    }
382
383    fn parse_annotation_key<'i>(
384        &self,
385        input: &'i [u8],
386    ) -> Result<Parsed<'i, &'i [u8]>, Error> {
387        let mkkey = parse::slicer(input);
388        let Parsed { mut input, .. } =
389            self.parse_annotation_key_leading_char(input)?;
390        loop {
391            let Parsed { value: did_consume, input: unconsumed } =
392                self.parse_annotation_key_char(input);
393            if !did_consume {
394                break;
395            }
396            input = unconsumed;
397        }
398        Ok(Parsed { value: mkkey(input), input })
399    }
400
401    // N.B. If we ever actually need the values, this should probably return a
402    // `Vec<&'i [u8]>`. (Well, no, because that wouldn't be good for core-only
403    // configurations. So it will probably need to be something else. But,
404    // probably Jiff will never care about other values.)
405    fn parse_annotation_values<'i>(
406        &self,
407        input: &'i [u8],
408    ) -> Result<Parsed<'i, ()>, Error> {
409        let Parsed { mut input, .. } = self.parse_annotation_value(input)?;
410        while let Some(tail) = input.strip_prefix(b"-") {
411            input = tail;
412            let Parsed { input: unconsumed, .. } =
413                self.parse_annotation_value(input)?;
414            input = unconsumed;
415        }
416        Ok(Parsed { value: (), input })
417    }
418
419    fn parse_annotation_value<'i>(
420        &self,
421        input: &'i [u8],
422    ) -> Result<Parsed<'i, &'i [u8]>, Error> {
423        let mkvalue = parse::slicer(input);
424        let Parsed { mut input, .. } =
425            self.parse_annotation_value_leading_char(input)?;
426        loop {
427            let Parsed { value: did_consume, input: unconsumed } =
428                self.parse_annotation_value_char(input);
429            if !did_consume {
430                break;
431            }
432            input = unconsumed;
433        }
434        let value = mkvalue(input);
435        Ok(Parsed { value, input })
436    }
437
438    fn parse_tz_annotation_leading_char<'i>(
439        &self,
440        input: &'i [u8],
441    ) -> Result<Parsed<'i, ()>, Error> {
442        let Some((&first, tail)) = input.split_first() else {
443            return Err(Error::from(E::EndOfInputAnnotation));
444        };
445        if !matches!(first, b'_' | b'.' | b'A'..=b'Z' | b'a'..=b'z') {
446            return Err(Error::from(E::UnexpectedByteAnnotation {
447                byte: first,
448            }));
449        }
450        Ok(Parsed { value: (), input: tail })
451    }
452
453    fn parse_tz_annotation_char<'i>(
454        &self,
455        input: &'i [u8],
456    ) -> Parsed<'i, bool> {
457        let Some((&first, tail)) = input.split_first() else {
458            return Parsed { value: false, input };
459        };
460
461        if !matches!(
462            first,
463            b'_' | b'.' | b'+' | b'-' | b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z',
464        ) {
465            return Parsed { value: false, input };
466        }
467        Parsed { value: true, input: tail }
468    }
469
470    fn parse_annotation_key_leading_char<'i>(
471        &self,
472        input: &'i [u8],
473    ) -> Result<Parsed<'i, ()>, Error> {
474        let Some((&first, tail)) = input.split_first() else {
475            return Err(Error::from(E::EndOfInputAnnotationKey));
476        };
477        if !matches!(first, b'_' | b'a'..=b'z') {
478            return Err(Error::from(E::UnexpectedByteAnnotationKey {
479                byte: first,
480            }));
481        }
482        Ok(Parsed { value: (), input: tail })
483    }
484
485    fn parse_annotation_key_char<'i>(
486        &self,
487        input: &'i [u8],
488    ) -> Parsed<'i, bool> {
489        let Some((&first, tail)) = input.split_first() else {
490            return Parsed { value: false, input };
491        };
492        if !matches!(first, b'_' | b'-' | b'0'..=b'9' | b'a'..=b'z') {
493            return Parsed { value: false, input };
494        }
495        Parsed { value: true, input: tail }
496    }
497
498    fn parse_annotation_value_leading_char<'i>(
499        &self,
500        input: &'i [u8],
501    ) -> Result<Parsed<'i, ()>, Error> {
502        let Some((&first, tail)) = input.split_first() else {
503            return Err(Error::from(E::EndOfInputAnnotationValue));
504        };
505        if !matches!(first, b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z') {
506            return Err(Error::from(E::UnexpectedByteAnnotationValue {
507                byte: first,
508            }));
509        }
510        Ok(Parsed { value: (), input: tail })
511    }
512
513    fn parse_annotation_value_char<'i>(
514        &self,
515        input: &'i [u8],
516    ) -> Parsed<'i, bool> {
517        let Some((&first, tail)) = input.split_first() else {
518            return Parsed { value: false, input };
519        };
520        if !matches!(first, b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z') {
521            return Parsed { value: false, input };
522        }
523        Parsed { value: true, input: tail }
524    }
525
526    fn parse_annotation_separator<'i>(
527        &self,
528        input: &'i [u8],
529    ) -> Result<Parsed<'i, ()>, Error> {
530        let Some((&first, tail)) = input.split_first() else {
531            return Err(Error::from(E::EndOfInputAnnotationSeparator));
532        };
533        if first != b'=' {
534            // If we see a /, then it's likely the user was trying to insert a
535            // time zone annotation in the wrong place.
536            return Err(Error::from(if first == b'/' {
537                E::UnexpectedSlashAnnotationSeparator
538            } else {
539                E::UnexpectedByteAnnotationSeparator { byte: first }
540            }));
541        }
542        Ok(Parsed { value: (), input: tail })
543    }
544
545    fn parse_annotation_close<'i>(
546        &self,
547        input: &'i [u8],
548    ) -> Result<Parsed<'i, ()>, Error> {
549        let Some((&first, tail)) = input.split_first() else {
550            return Err(Error::from(E::EndOfInputAnnotationClose));
551        };
552        if first != b']' {
553            return Err(Error::from(E::UnexpectedByteAnnotationClose {
554                byte: first,
555            }));
556        }
557        Ok(Parsed { value: (), input: tail })
558    }
559
560    fn parse_tz_annotation_close<'i>(
561        &self,
562        input: &'i [u8],
563    ) -> Result<Parsed<'i, ()>, Error> {
564        let Some((&first, tail)) = input.split_first() else {
565            return Err(Error::from(E::EndOfInputTzAnnotationClose));
566        };
567        if first != b']' {
568            return Err(Error::from(E::UnexpectedByteTzAnnotationClose {
569                byte: first,
570            }));
571        }
572        Ok(Parsed { value: (), input: tail })
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn ok_time_zone() {
582        if crate::tz::db().is_definitively_empty() {
583            return;
584        }
585
586        let p = |input| {
587            Parser::new()
588                .parse(input)
589                .unwrap()
590                .value
591                .to_time_zone_annotation()
592                .unwrap()
593                .map(|ann| (ann.to_time_zone().unwrap(), ann.is_critical()))
594        };
595
596        insta::assert_debug_snapshot!(p(b"[America/New_York]"), @r###"
597        Some(
598            (
599                TimeZone(
600                    TZif(
601                        "America/New_York",
602                    ),
603                ),
604                false,
605            ),
606        )
607        "###);
608        insta::assert_debug_snapshot!(p(b"[!America/New_York]"), @r###"
609        Some(
610            (
611                TimeZone(
612                    TZif(
613                        "America/New_York",
614                    ),
615                ),
616                true,
617            ),
618        )
619        "###);
620        insta::assert_debug_snapshot!(p(b"[america/new_york]"), @r###"
621        Some(
622            (
623                TimeZone(
624                    TZif(
625                        "America/New_York",
626                    ),
627                ),
628                false,
629            ),
630        )
631        "###);
632        insta::assert_debug_snapshot!(p(b"[+25:59]"), @r###"
633        Some(
634            (
635                TimeZone(
636                    25:59:00,
637                ),
638                false,
639            ),
640        )
641        "###);
642        insta::assert_debug_snapshot!(p(b"[-25:59]"), @r###"
643        Some(
644            (
645                TimeZone(
646                    -25:59:00,
647                ),
648                false,
649            ),
650        )
651        "###);
652    }
653
654    #[test]
655    fn ok_empty() {
656        let p = |input| Parser::new().parse(input).unwrap();
657
658        insta::assert_debug_snapshot!(p(b""), @r#"
659        Parsed {
660            value: ParsedAnnotations {
661                time_zone: None,
662            },
663            input: "",
664        }
665        "#);
666        insta::assert_debug_snapshot!(p(b"blah"), @r#"
667        Parsed {
668            value: ParsedAnnotations {
669                time_zone: None,
670            },
671            input: "blah",
672        }
673        "#);
674    }
675
676    #[test]
677    fn ok_unsupported() {
678        let p = |input| Parser::new().parse(input).unwrap();
679
680        insta::assert_debug_snapshot!(
681            p(b"[u-ca=chinese]"),
682            @r#"
683        Parsed {
684            value: ParsedAnnotations {
685                time_zone: None,
686            },
687            input: "",
688        }
689        "#,
690        );
691        insta::assert_debug_snapshot!(
692            p(b"[u-ca=chinese-japanese]"),
693            @r#"
694        Parsed {
695            value: ParsedAnnotations {
696                time_zone: None,
697            },
698            input: "",
699        }
700        "#,
701        );
702        insta::assert_debug_snapshot!(
703            p(b"[u-ca=chinese-japanese-russian]"),
704            @r#"
705        Parsed {
706            value: ParsedAnnotations {
707                time_zone: None,
708            },
709            input: "",
710        }
711        "#,
712        );
713    }
714
715    #[test]
716    fn ok_iana() {
717        let p = |input| Parser::new().parse(input).unwrap();
718
719        insta::assert_debug_snapshot!(p(b"[America/New_York]"), @r#"
720        Parsed {
721            value: ParsedAnnotations {
722                time_zone: Some(
723                    Named {
724                        critical: false,
725                        name: "America/New_York",
726                    },
727                ),
728            },
729            input: "",
730        }
731        "#);
732        insta::assert_debug_snapshot!(p(b"[!America/New_York]"), @r#"
733        Parsed {
734            value: ParsedAnnotations {
735                time_zone: Some(
736                    Named {
737                        critical: true,
738                        name: "America/New_York",
739                    },
740                ),
741            },
742            input: "",
743        }
744        "#);
745        insta::assert_debug_snapshot!(p(b"[UTC]"), @r#"
746        Parsed {
747            value: ParsedAnnotations {
748                time_zone: Some(
749                    Named {
750                        critical: false,
751                        name: "UTC",
752                    },
753                ),
754            },
755            input: "",
756        }
757        "#);
758        insta::assert_debug_snapshot!(p(b"[.._foo_../.0+-]"), @r#"
759        Parsed {
760            value: ParsedAnnotations {
761                time_zone: Some(
762                    Named {
763                        critical: false,
764                        name: ".._foo_../.0+-",
765                    },
766                ),
767            },
768            input: "",
769        }
770        "#);
771    }
772
773    #[test]
774    fn ok_offset() {
775        let p = |input| Parser::new().parse(input).unwrap();
776
777        insta::assert_debug_snapshot!(p(b"[-00]"), @r#"
778        Parsed {
779            value: ParsedAnnotations {
780                time_zone: Some(
781                    Offset {
782                        critical: false,
783                        offset: ParsedOffset {
784                            kind: Numeric(
785                                -00,
786                            ),
787                        },
788                    },
789                ),
790            },
791            input: "",
792        }
793        "#);
794        insta::assert_debug_snapshot!(p(b"[+00]"), @r#"
795        Parsed {
796            value: ParsedAnnotations {
797                time_zone: Some(
798                    Offset {
799                        critical: false,
800                        offset: ParsedOffset {
801                            kind: Numeric(
802                                +00,
803                            ),
804                        },
805                    },
806                ),
807            },
808            input: "",
809        }
810        "#);
811        insta::assert_debug_snapshot!(p(b"[-05]"), @r#"
812        Parsed {
813            value: ParsedAnnotations {
814                time_zone: Some(
815                    Offset {
816                        critical: false,
817                        offset: ParsedOffset {
818                            kind: Numeric(
819                                -05,
820                            ),
821                        },
822                    },
823                ),
824            },
825            input: "",
826        }
827        "#);
828        insta::assert_debug_snapshot!(p(b"[!+05:12]"), @r#"
829        Parsed {
830            value: ParsedAnnotations {
831                time_zone: Some(
832                    Offset {
833                        critical: true,
834                        offset: ParsedOffset {
835                            kind: Numeric(
836                                +05:12,
837                            ),
838                        },
839                    },
840                ),
841            },
842            input: "",
843        }
844        "#);
845    }
846
847    #[test]
848    fn ok_iana_unsupported() {
849        let p = |input| Parser::new().parse(input).unwrap();
850
851        insta::assert_debug_snapshot!(
852            p(b"[America/New_York][u-ca=chinese-japanese-russian]"),
853            @r#"
854        Parsed {
855            value: ParsedAnnotations {
856                time_zone: Some(
857                    Named {
858                        critical: false,
859                        name: "America/New_York",
860                    },
861                ),
862            },
863            input: "",
864        }
865        "#,
866        );
867    }
868
869    #[test]
870    fn err_iana() {
871        insta::assert_snapshot!(
872            Parser::new().parse(b"[0/Foo]").unwrap_err(),
873            @"expected ASCII alphabetic byte (or underscore or period) at the start of an RFC 9557 annotation or time zone component name, but found `0` instead",
874        );
875        insta::assert_snapshot!(
876            Parser::new().parse(b"[Foo/0Bar]").unwrap_err(),
877            @"expected ASCII alphabetic byte (or underscore or period) at the start of an RFC 9557 annotation or time zone component name, but found `0` instead",
878        );
879    }
880
881    #[test]
882    fn err_offset() {
883        insta::assert_snapshot!(
884            Parser::new().parse(b"[+").unwrap_err(),
885            @"failed to parse hours in UTC numeric offset: expected two digit hour after sign, but found end of input",
886        );
887        insta::assert_snapshot!(
888            Parser::new().parse(b"[+26]").unwrap_err(),
889            @"failed to parse hours in UTC numeric offset: failed to parse hours (requires a two digit integer): parameter 'time zone offset hours' is not in the required range of -25..=25",
890        );
891        insta::assert_snapshot!(
892            Parser::new().parse(b"[-26]").unwrap_err(),
893            @"failed to parse hours in UTC numeric offset: failed to parse hours (requires a two digit integer): parameter 'time zone offset hours' is not in the required range of -25..=25",
894        );
895        insta::assert_snapshot!(
896            Parser::new().parse(b"[+05:12:34]").unwrap_err(),
897            @"subminute precision for UTC numeric offset is not enabled in this context (must provide only integral minutes)",
898        );
899        insta::assert_snapshot!(
900            Parser::new().parse(b"[+05:12:34.123456789]").unwrap_err(),
901            @"subminute precision for UTC numeric offset is not enabled in this context (must provide only integral minutes)",
902        );
903    }
904
905    #[test]
906    fn err_critical_unsupported() {
907        insta::assert_snapshot!(
908            Parser::new().parse(b"[!u-ca=chinese]").unwrap_err(),
909            @"found unsupported RFC 9557 annotation with the critical flag (`!`) set",
910        );
911    }
912
913    #[test]
914    fn err_key_leading_char() {
915        insta::assert_snapshot!(
916            Parser::new().parse(b"[").unwrap_err(),
917            @"expected the start of an RFC 9557 annotation or IANA time zone component name, but found end of input instead",
918        );
919        insta::assert_snapshot!(
920            Parser::new().parse(b"[&").unwrap_err(),
921            @"expected ASCII alphabetic byte (or underscore or period) at the start of an RFC 9557 annotation or time zone component name, but found `&` instead",
922        );
923        insta::assert_snapshot!(
924            Parser::new().parse(b"[Foo][").unwrap_err(),
925            @"expected the start of an RFC 9557 annotation key, but found end of input instead",
926        );
927        insta::assert_snapshot!(
928            Parser::new().parse(b"[Foo][&").unwrap_err(),
929            @"expected lowercase alphabetic byte (or underscore) at the start of an RFC 9557 annotation key, but found `&` instead",
930        );
931    }
932
933    #[test]
934    fn err_separator() {
935        insta::assert_snapshot!(
936            Parser::new().parse(b"[abc").unwrap_err(),
937            @"expected an `]` after parsing an RFC 9557 time zone annotation, but found end of input instead",
938        );
939        insta::assert_snapshot!(
940            Parser::new().parse(b"[_abc").unwrap_err(),
941            @"expected an `]` after parsing an RFC 9557 time zone annotation, but found end of input instead",
942        );
943        insta::assert_snapshot!(
944            Parser::new().parse(b"[abc^").unwrap_err(),
945            @"expected an `]` after parsing an RFC 9557 time zone annotation, but found `^` instead",
946        );
947        insta::assert_snapshot!(
948            Parser::new().parse(b"[Foo][abc").unwrap_err(),
949            @"expected an `=` after parsing an RFC 9557 annotation key, but found end of input instead",
950        );
951        insta::assert_snapshot!(
952            Parser::new().parse(b"[Foo][_abc").unwrap_err(),
953            @"expected an `=` after parsing an RFC 9557 annotation key, but found end of input instead",
954        );
955        insta::assert_snapshot!(
956            Parser::new().parse(b"[Foo][abc^").unwrap_err(),
957            @"expected an `=` after parsing an RFC 9557 annotation key, but found `^` instead",
958        );
959    }
960
961    #[test]
962    fn err_value() {
963        insta::assert_snapshot!(
964            Parser::new().parse(b"[abc=").unwrap_err(),
965            @"expected the start of an RFC 9557 annotation value, but found end of input instead",
966        );
967        insta::assert_snapshot!(
968            Parser::new().parse(b"[_abc=").unwrap_err(),
969            @"expected the start of an RFC 9557 annotation value, but found end of input instead",
970        );
971        insta::assert_snapshot!(
972            Parser::new().parse(b"[abc=^").unwrap_err(),
973            @"expected alphanumeric ASCII byte at the start of an RFC 9557 annotation value, but found `^` instead",
974        );
975        insta::assert_snapshot!(
976            Parser::new().parse(b"[abc=]").unwrap_err(),
977            @"expected alphanumeric ASCII byte at the start of an RFC 9557 annotation value, but found `]` instead",
978        );
979    }
980
981    #[test]
982    fn err_close() {
983        insta::assert_snapshot!(
984            Parser::new().parse(b"[abc=123").unwrap_err(),
985            @"expected an `]` after parsing an RFC 9557 annotation key and value, but found end of input instead",
986        );
987        insta::assert_snapshot!(
988            Parser::new().parse(b"[abc=123*").unwrap_err(),
989            @"expected an `]` after parsing an RFC 9557 annotation key and value, but found `*` instead",
990        );
991    }
992
993    #[cfg(feature = "std")]
994    #[test]
995    fn err_time_zone_db_lookup() {
996        // The error message snapshotted below can vary based on tzdb
997        // config, so only run this when we know we've got a real tzdb.
998        if crate::tz::db().is_definitively_empty() {
999            return;
1000        }
1001
1002        let p = |input| {
1003            Parser::new()
1004                .parse(input)
1005                .unwrap()
1006                .value
1007                .to_time_zone_annotation()
1008                .unwrap()
1009                .unwrap()
1010                .to_time_zone()
1011                .unwrap_err()
1012        };
1013
1014        insta::assert_snapshot!(
1015            p(b"[Foo]"),
1016            @"failed to find time zone `Foo` in time zone database",
1017        );
1018    }
1019
1020    #[test]
1021    fn err_repeated_time_zone() {
1022        let p = |input| Parser::new().parse(input).unwrap_err();
1023        insta::assert_snapshot!(
1024            p(b"[america/new_york][america/new_york]"),
1025            @"expected an `=` after parsing an RFC 9557 annotation key, but found `/` instead (time zone annotations must come first)",
1026        );
1027    }
1028}