Skip to content

Commit

Permalink
fmt/strtime: add %V and %:V for formatting and parsing IANA time zone…
Browse files Browse the repository at this point in the history
… identifiers

This matches the V specifier used by [Java's formatting/parsing
infrastructure][java format].

The motivation for this is to bring the strtime APIs to parity with
`jiff::fmt::temporal`. Namely, without an IANA time zone identifier, one
cannot correctly roundtrip a `Zoned` value.

Jiff does support `%Z` when formatting for printing time zone
abbreviations like `EDT` or `EST`, but since these are ambiguous, they
aren't supported while parsing. (Perhaps we should support parsing and
validating them though, especially in conjunction with a IANA time zone
identifier.)

When using `%V`, if there is no IANA time zone identifier, then the
offset without colons is used instead. To get an offset with colons, use
`%:V`.

[java format]: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/format/DateTimeFormatter.html#ISO_ZONED_DATE_TIME
  • Loading branch information
BurntSushi committed Aug 3, 2024
1 parent 4286f1b commit 482e2b4
Show file tree
Hide file tree
Showing 6 changed files with 424 additions and 78 deletions.
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
0.1.5 (TBD)
==================
This releases fixes a bug with fixed precision fractional formatting.
This release includes some improvements and bug fixes for Jiff's `strtime`
APIs.

Enhancements:

* [#75](https://github.com/BurntSushi/jiff/issues/75):
Add support for `%V` for formatting _and_ parsing IANA time zone identifiers.

Bug fixes:

Expand Down
123 changes: 95 additions & 28 deletions src/fmt/strtime/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
util::{DecimalFormatter, FractionalFormatter},
Write, WriteExt,
},
tz::Offset,
util::{escape, parse},
Error,
};
Expand Down Expand Up @@ -63,6 +64,7 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
b'p' => self.fmt_ampm_upper(ext).context("%p failed")?,
b'S' => self.fmt_second(ext).context("%S failed")?,
b'T' => self.fmt_clock(ext).context("%T failed")?,
b'V' => self.fmt_iana_nocolon().context("%V failed")?,
b'Y' => self.fmt_year(ext).context("%Y failed")?,
b'y' => self.fmt_year_2digit(ext).context("%y failed")?,
b'Z' => self.fmt_tzabbrev(ext).context("%Z failed")?,
Expand All @@ -75,6 +77,7 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
));
}
match self.f() {
b'V' => self.fmt_iana_colon().context("%:V failed")?,
b'z' => {
self.fmt_offset_colon().context("%:z failed")?
}
Expand Down Expand Up @@ -318,46 +321,50 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
ext.write_str(Case::AsIs, month_name_abbrev(month), self.wtr)
}

/// %V
fn fmt_iana_nocolon(&mut self) -> Result<(), Error> {
let Some(iana) = self.tm.iana.as_ref() else {
let offset = self.tm.offset.ok_or_else(|| {
err!(
"requires IANA time zone identifier or time \
zone offset, but none were present"
)
})?;
return write_offset(offset, false, &mut self.wtr);
};
self.wtr.write_str(iana)?;
Ok(())
}

/// %:V
fn fmt_iana_colon(&mut self) -> Result<(), Error> {
let Some(iana) = self.tm.iana.as_ref() else {
let offset = self.tm.offset.ok_or_else(|| {
err!(
"requires IANA time zone identifier or time \
zone offset, but none were present"
)
})?;
return write_offset(offset, true, &mut self.wtr);
};
self.wtr.write_str(iana)?;
Ok(())
}

/// %z
fn fmt_offset_nocolon(&mut self) -> Result<(), Error> {
static FMT_TWO: DecimalFormatter = DecimalFormatter::new().padding(2);

let offset = self.tm.offset.ok_or_else(|| {
err!("requires offset to format time zone offset")
})?;
let hours = offset.part_hours_ranged().abs().get();
let minutes = offset.part_minutes_ranged().abs().get();
let seconds = offset.part_seconds_ranged().abs().get();

self.wtr.write_str(if offset.is_negative() { "-" } else { "+" })?;
self.wtr.write_int(&FMT_TWO, hours)?;
self.wtr.write_int(&FMT_TWO, minutes)?;
if seconds != 0 {
self.wtr.write_int(&FMT_TWO, seconds)?;
}
Ok(())
write_offset(offset, false, self.wtr)
}

/// %:z
fn fmt_offset_colon(&mut self) -> Result<(), Error> {
static FMT_TWO: DecimalFormatter = DecimalFormatter::new().padding(2);

let offset = self.tm.offset.ok_or_else(|| {
err!("requires offset to format time zone offset")
})?;
let hours = offset.part_hours_ranged().abs().get();
let minutes = offset.part_minutes_ranged().abs().get();
let seconds = offset.part_seconds_ranged().abs().get();

self.wtr.write_str(if offset.is_negative() { "-" } else { "+" })?;
self.wtr.write_int(&FMT_TWO, hours)?;
self.wtr.write_str(":")?;
self.wtr.write_int(&FMT_TWO, minutes)?;
if seconds != 0 {
self.wtr.write_str(":")?;
self.wtr.write_int(&FMT_TWO, seconds)?;
}
Ok(())
write_offset(offset, true, self.wtr)
}

/// %S
Expand Down Expand Up @@ -457,6 +464,36 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
}
}

/// Writes the given time zone offset to the writer.
///
/// When `colon` is true, the hour, minute and optional second components are
/// delimited by a colon. Otherwise, no delimiter is used.
fn write_offset<W: Write>(
offset: Offset,
colon: bool,
mut wtr: &mut W,
) -> Result<(), Error> {
static FMT_TWO: DecimalFormatter = DecimalFormatter::new().padding(2);

let hours = offset.part_hours_ranged().abs().get();
let minutes = offset.part_minutes_ranged().abs().get();
let seconds = offset.part_seconds_ranged().abs().get();

wtr.write_str(if offset.is_negative() { "-" } else { "+" })?;
wtr.write_int(&FMT_TWO, hours)?;
if colon {
wtr.write_str(":")?;
}
wtr.write_int(&FMT_TWO, minutes)?;
if seconds != 0 {
if colon {
wtr.write_str(":")?;
}
wtr.write_int(&FMT_TWO, seconds)?;
}
Ok(())
}

impl Extension {
/// Writes the given string using the default case rule provided, unless
/// an option in this extension config overrides the default case.
Expand Down Expand Up @@ -761,6 +798,36 @@ mod tests {
insta::assert_snapshot!(f("%Z", &zdt), @"EST");
}

#[test]
fn ok_format_iana() {
if crate::tz::db().is_definitively_empty() {
return;
}

let f = |fmt: &str, zdt: &Zoned| format(fmt, zdt).unwrap();

let zdt = date(2024, 7, 14)
.at(22, 24, 0, 0)
.intz("America/New_York")
.unwrap();
insta::assert_snapshot!(f("%V", &zdt), @"America/New_York");
insta::assert_snapshot!(f("%:V", &zdt), @"America/New_York");

let zdt = date(2024, 7, 14)
.at(22, 24, 0, 0)
.to_zoned(crate::tz::offset(-4).to_time_zone())
.unwrap();
insta::assert_snapshot!(f("%V", &zdt), @"-0400");
insta::assert_snapshot!(f("%:V", &zdt), @"-04:00");

let zdt = date(2024, 7, 14)
.at(22, 24, 0, 0)
.to_zoned(crate::tz::TimeZone::UTC)
.unwrap();
insta::assert_snapshot!(f("%V", &zdt), @"UTC");
insta::assert_snapshot!(f("%:V", &zdt), @"UTC");
}

#[test]
fn ok_format_weekday_name() {
let f = |fmt: &str, date: Date| format(fmt, date).unwrap();
Expand Down
Loading

0 comments on commit 482e2b4

Please sign in to comment.