Configurable, precise and fast rust string parser to a Duration

Released API Docs | Changelog


GitHub branch checks state Crates.io docs.rs MSRV

Table of Contents

Overview

fundu provides a flexible and fast parser to convert rust strings into a Duration. fundu parses into its own Duration but provides methods to convert into [std::time::Duration], [chrono::Duration] and [time::Duration]. If not stated otherwise, this README describes the main fundu package. Some examples for valid input strings with the standard feature

and the custom (or base) feature assuming some defined custom time units s, secs, minutes, , day, days, year, years, century and the time keyword yesterday

For more examples of the custom feature see the Customization section. Summary of features provided by this crate:

fundu aims for good performance and being a lightweight crate. It is purely built on top of the rust stdlib, and there are no additional dependencies required in the standard configuration. The accepted number format is per default the scientific floating point format and compatible with [f64::from_str]. However, the number format and other aspects can be customized up to formats like systemd time spans or gnu relative times. There are two dedicated, simple to use fundu side-projects:

See also the examples Examples section and the examples folder.

For further details see the Documentation!

Installation

Add this to Cargo.toml for fundu with the standard feature.

toml [dependencies] fundu = "1.2.0"

fundu is split into three main features, standard (providing DurationParser and parse_duration) and custom (providing the CustomDurationParser) and base for a more basic approach to the core parser. The first is described here in in detail, the custom feature adds fully customizable identifiers for time units. Most of the time only one of the parsers is needed. For example, to include only the CustomDurationParser add the following to Cargo.toml:

toml [dependencies] fundu = { version = "1.2.0", default-features = false, features = ["custom"] }

Activating the chrono or time feature provides a TryFrom and SaturatingInto implementation for [chrono::Duration] or [time::Duration]. Converting to/from [std::time::Duration] is supported without the need of an additional feature.

Activating the serde feature allows some structs and enums to be serialized or deserialized with serde

Examples

If only the default configuration is required once, the parse_duration method can be used. Note that parse_duration returns a [std::time::Duration] in contrast to the parse method of the other parsers which return a fundu::Duration.

```rust use std::time::Duration;

use fundu::parse_duration;

let input = "1.0e2s"; asserteq!(parseduration(input).unwrap(), Duration::new(100, 0)); ```

When a customization of the accepted TimeUnits is required, then DurationParser::with_time_units can be used.

```rust use fundu::{Duration, DurationParser};

let input = "3m"; asserteq!( DurationParser::withalltimeunits().parse(input).unwrap(), Duration::positive(180, 0) ); ```

When no time units are configured, seconds is assumed.

```rust use fundu::{Duration, DurationParser};

let input = "1.0e2"; asserteq!( DurationParser::withouttime_units().parse(input).unwrap(), Duration::positive(100, 0) ); ```

However, the following will return an error because y (Years) is not a default time unit:

```rust use fundu::DurationParser;

let input = "3y"; assert!(DurationParser::new().parse(input).is_err()); ```

The parser is reusable and the set of time units is fully customizable

```rust use fundu::TimeUnit::*; use fundu::{Duration, DurationParser};

let parser = DurationParser::withtimeunits(&[NanoSecond, Minute, Hour]);

asserteq!(parser.parse("9e3ns").unwrap(), Duration::positive(0, 9000)); asserteq!(parser.parse("10m").unwrap(), Duration::positive(600, 0)); asserteq!(parser.parse("1.1h").unwrap(), Duration::positive(3960, 0)); asserteq!(parser.parse("7").unwrap(), Duration::positive(7, 0)); ```

Setting the default time unit (if no time unit is given in the input string) to something different than seconds is also easily possible

```rust use fundu::TimeUnit::*; use fundu::{Duration, DurationParser};

asserteq!( DurationParser::withouttimeunits() .defaultunit(MilliSecond) .parse("1000") .unwrap(), Duration::positive(1, 0) ); ```

The identifiers for time units can be fully customized with any number of valid utf-8 sequences if the custom feature is activated:

```rust use fundu::TimeUnit::*; use fundu::{CustomTimeUnit, CustomDurationParser, Duration};

let parser = CustomDurationParser::withtimeunits(&[ CustomTimeUnit::withdefault(MilliSecond, &["χιλιοστό του δευτερολέπτου"]), CustomTimeUnit::withdefault(Second, &["s", "secs"]), CustomTimeUnit::with_default(Hour, &["⏳"]), ]);

asserteq!(parser.parse(".3χιλιοστό του δευτερολέπτου"), Ok(Duration::positive(0, 300000))); asserteq!(parser.parse("1e3secs"), Ok(Duration::positive(1000, 0))); asserteq!(parser.parse("1.1⏳"), Ok(Duration::positive(3960, 0))); ```

The custom feature can be used to customize a lot more. See the documentation of the exported items of the custom feature (like CustomTimeUnit, TimeKeyword) for more information.

Also, fundu tries to give informative error messages

```rust use fundu::DurationParser;

asserteq!( DurationParser::withouttimeunits() .parse("1y") .unwraperr() .to_string(), "Time unit error: No time units allowed but found: 'y' at column 1" ); ```

The number format can be easily adjusted to your needs. For example to allow numbers being optional, allow some ascii whitespace between the number and the time unit and restrict the number format to whole numbers, without fractional part and an exponent (Also note that the DurationParserBuilder can build a DurationParser at compile time in const context):

```rust use fundu::TimeUnit::*; use fundu::{Duration, DurationParser, ParseError};

const PARSER: DurationParser = DurationParser::builder() .timeunits(&[NanoSecond]) .allowdelimiter(|byte| matches!(byte, b'\t' | b'\n' | b'\r' | b' ')) .numberisoptional() .disablefraction() .disableexponent() .build();

asserteq!(PARSER.parse("ns").unwrap(), Duration::positive(0, 1)); asserteq!( PARSER.parse("1000\t\n\r ns").unwrap(), Duration::positive(0, 1000) );

asserteq!( PARSER.parse("1.0ns").unwraperr(), ParseError::Syntax(1, "No fraction allowed".tostring()) ); asserteq!( PARSER.parse("1e9ns").unwraperr(), ParseError::Syntax(1, "No exponent allowed".tostring()) ); ```

It's also possible to parse multiple durations at once with parse_multiple. The different durations can be separated by an optional delimiter (a closure matching a u8) defined with parse_multiple. If the delimiter is not encountered, a number can also indicate a new duration.

```rust use fundu::{Duration, DurationParser};

let parser = DurationParser::builder() .defaulttimeunits() .parse_multiple(|byte| matches!(byte, b' ' | b'\t'), Some(&["and"])) .build();

asserteq!( parser.parse("1.5h 2e+2ns"), Ok(Duration::positive(5400, 200)) ); asserteq!( parser.parse("55s500ms"), Ok(Duration::positive(55, 500000000)) ); asserteq!(parser.parse("1\t1"), Ok(Duration::positive(2, 0))); asserteq!( parser.parse("1. .1"), Ok(Duration::positive(1, 100000000)) ); asserteq!(parser.parse("2h"), Ok(Duration::positive(2 * 60 * 60, 0))); asserteq!( parser.parse("300ms20s 5d"), Ok(Duration::positive(5 * 60 * 60 * 24 + 20, 300000000)) ); asserteq!( parser.parse("300.0ms and 5d"), Ok(Duration::positive(5 * 60 * 60 * 24, 300000_000)) ); ```

See also the examples folder for common recipes and integration with other crates. Run an example with

shell cargo run --example $FILE_NAME_WITHOUT_FILETYPE_SUFFIX

like the systemd time span parser example

```shell

For some of the examples a help is available. To pass arguments to the example itself separate

the arguments for cargo and the example with --

$ cargo run --example systemd --features custom --no-default-features -- --help ...

To actually run the example execute

$ cargo run --example systemd --features custom --no-default-features '300ms20s 5day' Original: 300ms20s 5day μs: 432020300000 Human: 5d 20s 300ms ```

Time units

Second is the default time unit (if not specified otherwise for example with [DurationParser::default_unit]) which is applied when no time unit was encountered in the input string. The table below gives an overview of the constructor methods and which time units are available. If a custom set of time units is required, DurationParser::with_time_units can be used.

TimeUnit | Default identifier | Calculation | Default time unit ---:| ---:| ---:|:---: Nanosecond | ns | 1e-9s | ☑ Microsecond | Ms | 1e-6s | ☑ Millisecond | ms | 1e-3s | ☑ Second | s | SI definition | ☑ Minute | m | 60s | ☑ Hour | h | 60m | ☑ Day | d | 24h | ☑ Week | w | 7d | ☑ Month | M | Year / 12 | ☐ Year | y | 365.25d | ☐

Note that Months and Years are not included in the default set of time units. The current implementation uses an approximate calculation of Months and Years in seconds and if they are included in the final configuration, the Julian year based calculation is used. (See table above)

With the CustomDurationParser from the custom feature, the identifiers for time units can be fully customized.

Customization

Unlike other crates, fundu does not try to establish a standard for time units and their identifiers or a specific number format. A lot of these aspects can be adjusted when initializing or building the parser. Here's an incomplete example for possible customizations of the number format:

```rust use fundu::TimeUnit::*; use fundu::{Duration, DurationParser, ParseError};

let parser = DurationParser::builder() // Use a custom set of time units. For demonstration purposes just NanoSecond .timeunits(&[NanoSecond]) // Allow some whitespace characters as delimiter between the number and the time unit .allowdelimiter(|byte| matches!(byte, b'\t' | b'\n' | b'\r' | b' ')) // Makes the number optional. If no number was encountered 1 is assumed .numberisoptional() // Disable parsing the fractional part of the number => 1.0 will return an error .disablefraction() // Disable parsing the exponent => 1e0 will return an error .disableexponent() // Finally, build a reusable DurationParser .build();

// Some valid input asserteq!(parser.parse("ns").unwrap(), Duration::positive(0, 1)); asserteq!( parser.parse("1000\t\n\r ns").unwrap(), Duration::positive(0, 1000) );

// Some invalid input asserteq!( parser.parse("1.0ns").unwraperr(), ParseError::Syntax(1, "No fraction allowed".tostring()) ); asserteq!( parser.parse("1e9ns").unwraperr(), ParseError::Syntax(1, "No exponent allowed".tostring()) ); ```

Here's an example for fully-customizable time units which uses the CustomDurationParser from the custom feature:

```rust use fundu::TimeUnit::*; use fundu::{CustomDurationParser, CustomTimeUnit, Duration, Multiplier, TimeKeyword};

// Let's define a custom time unit fortnight which is worth 2 weeks. Note the creation // of CustomTimeUnits and TimeKeywords can be const and moved to compile time: const FORTNIGHT: CustomTimeUnit = CustomTimeUnit::new( Week, &["f", "fortnight", "fortnights"], Some(Multiplier(2, 0)), );

let parser = CustomDurationParser::builder() .timeunits(&[ CustomTimeUnit::withdefault(Second, &["s", "secs", "seconds"]), CustomTimeUnit::withdefault(Minute, &["min"]), CustomTimeUnit::withdefault(Hour, &["ώρα"]), FORTNIGHT, ]) // Additionally, define tomorrow, a keyword of time which is worth 1 day in the future. // In contrast to a CustomTimeUnit, a TimeKeyword doesn't accept a number in front of it // in the source string. .keyword(TimeKeyword::new(Day, &["tomorrow"], Some(Multiplier(1, 0)))) .build();

asserteq!( parser.parse("42e-1ώρα").unwrap(), Duration::positive(15120, 0) ); asserteq!( parser.parse("tomorrow").unwrap(), Duration::positive(60 * 60 * 24, 0) ); assert_eq!( parser.parse("1fortnight").unwrap(), Duration::positive(60 * 60 * 24 * 7 * 2, 0) ); ```

Benchmarks

To run the benchmarks on your machine, clone the repository

shell git clone https://github.com/fundu-rs/fundu.git cd fundu

and then run all benchmarks with

shell cargo bench --all-features

The iai-callgrind (feature = with-iai) and flamegraph (feature = with-flamegraph) benchmarks can only be run on unix. Use the --features option of cargo to run the benchmarks for specific features:

shell cargo bench --features standard,custom

The above won't run the flamegraph and iai-callgrind benchmarks.

Benchmarks can be further filtered for example with

shell cargo bench --bench benchmarks_standard cargo bench --bench benchmarks_standard -- 'parsing speed' cargo bench --features custom --no-default-features --bench benchmarks_custom

For more infos, see the help with

shell cargo bench --help # The cargo help for bench cargo bench --bench benchmarks_standard -- --help # The criterion help

To get a rough idea about the parsing times, here the average parsing speed of some inputs (Quad core 3000Mhz, 8GB DDR3, Linux)

Input | avg parsing time --- | ---:| 1 | 38.705 ns 123456789.123456789 | 67.578 ns format!("{0}.{0}e-1022", "1".repeat(1022)) | 464.65 ns 1s | 50.126 ns 1ns | 59.842 ns 1y | 83.729 ns 1years | 112.31 ns

Contributing

Contributions are always welcome! Either start with an issue that already exists or open a new issue where we can discuss everything so that no effort is wasted. Do not hesitate to ask questions!

Projects using fundu

License

MIT license (LICENSE or http://opensource.org/licenses/MIT)