Skip to content

Commit

Permalink
fmt/temporal: add new Pieces type for granular ISO 8601 parsing
Browse files Browse the repository at this point in the history
This commit adds a new type to the `jiff::fmt::temporal` module that
exposes the raw components of a parsed Temporal ISO 8601 datetime
string.

This is meant to address use cases that need something a bit more
flexible than what is provided by the higher level parsing routines.
Namely, the higher level routines go out of their way to stop you from
shooting yourself in the foot. For example, parsing into a `Zoned`
requires a time zone annotation.

But parsing into a `Pieces` doesn't require any of that. It just has
to match the Temporal ISO 8601 grammar. Then you can mix and match
the pieces in whatever way you desire. And indeed, you can pretty
easily shoot yourself in the foot with this API. I feel okay about this
because it's tucked into a corner of Jiff that you specifically have to
seek out to use. I've also included examples in the docs of _how_ you
can easily shoot yourself in the foot.

I've included a couple case studies in the docs reflecting some real
world examples I've come across in the issue tracker.

Ref #112, Ref #181, Closes #188,
Ref tc39/proposal-temporal#2930
  • Loading branch information
BurntSushi committed Jan 4, 2025
1 parent 4ea5eff commit 9717535
Show file tree
Hide file tree
Showing 11 changed files with 2,227 additions and 47 deletions.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# CHANGELOG

0.1.20 (2025-01-03)
===================
This release inclues a new type, `Pieces`, in the `jiff::fmt::temporal`
sub-module. This exposes the individual components of a parsed Temporal
ISO 8601 datetime string. It allows users of Jiff to circumvent the checks
in the higher level parsing routines that prevent you from shooting yourself
in the foot.

For example, parsing into a `Zoned` will return an error for raw RFC 3339
timestamps like `2025-01-03T22:03-05` because there is no time zone annotation.
Without a time zone, Jiff cannot do time zone aware arithmetic and rounding.
Instead, such a datetime can only be parsed into a `Timestamp`. This lower
level `Pieces` API now permits users of Jiff to parse this string into its
component parts and assemble it into a `Zoned` if they so choose.

Enhancements:

* [#188](https://github.com/BurntSushi/jiff/issues/188):
Add `fmt::temporal::Pieces` for granular datetime parsing and formatting.


0.1.19 (2025-01-02)
===================
This releases includes a UTF-8 related bug fix and a few enhancements.
Expand Down
19 changes: 19 additions & 0 deletions src/fmt/offset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ from [Temporal's hybrid grammar].
use crate::{
error::{err, Error, ErrorContext},
fmt::{
temporal::{PiecesNumericOffset, PiecesOffset},
util::{parse_temporal_fraction, FractionalFormatter},
Parsed,
},
Expand Down Expand Up @@ -156,6 +157,24 @@ impl ParsedOffset {
}
}

/// Convert a parsed offset to a more structured representation.
///
/// This is like `to_offset`, but preserves `Z` and `-00:00` versus
/// `+00:00`. This does still attempt to create an `Offset`, and that
/// construction can fail.
pub(crate) fn to_pieces_offset(&self) -> Result<PiecesOffset, Error> {
match self.kind {
ParsedOffsetKind::Zulu => Ok(PiecesOffset::Zulu),
ParsedOffsetKind::Numeric(ref numeric) => {
let mut off = PiecesNumericOffset::from(numeric.to_offset()?);
if numeric.sign < 0 {
off = off.with_negative_zero();
}
Ok(PiecesOffset::from(off))
}
}
}

/// Whether this parsed offset corresponds to Zulu time or not.
///
/// This is useful in error reporting for parsing civil times. Namely, we
Expand Down
76 changes: 40 additions & 36 deletions src/fmt/rfc9557.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ use crate::{
error::{err, Error},
fmt::{
offset::{self, ParsedOffset},
temporal::{TimeZoneAnnotation, TimeZoneAnnotationKind},
Parsed,
},
tz::{TimeZone, TimeZoneDatabase},
util::{escape, parse},
};

Expand Down Expand Up @@ -130,41 +130,16 @@ impl<'i> ParsedAnnotations<'i> {
ParsedAnnotations { input: escape::Bytes(&[]), time_zone: None }
}

/// If a time zone annotation was parsed, then this returns the annotation
/// converted to a `TimeZone`, along with a flag indicating whether it
/// is "critical" or not. (When it's "critical," there should be more
/// stringent validation.)
/// Turns this parsed time zone into a structured time zone annotation,
/// if an annotation was found. Otherwise, returns `Ok(None)`.
///
/// If the time zone annotation parsed successfully but was either not
/// found in the database given or otherwise invalid, then an error is
/// returned.
///
/// `None` is returned only when there was no time zone annotation.
pub(crate) fn to_time_zone(
/// This can return an error if the parsed offset could not be converted
/// to a `crate::tz::Offset`.
pub(crate) fn to_time_zone_annotation(
&self,
db: &TimeZoneDatabase,
) -> Result<Option<(TimeZone, bool)>, Error> {
) -> Result<Option<TimeZoneAnnotation<'i>>, Error> {
let Some(ref parsed) = self.time_zone else { return Ok(None) };
// NOTE: We don't currently utilize the critical flag here. Temporal
// seems to ignore it. It's not quite clear what else we'd do with it,
// particularly given that we provide a way to do conflict resolution
// between offsets and time zones.
match *parsed {
ParsedTimeZone::Named { critical, name } => {
let tz = match db.get(name) {
Ok(tz) => tz,
Err(err) => return Err(err!("{}", err)),
};
Ok(Some((tz, critical)))
}
ParsedTimeZone::Offset { critical, ref offset } => {
let offset = match offset.to_offset() {
Ok(offset) => offset,
Err(err) => return Err(err),
};
Ok(Some((TimeZone::fixed(offset), critical)))
}
}
Ok(Some(parsed.to_time_zone_annotation()?))
}
}

Expand All @@ -187,6 +162,31 @@ enum ParsedTimeZone<'i> {
},
}

impl<'i> ParsedTimeZone<'i> {
/// Turns this parsed time zone into a structured time zone annotation.
///
/// This can return an error if the parsed offset could not be converted
/// to a `crate::tz::Offset`.
///
/// This also includes a flag of whether the annotation is "critical" or
/// not.
pub(crate) fn to_time_zone_annotation(
&self,
) -> Result<TimeZoneAnnotation<'i>, Error> {
let (kind, critical) = match *self {
ParsedTimeZone::Named { name, critical } => {
let kind = TimeZoneAnnotationKind::from(name);
(kind, critical)
}
ParsedTimeZone::Offset { ref offset, critical } => {
let kind = TimeZoneAnnotationKind::Offset(offset.to_offset()?);
(kind, critical)
}
};
Ok(TimeZoneAnnotation { kind, critical })
}
}

/// A parser for RFC 9557 annotations.
#[derive(Debug)]
pub(crate) struct Parser {
Expand Down Expand Up @@ -603,8 +603,9 @@ mod tests {
.parse(input)
.unwrap()
.value
.to_time_zone(crate::tz::db())
.to_time_zone_annotation()
.unwrap()
.map(|ann| (ann.to_time_zone().unwrap(), ann.is_critical()))
};

insta::assert_debug_snapshot!(p(b"[America/New_York]"), @r###"
Expand Down Expand Up @@ -1024,13 +1025,16 @@ mod tests {

#[cfg(feature = "std")]
#[test]
fn err_time_zone() {
fn err_time_zone_db_lookup() {
let p = |input| {
Parser::new()
.parse(input)
.unwrap()
.value
.to_time_zone(crate::tz::db())
.to_time_zone_annotation()
.unwrap()
.unwrap()
.to_time_zone()
.unwrap_err()
};

Expand Down
Loading

0 comments on commit 9717535

Please sign in to comment.