Coverage Report

Created: 2025-12-31 06:22

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/rust/registry/src/index.crates.io-1949cf8c6b5b557f/jiff-0.2.16/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::{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`.
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
                    .with_context(|| {
241
0
                        err!(
242
0
                            "due to precision loss, UTC offset '{}' is \
243
0
                             rounded to a value that is out of bounds",
244
                            self,
245
                        )
246
0
                    })?;
247
0
            }
248
0
        }
249
0
        Ok(Offset::from_seconds_ranged(seconds * self.sign))
250
0
    }
251
}
252
253
// This impl is just used for error messages when converting a `Numeric` to an
254
// `Offset` fails.
255
impl core::fmt::Display for Numeric {
256
0
    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
257
0
        if self.sign == C(-1) {
258
0
            write!(f, "-")?;
259
        } else {
260
0
            write!(f, "+")?;
261
        }
262
0
        write!(f, "{:02}", self.hours)?;
263
0
        if let Some(minutes) = self.minutes {
264
0
            write!(f, ":{:02}", minutes)?;
265
0
        }
266
0
        if let Some(seconds) = self.seconds {
267
0
            write!(f, ":{:02}", seconds)?;
268
0
        }
269
0
        if let Some(nanos) = self.nanoseconds {
270
            static FMT: FractionalFormatter = FractionalFormatter::new();
271
0
            write!(
272
0
                f,
273
0
                ".{}",
274
0
                FMT.format(i32::from(nanos).unsigned_abs()).as_str()
275
0
            )?;
276
0
        }
277
0
        Ok(())
278
0
    }
279
}
280
281
// We give a succinct Debug impl (identical to Display) to make snapshot
282
// testing a bit nicer.
283
impl core::fmt::Debug for Numeric {
284
0
    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
285
0
        core::fmt::Display::fmt(self, f)
286
0
    }
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)]
302
pub(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
311
impl Parser {
312
    /// Create a new UTC offset parser with the default configuration.
313
0
    pub(crate) const fn new() -> Parser {
314
0
        Parser {
315
0
            zulu: true,
316
0
            require_minute: false,
317
0
            require_second: false,
318
0
            subminute: true,
319
0
            subsecond: true,
320
0
            colon: Colon::Optional,
321
0
        }
322
0
    }
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
0
    pub(crate) const fn zulu(self, yes: bool) -> Parser {
332
0
        Parser { zulu: yes, ..self }
333
0
    }
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
0
    pub(crate) const fn require_minute(self, yes: bool) -> Parser {
340
0
        Parser { require_minute: yes, ..self }
341
0
    }
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
0
    pub(crate) const fn require_second(self, yes: bool) -> Parser {
350
0
        Parser { require_second: yes, ..self }
351
0
    }
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
0
    pub(crate) const fn subminute(self, yes: bool) -> Parser {
360
0
        Parser { subminute: yes, ..self }
361
0
    }
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
0
    pub(crate) const fn subsecond(self, yes: bool) -> Parser {
374
0
        Parser { subsecond: yes, ..self }
375
0
    }
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
0
    pub(crate) const fn colon(self, colon: Colon) -> Parser {
381
0
        Parser { colon, ..self }
382
0
    }
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
0
    pub(crate) fn parse<'i>(
412
0
        &self,
413
0
        mut input: &'i [u8],
414
0
    ) -> Result<Parsed<'i, ParsedOffset>, Error> {
415
0
        if input.is_empty() {
416
0
            return Err(err!("expected UTC offset, but found end of input"));
417
0
        }
418
419
0
        if input[0] == b'Z' || input[0] == b'z' {
420
0
            if !self.zulu {
421
0
                return Err(err!(
422
0
                    "found {z:?} in {original:?} where a numeric UTC offset \
423
0
                     was expected (this context does not permit \
424
0
                     the Zulu offset)",
425
0
                    z = escape::Byte(input[0]),
426
0
                    original = escape::Bytes(input),
427
0
                ));
428
0
            }
429
0
            input = &input[1..];
430
0
            let value = ParsedOffset { kind: ParsedOffsetKind::Zulu };
431
0
            return Ok(Parsed { value, input });
432
0
        }
433
0
        let Parsed { value: numeric, input } = self.parse_numeric(input)?;
434
0
        let value = ParsedOffset { kind: ParsedOffsetKind::Numeric(numeric) };
435
0
        Ok(Parsed { value, input })
436
0
    }
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
0
    pub(crate) fn parse_optional<'i>(
445
0
        &self,
446
0
        input: &'i [u8],
447
0
    ) -> Result<Parsed<'i, Option<ParsedOffset>>, Error> {
448
0
        let Some(first) = input.first().copied() else {
449
0
            return Ok(Parsed { value: None, input });
450
        };
451
0
        if !matches!(first, b'z' | b'Z' | b'+' | b'-') {
452
0
            return Ok(Parsed { value: None, input });
453
0
        }
454
0
        let Parsed { value, input } = self.parse(input)?;
455
0
        Ok(Parsed { value: Some(value), input })
456
0
    }
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
0
    fn parse_numeric<'i>(
464
0
        &self,
465
0
        input: &'i [u8],
466
0
    ) -> Result<Parsed<'i, Numeric>, Error> {
467
0
        let original = escape::Bytes(input);
468
469
        // Parse sign component.
470
0
        let Parsed { value: sign, input } =
471
0
            self.parse_sign(input).with_context(|| {
472
0
                err!("failed to parse sign in UTC numeric offset {original:?}")
473
0
            })?;
474
475
        // Parse hours component.
476
0
        let Parsed { value: hours, input } =
477
0
            self.parse_hours(input).with_context(|| {
478
0
                err!(
479
0
                    "failed to parse hours in UTC numeric offset {original:?}"
480
                )
481
0
            })?;
482
0
        let extended = match self.colon {
483
0
            Colon::Optional => input.starts_with(b":"),
484
            Colon::Required => {
485
0
                if !input.is_empty() && !input.starts_with(b":") {
486
0
                    return Err(err!(
487
0
                        "parsed hour component of time zone offset from \
488
0
                         {original:?}, but could not find required colon \
489
0
                         separator",
490
0
                    ));
491
0
                }
492
0
                true
493
            }
494
            Colon::Absent => {
495
0
                if !input.is_empty() && input.starts_with(b":") {
496
0
                    return Err(err!(
497
0
                        "parsed hour component of time zone offset from \
498
0
                         {original:?}, but found colon after hours which \
499
0
                         is not allowed",
500
0
                    ));
501
0
                }
502
0
                false
503
            }
504
        };
505
506
        // Start building up our numeric offset value.
507
0
        let mut numeric = Numeric {
508
0
            sign,
509
0
            hours,
510
0
            minutes: None,
511
0
            seconds: None,
512
0
            nanoseconds: None,
513
0
        };
514
515
        // Parse optional separator after hours.
516
0
        let Parsed { value: has_minutes, input } =
517
0
            self.parse_separator(input, extended).with_context(|| {
518
0
                err!(
519
0
                    "failed to parse separator after hours in \
520
0
                     UTC numeric offset {original:?}"
521
                )
522
0
            })?;
523
0
        if !has_minutes {
524
0
            if self.require_minute || (self.subminute && self.require_second) {
525
0
                return Err(err!(
526
0
                    "parsed hour component of time zone offset from \
527
0
                     {original:?}, but could not find required minute \
528
0
                     component",
529
0
                ));
530
0
            }
531
0
            return Ok(Parsed { value: numeric, input });
532
0
        }
533
534
        // Parse minutes component.
535
0
        let Parsed { value: minutes, input } =
536
0
            self.parse_minutes(input).with_context(|| {
537
0
                err!(
538
0
                    "failed to parse minutes in UTC numeric offset \
539
0
                     {original:?}"
540
                )
541
0
            })?;
542
0
        numeric.minutes = Some(minutes);
543
544
        // If subminute resolution is not supported, then we're done here.
545
0
        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
0
            if input.get(0).map_or(false, |&b| b == b':') {
553
0
                return Err(err!(
554
0
                    "subminute precision for UTC numeric offset {original:?} \
555
0
                     is not enabled in this context (must provide only \
556
0
                     integral minutes)",
557
0
                ));
558
0
            }
559
0
            return Ok(Parsed { value: numeric, input });
560
0
        }
561
562
        // Parse optional separator after minutes.
563
0
        let Parsed { value: has_seconds, input } =
564
0
            self.parse_separator(input, extended).with_context(|| {
565
0
                err!(
566
0
                    "failed to parse separator after minutes in \
567
0
                     UTC numeric offset {original:?}"
568
                )
569
0
            })?;
570
0
        if !has_seconds {
571
0
            if self.require_second {
572
0
                return Err(err!(
573
0
                    "parsed hour and minute components of time zone offset \
574
0
                     from {original:?}, but could not find required second \
575
0
                     component",
576
0
                ));
577
0
            }
578
0
            return Ok(Parsed { value: numeric, input });
579
0
        }
580
581
        // Parse seconds component.
582
0
        let Parsed { value: seconds, input } =
583
0
            self.parse_seconds(input).with_context(|| {
584
0
                err!(
585
0
                    "failed to parse seconds in UTC numeric offset \
586
0
                     {original:?}"
587
                )
588
0
            })?;
589
0
        numeric.seconds = Some(seconds);
590
591
        // If subsecond resolution is not supported, then we're done here.
592
0
        if !self.subsecond {
593
0
            if input.get(0).map_or(false, |&b| b == b'.' || b == b',') {
594
0
                return Err(err!(
595
0
                    "subsecond precision for UTC numeric offset {original:?} \
596
0
                     is not enabled in this context (must provide only \
597
0
                     integral minutes or seconds)",
598
0
                ));
599
0
            }
600
0
            return Ok(Parsed { value: numeric, input });
601
0
        }
602
603
        // Parse an optional fractional component.
604
0
        let Parsed { value: nanoseconds, input } =
605
0
            parse_temporal_fraction(input).with_context(|| {
606
0
                err!(
607
0
                    "failed to parse fractional nanoseconds in \
608
0
                     UTC numeric offset {original:?}",
609
                )
610
0
            })?;
611
        // OK because `parse_temporal_fraction` guarantees `0..=999_999_999`.
612
        numeric.nanoseconds =
613
0
            nanoseconds.map(|n| t::SubsecNanosecond::new(n).unwrap());
614
0
        Ok(Parsed { value: numeric, input })
615
0
    }
616
617
    #[cfg_attr(feature = "perf-inline", inline(always))]
618
0
    fn parse_sign<'i>(
619
0
        &self,
620
0
        input: &'i [u8],
621
0
    ) -> Result<Parsed<'i, t::Sign>, Error> {
622
0
        let sign = input.get(0).copied().ok_or_else(|| {
623
0
            err!("expected UTC numeric offset, but found end of input")
624
0
        })?;
625
0
        let sign = if sign == b'+' {
626
0
            t::Sign::N::<1>()
627
0
        } else if sign == b'-' {
628
0
            t::Sign::N::<-1>()
629
        } else {
630
0
            return Err(err!(
631
0
                "expected '+' or '-' sign at start of UTC numeric offset, \
632
0
                 but found {found:?} instead",
633
0
                found = escape::Byte(sign),
634
0
            ));
635
        };
636
0
        Ok(Parsed { value: sign, input: &input[1..] })
637
0
    }
638
639
    #[cfg_attr(feature = "perf-inline", inline(always))]
640
0
    fn parse_hours<'i>(
641
0
        &self,
642
0
        input: &'i [u8],
643
0
    ) -> Result<Parsed<'i, ParsedOffsetHours>, Error> {
644
0
        let (hours, input) = parse::split(input, 2).ok_or_else(|| {
645
0
            err!("expected two digit hour after sign, but found end of input",)
646
0
        })?;
647
0
        let hours = parse::i64(hours).with_context(|| {
648
0
            err!(
649
0
                "failed to parse {hours:?} as hours (a two digit integer)",
650
0
                hours = escape::Bytes(hours),
651
            )
652
0
        })?;
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
0
        let hours = ParsedOffsetHours::try_new("hours", hours)
659
0
            .context("offset hours are not valid")?;
660
0
        Ok(Parsed { value: hours, input })
661
0
    }
662
663
    #[cfg_attr(feature = "perf-inline", inline(always))]
664
0
    fn parse_minutes<'i>(
665
0
        &self,
666
0
        input: &'i [u8],
667
0
    ) -> Result<Parsed<'i, ParsedOffsetMinutes>, Error> {
668
0
        let (minutes, input) = parse::split(input, 2).ok_or_else(|| {
669
0
            err!(
670
0
                "expected two digit minute after hours, \
671
0
                 but found end of input",
672
            )
673
0
        })?;
674
0
        let minutes = parse::i64(minutes).with_context(|| {
675
0
            err!(
676
0
                "failed to parse {minutes:?} as minutes (a two digit integer)",
677
0
                minutes = escape::Bytes(minutes),
678
            )
679
0
        })?;
680
0
        let minutes = ParsedOffsetMinutes::try_new("minutes", minutes)
681
0
            .context("minutes are not valid")?;
682
0
        Ok(Parsed { value: minutes, input })
683
0
    }
684
685
    #[cfg_attr(feature = "perf-inline", inline(always))]
686
0
    fn parse_seconds<'i>(
687
0
        &self,
688
0
        input: &'i [u8],
689
0
    ) -> Result<Parsed<'i, ParsedOffsetSeconds>, Error> {
690
0
        let (seconds, input) = parse::split(input, 2).ok_or_else(|| {
691
0
            err!(
692
0
                "expected two digit second after hours, \
693
0
                 but found end of input",
694
            )
695
0
        })?;
696
0
        let seconds = parse::i64(seconds).with_context(|| {
697
0
            err!(
698
0
                "failed to parse {seconds:?} as seconds (a two digit integer)",
699
0
                seconds = escape::Bytes(seconds),
700
            )
701
0
        })?;
702
0
        let seconds = ParsedOffsetSeconds::try_new("seconds", seconds)
703
0
            .context("time zone offset seconds are not valid")?;
704
0
        Ok(Parsed { value: seconds, input })
705
0
    }
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
0
    fn parse_separator<'i>(
719
0
        &self,
720
0
        mut input: &'i [u8],
721
0
        extended: bool,
722
0
    ) -> Result<Parsed<'i, bool>, Error> {
723
0
        if !extended {
724
0
            let expected =
725
0
                input.len() >= 2 && input[..2].iter().all(u8::is_ascii_digit);
726
0
            return Ok(Parsed { value: expected, input });
727
0
        }
728
0
        let is_separator = input.get(0).map_or(false, |&b| b == b':');
729
0
        if is_separator {
730
0
            input = &input[1..];
731
0
        }
732
0
        Ok(Parsed { value: is_separator, input })
733
0
    }
734
}
735
736
/// How to handle parsing of colons in a time zone offset.
737
#[derive(Debug)]
738
pub(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)]
749
mod 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
}