Skip to content

Commit

Permalink
fmt/strtime: support parsing and formatting fractional seconds
Browse files Browse the repository at this point in the history
This isn't supported in the analogous C APIs, but it turns out it was
supported in the `chrono` crate and was actually being used.

This adds two new directives, `%f` and `%.f`. The former always writes
out at least one digit (and parsing requires at least one digit) and
corresponds to the number of fractional nanoseconds. The latter includes
the leading `.` and is satisfied by the empty string. That is, if a `.`
isn't found when `%.f` is expected, then it is skipped entirely when
parsing. Similarly, when formatting, if there are no fractional seconds
(i.e., the subsecond component is `0`), then no fractional component is
written either.

It is expected that most use cases will reach for just `%.f`, since it
will just do the "right" thing without thinking much about it. For
example, `%H:%M:%S%.f` will parse both `23:30:01` and `23:30:01.789`.

Closes #54
  • Loading branch information
BurntSushi committed Jul 28, 2024
1 parent 70c6ca0 commit 77dc509
Show file tree
Hide file tree
Showing 6 changed files with 396 additions and 19 deletions.
2 changes: 1 addition & 1 deletion src/fmt/offset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ impl core::fmt::Display for Numeric {
}
if let Some(nanos) = self.nanoseconds {
static FMT: DecimalFormatter =
DecimalFormatter::new().fractional(9);
DecimalFormatter::new().fractional(0, 9);
write!(f, ".{}", FMT.format(i64::from(nanos)).as_str())?;
}
Ok(())
Expand Down
88 changes: 88 additions & 0 deletions src/fmt/strtime/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
b'd' => self.fmt_day_zero(ext).context("%d failed")?,
b'e' => self.fmt_day_space(ext).context("%e failed")?,
b'F' => self.fmt_iso_date(ext).context("%F failed")?,
b'f' => self.fmt_fractional(ext).context("%f failed")?,
b'H' => self.fmt_hour24(ext).context("%H failed")?,
b'h' => self.fmt_month_abbrev(ext).context("%b failed")?,
b'I' => self.fmt_hour12(ext).context("%H failed")?,
Expand Down Expand Up @@ -86,6 +87,29 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
}
}
}
b'.' => {
if !self.bump_fmt() {
return Err(err!(
"invalid format string, expected directive \
after '%.'",
));
}
// Parse precision settings after the `.`, effectively
// overriding any digits that came before it.
let ext = Extension { width: self.parse_width()?, ..ext };
match self.f() {
b'f' => self
.fmt_dot_fractional(ext)
.context("%.f failed")?,
unk => {
return Err(err!(
"found unrecognized directive %{unk} \
following %.",
unk = escape::Byte(unk),
));
}
}
}
unk => {
return Err(err!(
"found unrecognized specifier directive %{unk}",
Expand Down Expand Up @@ -157,6 +181,11 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
/// before the specifier directive itself. And if a width is parsed, the
/// parser is bumped to the next byte. (If no next byte exists, then an
/// error is returned.)
///
/// Note that this is also used to parse precision settings for `%f` and
/// `%.f`. In the former case, the width is just re-interpreted as a
/// precision setting. In the latter case, something like `%5.9f` is
/// technically valid, but the `5` is ignored.
fn parse_width(&mut self) -> Result<Option<u8>, Error> {
let mut digits = 0;
while digits < self.fmt.len() && self.fmt[digits].is_ascii_digit() {
Expand Down Expand Up @@ -380,6 +409,26 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
ext.write_int(b'0', Some(2), second, self.wtr)
}

/// %f
fn fmt_fractional(&mut self, ext: Extension) -> Result<(), Error> {
let subsec = self.tm.subsec.ok_or_else(|| {
err!("requires time to format subsecond nanoseconds")
})?;
ext.write_fractional_seconds(subsec, self.wtr)?;
Ok(())
}

/// %.f
fn fmt_dot_fractional(&mut self, ext: Extension) -> Result<(), Error> {
let Some(subsec) = self.tm.subsec else { return Ok(()) };
if subsec == 0 && ext.width.is_none() {
return Ok(());
}
ext.write_str(Case::AsIs, ".", self.wtr)?;
ext.write_fractional_seconds(subsec, self.wtr)?;
Ok(())
}

/// %Z
fn fmt_tzabbrev(&mut self, ext: Extension) -> Result<(), Error> {
let tzabbrev = self.tm.tzabbrev.as_ref().ok_or_else(|| {
Expand Down Expand Up @@ -507,6 +556,22 @@ impl Extension {
}
wtr.write_int(&formatter, number)
}

/// Writes the given number of nanoseconds as a fractional component of
/// a second. This does not include the leading `.`.
///
/// The `width` setting on `Extension` is treated as a precision setting.
fn write_fractional_seconds<W: Write>(
self,
number: impl Into<i64>,
mut wtr: &mut W,
) -> Result<(), Error> {
let number = number.into();

let mut formatter =
DecimalFormatter::new().fractional(self.width.unwrap_or(1), 9);
wtr.write_int(&formatter, number)
}
}

/// The different flags one can set. They are mutually exclusive.
Expand Down Expand Up @@ -688,6 +753,29 @@ mod tests {
insta::assert_snapshot!(f("%S", time(0, 0, 0, 0)), @"00");
}

#[test]
fn ok_format_subsec_nanosecond() {
let f = |fmt: &str, time: Time| format(fmt, time).unwrap();
let mk = |subsec| time(0, 0, 0, subsec);

insta::assert_snapshot!(f("%f", mk(123_000_000)), @"123");
insta::assert_snapshot!(f("%f", mk(0)), @"0");
insta::assert_snapshot!(f("%3f", mk(0)), @"000");
insta::assert_snapshot!(f("%3f", mk(123_000_000)), @"123");
insta::assert_snapshot!(f("%6f", mk(123_000_000)), @"123000");
insta::assert_snapshot!(f("%9f", mk(123_000_000)), @"123000000");
insta::assert_snapshot!(f("%255f", mk(123_000_000)), @"123000000");

insta::assert_snapshot!(f("%.f", mk(123_000_000)), @".123");
insta::assert_snapshot!(f("%.f", mk(0)), @"");
insta::assert_snapshot!(f("%3.f", mk(0)), @"");
insta::assert_snapshot!(f("%.3f", mk(0)), @".000");
insta::assert_snapshot!(f("%.3f", mk(123_000_000)), @".123");
insta::assert_snapshot!(f("%.6f", mk(123_000_000)), @".123000");
insta::assert_snapshot!(f("%.9f", mk(123_000_000)), @".123000000");
insta::assert_snapshot!(f("%.255f", mk(123_000_000)), @".123000000");
}

#[test]
fn ok_format_tzabbrev() {
if crate::tz::db().is_definitively_empty() {
Expand Down
146 changes: 143 additions & 3 deletions src/fmt/strtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ strings, the strings are matched without regard to ASCII case.
| `%D` | `7/14/24` | Equivalent to `%m/%d/%y`. |
| `%d`, `%e` | `25`, ` 5` | The day of the month. `%d` is zero-padded, `%e` is space padded. |
| `%F` | `2024-07-14` | Equivalent to `%Y-%m-%d`. |
| `%f` | `000456` | Fractional seconds, up to nanosecond precision. |
| `%.f` | `.000456` | Optional fractional seconds, with dot, up to nanosecond precision. |
| `%H` | `23` | The hour in a 24 hour clock. Zero padded. |
| `%I` | `11` | The hour in a 12 hour clock. Zero padded. |
| `%M` | `04` | The minute. Zero padded. |
Expand Down Expand Up @@ -182,13 +184,22 @@ The flags and padding amount above may be used when parsing as well, but they
are ignored since parsing supports all of the different flag modes all of the
time.
The `%f` and `%.f` flags also support specifying the precision, up to
nanoseconds. For example, `%3f` and `%.3f` will both always print a fractional
second component to at least 3 decimal places. When no precision is specified,
then `%f` will always emit at least one digit, even if it's zero. But `%.f`
will emit the empty string when the fractional component is zero. Otherwise, it
will include the leading `.`. For parsing, `%f` does not include the leading
dot, but `%.f` does. Note that all of the options above are still parsed for
`%f` and `%.f`, but they are all no-ops (except for the padding for `%f`, which
is instead interpreted as a precision setting).
# Unsupported
The following things are currently unsupported:
* Parsing or formatting IANA time zone identifiers.
* Parsing or formatting fractional seconds in either the clock time or time
time zone offset.
* Parsing or formatting fractional seconds in the time time zone offset.
* Conversion specifiers related to week numbers.
* Conversion specifiers related to day-of-year numbers, like the Julian day.
* The `%s` conversion specifier, for Unix timestamps in seconds.
Expand Down Expand Up @@ -255,6 +266,23 @@ mod parse;
/// `strptime`-like APIs have no way of expressing such requirements.
///
/// [RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822
///
/// # Example: parse RFC 3339 timestamp with fractional seconds
///
/// ```
/// use jiff::{civil::date, fmt::strtime};
///
/// let zdt = strtime::parse(
/// "%Y-%m-%dT%H:%M:%S%.f%:z",
/// "2024-07-15T16:24:59.123456789-04:00",
/// )?.to_zoned()?;
/// assert_eq!(
/// zdt,
/// date(2024, 7, 15).at(16, 24, 59, 123_456_789).intz("America/New_York")?,
/// );
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[inline]
pub fn parse(
format: impl AsRef<[u8]>,
Expand Down Expand Up @@ -317,6 +345,20 @@ pub fn parse(
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// # Example: RFC 3339 compatible output with fractional seconds
///
/// ```
/// use jiff::{civil::date, fmt::strtime, tz};
///
/// let zdt = date(2024, 7, 15)
/// .at(16, 24, 59, 123_456_789)
/// .intz("America/New_York")?;
/// let string = strtime::format("%Y-%m-%dT%H:%M:%S%.f%:z", &zdt)?;
/// assert_eq!(string, "2024-07-15T16:24:59.123456789-04:00");
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[inline]
pub fn format(
format: impl AsRef<[u8]>,
Expand Down Expand Up @@ -385,6 +427,7 @@ pub struct BrokenDownTime {
hour: Option<t::Hour>,
minute: Option<t::Minute>,
second: Option<t::Second>,
subsec: Option<t::SubsecNanosecond>,
offset: Option<Offset>,
// Only used to confirm that it is consistent
// with the date given. But otherwise cannot
Expand Down Expand Up @@ -837,6 +880,13 @@ impl BrokenDownTime {
smaller time units with bigger time units missing)",
));
}
if self.subsec.is_some() {
return Err(err!(
"parsing format did not include hour directive, \
but did include fractional second directive (cannot have \
smaller time units with bigger time units missing)",
));
}
return Ok(Time::midnight());
};
let Some(minute) = self.minute else {
Expand All @@ -847,12 +897,29 @@ impl BrokenDownTime {
smaller time units with bigger time units missing)",
));
}
if self.subsec.is_some() {
return Err(err!(
"parsing format did not include minute directive, \
but did include fractional second directive (cannot have \
smaller time units with bigger time units missing)",
));
}
return Ok(Time::new_ranged(hour, C(0), C(0), C(0)));
};
let Some(second) = self.second else {
if self.subsec.is_some() {
return Err(err!(
"parsing format did not include second directive, \
but did include fractional second directive (cannot have \
smaller time units with bigger time units missing)",
));
}
return Ok(Time::new_ranged(hour, minute, C(0), C(0)));
};
Ok(Time::new_ranged(hour, minute, second, C(0)))
let Some(subsec) = self.subsec else {
return Ok(Time::new_ranged(hour, minute, second, C(0)));
};
Ok(Time::new_ranged(hour, minute, second, subsec))
}

/// Returns the parsed year, if available.
Expand Down Expand Up @@ -1046,6 +1113,41 @@ impl BrokenDownTime {
self.second.map(|x| x.get())
}

/// Returns the parsed subsecond nanosecond, if available.
///
/// # Example
///
/// This shows how to parse fractional seconds:
///
/// ```
/// use jiff::fmt::strtime::BrokenDownTime;
///
/// let tm = BrokenDownTime::parse("%f", "123456")?;
/// assert_eq!(tm.subsec_nanosecond(), Some(123_456_000));
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// Note that when using `%.f`, the fractional component is optional!
///
/// ```
/// use jiff::fmt::strtime::BrokenDownTime;
///
/// let tm = BrokenDownTime::parse("%S%.f", "1")?;
/// assert_eq!(tm.second(), Some(1));
/// assert_eq!(tm.subsec_nanosecond(), None);
///
/// let tm = BrokenDownTime::parse("%S%.f", "1.789")?;
/// assert_eq!(tm.second(), Some(1));
/// assert_eq!(tm.subsec_nanosecond(), Some(789_000_000));
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[inline]
pub fn subsec_nanosecond(&self) -> Option<i32> {
self.subsec.map(|x| x.get())
}

/// Returns the parsed offset, if available.
///
/// # Example
Expand Down Expand Up @@ -1338,6 +1440,42 @@ impl BrokenDownTime {
Ok(())
}

/// Set the subsecond nanosecond on this broken down time.
///
/// # Errors
///
/// This returns an error if the given number of nanoseconds is out of
/// range. It must be non-negative and less than 1 whole second.
///
/// # Example
///
/// ```
/// use jiff::fmt::strtime::BrokenDownTime;
///
/// let mut tm = BrokenDownTime::default();
/// // out of range
/// assert!(tm.set_subsec_nanosecond(Some(1_000_000_000)).is_err());
/// tm.set_subsec_nanosecond(Some(123_000_000))?;
/// assert_eq!(tm.to_string("%f")?, "123");
/// assert_eq!(tm.to_string("%.6f")?, ".123000");
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[inline]
pub fn set_subsec_nanosecond(
&mut self,
subsec_nanosecond: Option<i32>,
) -> Result<(), Error> {
self.subsec = match subsec_nanosecond {
None => None,
Some(subsec_nanosecond) => Some(t::SubsecNanosecond::try_new(
"subsecond-nanosecond",
subsec_nanosecond,
)?),
};
Ok(())
}

/// Set the weekday on this broken down time.
///
/// # Example
Expand Down Expand Up @@ -1413,6 +1551,7 @@ impl From<DateTime> for BrokenDownTime {
hour: Some(t.hour_ranged()),
minute: Some(t.minute_ranged()),
second: Some(t.second_ranged()),
subsec: Some(t.subsec_nanosecond_ranged()),
weekday: Some(d.weekday()),
meridiem: Some(Meridiem::from(t)),
..BrokenDownTime::default()
Expand All @@ -1438,6 +1577,7 @@ impl From<Time> for BrokenDownTime {
hour: Some(t.hour_ranged()),
minute: Some(t.minute_ranged()),
second: Some(t.second_ranged()),
subsec: Some(t.subsec_nanosecond_ranged()),
meridiem: Some(Meridiem::from(t)),
..BrokenDownTime::default()
}
Expand Down
Loading

0 comments on commit 77dc509

Please sign in to comment.