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