mirror of
https://github.com/BurntSushi/jiff.git
synced 2025-12-23 08:47:45 +00:00
fmt: add a new "friendly" duration format
To quickly demonstrate this, you can now support friendly and correct
durations with Jiff easily. Here's a simple CLI program using Clap:
```rust
use clap::Parser;
use jiff::{Span, Zoned};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
duration: Span,
}
fn main() {
let args = Args::parse();
println!("adding duration to now: {}", &Zoned::now() + args.duration);
}
```
And running the program:
```
$ cargo run -q -- '1 year, 2 months, 5 hours'
adding duration to now: 2026-02-26T18:58:22-05:00[America/New_York]
$ cargo run -q -- 'P1Y2MT5H' # ISO 8601 durations are supported too!
adding duration to now: 2026-02-26T19:00:57-05:00[America/New_York]
```
With Jiff, you should no longer need to pull in crates like
[`humantime`](https://docs.rs/humantime) and
[`humantime-serde`](https://docs.rs/humantime-serde)
to accomplished a similar task.
-----
This ended up being a lot more work than I anticipated. But this PR adds
a second kind of duration format to Jiff. To make this PR description
more accessible, I'm just going to quote from the `jiff::fmt::friendly`
docs as to the motivation for this:
> This format was devised, in part, because the standard duration interchange
> format specified by [Temporal's ISO 8601 definition](https://docs.rs/jiff/latest/jiff/fmt/temporal/index.html) is
> sub-optimal in two important respects:
>
> 1. It doesn't support individual sub-second components.
> 2. It is difficult to read.
>
> In the first case, ISO 8601 durations do support sub-second components, but are
> only expressible as fractional seconds. For example:
>
> ```text
> PT1.100S
> ```
>
> This is problematic in some cases because it doesn't permit distinguishing
> between some spans. For example, `1.second().milliseconds(100)` and
> `1100.milliseconds()` both serialize to the same ISO 8601 duration as shown
> above. At deserialization time, it's impossible to know what the span originally
> looked like. Thus, using the ISO 8601 format means the serialization and
> deserialization of [`Span`](crate::Span) values is lossy.
>
> In the second case, ISO 8601 durations appear somewhat difficult to quickly
> read. For example:
>
> ```text
> P1Y2M3DT4H59M1.1S
> P1y2m3dT4h59m1.1S
> ```
>
> When all of the unit designators are capital letters in particular, everything
> runs together and it's hard for the eye to distinguish where digits stop and
> letters begin. Using lowercase letters for unit designators helps somewhat,
> but this is an extension to ISO 8601 that isn't broadly supported.
>
> The "friendly" format resolves both of these problems by permitting sub-second
> components and allowing the use of whitespace and longer unit designator labels
> to improve readability. For example, all of the following are equivalent and
> will parse to the same `Span`:
>
> ```text
> 1y 2mo 3d 4h 59m 1100ms
> 1 year 2 months 3 days 4h59m1100ms
> 1 year, 2 months, 3 days, 4h59m1100ms
> 1 year, 2 months, 3 days, 4 hours 59 minutes 1100 milliseconds
> ```
>
> At the same time, the friendly format continues to support fractional
> time components since they may be desirable in some cases. For example, all
> of the following are equivalent:
>
> ```text
> 1h 1m 1.5s
> 1h 1m 1,5s
> 01:01:01.5
> 01:01:01,5
> ```
>
> The idea with the friendly format is that end users who know how to write
> English durations are happy to both read and write durations in this format.
> And moreover, the format is flexible enough that end users generally don't need
> to stare at a grammar to figure out how to write a valid duration. Most of the
> intuitive things you'd expect to work will work.
While this doesn't support any kind of internationalization, the
prevalence of the `humantime` crate suggests there's a desire for
something like this. The "friendly" format is meant to service all the
same use cases as `humantime` does for durations, but in a way that
doesn't let you shoot yourself in the foot.
The new "friendly" format is now the default for the `Debug`
implementations of both `Span` and `SignedDuration`. It's also
available via the "alternate" `Display` implementations for `Span` and
`SignedDuration` as well. Moreover, both `Span` and `SignedDuration`
will parse _both_ the ISO 8601 duration and this new "friendly" format.
Closes #60, Closes #111
This commit is contained in:
parent
b482926123
commit
f6fc1b07e9
21 changed files with 7127 additions and 289 deletions
|
|
@ -29,6 +29,8 @@ tzfile = "0.1.3"
|
|||
|
||||
time = { version = "0.3.36", features = ["macros", "parsing"] }
|
||||
|
||||
humantime = "2.1.0"
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
||||
|
|
|
|||
|
|
@ -604,6 +604,170 @@ fn parse_strptime(c: &mut Criterion) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Benchmarks parsing a "friendly" or "human" duration. We compare Jiff with
|
||||
/// `humantime`.
|
||||
fn parse_friendly(c: &mut Criterion) {
|
||||
use jiff::{SignedDuration, ToSpan};
|
||||
use std::time::Duration;
|
||||
|
||||
const NAME: &str = "parse_friendly";
|
||||
const TINY: &str = "2s";
|
||||
const SHORT: &str = "2h30m";
|
||||
const MEDIUM: &str = "2d5h30m";
|
||||
const LONG_JIFF: &str = "2y1mo15d5h59m1s";
|
||||
const LONG_HUMANTIME: &str = "2y1M15d5h59m1s";
|
||||
const LONGER: &str = "2 years 1 month 15 days 5 hours 59 minutes 1 second";
|
||||
const LONGEST: &str = "\
|
||||
2 years 1 month 15 days \
|
||||
5 hours 59 minutes 1 second \
|
||||
123 millis 456 usec 789 nanos\
|
||||
";
|
||||
// The longest duration parsable by Jiff and humantime that doesn't involve
|
||||
// units whose duration can change. This lets us benchmark parsing into a
|
||||
// `SignedDuration`, which is more of an apples-to-apples comparison to
|
||||
// humantime.
|
||||
const LONGEST_TIME: &str = "\
|
||||
5 hours 59 minutes 1 second \
|
||||
123 millis 456 usec 789 nanos\
|
||||
";
|
||||
|
||||
let benches = [
|
||||
("tiny", TINY, 2.seconds()),
|
||||
("short", SHORT, 2.hours().minutes(30)),
|
||||
("medium", MEDIUM, 2.days().hours(5).minutes(30)),
|
||||
(
|
||||
"long",
|
||||
LONG_JIFF,
|
||||
2.years().months(1).days(15).hours(5).minutes(59).seconds(1),
|
||||
),
|
||||
(
|
||||
"longer",
|
||||
LONGER,
|
||||
2.years().months(1).days(15).hours(5).minutes(59).seconds(1),
|
||||
),
|
||||
(
|
||||
"longest",
|
||||
LONGEST,
|
||||
2.years()
|
||||
.months(1)
|
||||
.days(15)
|
||||
.hours(5)
|
||||
.minutes(59)
|
||||
.seconds(1)
|
||||
.milliseconds(123)
|
||||
.microseconds(456)
|
||||
.nanoseconds(789),
|
||||
),
|
||||
(
|
||||
"longest-time",
|
||||
LONGEST_TIME,
|
||||
5.hours()
|
||||
.minutes(59)
|
||||
.seconds(1)
|
||||
.milliseconds(123)
|
||||
.microseconds(456)
|
||||
.nanoseconds(789),
|
||||
),
|
||||
];
|
||||
for (kind, input, expected) in benches {
|
||||
c.bench_function(&format!("jiff-span/{NAME}/{kind}"), |b| {
|
||||
b.iter(|| {
|
||||
let got: jiff::Span = input.parse().unwrap();
|
||||
assert_eq!(got, expected);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let benches = [
|
||||
("tiny", TINY, SignedDuration::new(2, 0)),
|
||||
("short", SHORT, SignedDuration::new(2 * 60 * 60 + 30 * 60, 0)),
|
||||
(
|
||||
"longest-time",
|
||||
LONGEST_TIME,
|
||||
SignedDuration::new(5 * 3600 + 59 * 60 + 1, 123_456_789),
|
||||
),
|
||||
];
|
||||
for (kind, input, expected) in benches {
|
||||
c.bench_function(&format!("jiff-duration/{NAME}/{kind}"), |b| {
|
||||
b.iter(|| {
|
||||
let got: jiff::SignedDuration = input.parse().unwrap();
|
||||
assert_eq!(got, expected);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let benches = [
|
||||
("tiny", TINY, Duration::new(2, 0)),
|
||||
("short", SHORT, Duration::new(2 * 60 * 60 + 30 * 60, 0)),
|
||||
(
|
||||
"medium",
|
||||
MEDIUM,
|
||||
Duration::new(2 * 24 * 60 * 60 + 5 * 60 * 60 + 30 * 60, 0),
|
||||
),
|
||||
(
|
||||
"long",
|
||||
LONG_HUMANTIME,
|
||||
// humantime uses a fixed number of seconds to represent years
|
||||
// and months. That is, 365.25d and 30.44d, respectively, where
|
||||
// a day is 86400 seconds.
|
||||
Duration::new(
|
||||
2 * 31_557_600
|
||||
+ 1 * 2_630_016
|
||||
+ 15 * 86400
|
||||
+ 5 * 3600
|
||||
+ 59 * 60
|
||||
+ 1,
|
||||
0,
|
||||
),
|
||||
),
|
||||
(
|
||||
"longer",
|
||||
LONGER,
|
||||
// humantime uses a fixed number of seconds to represent years
|
||||
// and months. That is, 365.25d and 30.44d, respectively, where
|
||||
// a day is 86400 seconds.
|
||||
Duration::new(
|
||||
2 * 31_557_600
|
||||
+ 1 * 2_630_016
|
||||
+ 15 * 86400
|
||||
+ 5 * 3600
|
||||
+ 59 * 60
|
||||
+ 1,
|
||||
0,
|
||||
),
|
||||
),
|
||||
(
|
||||
"longest",
|
||||
LONGEST,
|
||||
// humantime uses a fixed number of seconds to represent years
|
||||
// and months. That is, 365.25d and 30.44d, respectively, where
|
||||
// a day is 86400 seconds.
|
||||
Duration::new(
|
||||
2 * 31_557_600
|
||||
+ 1 * 2_630_016
|
||||
+ 15 * 86400
|
||||
+ 5 * 3600
|
||||
+ 59 * 60
|
||||
+ 1,
|
||||
123_456_789,
|
||||
),
|
||||
),
|
||||
(
|
||||
"longest-time",
|
||||
LONGEST_TIME,
|
||||
Duration::new(5 * 3600 + 59 * 60 + 1, 123_456_789),
|
||||
),
|
||||
];
|
||||
for (kind, input, expected) in benches {
|
||||
c.bench_function(&format!("humantime/{NAME}/{kind}"), |b| {
|
||||
b.iter(|| {
|
||||
let got = humantime::parse_duration(input).unwrap();
|
||||
assert_eq!(got, expected);
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
criterion::criterion_group!(
|
||||
benches,
|
||||
civil_datetime_to_instant_with_tzdb_lookup,
|
||||
|
|
@ -618,5 +782,6 @@ criterion::criterion_group!(
|
|||
parse_rfc2822,
|
||||
parse_strptime,
|
||||
print_civil_datetime,
|
||||
parse_friendly,
|
||||
);
|
||||
criterion::criterion_main!(benches);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue