Coverage Report

Created: 2026-01-15 06:51

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