Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fmt/strtime: support parsing and formatting fractional seconds #55

Merged
merged 1 commit into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Enhancements:
Improve documentation for `Span` getter methods.
* [PR #53](https://github.com/BurntSushi/jiff/pull/53):
Add support for skipping weekday checking when parsing datetimes.
* [PR #55](https://github.com/BurntSushi/jiff/pull/55):
Add support for fractional seconds in `jiff::fmt::strtime`.

Bug fixes:

Expand Down
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