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