jiff/fmt/
offset.rs

1/*!
2This module provides facilities for parsing time zone offsets.
3
4The parsing here follows primarily from [RFC 3339] and [ISO 8601], but also
5from [Temporal's hybrid grammar].
6
7[RFC 3339]: https://www.rfc-editor.org/rfc/rfc3339
8[ISO 8601]: https://www.iso.org/iso-8601-date-and-time-format.html
9[Temporal's hybrid grammar]: https://tc39.es/proposal-temporal/#sec-temporal-iso8601grammar
10*/
11
12// Here's the specific part of Temporal's grammar that is implemented below:
13//
14// DateTimeUTCOffset :::
15//   UTCDesignator
16//   UTCOffsetSubMinutePrecision
17//
18// TimeZoneUTCOffsetName :::
19//   UTCOffsetMinutePrecision
20//
21// UTCDesignator ::: one of
22//   Z z
23//
24// UTCOffsetSubMinutePrecision :::
25//   UTCOffsetMinutePrecision
26//   UTCOffsetWithSubMinuteComponents[+Extended]
27//   UTCOffsetWithSubMinuteComponents[~Extended]
28//
29// UTCOffsetMinutePrecision :::
30//   TemporalSign Hour
31//   TemporalSign Hour TimeSeparator[+Extended] MinuteSecond
32//   TemporalSign Hour TimeSeparator[~Extended] MinuteSecond
33//
34// UTCOffsetWithSubMinuteComponents[Extended] :::
35//   TemporalSign Hour
36//     TimeSeparator[?Extended] MinuteSecond
37//     TimeSeparator[?Extended] MinuteSecond
38//     TemporalDecimalFraction[opt]
39//
40// TimeSeparator[Extended] :::
41//   [+Extended] :
42//   [~Extended] [empty]
43//
44// TemporalSign :::
45//   ASCIISign
46//   <MINUS>
47//
48// ASCIISign ::: one of
49//   + -
50//
51// Hour :::
52//   0 DecimalDigit
53//   1 DecimalDigit
54//   20
55//   21
56//   22
57//   23
58//
59// MinuteSecond :::
60//   0 DecimalDigit
61//   1 DecimalDigit
62//   2 DecimalDigit
63//   3 DecimalDigit
64//   4 DecimalDigit
65//   5 DecimalDigit
66//
67// DecimalDigit :: one of
68//   0 1 2 3 4 5 6 7 8 9
69//
70// TemporalDecimalFraction :::
71//   TemporalDecimalSeparator DecimalDigit
72//   TemporalDecimalSeparator DecimalDigit DecimalDigit
73//   TemporalDecimalSeparator DecimalDigit DecimalDigit DecimalDigit
74//   TemporalDecimalSeparator DecimalDigit DecimalDigit DecimalDigit
75//                            DecimalDigit
76//   TemporalDecimalSeparator DecimalDigit DecimalDigit DecimalDigit
77//                            DecimalDigit DecimalDigit
78//   TemporalDecimalSeparator DecimalDigit DecimalDigit DecimalDigit
79//                            DecimalDigit DecimalDigit DecimalDigit
80//   TemporalDecimalSeparator DecimalDigit DecimalDigit DecimalDigit
81//                            DecimalDigit DecimalDigit DecimalDigit
82//                            DecimalDigit
83//   TemporalDecimalSeparator DecimalDigit DecimalDigit DecimalDigit
84//                            DecimalDigit DecimalDigit DecimalDigit
85//                            DecimalDigit DecimalDigit
86//   TemporalDecimalSeparator DecimalDigit DecimalDigit DecimalDigit
87//                            DecimalDigit DecimalDigit DecimalDigit
88//                            DecimalDigit DecimalDigit DecimalDigit
89//   TemporalDecimalSeparator ::: one of
90//   . ,
91//
92// The quick summary of the above is that offsets up to nanosecond precision
93// are supported. The general format is `{+,-}HH[:MM[:SS[.NNNNNNNNN]]]`. But
94// ISO 8601 extended or basic formats are also supported. For example, the
95// basic format `-0530` is equivalent to the extended format `-05:30`.
96//
97// Note that even though we support parsing up to nanosecond precision, Jiff
98// currently only supports offsets up to second precision. I don't think there
99// is any real practical need for any greater precision, but I don't think it
100// would be too hard to switch an `Offset` from an `i32` representation in
101// seconds to a `i64` representation in nanoseconds. (Since it only needs to
102// support a span of time of about 52 hours or so.)
103
104use crate::{
105    error::{err, Error, ErrorContext},
106    fmt::{
107        temporal::{PiecesNumericOffset, PiecesOffset},
108        util::{parse_temporal_fraction, FractionalFormatter},
109        Parsed,
110    },
111    tz::Offset,
112    util::{
113        escape, parse,
114        rangeint::{ri8, RFrom},
115        t::{self, C},
116    },
117};
118
119// We define our own ranged types because we want them to only be positive. We
120// represent the sign explicitly as a separate field. But the range supported
121// is the same as the component fields of `Offset`.
122type ParsedOffsetHours = ri8<0, { t::SpanZoneOffsetHours::MAX }>;
123type ParsedOffsetMinutes = ri8<0, { t::SpanZoneOffsetMinutes::MAX }>;
124type ParsedOffsetSeconds = ri8<0, { t::SpanZoneOffsetSeconds::MAX }>;
125
126/// An offset that has been parsed from a datetime string.
127///
128/// This represents either a Zulu offset (corresponding to UTC with an unknown
129/// time zone offset), or a specific numeric offset given in hours, minutes,
130/// seconds and nanoseconds (with everything except hours being optional).
131#[derive(Debug)]
132pub(crate) struct ParsedOffset {
133    /// The kind of offset parsed.
134    kind: ParsedOffsetKind,
135}
136
137impl ParsedOffset {
138    /// Convert a parsed offset into a Jiff offset.
139    ///
140    /// If the offset was parsed from a Zulu designator, then the offset
141    /// returned is indistinguishable from `+00` or `-00`.
142    ///
143    /// # Errors
144    ///
145    /// A variety of parsing errors are possible.
146    ///
147    /// Also, beyond normal range checks on the allowed components of a UTC
148    /// offset, this does rounding based on the fractional nanosecond part. As
149    /// a result, if the parsed value would be rounded to a value not in bounds
150    /// for a Jiff offset, this returns an error.
151    pub(crate) fn to_offset(&self) -> Result<Offset, Error> {
152        match self.kind {
153            ParsedOffsetKind::Zulu => Ok(Offset::UTC),
154            ParsedOffsetKind::Numeric(ref numeric) => numeric.to_offset(),
155        }
156    }
157
158    /// Convert a parsed offset to a more structured representation.
159    ///
160    /// This is like `to_offset`, but preserves `Z` and `-00:00` versus
161    /// `+00:00`. This does still attempt to create an `Offset`, and that
162    /// construction can fail.
163    pub(crate) fn to_pieces_offset(&self) -> Result<PiecesOffset, Error> {
164        match self.kind {
165            ParsedOffsetKind::Zulu => Ok(PiecesOffset::Zulu),
166            ParsedOffsetKind::Numeric(ref numeric) => {
167                let mut off = PiecesNumericOffset::from(numeric.to_offset()?);
168                if numeric.sign < C(0) {
169                    off = off.with_negative_zero();
170                }
171                Ok(PiecesOffset::from(off))
172            }
173        }
174    }
175
176    /// Whether this parsed offset corresponds to Zulu time or not.
177    ///
178    /// This is useful in error reporting for parsing civil times. Namely, we
179    /// report an error when parsing a civil time with a Zulu offset since it
180    /// is almost always the wrong thing to do.
181    pub(crate) fn is_zulu(&self) -> bool {
182        matches!(self.kind, ParsedOffsetKind::Zulu)
183    }
184
185    /// Whether the parsed offset had an explicit sub-minute component or not.
186    pub(crate) fn has_subminute(&self) -> bool {
187        let ParsedOffsetKind::Numeric(ref numeric) = self.kind else {
188            return false;
189        };
190        numeric.seconds.is_some()
191    }
192}
193
194/// The kind of a parsed offset.
195#[derive(Debug)]
196enum ParsedOffsetKind {
197    /// The zulu offset, corresponding to UTC in a context where the offset for
198    /// civil time is unknown or unavailable.
199    Zulu,
200    /// The specific numeric offset.
201    Numeric(Numeric),
202}
203
204/// A numeric representation of a UTC offset.
205struct Numeric {
206    /// The sign that was parsed from the numeric UTC offset. This is always
207    /// either `1` or `-1`, never `0`.
208    sign: t::Sign,
209    /// The hours component. This is non-optional because every UTC offset must
210    /// have at least hours.
211    hours: ParsedOffsetHours,
212    /// The minutes component.
213    minutes: Option<ParsedOffsetMinutes>,
214    /// The seconds component. This is only possible when subminute resolution
215    /// is enabled.
216    seconds: Option<ParsedOffsetSeconds>,
217    /// The nanoseconds fractional component. This is only possible when
218    /// subminute resolution is enabled.
219    nanoseconds: Option<t::SubsecNanosecond>,
220}
221
222impl Numeric {
223    /// Convert a parsed numeric offset into a Jiff offset.
224    ///
225    /// This does rounding based on the fractional nanosecond part. As a
226    /// result, if the parsed value would be rounded to a value not in bounds
227    /// for a Jiff offset, this returns an error.
228    fn to_offset(&self) -> Result<Offset, Error> {
229        let mut seconds = t::SpanZoneOffset::rfrom(C(3_600) * self.hours);
230        if let Some(part_minutes) = self.minutes {
231            seconds += C(60) * part_minutes;
232        }
233        if let Some(part_seconds) = self.seconds {
234            seconds += part_seconds;
235        }
236        if let Some(part_nanoseconds) = self.nanoseconds {
237            if part_nanoseconds >= C(500_000_000) {
238                seconds = seconds
239                    .try_checked_add("offset-seconds", C(1))
240                    .with_context(|| {
241                        err!(
242                            "due to precision loss, UTC offset '{}' is \
243                             rounded to a value that is out of bounds",
244                            self,
245                        )
246                    })?;
247            }
248        }
249        Ok(Offset::from_seconds_ranged(seconds * self.sign))
250    }
251}
252
253// This impl is just used for error messages when converting a `Numeric` to an
254// `Offset` fails.
255impl core::fmt::Display for Numeric {
256    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
257        if self.sign == C(-1) {
258            write!(f, "-")?;
259        } else {
260            write!(f, "+")?;
261        }
262        write!(f, "{:02}", self.hours)?;
263        if let Some(minutes) = self.minutes {
264            write!(f, ":{:02}", minutes)?;
265        }
266        if let Some(seconds) = self.seconds {
267            write!(f, ":{:02}", seconds)?;
268        }
269        if let Some(nanos) = self.nanoseconds {
270            static FMT: FractionalFormatter = FractionalFormatter::new();
271            write!(
272                f,
273                ".{}",
274                FMT.format(i32::from(nanos).unsigned_abs()).as_str()
275            )?;
276        }
277        Ok(())
278    }
279}
280
281// We give a succinct Debug impl (identical to Display) to make snapshot
282// testing a bit nicer.
283impl core::fmt::Debug for Numeric {
284    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
285        core::fmt::Display::fmt(self, f)
286    }
287}
288
289/// A parser for UTC offsets.
290///
291/// At time of writing, the typical configuration for offset parsing is to
292/// enable Zulu support and subminute precision. But when parsing zoned
293/// datetimes, and specifically, offsets within time zone annotations (the RFC
294/// 9557 extension to RFC 3339), then neither zulu nor subminute support are
295/// enabled.
296///
297/// N.B. I'm not actually totally clear on why zulu/subminute aren't allowed in
298/// time zone annotations, but that's what Temporal's grammar seems to dictate.
299/// One might argue that this is what RFCs 3339 and 9557 require, but the
300/// Temporal grammar is already recognizing a superset anyway.
301#[derive(Debug)]
302pub(crate) struct Parser {
303    zulu: bool,
304    require_minute: bool,
305    require_second: bool,
306    subminute: bool,
307    subsecond: bool,
308    colon: Colon,
309}
310
311impl Parser {
312    /// Create a new UTC offset parser with the default configuration.
313    pub(crate) const fn new() -> Parser {
314        Parser {
315            zulu: true,
316            require_minute: false,
317            require_second: false,
318            subminute: true,
319            subsecond: true,
320            colon: Colon::Optional,
321        }
322    }
323
324    /// When enabled, the `z` and `Z` designators are recognized as a "zulu"
325    /// indicator for UTC when the civil time offset is unknown or unavailable.
326    ///
327    /// When disabled, neither `z` nor `Z` will be recognized and a parser
328    /// error will occur if one is found.
329    ///
330    /// This is enabled by default.
331    pub(crate) const fn zulu(self, yes: bool) -> Parser {
332        Parser { zulu: yes, ..self }
333    }
334
335    /// When enabled, the minute component of a time zone offset is required.
336    /// If no minutes are found, then an error is returned.
337    ///
338    /// This is disabled by default.
339    pub(crate) const fn require_minute(self, yes: bool) -> Parser {
340        Parser { require_minute: yes, ..self }
341    }
342
343    /// When enabled, the second component of a time zone offset is required.
344    /// If no seconds (or minutes) are found, then an error is returned.
345    ///
346    /// When `subminute` is disabled, this setting has no effect.
347    ///
348    /// This is disabled by default.
349    pub(crate) const fn require_second(self, yes: bool) -> Parser {
350        Parser { require_second: yes, ..self }
351    }
352
353    /// When enabled, offsets with precision greater than integral minutes
354    /// are supported. Specifically, when enabled, nanosecond precision is
355    /// supported.
356    ///
357    /// When disabled, offsets must be integral minutes. And the `subsecond`
358    /// option is ignored.
359    pub(crate) const fn subminute(self, yes: bool) -> Parser {
360        Parser { subminute: yes, ..self }
361    }
362
363    /// When enabled, offsets with precision greater than integral seconds
364    /// are supported. Specifically, when enabled, nanosecond precision is
365    /// supported. Note though that when a fractional second is found, it is
366    /// used to round to the nearest second. (Jiff's `Offset` type only has
367    /// second resolution.)
368    ///
369    /// When disabled, offsets must be integral seconds (or integrate minutes
370    /// if the `subminute` option is disabled as well).
371    ///
372    /// This is ignored if `subminute` is disabled.
373    pub(crate) const fn subsecond(self, yes: bool) -> Parser {
374        Parser { subsecond: yes, ..self }
375    }
376
377    /// Sets how to handle parsing of colons in a time zone offset.
378    ///
379    /// This is set to `Colon::Optional` by default.
380    pub(crate) const fn colon(self, colon: Colon) -> Parser {
381        Parser { colon, ..self }
382    }
383
384    /// Parse an offset from the beginning of `input`.
385    ///
386    /// If no offset could be found or it was otherwise invalid, then an error
387    /// is returned.
388    ///
389    /// In general, parsing stops when, after all required components are seen,
390    /// an optional component is not present (either because of the end of the
391    /// input or because of a character that cannot possibly begin said optional
392    /// component). This does mean that there are some corner cases where error
393    /// messages will not be as good as they possibly can be. But there are
394    /// two exceptions here:
395    ///
396    /// 1. When Zulu support is disabled and a `Z` or `z` are found, then an
397    /// error is returned indicating that `Z` was recognized but specifically
398    /// not allowed.
399    /// 2. When subminute precision is disabled and a `:` is found after the
400    /// minutes component, then an error is returned indicating that the
401    /// seconds component was recognized but specifically not allowed.
402    ///
403    /// Otherwise, for example, if `input` is `-0512:34`, then the `-0512`
404    /// will be parsed as `-5 hours, 12 minutes` with an offset of `5`.
405    /// Presumably, whatever higher level parser is invoking this routine will
406    /// then see an unexpected `:`. But it's likely that a better error message
407    /// would call out the fact that mixed basic and extended formats (from
408    /// ISO 8601) aren't allowed, and that the offset needs to be written as
409    /// either `-05:12:34` or `-051234`. But... these are odd corner cases, so
410    /// we abide them.
411    pub(crate) fn parse<'i>(
412        &self,
413        mut input: &'i [u8],
414    ) -> Result<Parsed<'i, ParsedOffset>, Error> {
415        if input.is_empty() {
416            return Err(err!("expected UTC offset, but found end of input"));
417        }
418
419        if input[0] == b'Z' || input[0] == b'z' {
420            if !self.zulu {
421                return Err(err!(
422                    "found {z:?} in {original:?} where a numeric UTC offset \
423                     was expected (this context does not permit \
424                     the Zulu offset)",
425                    z = escape::Byte(input[0]),
426                    original = escape::Bytes(input),
427                ));
428            }
429            input = &input[1..];
430            let value = ParsedOffset { kind: ParsedOffsetKind::Zulu };
431            return Ok(Parsed { value, input });
432        }
433        let Parsed { value: numeric, input } = self.parse_numeric(input)?;
434        let value = ParsedOffset { kind: ParsedOffsetKind::Numeric(numeric) };
435        Ok(Parsed { value, input })
436    }
437
438    /// Like `parse`, but will return `None` if `input` cannot possibly start
439    /// with an offset.
440    ///
441    /// Basically, if `input` is empty, or is not one of `z`, `Z`, `+` or `-`
442    /// then this returns `None`.
443    #[cfg_attr(feature = "perf-inline", inline(always))]
444    pub(crate) fn parse_optional<'i>(
445        &self,
446        input: &'i [u8],
447    ) -> Result<Parsed<'i, Option<ParsedOffset>>, Error> {
448        let Some(first) = input.first().copied() else {
449            return Ok(Parsed { value: None, input });
450        };
451        if !matches!(first, b'z' | b'Z' | b'+' | b'-') {
452            return Ok(Parsed { value: None, input });
453        }
454        let Parsed { value, input } = self.parse(input)?;
455        Ok(Parsed { value: Some(value), input })
456    }
457
458    /// Parses a numeric offset from the beginning of `input`.
459    ///
460    /// The beginning of the input is expected to start with a `+` or a `-`.
461    /// Any other case (including an empty string) will result in an error.
462    #[cfg_attr(feature = "perf-inline", inline(always))]
463    fn parse_numeric<'i>(
464        &self,
465        input: &'i [u8],
466    ) -> Result<Parsed<'i, Numeric>, Error> {
467        let original = escape::Bytes(input);
468
469        // Parse sign component.
470        let Parsed { value: sign, input } =
471            self.parse_sign(input).with_context(|| {
472                err!("failed to parse sign in UTC numeric offset {original:?}")
473            })?;
474
475        // Parse hours component.
476        let Parsed { value: hours, input } =
477            self.parse_hours(input).with_context(|| {
478                err!(
479                    "failed to parse hours in UTC numeric offset {original:?}"
480                )
481            })?;
482        let extended = match self.colon {
483            Colon::Optional => input.starts_with(b":"),
484            Colon::Required => {
485                if !input.is_empty() && !input.starts_with(b":") {
486                    return Err(err!(
487                        "parsed hour component of time zone offset from \
488                         {original:?}, but could not find required colon \
489                         separator",
490                    ));
491                }
492                true
493            }
494            Colon::Absent => {
495                if !input.is_empty() && input.starts_with(b":") {
496                    return Err(err!(
497                        "parsed hour component of time zone offset from \
498                         {original:?}, but found colon after hours which \
499                         is not allowed",
500                    ));
501                }
502                false
503            }
504        };
505
506        // Start building up our numeric offset value.
507        let mut numeric = Numeric {
508            sign,
509            hours,
510            minutes: None,
511            seconds: None,
512            nanoseconds: None,
513        };
514
515        // Parse optional separator after hours.
516        let Parsed { value: has_minutes, input } =
517            self.parse_separator(input, extended).with_context(|| {
518                err!(
519                    "failed to parse separator after hours in \
520                     UTC numeric offset {original:?}"
521                )
522            })?;
523        if !has_minutes {
524            if self.require_minute || (self.subminute && self.require_second) {
525                return Err(err!(
526                    "parsed hour component of time zone offset from \
527                     {original:?}, but could not find required minute \
528                     component",
529                ));
530            }
531            return Ok(Parsed { value: numeric, input });
532        }
533
534        // Parse minutes component.
535        let Parsed { value: minutes, input } =
536            self.parse_minutes(input).with_context(|| {
537                err!(
538                    "failed to parse minutes in UTC numeric offset \
539                     {original:?}"
540                )
541            })?;
542        numeric.minutes = Some(minutes);
543
544        // If subminute resolution is not supported, then we're done here.
545        if !self.subminute {
546            // While we generally try to "stop" parsing once we're done
547            // seeing things we expect, in this case, if we see a colon, it
548            // almost certainly indicates that someone has tried to provide
549            // more precision than is supported. So we return an error here.
550            // If this winds up being problematic, we can make this error
551            // configurable or remove it altogether (unfortunate).
552            if input.get(0).map_or(false, |&b| b == b':') {
553                return Err(err!(
554                    "subminute precision for UTC numeric offset {original:?} \
555                     is not enabled in this context (must provide only \
556                     integral minutes)",
557                ));
558            }
559            return Ok(Parsed { value: numeric, input });
560        }
561
562        // Parse optional separator after minutes.
563        let Parsed { value: has_seconds, input } =
564            self.parse_separator(input, extended).with_context(|| {
565                err!(
566                    "failed to parse separator after minutes in \
567                     UTC numeric offset {original:?}"
568                )
569            })?;
570        if !has_seconds {
571            if self.require_second {
572                return Err(err!(
573                    "parsed hour and minute components of time zone offset \
574                     from {original:?}, but could not find required second \
575                     component",
576                ));
577            }
578            return Ok(Parsed { value: numeric, input });
579        }
580
581        // Parse seconds component.
582        let Parsed { value: seconds, input } =
583            self.parse_seconds(input).with_context(|| {
584                err!(
585                    "failed to parse seconds in UTC numeric offset \
586                     {original:?}"
587                )
588            })?;
589        numeric.seconds = Some(seconds);
590
591        // If subsecond resolution is not supported, then we're done here.
592        if !self.subsecond {
593            if input.get(0).map_or(false, |&b| b == b'.' || b == b',') {
594                return Err(err!(
595                    "subsecond precision for UTC numeric offset {original:?} \
596                     is not enabled in this context (must provide only \
597                     integral minutes or seconds)",
598                ));
599            }
600            return Ok(Parsed { value: numeric, input });
601        }
602
603        // Parse an optional fractional component.
604        let Parsed { value: nanoseconds, input } =
605            parse_temporal_fraction(input).with_context(|| {
606                err!(
607                    "failed to parse fractional nanoseconds in \
608                     UTC numeric offset {original:?}",
609                )
610            })?;
611        // OK because `parse_temporal_fraction` guarantees `0..=999_999_999`.
612        numeric.nanoseconds =
613            nanoseconds.map(|n| t::SubsecNanosecond::new(n).unwrap());
614        Ok(Parsed { value: numeric, input })
615    }
616
617    #[cfg_attr(feature = "perf-inline", inline(always))]
618    fn parse_sign<'i>(
619        &self,
620        input: &'i [u8],
621    ) -> Result<Parsed<'i, t::Sign>, Error> {
622        let sign = input.get(0).copied().ok_or_else(|| {
623            err!("expected UTC numeric offset, but found end of input")
624        })?;
625        let sign = if sign == b'+' {
626            t::Sign::N::<1>()
627        } else if sign == b'-' {
628            t::Sign::N::<-1>()
629        } else {
630            return Err(err!(
631                "expected '+' or '-' sign at start of UTC numeric offset, \
632                 but found {found:?} instead",
633                found = escape::Byte(sign),
634            ));
635        };
636        Ok(Parsed { value: sign, input: &input[1..] })
637    }
638
639    #[cfg_attr(feature = "perf-inline", inline(always))]
640    fn parse_hours<'i>(
641        &self,
642        input: &'i [u8],
643    ) -> Result<Parsed<'i, ParsedOffsetHours>, Error> {
644        let (hours, input) = parse::split(input, 2).ok_or_else(|| {
645            err!("expected two digit hour after sign, but found end of input",)
646        })?;
647        let hours = parse::i64(hours).with_context(|| {
648            err!(
649                "failed to parse {hours:?} as hours (a two digit integer)",
650                hours = escape::Bytes(hours),
651            )
652        })?;
653        // Note that we support a slightly bigger range of offsets than
654        // Temporal. Temporal seems to support only up to 23 hours, but
655        // we go up to 25 hours. This is done to support POSIX time zone
656        // strings, which also require 25 hours (plus the maximal minute/second
657        // components).
658        let hours = ParsedOffsetHours::try_new("hours", hours)
659            .context("offset hours are not valid")?;
660        Ok(Parsed { value: hours, input })
661    }
662
663    #[cfg_attr(feature = "perf-inline", inline(always))]
664    fn parse_minutes<'i>(
665        &self,
666        input: &'i [u8],
667    ) -> Result<Parsed<'i, ParsedOffsetMinutes>, Error> {
668        let (minutes, input) = parse::split(input, 2).ok_or_else(|| {
669            err!(
670                "expected two digit minute after hours, \
671                 but found end of input",
672            )
673        })?;
674        let minutes = parse::i64(minutes).with_context(|| {
675            err!(
676                "failed to parse {minutes:?} as minutes (a two digit integer)",
677                minutes = escape::Bytes(minutes),
678            )
679        })?;
680        let minutes = ParsedOffsetMinutes::try_new("minutes", minutes)
681            .context("minutes are not valid")?;
682        Ok(Parsed { value: minutes, input })
683    }
684
685    #[cfg_attr(feature = "perf-inline", inline(always))]
686    fn parse_seconds<'i>(
687        &self,
688        input: &'i [u8],
689    ) -> Result<Parsed<'i, ParsedOffsetSeconds>, Error> {
690        let (seconds, input) = parse::split(input, 2).ok_or_else(|| {
691            err!(
692                "expected two digit second after hours, \
693                 but found end of input",
694            )
695        })?;
696        let seconds = parse::i64(seconds).with_context(|| {
697            err!(
698                "failed to parse {seconds:?} as seconds (a two digit integer)",
699                seconds = escape::Bytes(seconds),
700            )
701        })?;
702        let seconds = ParsedOffsetSeconds::try_new("seconds", seconds)
703            .context("time zone offset seconds are not valid")?;
704        Ok(Parsed { value: seconds, input })
705    }
706
707    /// Parses a separator between hours/minutes or minutes/seconds. When
708    /// `true` is returned, we expect to parse the next component. When `false`
709    /// is returned, then no separator was found and there is no expectation of
710    /// finding another component.
711    ///
712    /// When in extended mode, true is returned if and only if a separator is
713    /// found.
714    ///
715    /// When in basic mode (not extended), then a subsequent component is only
716    /// expected when `input` begins with two ASCII digits.
717    #[cfg_attr(feature = "perf-inline", inline(always))]
718    fn parse_separator<'i>(
719        &self,
720        mut input: &'i [u8],
721        extended: bool,
722    ) -> Result<Parsed<'i, bool>, Error> {
723        if !extended {
724            let expected =
725                input.len() >= 2 && input[..2].iter().all(u8::is_ascii_digit);
726            return Ok(Parsed { value: expected, input });
727        }
728        let is_separator = input.get(0).map_or(false, |&b| b == b':');
729        if is_separator {
730            input = &input[1..];
731        }
732        Ok(Parsed { value: is_separator, input })
733    }
734}
735
736/// How to handle parsing of colons in a time zone offset.
737#[derive(Debug)]
738pub(crate) enum Colon {
739    /// Colons may be present or not. When present, colons must be used
740    /// consistently. For example, `+05:3015` and `-0530:15` are not allowed.
741    Optional,
742    /// Colons must be present.
743    Required,
744    /// Colons must be absent.
745    Absent,
746}
747
748#[cfg(test)]
749mod tests {
750    use crate::util::rangeint::RInto;
751
752    use super::*;
753
754    #[test]
755    fn ok_zulu() {
756        let p = |input| Parser::new().parse(input).unwrap();
757
758        insta::assert_debug_snapshot!(p(b"Z"), @r###"
759        Parsed {
760            value: ParsedOffset {
761                kind: Zulu,
762            },
763            input: "",
764        }
765        "###);
766        insta::assert_debug_snapshot!(p(b"z"), @r###"
767        Parsed {
768            value: ParsedOffset {
769                kind: Zulu,
770            },
771            input: "",
772        }
773        "###);
774    }
775
776    #[test]
777    fn ok_numeric() {
778        let p = |input| Parser::new().parse(input).unwrap();
779
780        insta::assert_debug_snapshot!(p(b"-05"), @r###"
781        Parsed {
782            value: ParsedOffset {
783                kind: Numeric(
784                    -05,
785                ),
786            },
787            input: "",
788        }
789        "###);
790    }
791
792    // Successful parse tests where the offset ends at the end of the string.
793    #[test]
794    fn ok_numeric_complete() {
795        let p = |input| Parser::new().parse_numeric(input).unwrap();
796
797        insta::assert_debug_snapshot!(p(b"-05"), @r###"
798        Parsed {
799            value: -05,
800            input: "",
801        }
802        "###);
803        insta::assert_debug_snapshot!(p(b"+05"), @r###"
804        Parsed {
805            value: +05,
806            input: "",
807        }
808        "###);
809
810        insta::assert_debug_snapshot!(p(b"+25:59"), @r###"
811        Parsed {
812            value: +25:59,
813            input: "",
814        }
815        "###);
816        insta::assert_debug_snapshot!(p(b"+2559"), @r###"
817        Parsed {
818            value: +25:59,
819            input: "",
820        }
821        "###);
822
823        insta::assert_debug_snapshot!(p(b"+25:59:59"), @r###"
824        Parsed {
825            value: +25:59:59,
826            input: "",
827        }
828        "###);
829        insta::assert_debug_snapshot!(p(b"+255959"), @r###"
830        Parsed {
831            value: +25:59:59,
832            input: "",
833        }
834        "###);
835
836        insta::assert_debug_snapshot!(p(b"+25:59:59.999"), @r###"
837        Parsed {
838            value: +25:59:59.999,
839            input: "",
840        }
841        "###);
842        insta::assert_debug_snapshot!(p(b"+25:59:59,999"), @r###"
843        Parsed {
844            value: +25:59:59.999,
845            input: "",
846        }
847        "###);
848        insta::assert_debug_snapshot!(p(b"+255959.999"), @r###"
849        Parsed {
850            value: +25:59:59.999,
851            input: "",
852        }
853        "###);
854        insta::assert_debug_snapshot!(p(b"+255959,999"), @r###"
855        Parsed {
856            value: +25:59:59.999,
857            input: "",
858        }
859        "###);
860
861        insta::assert_debug_snapshot!(p(b"+25:59:59.999999999"), @r###"
862        Parsed {
863            value: +25:59:59.999999999,
864            input: "",
865        }
866        "###);
867    }
868
869    // Successful parse tests where the offset ends before the end of the
870    // string.
871    #[test]
872    fn ok_numeric_incomplete() {
873        let p = |input| Parser::new().parse_numeric(input).unwrap();
874
875        insta::assert_debug_snapshot!(p(b"-05a"), @r###"
876        Parsed {
877            value: -05,
878            input: "a",
879        }
880        "###);
881        insta::assert_debug_snapshot!(p(b"-05:12a"), @r###"
882        Parsed {
883            value: -05:12,
884            input: "a",
885        }
886        "###);
887        insta::assert_debug_snapshot!(p(b"-05:12."), @r###"
888        Parsed {
889            value: -05:12,
890            input: ".",
891        }
892        "###);
893        insta::assert_debug_snapshot!(p(b"-05:12,"), @r###"
894        Parsed {
895            value: -05:12,
896            input: ",",
897        }
898        "###);
899        insta::assert_debug_snapshot!(p(b"-0512a"), @r###"
900        Parsed {
901            value: -05:12,
902            input: "a",
903        }
904        "###);
905        insta::assert_debug_snapshot!(p(b"-0512:"), @r###"
906        Parsed {
907            value: -05:12,
908            input: ":",
909        }
910        "###);
911        insta::assert_debug_snapshot!(p(b"-05:12:34a"), @r###"
912        Parsed {
913            value: -05:12:34,
914            input: "a",
915        }
916        "###);
917        insta::assert_debug_snapshot!(p(b"-05:12:34.9a"), @r###"
918        Parsed {
919            value: -05:12:34.9,
920            input: "a",
921        }
922        "###);
923        insta::assert_debug_snapshot!(p(b"-05:12:34.9."), @r###"
924        Parsed {
925            value: -05:12:34.9,
926            input: ".",
927        }
928        "###);
929        insta::assert_debug_snapshot!(p(b"-05:12:34.9,"), @r###"
930        Parsed {
931            value: -05:12:34.9,
932            input: ",",
933        }
934        "###);
935    }
936
937    // An empty string is invalid. The parser is written from the perspective
938    // that if it's called, then the caller expects a numeric UTC offset at
939    // that position.
940    #[test]
941    fn err_numeric_empty() {
942        insta::assert_snapshot!(
943            Parser::new().parse_numeric(b"").unwrap_err(),
944            @r###"failed to parse sign in UTC numeric offset "": expected UTC numeric offset, but found end of input"###,
945        );
946    }
947
948    // A numeric offset always has to begin with a '+' or a '-'.
949    #[test]
950    fn err_numeric_notsign() {
951        insta::assert_snapshot!(
952            Parser::new().parse_numeric(b"*").unwrap_err(),
953            @r###"failed to parse sign in UTC numeric offset "*": expected '+' or '-' sign at start of UTC numeric offset, but found "*" instead"###,
954        );
955    }
956
957    // The hours component must be at least two bytes.
958    #[test]
959    fn err_numeric_hours_too_short() {
960        insta::assert_snapshot!(
961            Parser::new().parse_numeric(b"+a").unwrap_err(),
962            @r###"failed to parse hours in UTC numeric offset "+a": expected two digit hour after sign, but found end of input"###,
963        );
964    }
965
966    // The hours component must be at least two ASCII digits.
967    #[test]
968    fn err_numeric_hours_invalid_digits() {
969        insta::assert_snapshot!(
970            Parser::new().parse_numeric(b"+ab").unwrap_err(),
971            @r###"failed to parse hours in UTC numeric offset "+ab": failed to parse "ab" as hours (a two digit integer): invalid digit, expected 0-9 but got a"###,
972        );
973    }
974
975    // The hours component must be in range.
976    #[test]
977    fn err_numeric_hours_out_of_range() {
978        insta::assert_snapshot!(
979            Parser::new().parse_numeric(b"-26").unwrap_err(),
980            @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"###,
981        );
982    }
983
984    // The minutes component must be at least two bytes.
985    #[test]
986    fn err_numeric_minutes_too_short() {
987        insta::assert_snapshot!(
988            Parser::new().parse_numeric(b"+05:a").unwrap_err(),
989            @r###"failed to parse minutes in UTC numeric offset "+05:a": expected two digit minute after hours, but found end of input"###,
990        );
991    }
992
993    // The minutes component must be at least two ASCII digits.
994    #[test]
995    fn err_numeric_minutes_invalid_digits() {
996        insta::assert_snapshot!(
997            Parser::new().parse_numeric(b"+05:ab").unwrap_err(),
998            @r###"failed to parse minutes in UTC numeric offset "+05:ab": failed to parse "ab" as minutes (a two digit integer): invalid digit, expected 0-9 but got a"###,
999        );
1000    }
1001
1002    // The minutes component must be in range.
1003    #[test]
1004    fn err_numeric_minutes_out_of_range() {
1005        insta::assert_snapshot!(
1006            Parser::new().parse_numeric(b"-05:60").unwrap_err(),
1007            @r###"failed to parse minutes in UTC numeric offset "-05:60": minutes are not valid: parameter 'minutes' with value 60 is not in the required range of 0..=59"###,
1008        );
1009    }
1010
1011    // The seconds component must be at least two bytes.
1012    #[test]
1013    fn err_numeric_seconds_too_short() {
1014        insta::assert_snapshot!(
1015            Parser::new().parse_numeric(b"+05:30:a").unwrap_err(),
1016            @r###"failed to parse seconds in UTC numeric offset "+05:30:a": expected two digit second after hours, but found end of input"###,
1017        );
1018    }
1019
1020    // The seconds component must be at least two ASCII digits.
1021    #[test]
1022    fn err_numeric_seconds_invalid_digits() {
1023        insta::assert_snapshot!(
1024            Parser::new().parse_numeric(b"+05:30:ab").unwrap_err(),
1025            @r###"failed to parse seconds in UTC numeric offset "+05:30:ab": failed to parse "ab" as seconds (a two digit integer): invalid digit, expected 0-9 but got a"###,
1026        );
1027    }
1028
1029    // The seconds component must be in range.
1030    #[test]
1031    fn err_numeric_seconds_out_of_range() {
1032        insta::assert_snapshot!(
1033            Parser::new().parse_numeric(b"-05:30:60").unwrap_err(),
1034            @r###"failed to parse seconds in UTC numeric offset "-05:30:60": time zone offset seconds are not valid: parameter 'seconds' with value 60 is not in the required range of 0..=59"###,
1035        );
1036    }
1037
1038    // The fraction component, if present as indicated by a separator, must be
1039    // non-empty.
1040    #[test]
1041    fn err_numeric_fraction_non_empty() {
1042        insta::assert_snapshot!(
1043            Parser::new().parse_numeric(b"-05:30:44.").unwrap_err(),
1044            @r###"failed to parse fractional nanoseconds in UTC numeric offset "-05:30:44.": found decimal after seconds component, but did not find any decimal digits after decimal"###,
1045        );
1046        insta::assert_snapshot!(
1047            Parser::new().parse_numeric(b"-05:30:44,").unwrap_err(),
1048            @r###"failed to parse fractional nanoseconds in UTC numeric offset "-05:30:44,": found decimal after seconds component, but did not find any decimal digits after decimal"###,
1049        );
1050
1051        // Instead of end-of-string, add invalid digit.
1052        insta::assert_snapshot!(
1053            Parser::new().parse_numeric(b"-05:30:44.a").unwrap_err(),
1054            @r###"failed to parse fractional nanoseconds in UTC numeric offset "-05:30:44.a": found decimal after seconds component, but did not find any decimal digits after decimal"###,
1055        );
1056        insta::assert_snapshot!(
1057            Parser::new().parse_numeric(b"-05:30:44,a").unwrap_err(),
1058            @r###"failed to parse fractional nanoseconds in UTC numeric offset "-05:30:44,a": found decimal after seconds component, but did not find any decimal digits after decimal"###,
1059        );
1060
1061        // And also test basic format.
1062        insta::assert_snapshot!(
1063            Parser::new().parse_numeric(b"-053044.a").unwrap_err(),
1064            @r###"failed to parse fractional nanoseconds in UTC numeric offset "-053044.a": found decimal after seconds component, but did not find any decimal digits after decimal"###,
1065        );
1066        insta::assert_snapshot!(
1067            Parser::new().parse_numeric(b"-053044,a").unwrap_err(),
1068            @r###"failed to parse fractional nanoseconds in UTC numeric offset "-053044,a": found decimal after seconds component, but did not find any decimal digits after decimal"###,
1069        );
1070    }
1071
1072    // A special case where it is clear that sub-minute precision has been
1073    // requested, but that it is has been forcefully disabled. This error is
1074    // meant to make what is likely a subtle failure mode more explicit.
1075    #[test]
1076    fn err_numeric_subminute_disabled_but_desired() {
1077        insta::assert_snapshot!(
1078            Parser::new().subminute(false).parse_numeric(b"-05:59:32").unwrap_err(),
1079            @r###"subminute precision for UTC numeric offset "-05:59:32" is not enabled in this context (must provide only integral minutes)"###,
1080        );
1081    }
1082
1083    // Another special case where Zulu parsing has been explicitly disabled,
1084    // but a Zulu string was found.
1085    #[test]
1086    fn err_zulu_disabled_but_desired() {
1087        insta::assert_snapshot!(
1088            Parser::new().zulu(false).parse(b"Z").unwrap_err(),
1089            @r###"found "Z" in "Z" where a numeric UTC offset was expected (this context does not permit the Zulu offset)"###,
1090        );
1091        insta::assert_snapshot!(
1092            Parser::new().zulu(false).parse(b"z").unwrap_err(),
1093            @r###"found "z" in "z" where a numeric UTC offset was expected (this context does not permit the Zulu offset)"###,
1094        );
1095    }
1096
1097    // Once a `Numeric` has been parsed, it is almost possible to assume that
1098    // it can be infallibly converted to an `Offset`. The one case where this
1099    // isn't true is when there is a fractional nanosecond part along with
1100    // maximal
1101    #[test]
1102    fn err_numeric_too_big_for_offset() {
1103        let numeric = Numeric {
1104            sign: t::Sign::MAX_SELF,
1105            hours: ParsedOffsetHours::MAX_SELF,
1106            minutes: Some(ParsedOffsetMinutes::MAX_SELF),
1107            seconds: Some(ParsedOffsetSeconds::MAX_SELF),
1108            nanoseconds: Some(C(499_999_999).rinto()),
1109        };
1110        assert_eq!(numeric.to_offset().unwrap(), Offset::MAX);
1111
1112        let numeric = Numeric {
1113            sign: t::Sign::MAX_SELF,
1114            hours: ParsedOffsetHours::MAX_SELF,
1115            minutes: Some(ParsedOffsetMinutes::MAX_SELF),
1116            seconds: Some(ParsedOffsetSeconds::MAX_SELF),
1117            nanoseconds: Some(C(500_000_000).rinto()),
1118        };
1119        insta::assert_snapshot!(
1120            numeric.to_offset().unwrap_err(),
1121            @"due to precision loss, UTC offset '+25:59:59.5' is rounded to a value that is out of bounds: parameter 'offset-seconds' with value 1 is not in the required range of -93599..=93599",
1122        );
1123    }
1124
1125    // Same as numeric_too_big_for_offset, but at the minimum boundary.
1126    #[test]
1127    fn err_numeric_too_small_for_offset() {
1128        let numeric = Numeric {
1129            sign: t::Sign::MIN_SELF,
1130            hours: ParsedOffsetHours::MAX_SELF,
1131            minutes: Some(ParsedOffsetMinutes::MAX_SELF),
1132            seconds: Some(ParsedOffsetSeconds::MAX_SELF),
1133            nanoseconds: Some(C(499_999_999).rinto()),
1134        };
1135        assert_eq!(numeric.to_offset().unwrap(), Offset::MIN);
1136
1137        let numeric = Numeric {
1138            sign: t::Sign::MIN_SELF,
1139            hours: ParsedOffsetHours::MAX_SELF,
1140            minutes: Some(ParsedOffsetMinutes::MAX_SELF),
1141            seconds: Some(ParsedOffsetSeconds::MAX_SELF),
1142            nanoseconds: Some(C(500_000_000).rinto()),
1143        };
1144        insta::assert_snapshot!(
1145            numeric.to_offset().unwrap_err(),
1146            @"due to precision loss, UTC offset '-25:59:59.5' is rounded to a value that is out of bounds: parameter 'offset-seconds' with value 1 is not in the required range of -93599..=93599",
1147        );
1148    }
1149}