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                    Fixed(
647                        25:59:00,
648                    ),
649                ),
650                false,
651            ),
652        )
653        "###);
654        insta::assert_debug_snapshot!(p(b"[-25:59]"), @r###"
655        Some(
656            (
657                TimeZone(
658                    Fixed(
659                        -25:59:00,
660                    ),
661                ),
662                false,
663            ),
664        )
665        "###);
666    }
667
668    #[test]
669    fn ok_empty() {
670        let p = |input| Parser::new().parse(input).unwrap();
671
672        insta::assert_debug_snapshot!(p(b""), @r###"
673        Parsed {
674            value: ParsedAnnotations {
675                input: "",
676                time_zone: None,
677            },
678            input: "",
679        }
680        "###);
681        insta::assert_debug_snapshot!(p(b"blah"), @r###"
682        Parsed {
683            value: ParsedAnnotations {
684                input: "",
685                time_zone: None,
686            },
687            input: "blah",
688        }
689        "###);
690    }
691
692    #[test]
693    fn ok_unsupported() {
694        let p = |input| Parser::new().parse(input).unwrap();
695
696        insta::assert_debug_snapshot!(
697            p(b"[u-ca=chinese]"),
698            @r###"
699        Parsed {
700            value: ParsedAnnotations {
701                input: "[u-ca=chinese]",
702                time_zone: None,
703            },
704            input: "",
705        }
706        "###,
707        );
708        insta::assert_debug_snapshot!(
709            p(b"[u-ca=chinese-japanese]"),
710            @r###"
711        Parsed {
712            value: ParsedAnnotations {
713                input: "[u-ca=chinese-japanese]",
714                time_zone: None,
715            },
716            input: "",
717        }
718        "###,
719        );
720        insta::assert_debug_snapshot!(
721            p(b"[u-ca=chinese-japanese-russian]"),
722            @r###"
723        Parsed {
724            value: ParsedAnnotations {
725                input: "[u-ca=chinese-japanese-russian]",
726                time_zone: None,
727            },
728            input: "",
729        }
730        "###,
731        );
732    }
733
734    #[test]
735    fn ok_iana() {
736        let p = |input| Parser::new().parse(input).unwrap();
737
738        insta::assert_debug_snapshot!(p(b"[America/New_York]"), @r###"
739        Parsed {
740            value: ParsedAnnotations {
741                input: "[America/New_York]",
742                time_zone: Some(
743                    Named {
744                        critical: false,
745                        name: "America/New_York",
746                    },
747                ),
748            },
749            input: "",
750        }
751        "###);
752        insta::assert_debug_snapshot!(p(b"[!America/New_York]"), @r###"
753        Parsed {
754            value: ParsedAnnotations {
755                input: "[!America/New_York]",
756                time_zone: Some(
757                    Named {
758                        critical: true,
759                        name: "America/New_York",
760                    },
761                ),
762            },
763            input: "",
764        }
765        "###);
766        insta::assert_debug_snapshot!(p(b"[UTC]"), @r###"
767        Parsed {
768            value: ParsedAnnotations {
769                input: "[UTC]",
770                time_zone: Some(
771                    Named {
772                        critical: false,
773                        name: "UTC",
774                    },
775                ),
776            },
777            input: "",
778        }
779        "###);
780        insta::assert_debug_snapshot!(p(b"[.._foo_../.0+-]"), @r###"
781        Parsed {
782            value: ParsedAnnotations {
783                input: "[.._foo_../.0+-]",
784                time_zone: Some(
785                    Named {
786                        critical: false,
787                        name: ".._foo_../.0+-",
788                    },
789                ),
790            },
791            input: "",
792        }
793        "###);
794    }
795
796    #[test]
797    fn ok_offset() {
798        let p = |input| Parser::new().parse(input).unwrap();
799
800        insta::assert_debug_snapshot!(p(b"[-00]"), @r###"
801        Parsed {
802            value: ParsedAnnotations {
803                input: "[-00]",
804                time_zone: Some(
805                    Offset {
806                        critical: false,
807                        offset: ParsedOffset {
808                            kind: Numeric(
809                                -00,
810                            ),
811                        },
812                    },
813                ),
814            },
815            input: "",
816        }
817        "###);
818        insta::assert_debug_snapshot!(p(b"[+00]"), @r###"
819        Parsed {
820            value: ParsedAnnotations {
821                input: "[+00]",
822                time_zone: Some(
823                    Offset {
824                        critical: false,
825                        offset: ParsedOffset {
826                            kind: Numeric(
827                                +00,
828                            ),
829                        },
830                    },
831                ),
832            },
833            input: "",
834        }
835        "###);
836        insta::assert_debug_snapshot!(p(b"[-05]"), @r###"
837        Parsed {
838            value: ParsedAnnotations {
839                input: "[-05]",
840                time_zone: Some(
841                    Offset {
842                        critical: false,
843                        offset: ParsedOffset {
844                            kind: Numeric(
845                                -05,
846                            ),
847                        },
848                    },
849                ),
850            },
851            input: "",
852        }
853        "###);
854        insta::assert_debug_snapshot!(p(b"[!+05:12]"), @r###"
855        Parsed {
856            value: ParsedAnnotations {
857                input: "[!+05:12]",
858                time_zone: Some(
859                    Offset {
860                        critical: true,
861                        offset: ParsedOffset {
862                            kind: Numeric(
863                                +05:12,
864                            ),
865                        },
866                    },
867                ),
868            },
869            input: "",
870        }
871        "###);
872    }
873
874    #[test]
875    fn ok_iana_unsupported() {
876        let p = |input| Parser::new().parse(input).unwrap();
877
878        insta::assert_debug_snapshot!(
879            p(b"[America/New_York][u-ca=chinese-japanese-russian]"),
880            @r###"
881        Parsed {
882            value: ParsedAnnotations {
883                input: "[America/New_York][u-ca=chinese-japanese-russian]",
884                time_zone: Some(
885                    Named {
886                        critical: false,
887                        name: "America/New_York",
888                    },
889                ),
890            },
891            input: "",
892        }
893        "###,
894        );
895    }
896
897    #[test]
898    fn err_iana() {
899        insta::assert_snapshot!(
900            Parser::new().parse(b"[0/Foo]").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        insta::assert_snapshot!(
904            Parser::new().parse(b"[Foo/0Bar]").unwrap_err(),
905            @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"###,
906        );
907    }
908
909    #[test]
910    fn err_offset() {
911        insta::assert_snapshot!(
912            Parser::new().parse(b"[+").unwrap_err(),
913            @r###"failed to parse hours in UTC numeric offset "+": expected two digit hour after sign, but found end of input"###,
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"[-26]").unwrap_err(),
921            @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"###,
922        );
923        insta::assert_snapshot!(
924            Parser::new().parse(b"[+05:12:34]").unwrap_err(),
925            @r###"subminute precision for UTC numeric offset "+05:12:34]" is not enabled in this context (must provide only integral minutes)"###,
926        );
927        insta::assert_snapshot!(
928            Parser::new().parse(b"[+05:12:34.123456789]").unwrap_err(),
929            @r###"subminute precision for UTC numeric offset "+05:12:34.123456789]" is not enabled in this context (must provide only integral minutes)"###,
930        );
931    }
932
933    #[test]
934    fn err_critical_unsupported() {
935        insta::assert_snapshot!(
936            Parser::new().parse(b"[!u-ca=chinese]").unwrap_err(),
937            @r###"found unsupported RFC 9557 annotation with key "u-ca" with the critical flag ('!') set"###,
938        );
939    }
940
941    #[test]
942    fn err_key_leading_char() {
943        insta::assert_snapshot!(
944            Parser::new().parse(b"[").unwrap_err(),
945            @"expected the start of an RFC 9557 annotation or IANA time zone component name, but found end of input instead",
946        );
947        insta::assert_snapshot!(
948            Parser::new().parse(b"[&").unwrap_err(),
949            @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"###,
950        );
951        insta::assert_snapshot!(
952            Parser::new().parse(b"[Foo][").unwrap_err(),
953            @"expected the start of an RFC 9557 annotation key, but found end of input instead",
954        );
955        insta::assert_snapshot!(
956            Parser::new().parse(b"[Foo][&").unwrap_err(),
957            @r###"expected lowercase alphabetic byte (or underscore) at the start of an RFC 9557 annotation key, but found "&" instead"###,
958        );
959    }
960
961    #[test]
962    fn err_separator() {
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            @"expected an ']' after parsing an RFC 9557 time zone annotation, but found end of input instead",
970        );
971        insta::assert_snapshot!(
972            Parser::new().parse(b"[abc^").unwrap_err(),
973            @r###"expected an ']' after parsing an RFC 9557 time zone annotation, but found "^" 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            @"expected an '=' after parsing an RFC 9557 annotation key, but found end of input instead",
982        );
983        insta::assert_snapshot!(
984            Parser::new().parse(b"[Foo][abc^").unwrap_err(),
985            @r###"expected an '=' after parsing an RFC 9557 annotation key, but found "^" instead"###,
986        );
987    }
988
989    #[test]
990    fn err_value() {
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            @"expected the start of an RFC 9557 annotation value, but found end of input 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        insta::assert_snapshot!(
1004            Parser::new().parse(b"[abc=]").unwrap_err(),
1005            @r###"expected alphanumeric ASCII byte at the start of an RFC 9557 annotation value, but found "]" instead"###,
1006        );
1007    }
1008
1009    #[test]
1010    fn err_close() {
1011        insta::assert_snapshot!(
1012            Parser::new().parse(b"[abc=123").unwrap_err(),
1013            @"expected an ']' after parsing an RFC 9557 annotation key and value, but found end of input instead",
1014        );
1015        insta::assert_snapshot!(
1016            Parser::new().parse(b"[abc=123*").unwrap_err(),
1017            @r###"expected an ']' after parsing an RFC 9557 annotation key and value, but found "*" instead"###,
1018        );
1019    }
1020
1021    #[cfg(feature = "std")]
1022    #[test]
1023    fn err_time_zone_db_lookup() {
1024        // The error message snapshotted below can vary based on tzdb
1025        // config, so only run this when we know we've got a real tzdb.
1026        if crate::tz::db().is_definitively_empty() {
1027            return;
1028        }
1029
1030        let p = |input| {
1031            Parser::new()
1032                .parse(input)
1033                .unwrap()
1034                .value
1035                .to_time_zone_annotation()
1036                .unwrap()
1037                .unwrap()
1038                .to_time_zone()
1039                .unwrap_err()
1040        };
1041
1042        insta::assert_snapshot!(
1043            p(b"[Foo]"),
1044            @"failed to find time zone `Foo` in time zone database",
1045        );
1046    }
1047
1048    #[test]
1049    fn err_repeated_time_zone() {
1050        let p = |input| Parser::new().parse(input).unwrap_err();
1051        insta::assert_snapshot!(
1052            p(b"[america/new_york][america/new_york]"),
1053            @"expected an '=' after parsing an RFC 9557 annotation key, but found / instead (time zone annotations must come first)",
1054        );
1055    }
1056}