60 Commits

Author SHA1 Message Date
Daniel Hofstetter 5662a1b47f Merge pull request #228 from cakebaker/bump_version_to_0_12_0
Bump version to `0.12.0`
2025-09-22 14:29:38 +02:00
Daniel Hofstetter f75ec46d63 Merge pull request #230 from yuankunzhang/parser-combinators
refactor: replace imperative parsing loop with functional combinators
2025-09-15 16:09:46 +02:00
yuankunzhang 2dc4fa3ffb refactor: replace imperative parsing loop with functional combinators
- Add `TryFrom<Vec<Items>>` implementation for `DateTimeBuilder`
  to improve code separation and reusability.
- Replace manual parsing loop in `parse_items()` with
  `repeat_until` combinator for cleaner approach.
- Allow timestamp strings ending with whitespaces.
2025-09-15 21:49:24 +08:00
Daniel Hofstetter 69649bf6c6 chore: bump version to 0.12.0 2025-09-14 16:44:42 +02:00
Daniel Hofstetter 583eafe62c Merge pull request #229 from yuankunzhang/update-deps
chore: update dependencies
2025-09-14 16:38:36 +02:00
yuankunzhang 6815ba1406 chore: update dependencies 2025-09-14 22:29:31 +08:00
Sylvestre Ledru a4c29bafcb Merge pull request #227 from cakebaker/fuzz_remove_regex_and_chrono
fuzz/Cargo.toml: remove unused `regex` & `chrono`
2025-09-08 14:27:10 +02:00
Daniel Hofstetter e70d066e03 fuzz/Cargo.toml: remove unused regex & chrono 2025-09-08 13:58:12 +02:00
Daniel Hofstetter e5f9894b11 Merge pull request #225 from yuankunzhang/result-based-error-handling
refactor: replace Option-based error handling with Result-based error handling
2025-08-26 10:34:35 +02:00
yuankunzhang 1f4ebd93c6 refactor: replace Option-based error handling with Result-based error handling 2025-08-25 22:38:14 +08:00
Daniel Hofstetter 74dd77db53 Merge pull request #224 from uutils/renovate/regex-1.x-lockfile
fix(deps): update rust crate regex to v1.11.2
2025-08-25 07:58:18 +02:00
renovate[bot] 6f15b90e36 fix(deps): update rust crate regex to v1.11.2 2025-08-24 18:02:53 +00:00
Daniel Hofstetter 4db8bf4b22 Merge pull request #223 from yuankunzhang/support-timezone-offset-larger-than-24-hour
fix: support timezone offsets that exceed 24-hour range
2025-08-24 15:29:11 +02:00
yuankunzhang 84d27e6386 fix: support timezone offsets that exceed 24-hour range 2025-08-24 19:08:48 +08:00
Daniel Hofstetter a0f1376d2f Merge pull request #222 from yuankunzhang/improve-timezone
refactor: improve timezone offset parsing and validation
2025-08-23 17:56:53 +02:00
yuankunzhang 59eac72c44 refactor: improve timezone offset parsing and validation 2025-08-23 16:51:38 +02:00
Daniel Hofstetter d6bde134df Merge pull request #221 from yuankunzhang/misc-refactors
refactor: misc minor refactors
2025-08-23 16:47:26 +02:00
yuankunzhang 43842ebf8e refactor: misc minor refactors 2025-08-23 22:22:33 +08:00
Sylvestre Ledru c31711d351 Merge pull request #214 from cakebaker/refactor_use_better_names
refactor: rename a function name and a var name
2025-08-22 16:52:41 +02:00
Sylvestre Ledru ea5d61b3eb Merge pull request #216 from cakebaker/refactor_rename_var_to_fix_typo
refactor: rename var to fix typo
2025-08-22 16:52:24 +02:00
Daniel Hofstetter c55bea1d8b Merge pull request #220 from uutils/renovate/winnow-0.x-lockfile
fix(deps): update rust crate winnow to v0.7.13
2025-08-22 16:51:18 +02:00
Daniel Hofstetter 6baf7e301d refactor: rename var to fix typo 2025-08-22 16:48:19 +02:00
renovate[bot] 795d7e6c43 fix(deps): update rust crate winnow to v0.7.13 2025-08-22 14:43:38 +00:00
Sylvestre Ledru 3f4bd53eba Merge pull request #219 from cakebaker/fix_use_checked_mul
fix: use `checked_mul()` to avoid overflow with "week"
2025-08-22 16:43:00 +02:00
Daniel Hofstetter ec30e0e1f2 Merge pull request #218 from yuankunzhang/improve-epoch
refactor: improve timestamp handling and test organization
2025-08-22 15:28:10 +02:00
yuankunzhang 70055eaa21 refactor: improve timestamp handling and test organization 2025-08-22 20:47:48 +08:00
Daniel Hofstetter 871cae962e fix: use checked_mul() to avoid overflow with week 2025-08-22 14:46:54 +02:00
Sylvestre Ledru 91692a17e0 Merge pull request #217 from yuankunzhang/checked-mul
fix: use `checked_mul()` to avoid overflow panic
2025-08-22 14:25:52 +02:00
Daniel Hofstetter e225cf0557 Merge pull request #215 from yuankunzhang/improve-api-encapsulation
refactor: improve api encapsulation
2025-08-22 14:14:51 +02:00
yuankunzhang 8e2816d187 fix: use checked_mul() to avoid overflow panic 2025-08-22 20:10:50 +08:00
yuankunzhang 84d4e294df refactor: improve api encapsulation 2025-08-22 19:21:29 +08:00
Daniel Hofstetter c87e103745 refactor: rename a function name and a var name 2025-08-21 13:38:08 +02:00
Daniel Hofstetter 7d0c3e4500 Merge pull request #213 from yuankunzhang/replace-chrono-with-jiff-6
(jiff series #6) refactor: replace chrono with jiff
2025-08-20 17:18:32 +02:00
yuankunzhang ae25053473 refactor: replace chrono with jiff 2025-08-20 11:44:58 +08:00
Daniel Hofstetter 17847a8d9c Merge pull request #212 from yuankunzhang/replace-chrono-with-jiff-5
(jiff series #5) refactor: add conversion from `Day` to `jiff::civil::Weekday`
2025-08-16 16:39:24 +02:00
yuankunzhang 6fce02949d refactor: add conversion from Day to jiff::civil::Weekday 2025-08-16 22:31:12 +08:00
Daniel Hofstetter cc5029c177 Merge pull request #211 from yuankunzhang/replace-chrono-with-jiff-4
(jiff series #4) refactor: optimize offset type sizes and add conversion from `Offset` to `jifff::tz::Offset`
2025-08-16 16:29:12 +02:00
yuankunzhang 1b96dd5707 refactor: optimize offset type sizes and add conversion from Offset to jifff::tz::Offset 2025-08-16 16:10:18 +02:00
Daniel Hofstetter b5439d8cc2 Merge pull request #210 from yuankunzhang/replace-chrono-with-jiff-3
(jiff series #3) refactor: use `(i64, u32)` for fractional seconds in relative time parsing
2025-08-16 16:06:23 +02:00
yuankunzhang d2e6e7819d refactor: use (i64, u32) for fractional seconds in relative time parsing 2025-08-16 21:55:10 +08:00
Daniel Hofstetter a8e26ab539 Merge pull request #209 from yuankunzhang/replace-chrono-with-jiff-2
(jiff series #2) refactor: optimize time type sizes and add conversion from `Time` to `jiff::civil::Time`
2025-08-16 15:20:36 +02:00
yuankunzhang 920cfa5f1b refactor: optimize time type sizes and add convertion from Time to jiff::civil::Time 2025-08-16 15:05:17 +02:00
Daniel Hofstetter a79ce1fc31 Merge pull request #208 from yuankunzhang/replace-chrono-with-jiff-1
(jiff series #1) refactor: add jiff and optimize date type sizes
2025-08-16 15:01:53 +02:00
yuankunzhang c87b6f19eb refactor: add jiff and optimize date type sizes 2025-08-16 20:18:41 +08:00
Daniel Hofstetter 25f9e48e55 Merge pull request #205 from yuankunzhang/refactor-timestamp-parsing
refactor: replace floating-point seconds with precise integer representation
2025-08-13 11:03:37 +02:00
Daniel Hofstetter 14b8a9447c Merge pull request #206 from uutils/dependabot/cargo/slab-0.4.11
chore(deps): bump slab from 0.4.10 to 0.4.11
2025-08-13 08:05:42 +02:00
dependabot[bot] 692f17526d chore(deps): bump slab from 0.4.10 to 0.4.11
Bumps [slab](https://github.com/tokio-rs/slab) from 0.4.10 to 0.4.11.
- [Release notes](https://github.com/tokio-rs/slab/releases)
- [Changelog](https://github.com/tokio-rs/slab/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/slab/compare/v0.4.10...v0.4.11)

---
updated-dependencies:
- dependency-name: slab
  dependency-version: 0.4.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-13 04:39:23 +00:00
yuankunzhang 5c686bcfdf refactor: replace floating-point seconds with precise integer representation
Replace f64-based timestamp and second parsing with structured
types using separate second and nanosecond fields to eliminate
floating-point precision issues.
2025-08-12 20:24:38 +08:00
Daniel Hofstetter c86b137e6f Merge pull request #204 from uutils/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5
2025-08-11 17:29:32 +02:00
renovate[bot] f075ba8d8d chore(deps): update actions/checkout action to v5 2025-08-11 15:24:18 +00:00
Daniel Hofstetter 5ca5586523 Merge pull request #203 from yuankunzhang/refactor-timezone-related-code
refactor: extract timezone functionality from time module to timezone module
2025-08-10 16:38:54 +02:00
yuankunzhang 2bc18c52b7 refactor: extract timezone functionality from time module to timezone module.
Move timezone-related code from `time.rs` to `timezone.rs` to
improve separation of concerns. The `Offset` struct and all
timezone parsing functions are now properly separated from time
parsing logic.
2025-08-10 16:30:09 +02:00
Daniel Hofstetter 3eb58ab00a Merge pull request #202 from yuankunzhang/refactor-timestamp-handling
refactor: improve timestamp handling and validation in builder
2025-08-10 16:04:30 +02:00
yuankunzhang 628011ae97 refactor: improve timestamp handling and validation in builder 2025-08-10 20:40:16 +08:00
Daniel Hofstetter 42d09dadf7 Merge pull request #201 from yuankunzhang/minor-improvements
code and docs minor improvements
2025-08-06 16:36:19 +02:00
yuankunzhang 908356f3a3 code and docs minor improvements 2025-08-06 22:15:03 +08:00
Daniel Hofstetter 65965a85d1 Merge pull request #199 from yuankunzhang/implement-pure-number-parsing
feat: implement pure number parsing
2025-08-05 16:02:18 +02:00
yuankunzhang 551985ef91 feat: implement pure number parsing
Implement GNU-compatible pure number interpretation that treats
pure number as year or time based on the current parsing context.
2025-08-04 23:11:41 +08:00
Daniel Hofstetter 8fa8928a6a Merge pull request #198 from yuankunzhang/remove-unsupported-named-time-zones
fix: remove unsupported named time zones
2025-08-04 14:40:00 +02:00
yuankunzhang fa90303836 fix: remove unsupported named time zones 2025-08-04 19:53:13 +08:00
23 changed files with 1781 additions and 1638 deletions
+7 -7
View File
@@ -14,7 +14,7 @@ jobs:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo check
@@ -25,7 +25,7 @@ jobs:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo test
@@ -33,7 +33,7 @@ jobs:
name: cargo fmt --all -- --check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: rustup component add rustfmt
- run: cargo fmt --all -- --check
@@ -45,7 +45,7 @@ jobs:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: rustup component add clippy
- run: cargo clippy --all-targets -- -D warnings
@@ -57,7 +57,7 @@ jobs:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_MIN_SRV }}
@@ -74,7 +74,7 @@ jobs:
- { os: macos-latest , features: macos }
- { os: windows-latest , features: windows }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Initialize workflow variables
id: vars
shell: bash
@@ -152,7 +152,7 @@ jobs:
env:
RUN_FOR: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@nightly
- name: Install `cargo-fuzz`
run: cargo install cargo-fuzz
Generated
+145 -210
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a DateTime"
version = "0.11.0"
version = "0.12.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
@@ -10,9 +10,9 @@ rust-version = "1.71.1"
[dependencies]
regex = "1.10.4"
chrono = { version="0.4.38", default-features=false, features=["std", "alloc", "clock"] }
winnow = "0.7.10"
num-traits = "0.2.19"
jiff = { version = "0.2.15", default-features = false, features = ["tz-system", "tzdb-bundle-platform", "tzdb-zoneinfo"] }
[dev-dependencies]
rstest = "0.26"
+10 -10
View File
@@ -4,7 +4,7 @@
[![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/parse_datetime/blob/main/LICENSE)
[![CodeCov](https://codecov.io/gh/uutils/parse_datetime/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/parse_datetime)
A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a `DateTime`.
A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a jiff's `Zoned` object.
## Features
@@ -12,7 +12,7 @@ A Rust crate for parsing human-readable relative time strings and human-readable
- Supports positive and negative durations.
- Allows for chaining time units (e.g., "1 hour 2 minutes" or "2 days 2 hours ago").
- Calculate durations relative to a specified date.
- Relies on Chrono
- Relies on Jiff
## Usage
@@ -25,26 +25,26 @@ cargo add parse_datetime
Then, import the crate and use the `parse_datetime_at_date` function:
```rs
use chrono::{Duration, Local};
use jiff::{ToSpan, Zoned};
use parse_datetime::parse_datetime_at_date;
let now = Local::now();
let after = parse_datetime_at_date(now, "+3 days");
let now = Zoned::now();
let after = parse_datetime_at_date(now.clone(), "+3 days");
assert_eq!(
(now + Duration::days(3)).naive_utc(),
after.unwrap().naive_utc()
now.checked_add(3.days()).unwrap(),
after.unwrap()
);
```
For DateTime parsing, import the `parse_datetime` function:
```rs
use jiff::{civil::{date, time} ,Zoned};
use parse_datetime::parse_datetime;
use chrono::{Local, TimeZone};
let dt = parse_datetime("2021-02-14 06:37:47");
assert_eq!(dt.unwrap(), Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap());
assert_eq!(dt.unwrap(), Zoned::now().with().date(date(2021, 2, 14)).time(time(6, 37, 47, 0)).build().unwrap());
```
### Supported Formats
@@ -69,7 +69,7 @@ The `parse_datetime` and `parse_datetime_at_date` functions support absolute dat
The `parse_datetime` and `parse_datetime_at_date` function return:
- `Ok(DateTime<FixedOffset>)` - If the input string can be parsed as a datetime
- `Ok(Zoned)` - If the input string can be parsed as a `Zoned` object
- `Err(ParseDateTimeError::InvalidInput)` - If the input string cannot be parsed
## Fuzzer
+153 -158
View File
@@ -4,52 +4,32 @@ version = 4
[[package]]
name = "aho-corasick"
version = "1.0.1"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "arbitrary"
version = "1.3.0"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2d098ff73c1ca148721f37baad5ea6a465a13f9573aba8641fbbbae8164a54e"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "autocfg"
version = "1.1.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bumpalo"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "cc"
version = "1.2.10"
version = "1.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229"
checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
@@ -57,84 +37,92 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.0"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "chrono"
version = "0.4.41"
name = "find-msvc-tools"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d"
[[package]]
name = "fuzz_parse_datetime"
version = "0.2.0"
dependencies = [
"chrono",
"libfuzzer-sys",
"parse_datetime",
"regex",
]
[[package]]
name = "iana-time-zone"
version = "0.1.61"
name = "getrandom"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
"cfg-if",
"libc",
"r-efi",
"wasi",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
name = "jiff"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
dependencies = [
"cc",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde",
"windows-sys",
]
[[package]]
name = "jiff-static"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "jobserver"
version = "0.1.32"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.142"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "libfuzzer-sys"
@@ -148,15 +136,15 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.18"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.6.2"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "num-traits"
@@ -167,45 +155,60 @@ dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
[[package]]
name = "parse_datetime"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"chrono",
"jiff",
"num-traits",
"regex",
"winnow",
]
[[package]]
name = "proc-macro2"
version = "1.0.59"
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.28"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.11.1"
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "regex"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
@@ -215,9 +218,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.8"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
@@ -226,9 +229,38 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "serde"
version = "1.0.221"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "341877e04a22458705eb4e131a1508483c877dca2792b3781d4e5d8a6019ec43"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.221"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c459bc0a14c840cb403fc14b148620de1e0778c96ecd6e0c8c3cacb6d8d00fe"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.221"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6185cf75117e20e62b1ff867b9518577271e58abe0037c40bb4794969355ab0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shlex"
@@ -238,9 +270,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.18"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
@@ -249,80 +281,37 @@ dependencies = [
[[package]]
name = "unicode-ident"
version = "1.0.9"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "wasm-bindgen"
version = "0.2.93"
name = "wasi"
version = "0.14.5+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4"
dependencies = [
"cfg-if",
"once_cell",
"wasm-bindgen-macro",
"wasip2",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.93"
name = "wasip2"
version = "1.0.0+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.93"
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-link"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -389,9 +378,15 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.10"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36"
-2
View File
@@ -8,8 +8,6 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4.7"
regex = "1.10.4"
chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] }
[dependencies.parse_datetime]
path = "../"
+155 -202
View File
@@ -1,9 +1,9 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike};
use jiff::{civil, Span, Zoned};
use super::{date, relative, time, weekday};
use super::{date, epoch, error, relative, time, timezone, weekday, year, Item};
/// The builder is used to construct a DateTime object from various components.
/// The parser creates a `DateTimeBuilder` object with the parsed components,
@@ -11,13 +11,13 @@ use super::{date, relative, time, weekday};
/// date and time using the `set_base()` method before calling `build()`, or
/// leave it unset to use the current date and time as the base.
#[derive(Debug, Default)]
pub struct DateTimeBuilder {
base: Option<DateTime<FixedOffset>>,
timestamp: Option<f64>,
pub(crate) struct DateTimeBuilder {
base: Option<Zoned>,
timestamp: Option<epoch::Timestamp>,
date: Option<date::Date>,
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
timezone: Option<time::Offset>,
timezone: Option<timezone::Offset>,
relative: Vec<relative::Relative>,
}
@@ -28,83 +28,133 @@ impl DateTimeBuilder {
/// Sets the base date and time for the builder. If not set, the current
/// date and time will be used.
pub(super) fn set_base(mut self, base: DateTime<FixedOffset>) -> Self {
pub(super) fn set_base(mut self, base: Zoned) -> Self {
self.base = Some(base);
self
}
/// Timestamp value is exclusive to other date/time components. Caller of
/// the builder must ensure that it is not combined with other items.
pub(super) fn set_timestamp(mut self, ts: f64) -> Result<Self, &'static str> {
/// Sets a timestamp value. Timestamp values are exclusive to other date/time
/// items (date, time, weekday, timezone, relative adjustments).
pub(super) fn set_timestamp(mut self, ts: epoch::Timestamp) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot appear more than once");
} else if self.date.is_some()
|| self.time.is_some()
|| self.weekday.is_some()
|| self.timezone.is_some()
|| !self.relative.is_empty()
{
return Err("timestamp cannot be combined with other date/time items");
}
self.timestamp = Some(ts);
Ok(self)
}
pub(super) fn set_year(mut self, year: u32) -> Result<Self, &'static str> {
if let Some(date) = self.date.as_mut() {
if date.year.is_some() {
Err("year cannot appear more than once")
} else {
date.year = Some(year);
Ok(self)
}
} else {
self.date = Some(date::Date {
day: 1,
month: 1,
year: Some(year),
});
Ok(self)
}
}
pub(super) fn set_date(mut self, date: date::Date) -> Result<Self, &'static str> {
if self.date.is_some() || self.timestamp.is_some() {
Err("date cannot appear more than once")
} else {
self.date = Some(date);
Ok(self)
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.date.is_some() {
return Err("date cannot appear more than once");
}
self.date = Some(date);
Ok(self)
}
pub(super) fn set_time(mut self, time: time::Time) -> Result<Self, &'static str> {
if self.time.is_some() || self.timestamp.is_some() {
Err("time cannot appear more than once")
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.time.is_some() {
return Err("time cannot appear more than once");
} else if self.timezone.is_some() && time.offset.is_some() {
Err("time offset and timezone are mutually exclusive")
} else {
self.time = Some(time);
Ok(self)
return Err("time offset and timezone are mutually exclusive");
}
self.time = Some(time);
Ok(self)
}
pub(super) fn set_weekday(mut self, weekday: weekday::Weekday) -> Result<Self, &'static str> {
if self.weekday.is_some() {
Err("weekday cannot appear more than once")
} else {
self.weekday = Some(weekday);
Ok(self)
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.weekday.is_some() {
return Err("weekday cannot appear more than once");
}
self.weekday = Some(weekday);
Ok(self)
}
pub(super) fn set_timezone(mut self, timezone: time::Offset) -> Result<Self, &'static str> {
if self.timezone.is_some() {
Err("timezone cannot appear more than once")
pub(super) fn set_timezone(mut self, timezone: timezone::Offset) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.timezone.is_some() {
return Err("timezone cannot appear more than once");
} else if self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() {
Err("time offset and timezone are mutually exclusive")
} else {
self.timezone = Some(timezone);
Ok(self)
return Err("time offset and timezone are mutually exclusive");
}
self.timezone = Some(timezone);
Ok(self)
}
pub(super) fn push_relative(mut self, relative: relative::Relative) -> Self {
pub(super) fn push_relative(
mut self,
relative: relative::Relative,
) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
}
self.relative.push(relative);
self
Ok(self)
}
pub(super) fn build(self) -> Option<DateTime<FixedOffset>> {
let base = self.base.unwrap_or_else(|| chrono::Local::now().into());
/// Sets a pure number that can be interpreted as either a year or time
/// depending on the current state of the builder.
///
/// If a date is already set but lacks a year, the number is interpreted as
/// a year. Otherwise, it's interpreted as a time in HHMM, HMM, HH, or H
/// format.
pub(super) fn set_pure(mut self, pure: String) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
}
if let Some(date) = self.date.as_mut() {
if date.year.is_none() {
date.year = Some(year::year_from_str(&pure)?);
return Ok(self);
}
}
let (mut hour_str, mut minute_str) = match pure.len() {
1..=2 => (pure.as_str(), "0"),
3..=4 => pure.split_at(pure.len() - 2),
_ => {
return Err("pure number must be 1-4 digits when interpreted as time");
}
};
let hour = time::hour24(&mut hour_str).map_err(|_| "invalid hour in pure number")?;
let minute = time::minute(&mut minute_str).map_err(|_| "invalid minute in pure number")?;
let time = time::Time {
hour,
minute,
..Default::default()
};
self.set_time(time)
}
pub(super) fn build(self) -> Result<Zoned, error::Error> {
let base = self.base.unwrap_or(Zoned::now());
// If a timestamp is set, we use it to build the `Zoned` object.
if let Some(ts) = self.timestamp {
return Ok(jiff::Timestamp::try_from(ts)?.to_zoned(base.offset().to_time_zone()));
}
// If any of the following items are set, we truncate the time portion
// of the base date to zero; otherwise, we use the base date as is.
@@ -116,79 +166,30 @@ impl DateTimeBuilder {
{
base
} else {
new_date(
base.year(),
base.month(),
base.day(),
0,
0,
0,
0,
*base.offset(),
)?
base.with().time(civil::time(0, 0, 0, 0)).build()?
};
if let Some(ts) = self.timestamp {
// TODO: How to make the fract -> nanosecond conversion more precise?
// Maybe considering using the
// [rust_decimal](https://crates.io/crates/rust_decimal) crate?
match chrono::Utc.timestamp_opt(ts as i64, (ts.fract() * 10f64.powi(9)).round() as u32)
{
chrono::MappedLocalTime::Single(t) => {
// If the timestamp is valid, we can use it directly.
dt = t.with_timezone(&dt.timezone());
}
chrono::MappedLocalTime::Ambiguous(earliest, _latest) => {
// TODO: When there is a fold in the local time, which value
// do we choose? For now, we use the earliest one.
dt = earliest.with_timezone(&dt.timezone());
}
chrono::MappedLocalTime::None => {
return None; // Invalid timestamp
}
if let Some(date) = self.date {
let d: civil::Date = if date.year.is_some() {
date.try_into()?
} else {
date.with_year(dt.date().year() as u16).try_into()?
};
dt = dt.with().date(d).build()?;
}
if let Some(time) = self.time.clone() {
if let Some(offset) = &time.offset {
dt = dt.datetime().to_zoned(offset.try_into()?)?;
}
}
if let Some(date::Date { year, month, day }) = self.date {
dt = new_date(
year.map(|x| x as i32).unwrap_or(dt.year()),
month,
day,
dt.hour(),
dt.minute(),
dt.second(),
dt.nanosecond(),
*dt.offset(),
)?;
}
if let Some(time::Time {
hour,
minute,
second,
ref offset,
}) = self.time
{
let offset = offset
.clone()
.and_then(|o| chrono::FixedOffset::try_from(o).ok())
.unwrap_or(*dt.offset());
dt = new_date(
dt.year(),
dt.month(),
dt.day(),
hour,
minute,
second as u32,
(second.fract() * 10f64.powi(9)).round() as u32,
offset,
)?;
let t: civil::Time = time.try_into()?;
dt = dt.with().time(t).build()?;
}
if let Some(weekday::Weekday { offset, day }) = self.weekday {
if self.time.is_none() {
dt = new_date(dt.year(), dt.month(), dt.day(), 0, 0, 0, 0, *dt.offset())?;
dt = dt.with().time(civil::time(0, 0, 0, 0)).build()?;
}
let mut offset = offset;
@@ -200,7 +201,7 @@ impl DateTimeBuilder {
// Consider this:
// Assuming today is Monday, next Friday is actually THIS Friday;
// but next Monday is indeed NEXT Monday.
if dt.weekday() != day && offset > 0 {
if dt.date().weekday() != day && offset > 0 {
offset -= 1;
}
@@ -220,101 +221,53 @@ impl DateTimeBuilder {
//
// Example 4: next Thursday (x = 1, day = Thursday)
// delta = (3 - 3) % 7 + (1) * 7 = 7
let delta = (day.num_days_from_monday() as i32
- dt.weekday().num_days_from_monday() as i32)
let delta = (day.since(civil::Weekday::Monday) as i32
- dt.date().weekday().since(civil::Weekday::Monday) as i32)
.rem_euclid(7)
+ offset.checked_mul(7)?;
+ offset.checked_mul(7).ok_or("multiplication overflow")?;
dt = if delta < 0 {
dt.checked_sub_days(chrono::Days::new((-delta) as u64))?
} else {
dt.checked_add_days(chrono::Days::new(delta as u64))?
}
dt = dt.checked_add(Span::new().try_days(delta)?)?;
}
for rel in self.relative {
// TODO: Handle potential overflows in the addition operations.
match rel {
relative::Relative::Years(x) => {
dt = dt.with_year(dt.year() + x)?;
}
relative::Relative::Months(x) => {
// *NOTE* This is done in this way to conform to
// GNU behavior.
let days = last_day_of_month(dt.year(), dt.month());
if x >= 0 {
dt += dt
.date_naive()
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
.signed_duration_since(dt.date_naive());
} else {
dt += dt
.date_naive()
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
.signed_duration_since(dt.date_naive());
}
}
relative::Relative::Days(x) => dt += chrono::Duration::days(x.into()),
relative::Relative::Hours(x) => dt += chrono::Duration::hours(x.into()),
relative::Relative::Minutes(x) => {
dt += chrono::Duration::try_minutes(x.into())?;
}
// Seconds are special because they can be given as a float.
relative::Relative::Seconds(x) => {
dt += chrono::Duration::try_seconds(x as i64)?;
}
}
dt = dt.checked_add::<Span>(if let relative::Relative::Months(x) = rel {
// *NOTE* This is done in this way to conform to GNU behavior.
let days = dt.date().last_of_month().day() as i32;
Span::new().try_days(days.checked_mul(x).ok_or("multiplication overflow")?)?
} else {
rel.try_into()?
})?;
}
if let Some(offset) = self.timezone {
dt = with_timezone_restore(offset, dt)?;
let (offset, hour_adjustment) = offset.normalize();
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
}
Some(dt)
Ok(dt)
}
}
#[allow(clippy::too_many_arguments)]
fn new_date(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
nano: u32,
offset: FixedOffset,
) -> Option<DateTime<FixedOffset>> {
let newdate = NaiveDate::from_ymd_opt(year, month, day)
.and_then(|naive| naive.and_hms_nano_opt(hour, minute, second, nano))?;
impl TryFrom<Vec<Item>> for DateTimeBuilder {
type Error = &'static str;
Some(DateTime::<FixedOffset>::from_local(newdate, offset))
}
fn try_from(items: Vec<Item>) -> Result<Self, Self::Error> {
let mut builder = DateTimeBuilder::new();
/// Restores year, month, day, etc after applying the timezone
/// returns None if timezone overflows the date
fn with_timezone_restore(
offset: time::Offset,
at: DateTime<FixedOffset>,
) -> Option<DateTime<FixedOffset>> {
let offset: FixedOffset = chrono::FixedOffset::try_from(offset).ok()?;
let copy = at;
let x = at
.with_timezone(&offset)
.with_day(copy.day())?
.with_month(copy.month())?
.with_year(copy.year())?
.with_hour(copy.hour())?
.with_minute(copy.minute())?
.with_second(copy.second())?
.with_nanosecond(copy.nanosecond())?;
Some(x)
}
for item in items {
builder = match item {
Item::Timestamp(ts) => builder.set_timestamp(ts)?,
Item::DateTime(dt) => builder.set_date(dt.date)?.set_time(dt.time)?,
Item::Date(d) => builder.set_date(d)?,
Item::Time(t) => builder.set_time(t)?,
Item::Weekday(weekday) => builder.set_weekday(weekday)?,
Item::TimeZone(tz) => builder.set_timezone(tz)?,
Item::Relative(rel) => builder.push_relative(rel)?,
Item::Pure(pure) => builder.set_pure(pure)?,
}
}
fn last_day_of_month(year: i32, month: u32) -> u32 {
NaiveDate::from_ymd_opt(year, month + 1, 1)
.unwrap_or(NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
.pred_opt()
.unwrap()
.day()
Ok(builder)
}
}
+5 -8
View File
@@ -21,16 +21,12 @@ use winnow::{
use crate::items::space;
use super::{
date::{self, Date},
primitive::s,
time::{self, Time},
};
use super::{date, primitive::s, time};
#[derive(PartialEq, Debug, Clone, Default)]
pub(crate) struct DateTime {
pub(crate) date: Date,
pub(crate) time: Time,
pub(crate) date: date::Date,
pub(crate) time: time::Time,
}
pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTime> {
@@ -59,7 +55,8 @@ mod tests {
time: Time {
hour: 10,
minute: 10,
second: 55.0,
second: 55,
nanosecond: 0,
offset: None,
},
});
+50 -29
View File
@@ -41,13 +41,23 @@ use super::{
};
#[derive(PartialEq, Eq, Clone, Debug, Default)]
pub struct Date {
pub day: u32,
pub month: u32,
pub year: Option<u32>,
pub(crate) struct Date {
pub(crate) day: u8,
pub(crate) month: u8,
pub(crate) year: Option<u16>,
}
impl TryFrom<(&str, u32, u32)> for Date {
impl Date {
pub(super) fn with_year(self, year: u16) -> Self {
Date {
day: self.day,
month: self.month,
year: Some(year),
}
}
}
impl TryFrom<(&str, u8, u8)> for Date {
type Error = &'static str;
/// Create a `Date` from a tuple of `(year, month, day)`.
@@ -55,7 +65,7 @@ impl TryFrom<(&str, u32, u32)> for Date {
/// Note: The `year` is represented as a `&str` to handle a specific GNU
/// compatibility quirk. See the comment in [`year`](super::year) for more
/// details.
fn try_from(value: (&str, u32, u32)) -> Result<Self, Self::Error> {
fn try_from(value: (&str, u8, u8)) -> Result<Self, Self::Error> {
let (year_str, month, day) = value;
let year = year_from_str(year_str)?;
@@ -80,11 +90,11 @@ impl TryFrom<(&str, u32, u32)> for Date {
}
}
impl TryFrom<(u32, u32)> for Date {
impl TryFrom<(u8, u8)> for Date {
type Error = &'static str;
/// Create a `Date` from a tuple of `(month, day)`.
fn try_from((month, day): (u32, u32)) -> Result<Self, Self::Error> {
fn try_from((month, day): (u8, u8)) -> Result<Self, Self::Error> {
if !(1..=12).contains(&month) {
return Err("month must be between 1 and 12");
}
@@ -104,14 +114,27 @@ impl TryFrom<(u32, u32)> for Date {
}
}
pub fn parse(input: &mut &str) -> ModalResult<Date> {
impl TryFrom<Date> for jiff::civil::Date {
type Error = &'static str;
fn try_from(date: Date) -> Result<Self, Self::Error> {
jiff::civil::Date::new(
date.year.unwrap_or(0) as i16,
date.month as i8,
date.day as i8,
)
.map_err(|_| "date is not valid")
}
}
pub(super) fn parse(input: &mut &str) -> ModalResult<Date> {
alt((iso1, iso2, us, literal1, literal2)).parse_next(input)
}
/// Parse `[year]-[month]-[day]`
///
/// This is also used by [`combined`](super::combined).
pub fn iso1(input: &mut &str) -> ModalResult<Date> {
pub(super) fn iso1(input: &mut &str) -> ModalResult<Date> {
let (year, _, month, _, day) =
(year_str, s('-'), s(dec_uint), s('-'), s(dec_uint)).parse_next(input)?;
@@ -123,19 +146,13 @@ pub fn iso1(input: &mut &str) -> ModalResult<Date> {
/// Parse `[year][month][day]`
///
/// This is also used by [`combined`](super::combined).
pub fn iso2(input: &mut &str) -> ModalResult<Date> {
pub(super) fn iso2(input: &mut &str) -> ModalResult<Date> {
let date_str = take_while(5.., AsChar::is_dec_digit).parse_next(input)?;
let len = date_str.len();
let year = &date_str[..len - 4];
let month = date_str[len - 4..len - 2]
.parse::<u32>()
.map_err(|_| ErrMode::Cut(ctx_err("month must be a valid number")))?;
let day = date_str[len - 2..]
.parse::<u32>()
.map_err(|_| ErrMode::Cut(ctx_err("day must be a valid number")))?;
let month = month_from_str(&date_str[len - 4..len - 2])?;
let day = day_from_str(&date_str[len - 2..])?;
(year, month, day)
.try_into()
@@ -158,27 +175,21 @@ fn us(input: &mut &str) -> ModalResult<Date> {
//
// GNU quirk: interpret as [year]/[month]/[day] if the first part is at
// least 4 characters long.
let day = s2
.parse::<u32>()
.map_err(|_| ErrMode::Cut(ctx_err("day must be a valid number")))?;
let day = day_from_str(s2)?;
(s1, n, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
}
Some(s2) => {
// [month]/[day]/[year]
let month = s1
.parse::<u32>()
.map_err(|_| ErrMode::Cut(ctx_err("month must be a valid number")))?;
let month = month_from_str(s1)?;
(s2, month, n)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
}
None => {
// [month]/[day]
let month = s1
.parse::<u32>()
.map_err(|_| ErrMode::Cut(ctx_err("month must be a valid number")))?;
let month = month_from_str(s1)?;
(month, n).try_into().map_err(|e| ErrMode::Cut(ctx_err(e)))
}
}
@@ -239,7 +250,7 @@ fn literal2(input: &mut &str) -> ModalResult<Date> {
}
/// Parse the name of a month (case-insensitive)
fn literal_month(input: &mut &str) -> ModalResult<u32> {
fn literal_month(input: &mut &str) -> ModalResult<u8> {
s(alpha1)
.verify_map(|s: &str| {
Some(match s {
@@ -261,6 +272,16 @@ fn literal_month(input: &mut &str) -> ModalResult<u32> {
.parse_next(input)
}
fn month_from_str(s: &str) -> ModalResult<u8> {
s.parse::<u8>()
.map_err(|_| ErrMode::Cut(ctx_err("month must be a valid u8 number")))
}
fn day_from_str(s: &str) -> ModalResult<u8> {
s.parse::<u8>()
.map_err(|_| ErrMode::Cut(ctx_err("day must be a valid u8 number")))
}
#[cfg(test)]
mod tests {
use super::{parse, Date};
+120 -18
View File
@@ -1,35 +1,137 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use winnow::{combinator::preceded, ModalResult, Parser};
//! Parse a timestamp item.
//!
//! From the GNU docs:
//!
//! > If you precede a number with @, it represents an internal timestamp as
//! > a count of seconds. The number can contain an internal decimal point
//! > (either . or ,); any excess precision not supported by the internal
//! > representation is truncated toward minus infinity. Such a number cannot
//! > be combined with any other date item, as it specifies a complete
//! > timestamp.
//! >
//! > On most hosts, these counts ignore the presence of leap seconds. For
//! > example, on most hosts @1483228799 represents 2016-12-31 23:59:59 UTC,
//! > @1483228800 represents 2017-01-01 00:00:00 UTC, and there is no way to
//! > represent the intervening leap second 2016-12-31 23:59:60 UTC.
use super::primitive::{float, s};
use winnow::{
ascii::digit1,
combinator::{opt, preceded},
token::one_of,
ModalResult, Parser,
};
/// Parse a timestamp in the form of `@1234567890`.
pub fn parse(input: &mut &str) -> ModalResult<f64> {
s(preceded("@", float)).parse_next(input)
use super::primitive::{dec_uint, plus_or_minus, s};
/// Represents a timestamp with nanosecond accuracy.
///
/// # Invariants
///
/// - `nanosecond` is always in the range of `0..1_000_000_000`.
/// - Negative timestamps are represented by a negative `second` value and a
/// positive `nanosecond` value.
#[derive(Debug, PartialEq)]
pub(super) struct Timestamp {
second: i64,
nanosecond: u32,
}
impl TryFrom<Timestamp> for jiff::Timestamp {
type Error = &'static str;
fn try_from(ts: Timestamp) -> Result<Self, Self::Error> {
jiff::Timestamp::new(
ts.second,
i32::try_from(ts.nanosecond).map_err(|_| "nanosecond in timestamp exceeds i32::MAX")?,
)
.map_err(|_| "timestamp value is out of valid range")
}
}
/// Parse a timestamp in the form of `@1234567890` or `@-1234567890.12345` or
/// `@1234567890,12345`.
pub(super) fn parse(input: &mut &str) -> ModalResult<Timestamp> {
(s("@"), opt(plus_or_minus), s(sec_and_nsec))
.verify_map(|(_, sign, (sec, nsec))| {
let sec = i64::try_from(sec).ok()?;
let (second, nanosecond) = match (sign, nsec) {
(Some('-'), 0) => (-sec, 0),
// Truncate towards minus infinity.
(Some('-'), _) => ((-sec).checked_sub(1)?, 1_000_000_000 - nsec),
_ => (sec, nsec),
};
Some(Timestamp { second, nanosecond })
})
.parse_next(input)
}
/// Parse a second value in the form of `1234567890` or `1234567890.12345` or
/// `1234567890,12345`.
///
/// The first part represents whole seconds. The optional second part represents
/// fractional seconds, parsed as a nanosecond value from up to 9 digits
/// (padded with zeros on the right if fewer digits are present). If the second
/// part is omitted, it defaults to 0 nanoseconds.
pub(super) fn sec_and_nsec(input: &mut &str) -> ModalResult<(u64, u32)> {
(dec_uint, opt(preceded(one_of(['.', ',']), digit1)))
.verify_map(|(sec, opt_nsec_str)| match opt_nsec_str {
Some(nsec_str) if nsec_str.len() >= 9 => Some((sec, nsec_str[..9].parse().ok()?)),
Some(nsec_str) => {
let multiplier = 10_u32.pow(9 - nsec_str.len() as u32);
Some((sec, nsec_str.parse::<u32>().ok()?.checked_mul(multiplier)?))
}
None => Some((sec, 0)),
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::parse;
use super::*;
fn float_eq(a: f64, b: f64) -> bool {
(a - b).abs() < f64::EPSILON
fn ts(second: i64, nanosecond: u32) -> Timestamp {
Timestamp { second, nanosecond }
}
#[test]
fn float() {
let mut input = "@1234567890";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.0));
fn parse_sec_and_nsec() {
for (input, expected) in [
("1234567890", (1234567890, 0)), // only seconds
("1234567890.12345", (1234567890, 123450000)), // seconds and nanoseconds, '.' as floating point
("1234567890,12345", (1234567890, 123450000)), // seconds and nanoseconds, ',' as floating point
("1234567890.1234567890123", (1234567890, 123456789)), // nanoseconds with more than 9 digits, truncated
] {
let mut s = input;
assert_eq!(sec_and_nsec(&mut s).unwrap(), expected, "{input}");
}
let mut input = "@1234567890.12345";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.12345));
for input in [
".1234567890", // invalid: no leading seconds
"-1234567890", // invalid: negative input not allowed
] {
let mut s = input;
assert!(sec_and_nsec(&mut s).is_err(), "{input}");
}
}
let mut input = "@1234567890,12345";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.12345));
let mut input = "@-1234567890.12345";
assert_eq!(parse(&mut input).unwrap(), -1234567890.12345);
#[test]
fn timestamp() {
for (input, expected) in [
("@1234567890", ts(1234567890, 0)), // positive seconds, no nanoseconds
("@ 1234567890", ts(1234567890, 0)), // space after '@', positive seconds, no nanoseconds
("@-1234567890", ts(-1234567890, 0)), // negative seconds, no nanoseconds
("@ -1234567890", ts(-1234567890, 0)), // space after '@', negative seconds, no nanoseconds
("@ - 1234567890", ts(-1234567890, 0)), // space after '@' and after '-', negative seconds, no nanoseconds
("@1234567890.12345", ts(1234567890, 123450000)), // positive seconds with nanoseconds, '.' as floating point
("@1234567890,12345", ts(1234567890, 123450000)), // positive seconds with nanoseconds, ',' as floating point
("@-1234567890.12345", ts(-1234567891, 876550000)), // negative seconds with nanoseconds, '.' as floating point
("@1234567890.1234567890123", ts(1234567890, 123456789)), // nanoseconds with more than 9 digits, truncated
] {
let mut s = input;
assert_eq!(parse(&mut s).unwrap(), expected, "{input}");
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use std::fmt;
use winnow::error::{ContextError, ErrMode};
#[derive(Debug)]
pub(crate) enum Error {
ParseError(String),
}
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::ParseError(reason) => {
write!(f, "{reason}")
}
}
}
}
impl From<&'static str> for Error {
fn from(reason: &'static str) -> Self {
Error::ParseError(reason.to_owned())
}
}
impl From<ErrMode<ContextError>> for Error {
fn from(err: ErrMode<ContextError>) -> Self {
Error::ParseError(err.to_string())
}
}
impl From<jiff::Error> for Error {
fn from(err: jiff::Error) -> Self {
Error::ParseError(err.to_string())
}
}
+160 -131
View File
@@ -21,18 +21,18 @@
//! - [`combined`]
//! - [`date`]
//! - [`epoch`]
//! - [`pure`]
//! - [`relative`]
//! - [`time`]
//! - [`timezone`]
//! - [`weekday`]
//! - [`year`]
#![allow(deprecated)]
// date and time items
mod combined;
mod date;
mod epoch;
mod pure;
mod relative;
mod time;
mod timezone;
@@ -44,47 +44,50 @@ mod builder;
mod ordinal;
mod primitive;
use builder::DateTimeBuilder;
use chrono::{DateTime, FixedOffset};
pub(crate) mod error;
use jiff::Zoned;
use primitive::space;
use winnow::{
combinator::{alt, eof, terminated, trace},
combinator::{alt, eof, preceded, repeat_till, terminated, trace},
error::{AddContext, ContextError, ErrMode, StrContext, StrContextValue},
stream::Stream,
ModalResult, Parser,
};
use crate::ParseDateTimeError;
use builder::DateTimeBuilder;
use error::Error;
#[derive(PartialEq, Debug)]
pub(crate) enum Item {
Timestamp(f64),
Year(u32),
enum Item {
Timestamp(epoch::Timestamp),
DateTime(combined::DateTime),
Date(date::Date),
Time(time::Time),
Weekday(weekday::Weekday),
Relative(relative::Relative),
TimeZone(time::Offset),
TimeZone(timezone::Offset),
Pure(String),
}
/// Build a `DateTime<FixedOffset>` from a `DateTimeBuilder` and a base date.
pub(crate) fn at_date(
builder: DateTimeBuilder,
base: DateTime<FixedOffset>,
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
builder
.set_base(base)
.build()
.ok_or(ParseDateTimeError::InvalidInput)
/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the given base date and time.
pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(base: Zoned, input: S) -> Result<Zoned, Error> {
let input = input.as_ref().to_ascii_lowercase();
match parse(&mut input.as_str()) {
Ok(builder) => builder.set_base(base).build(),
Err(e) => Err(e.into()),
}
}
/// Build a `DateTime<FixedOffset>` from a `DateTimeBuilder` and the current
/// time.
pub(crate) fn at_local(
builder: DateTimeBuilder,
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
builder.build().ok_or(ParseDateTimeError::InvalidInput)
/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the current local date and time.
pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, Error> {
let input = input.as_ref().to_ascii_lowercase();
match parse(&mut input.as_str()) {
Ok(builder) => builder.build(), // the builder uses current local date and time if no base is given.
Err(e) => Err(e.into()),
}
}
/// Parse a date and time string.
@@ -97,7 +100,7 @@ pub(crate) fn at_local(
/// timestamp = "@" , float ;
///
/// items = item , { item } ;
/// item = datetime | date | time | relative | weekday | timezone | year ;
/// item = datetime | date | time | relative | weekday | timezone | pure ;
///
/// datetime = date , [ "t" | whitespace ] , iso_time ;
///
@@ -178,9 +181,11 @@ pub(crate) fn at_local(
///
/// timezone = named_zone , [ time_offset ] ;
///
/// pure = { digit }
///
/// optional_whitespace = { whitespace } ;
/// ```
pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
trace("parse", alt((parse_timestamp, parse_items))).parse_next(input)
}
@@ -193,7 +198,7 @@ pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
fn parse_timestamp(input: &mut &str) -> ModalResult<DateTimeBuilder> {
trace(
"parse_timestamp",
terminated(epoch::parse.map(Item::Timestamp), eof),
terminated(epoch::parse.map(Item::Timestamp), preceded(space, eof)),
)
.verify_map(|ts: Item| {
if let Item::Timestamp(ts) = ts {
@@ -207,57 +212,13 @@ fn parse_timestamp(input: &mut &str) -> ModalResult<DateTimeBuilder> {
/// Parse a sequence of items.
fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
let mut builder = DateTimeBuilder::new();
let (items, _): (Vec<Item>, _) = trace(
"parse_items",
repeat_till(0.., parse_item, preceded(space, eof)),
)
.parse_next(input)?;
loop {
match parse_item.parse_next(input) {
Ok(item) => match item {
Item::Timestamp(ts) => {
builder = builder
.set_timestamp(ts)
.map_err(|e| expect_error(input, e))?;
}
Item::Year(year) => {
builder = builder.set_year(year).map_err(|e| expect_error(input, e))?;
}
Item::DateTime(dt) => {
builder = builder
.set_date(dt.date)
.map_err(|e| expect_error(input, e))?
.set_time(dt.time)
.map_err(|e| expect_error(input, e))?;
}
Item::Date(d) => {
builder = builder.set_date(d).map_err(|e| expect_error(input, e))?;
}
Item::Time(t) => {
builder = builder.set_time(t).map_err(|e| expect_error(input, e))?;
}
Item::Weekday(weekday) => {
builder = builder
.set_weekday(weekday)
.map_err(|e| expect_error(input, e))?;
}
Item::TimeZone(tz) => {
builder = builder
.set_timezone(tz)
.map_err(|e| expect_error(input, e))?;
}
Item::Relative(rel) => {
builder = builder.push_relative(rel);
}
},
Err(ErrMode::Backtrack(_)) => break,
Err(e) => return Err(e),
}
}
space.parse_next(input)?;
if !input.is_empty() {
return Err(expect_error(input, "unexpected input"));
}
Ok(builder)
items.try_into().map_err(|e| expect_error(input, e))
}
/// Parse an item.
@@ -271,7 +232,7 @@ fn parse_item(input: &mut &str) -> ModalResult<Item> {
relative::parse.map(Item::Relative),
weekday::parse.map(Item::Weekday),
timezone::parse.map(Item::TimeZone),
year::parse.map(Item::Year),
pure::parse.map(Item::Pure),
)),
)
.parse_next(input)
@@ -288,13 +249,16 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode<ContextError>
#[cfg(test)]
mod tests {
use super::{at_date, parse, DateTimeBuilder};
use chrono::{
DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
};
use jiff::{civil::DateTime, tz::TimeZone, ToSpan, Zoned};
fn at_utc(builder: DateTimeBuilder) -> DateTime<FixedOffset> {
at_date(builder, Utc::now().fixed_offset()).unwrap()
use super::{parse, DateTimeBuilder};
fn at_date(builder: DateTimeBuilder, base: Zoned) -> Zoned {
builder.set_base(base).build().unwrap()
}
fn at_utc(builder: DateTimeBuilder) -> Zoned {
at_date(builder, Zoned::now().with_time_zone(TimeZone::UTC))
}
fn test_eq_fmt(fmt: &str, input: &str) -> String {
@@ -303,7 +267,7 @@ mod tests {
.map(at_utc)
.map_err(|e| eprintln!("TEST FAILED AT:\n{e}"))
.expect("parsing failed during tests")
.format(fmt)
.strftime(fmt)
.to_string()
}
@@ -338,6 +302,11 @@ mod tests {
test_eq_fmt("%Y-%m-%dT%H:%M:%S%:z", "@1690466034")
);
assert_eq!(
"2023-07-27T13:53:54+00:00",
test_eq_fmt("%Y-%m-%dT%H:%M:%S%:z", " @1690466034 ")
);
// https://github.com/uutils/coreutils/issues/6398
// TODO: make this work
// assert_eq!("1111 1111 00", test_eq_fmt("%m%d %H%M %S", "11111111"));
@@ -363,6 +332,12 @@ mod tests {
);
}
#[test]
fn empty() {
let result = parse(&mut "");
assert!(result.is_ok());
}
#[test]
fn invalid() {
let result = parse(&mut "2025-05-19 2024-05-20 06:14:49");
@@ -386,16 +361,8 @@ mod tests {
.to_string()
.contains("time cannot appear more than once"));
let result = parse(&mut "2025-05-19 2024");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("year cannot appear more than once"));
let result = parse(&mut "2025-05-19 +00:00 +01:00");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unexpected input"));
let result = parse(&mut "m1y");
assert!(result.is_err());
@@ -406,96 +373,158 @@ mod tests {
let result = parse(&mut "2025-05-19 abcdef");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unexpected input"));
let result = parse(&mut "@1690466034 2025-05-19");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unexpected input"));
let result = parse(&mut "2025-05-19 @1690466034");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unexpected input"));
// Pure number as year (too large).
let result = parse(&mut "jul 18 12:30 10000");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("year must be no greater than 9999"));
// Pure number as time (too long).
let result = parse(&mut "01:02 12345");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("pure number must be 1-4 digits when interpreted as time"));
// Pure number as time (repeated time).
let result = parse(&mut "01:02 1234");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("time cannot appear more than once"));
// Pure number as time (invalid hour).
let result = parse(&mut "jul 18 2025 2400");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid hour in pure number"));
// Pure number as time (invalid minute).
let result = parse(&mut "jul 18 2025 2360");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid minute in pure number"));
}
#[test]
fn relative_weekday() {
// Jan 1 2025 is a Wed
let now = Utc
.from_utc_datetime(&NaiveDateTime::new(
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
))
.fixed_offset();
let now = "2025-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_eq!(
at_date(parse(&mut "last wed").unwrap(), now).unwrap(),
now - chrono::Duration::days(7)
at_date(parse(&mut "last wed").unwrap(), now.clone()),
now.checked_sub(7.days()).unwrap()
);
assert_eq!(at_date(parse(&mut "this wed").unwrap(), now).unwrap(), now);
assert_eq!(at_date(parse(&mut "this wed").unwrap(), now.clone()), now);
assert_eq!(
at_date(parse(&mut "next wed").unwrap(), now).unwrap(),
now + chrono::Duration::days(7)
at_date(parse(&mut "next wed").unwrap(), now.clone()),
now.checked_add(7.days()).unwrap()
);
assert_eq!(
at_date(parse(&mut "last thu").unwrap(), now).unwrap(),
now - chrono::Duration::days(6)
at_date(parse(&mut "last thu").unwrap(), now.clone()),
now.checked_sub(6.days()).unwrap()
);
assert_eq!(
at_date(parse(&mut "this thu").unwrap(), now).unwrap(),
now + chrono::Duration::days(1)
at_date(parse(&mut "this thu").unwrap(), now.clone()),
now.checked_add(1.days()).unwrap()
);
assert_eq!(
at_date(parse(&mut "next thu").unwrap(), now).unwrap(),
now + chrono::Duration::days(1)
at_date(parse(&mut "next thu").unwrap(), now.clone()),
now.checked_add(1.days()).unwrap()
);
assert_eq!(
at_date(parse(&mut "1 wed").unwrap(), now).unwrap(),
now + chrono::Duration::days(7)
at_date(parse(&mut "1 wed").unwrap(), now.clone()),
now.checked_add(7.days()).unwrap()
);
assert_eq!(
at_date(parse(&mut "1 thu").unwrap(), now).unwrap(),
now + chrono::Duration::days(1)
at_date(parse(&mut "1 thu").unwrap(), now.clone()),
now.checked_add(1.days()).unwrap()
);
assert_eq!(
at_date(parse(&mut "2 wed").unwrap(), now).unwrap(),
now + chrono::Duration::days(14)
at_date(parse(&mut "2 wed").unwrap(), now.clone()),
now.checked_add(14.days()).unwrap()
);
assert_eq!(
at_date(parse(&mut "2 thu").unwrap(), now).unwrap(),
now + chrono::Duration::days(8)
at_date(parse(&mut "2 thu").unwrap(), now.clone()),
now.checked_add(8.days()).unwrap()
);
}
#[test]
fn relative_date_time() {
let now = Utc::now().fixed_offset();
let now = Zoned::now().with_time_zone(TimeZone::UTC);
let result = at_date(parse(&mut "2 days ago").unwrap(), now).unwrap();
assert_eq!(result, now - chrono::Duration::days(2));
let result = at_date(parse(&mut "2 days ago").unwrap(), now.clone());
assert_eq!(result, now.checked_sub(2.days()).unwrap());
assert_eq!(result.hour(), now.hour());
assert_eq!(result.minute(), now.minute());
assert_eq!(result.second(), now.second());
let result = at_date(parse(&mut "2 days 3 days ago").unwrap(), now).unwrap();
assert_eq!(result, now - chrono::Duration::days(1));
let result = at_date(parse(&mut "2 days 3 days ago").unwrap(), now.clone());
assert_eq!(result, now.checked_sub(1.days()).unwrap());
assert_eq!(result.hour(), now.hour());
assert_eq!(result.minute(), now.minute());
assert_eq!(result.second(), now.second());
let result = at_date(parse(&mut "2025-01-01 2 days ago").unwrap(), now).unwrap();
let result = at_date(parse(&mut "2025-01-01 2 days ago").unwrap(), now.clone());
assert_eq!(result.hour(), 0);
assert_eq!(result.minute(), 0);
assert_eq!(result.second(), 0);
let result = at_date(parse(&mut "3 weeks").unwrap(), now).unwrap();
assert_eq!(result, now + chrono::Duration::days(21));
let result = at_date(parse(&mut "3 weeks").unwrap(), now.clone());
assert_eq!(result, now.checked_add(21.days()).unwrap());
assert_eq!(result.hour(), now.hour());
assert_eq!(result.minute(), now.minute());
assert_eq!(result.second(), now.second());
let result = at_date(parse(&mut "2025-01-01 3 weeks").unwrap(), now).unwrap();
let result = at_date(parse(&mut "2025-01-01 3 weeks").unwrap(), now);
assert_eq!(result.hour(), 0);
assert_eq!(result.minute(), 0);
assert_eq!(result.second(), 0);
}
#[test]
fn pure() {
let now = Zoned::now().with_time_zone(TimeZone::UTC);
// Pure number as year.
let result = at_date(parse(&mut "jul 18 12:30 2025").unwrap(), now.clone());
assert_eq!(result.year(), 2025);
// Pure number as time.
let result = at_date(parse(&mut "1230").unwrap(), now.clone());
assert_eq!(result.hour(), 12);
assert_eq!(result.minute(), 30);
let result = at_date(parse(&mut "123").unwrap(), now.clone());
assert_eq!(result.hour(), 1);
assert_eq!(result.minute(), 23);
let result = at_date(parse(&mut "12").unwrap(), now.clone());
assert_eq!(result.hour(), 12);
assert_eq!(result.minute(), 0);
let result = at_date(parse(&mut "1").unwrap(), now.clone());
assert_eq!(result.hour(), 1);
assert_eq!(result.minute(), 0);
}
}
+25 -21
View File
@@ -3,8 +3,10 @@
//! Primitive combinators.
use std::str::FromStr;
use winnow::{
ascii::{digit1, multispace0},
ascii::{digit1, multispace0, Uint},
combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated},
error::{ContextError, ParserError, StrContext, StrContextValue},
stream::AsChar,
@@ -100,36 +102,38 @@ where
///
/// See the rationale for `dec_int` for why we don't use
/// `winnow::ascii::dec_uint`.
pub(super) fn dec_uint<'a, E>(input: &mut &'a str) -> winnow::Result<u32, E>
pub(super) fn dec_uint<'a, O, E>(input: &mut &'a str) -> winnow::Result<O, E>
where
O: Uint + FromStr,
E: ParserError<&'a str>,
{
digit1
.void()
.take()
dec_uint_str
.verify_map(|s: &str| s.parse().ok())
.parse_next(input)
}
/// Parse a float number.
///
/// Rationale for not using `winnow::ascii::float`: the `float` parser provided
/// by winnow accepts E-notation numbers (e.g., `1.23e4`), whereas GNU date
/// rejects such numbers. To remain compatible with GNU date, we provide a
/// custom implementation that only accepts inputs like [+-]?[0-9]+(\.[0-9]+)?.
pub(super) fn float<'a, E>(input: &mut &'a str) -> winnow::Result<f64, E>
/// Parse an unsigned decimal integer as a string slice.
pub(super) fn dec_uint_str<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
where
E: ParserError<&'a str>,
{
(
opt(one_of(['+', '-'])),
digit1,
opt(preceded(one_of(['.', ',']), digit1)),
)
.void()
.take()
.verify_map(|s: &str| s.replace(",", ".").parse().ok())
.parse_next(input)
digit1.void().take().parse_next(input)
}
/// Parse a colon preceded by whitespace.
pub(super) fn colon<'a, E>(input: &mut &'a str) -> winnow::Result<(), E>
where
E: ParserError<&'a str>,
{
s(':').void().parse_next(input)
}
/// Parse a plus or minus character optionally preceeded by whitespace.
pub(super) fn plus_or_minus<'a, E>(input: &mut &'a str) -> winnow::Result<char, E>
where
E: ParserError<&'a str>,
{
s(alt(('+', '-'))).parse_next(input)
}
/// Create a context error with a reason.
+37
View File
@@ -0,0 +1,37 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Parse a pure number string.
//!
//! From the GNU docs:
//!
//! > The precise interpretation of a pure decimal number depends on the
//! > context in the date string.
//! >
//! > If the decimal number is of the form YYYYMMDD and no other calendar
//! > date item (*note Calendar date items::) appears before it in the date
//! > string, then YYYY is read as the year, MM as the month number and DD as
//! > the day of the month, for the specified calendar date.
//! >
//! > If the decimal number is of the form HHMM and no other time of day
//! > item appears before it in the date string, then HH is read as the hour
//! > of the day and MM as the minute of the hour, for the specified time of
//! > day. MM can also be omitted.
//! >
//! > If both a calendar date and a time of day appear to the left of a
//! > number in the date string, but no relative item, then the number
//! > overrides the year.
use winnow::{ModalResult, Parser};
use super::primitive::{dec_uint_str, s};
/// Parse a pure number string and return it as an owned `String`. We return a
/// `String` here because the interpretation of the number depends on the
/// parsing context in which it appears. The interpretation is deferred to the
/// result building phase.
pub(super) fn parse(input: &mut &str) -> ModalResult<String> {
s(dec_uint_str)
.map(|s: &str| s.to_owned())
.parse_next(input)
}
+60 -53
View File
@@ -37,38 +37,37 @@ use winnow::{
ModalResult, Parser,
};
use super::{
ordinal::ordinal,
primitive::{float, s},
};
use super::{epoch::sec_and_nsec, ordinal::ordinal, primitive::s};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Relative {
pub(crate) enum Relative {
Years(i32),
Months(i32),
Days(i32),
Hours(i32),
Minutes(i32),
// Seconds are special because they can be given as a float
Seconds(f64),
Seconds(i64, u32),
}
impl Relative {
// TODO: determine how to handle multiplication overflows,
// using saturating_mul for now.
fn mul(self, n: i32) -> Self {
match self {
Self::Years(x) => Self::Years(n.saturating_mul(x)),
Self::Months(x) => Self::Months(n.saturating_mul(x)),
Self::Days(x) => Self::Days(n.saturating_mul(x)),
Self::Hours(x) => Self::Hours(n.saturating_mul(x)),
Self::Minutes(x) => Self::Minutes(n.saturating_mul(x)),
Self::Seconds(x) => Self::Seconds(f64::from(n) * x),
impl TryFrom<Relative> for jiff::Span {
type Error = &'static str;
fn try_from(relative: Relative) -> Result<Self, Self::Error> {
match relative {
Relative::Years(years) => jiff::Span::new().try_years(years),
Relative::Months(months) => jiff::Span::new().try_months(months),
Relative::Days(days) => jiff::Span::new().try_days(days),
Relative::Hours(hours) => jiff::Span::new().try_hours(hours),
Relative::Minutes(minutes) => jiff::Span::new().try_minutes(minutes),
Relative::Seconds(seconds, nanoseconds) => jiff::Span::new()
.try_seconds(seconds)
.and_then(|span| span.try_nanoseconds(nanoseconds)),
}
.map_err(|_| "relative value is invalid")
}
}
pub fn parse(input: &mut &str) -> ModalResult<Relative> {
pub(super) fn parse(input: &mut &str) -> ModalResult<Relative> {
alt((
s("tomorrow").value(Relative::Days(1)),
s("yesterday").value(Relative::Days(-1)),
@@ -76,24 +75,48 @@ pub fn parse(input: &mut &str) -> ModalResult<Relative> {
s("today").value(Relative::Days(0)),
s("now").value(Relative::Days(0)),
seconds,
other,
displacement,
))
.parse_next(input)
}
fn seconds(input: &mut &str) -> ModalResult<Relative> {
(
opt(alt((s(float), ordinal.map(|x| x as f64)))),
opt(alt((s('+').value(1), s('-').value(-1)))),
sec_and_nsec,
s(alpha1).verify(|s: &str| matches!(s, "seconds" | "second" | "sec" | "secs")),
ago,
)
.map(|(n, _, ago)| Relative::Seconds(n.unwrap_or(1.0) * if ago { -1.0 } else { 1.0 }))
.verify_map(|(sign, (sec, nsec), _, ago)| {
let sec = i64::try_from(sec).ok()?;
let sign = sign.unwrap_or(1) * if ago { -1 } else { 1 };
let (second, nanosecond) = match (sign, nsec) {
(-1, 0) => (-sec, 0),
// Truncate towards minus infinity.
(-1, _) => ((-sec).checked_sub(1)?, 1_000_000_000 - nsec),
_ => (sec, nsec),
};
Some(Relative::Seconds(second, nanosecond))
})
.parse_next(input)
}
fn other(input: &mut &str) -> ModalResult<Relative> {
(opt(ordinal), integer_unit, ago)
.map(|(n, unit, ago)| unit.mul(n.unwrap_or(1) * if ago { -1 } else { 1 }))
fn displacement(input: &mut &str) -> ModalResult<Relative> {
(opt(ordinal), s(alpha1), ago)
.verify_map(|(n, unit, ago): (Option<i32>, &str, bool)| {
let multiplier = n.unwrap_or(1) * if ago { -1 } else { 1 };
Some(match unit.strip_suffix('s').unwrap_or(unit) {
"year" => Relative::Years(multiplier),
"month" => Relative::Months(multiplier),
"fortnight" => Relative::Days(multiplier.checked_mul(14)?),
"week" => Relative::Days(multiplier.checked_mul(7)?),
"day" => Relative::Days(multiplier),
"hour" => Relative::Hours(multiplier),
"minute" | "min" => Relative::Minutes(multiplier),
"second" | "sec" => Relative::Seconds(multiplier as i64, 0),
_ => return None,
})
})
.parse_next(input)
}
@@ -101,23 +124,6 @@ fn ago(input: &mut &str) -> ModalResult<bool> {
opt(s("ago")).map(|o| o.is_some()).parse_next(input)
}
fn integer_unit(input: &mut &str) -> ModalResult<Relative> {
s(alpha1)
.verify_map(|s: &str| {
Some(match s.strip_suffix('s').unwrap_or(s) {
"year" => Relative::Years(1),
"month" => Relative::Months(1),
"fortnight" => Relative::Days(14),
"week" => Relative::Days(7),
"day" => Relative::Days(1),
"hour" => Relative::Hours(1),
"minute" | "min" => Relative::Minutes(1),
_ => return None,
})
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::{parse, Relative};
@@ -126,16 +132,17 @@ mod tests {
fn all() {
for (s, rel) in [
// Seconds
("second", Relative::Seconds(1.0)),
("sec", Relative::Seconds(1.0)),
("seconds", Relative::Seconds(1.0)),
("secs", Relative::Seconds(1.0)),
("second ago", Relative::Seconds(-1.0)),
("3 seconds", Relative::Seconds(3.0)),
("3.5 seconds", Relative::Seconds(3.5)),
// ("+3.5 seconds", Relative::Seconds(3.5)),
("3.5 seconds ago", Relative::Seconds(-3.5)),
("-3.5 seconds ago", Relative::Seconds(3.5)),
("second", Relative::Seconds(1, 0)),
("sec", Relative::Seconds(1, 0)),
("seconds", Relative::Seconds(1, 0)),
("secs", Relative::Seconds(1, 0)),
("second ago", Relative::Seconds(-1, 0)),
("3 seconds", Relative::Seconds(3, 0)),
("3.5 seconds", Relative::Seconds(3, 500_000_000)),
("-3.5 seconds", Relative::Seconds(-4, 500_000_000)),
("+3.5 seconds", Relative::Seconds(3, 500_000_000)),
("3.5 seconds ago", Relative::Seconds(-4, 500_000_000)),
("-3.5 seconds ago", Relative::Seconds(3, 500_000_000)),
// Minutes
("minute", Relative::Minutes(1)),
("minutes", Relative::Minutes(1)),
@@ -181,7 +188,7 @@ mod tests {
("now", Relative::Days(0)),
// This something
("this day", Relative::Days(0)),
("this second", Relative::Seconds(0.0)),
("this second", Relative::Seconds(0, 0)),
("this year", Relative::Years(0)),
// Weird stuff
("next week ago", Relative::Days(-7)),
+112 -533
View File
File diff suppressed because it is too large Load Diff
+462 -4
View File
@@ -1,10 +1,468 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use winnow::ModalResult;
//! Parse a timezone item.
//!
//! From the GNU docs:
//!
//! > A “time zone item” specifies an international time zone, indicated by a
//! > small set of letters, e.g., UTC or Z for Coordinated Universal Time.
//! > Any included periods are ignored. By following a non-daylight-saving
//! > time zone by the string DST in a separate word (that is, separated by
//! > some white space), the corresponding daylight saving time zone may be
//! > specified. Alternatively, a non-daylight-saving time zone can be
//! > followed by a time zone correction, to add the two values. This is
//! > normally done only for UTC; for example, UTC+05:30 is equivalent to
//! > +05:30.
//! >
//! > Time zone items other than UTC and Z are obsolescent and are not
//! > recommended, because they are ambiguous; for example, EST has a
//! > different meaning in Australia than in the United States, and A has
//! > different meaning as a military time zone than as an obsolete RFC 822
//! > time zone. Instead, it's better to use unambiguous numeric time zone
//! > corrections like -0500, as described in the previous section.
//! >
//! > If neither a time zone item nor a time zone correction is supplied,
//! > timestamps are interpreted using the rules of the default time zone.
use super::time;
use std::fmt::Display;
pub(crate) fn parse(input: &mut &str) -> ModalResult<time::Offset> {
time::timezone(input)
use winnow::{
combinator::{alt, peek},
error::{ContextError, ErrMode},
stream::{AsChar, Stream},
token::take_while,
ModalResult, Parser,
};
use super::{
primitive::{colon, ctx_err, dec_uint, dec_uint_str, plus_or_minus, s},
relative,
};
/// Represents a time zone offset from UTC.
///
/// This struct is used to represent a time zone offset in hours and minutes,
/// with a boolean indicating whether the offset is negative (i.e., west of
/// UTC).
#[derive(PartialEq, Debug, Clone, Default)]
pub(super) struct Offset {
negative: bool,
hours: u8,
minutes: u8,
}
impl Offset {
/// Merge two timezone offsets.
///
/// Note: when parsing an offset from a string (e.g., "+08:00"), the hours
/// and minutes are validated to ensure they fall within valid bounds. In
/// contrast, merging two offsets does not perform such validation. This
/// behavior is intentional to match GNU date.
fn merge(self, offset: Offset) -> Offset {
fn combine(a: u16, neg_a: bool, b: u16, neg_b: bool) -> (u16, bool) {
if neg_a == neg_b {
(a + b, neg_a)
} else if a > b {
(a - b, neg_a)
} else {
(b - a, neg_b)
}
}
let (total_minutes, negative) = combine(
(self.hours as u16) * 60 + (self.minutes as u16),
self.negative,
(offset.hours as u16) * 60 + (offset.minutes as u16),
offset.negative,
);
let hours = (total_minutes / 60) as u8;
let minutes = (total_minutes % 60) as u8;
Offset {
negative,
hours,
minutes,
}
}
/// Normalize the offset so that the hour field is within the accepted range.
///
/// - If the hour field is less than 24, or exactly 24 with a zero minute,
/// the offset is already normalized, and the function returns the offset
/// itself along with a zero hour adjustment.
/// - Otherwise, the hour field is reduced to 23 while preserving the minute
/// field, and the function returns the normalized offset along with the
/// hour adjustment needed to reach the original offset.
pub(super) fn normalize(self) -> (Offset, i8) {
if self.hours < 24 || (self.hours == 24 && self.minutes == 0) {
return (self, 0);
}
let hour_adjustment = (self.hours as i8 - 23) * if self.negative { 1 } else { -1 };
(
Offset {
negative: self.negative,
hours: 23,
minutes: self.minutes,
},
hour_adjustment,
)
}
}
impl TryFrom<(bool, u8, u8)> for Offset {
type Error = &'static str;
fn try_from((negative, hours, minutes): (bool, u8, u8)) -> Result<Self, Self::Error> {
if hours > 24 {
return Err("timezone hour must be between 0 and 24");
}
if minutes > 60 || (hours == 24 && minutes != 0) {
return Err("timezone minute must be between 0 and 60");
}
Ok(Offset {
negative,
hours,
minutes,
})
}
}
impl TryFrom<&Offset> for jiff::tz::TimeZone {
type Error = &'static str;
fn try_from(
Offset {
negative,
hours,
minutes,
}: &Offset,
) -> Result<Self, Self::Error> {
let secs = (*hours as i32) * 3600 + (*minutes as i32) * 60;
let secs = if *negative { -secs } else { secs };
let offset = jiff::tz::Offset::from_seconds(secs).map_err(|_| "offset is invalid")?;
Ok(jiff::tz::TimeZone::fixed(offset))
}
}
impl Display for Offset {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(
fmt,
"{}{:02}:{:02}",
if self.negative { "-" } else { "+" },
self.hours,
self.minutes
)
}
}
pub(super) fn parse(input: &mut &str) -> ModalResult<Offset> {
timezone_name_offset.parse_next(input)
}
/// Parse a timezone starting with `+` or `-`.
pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
// Strings like "+8 years" are ambiguous, they can either be parsed as a
// timezone offset "+8" and a relative time "years", or just a relative time
// "+8 years". GNU date parses them the second way, so we do the same here.
//
// Return early if the input can be parsed as a relative time.
if peek(relative::parse).parse_next(input).is_ok() {
return Err(ErrMode::Backtrack(ContextError::new()));
}
alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input)
}
/// Parse a timezone by name, with an optional numeric offset appended.
fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
/// I'm assuming there are no timezone abbreviations with more
/// than 6 charactres
const MAX_TZ_SIZE: usize = 6;
let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?;
let tz = timezone_name_to_offset(nextword)?;
// Strings like "UTC +8 years" are ambiguous, they can either be parsed as
// "UTC+8" and "years", or "UTC" and "+8 years". GNU date parses them the
// second way, so we do the same here.
//
// Only process if the input cannot be parsed as a relative time.
if peek(relative::parse).parse_next(input).is_err() {
let start = input.checkpoint();
if let Ok(other_tz) = timezone_offset.parse_next(input) {
let new_tz = tz.merge(other_tz);
return Ok(new_tz);
};
input.reset(&start);
}
Ok(tz)
}
/// Parse a timezone offset with a colon separating hours and minutes, e.g.,
/// `+08:00`, `+8:00`, `+8:0`.
fn timezone_offset_colon(input: &mut &str) -> ModalResult<Offset> {
(plus_or_minus, s(dec_uint), s(colon), s(dec_uint))
.parse_next(input)
.and_then(|(sign, hours, _, minutes)| {
(sign == '-', hours, minutes)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
})
}
/// Parse a timezone offset without colon, e.g., `+0800`, `+800`, `+08`, `+8`.
fn timezone_offset_colonless(input: &mut &str) -> ModalResult<Offset> {
(plus_or_minus, s(dec_uint_str))
.verify_map(|(sign, s)| {
// GNU date accepts numeric offset strings with leading zeroes. For
// example, `+000000110` is valid. In such cases, the string is
// truncated to the last four characters. Thus, `+000000110` becomes
// `+0110` (note that one leading zero is kept).
let s = if s.len() > 4 && s.trim_start_matches('0').len() <= 4 {
&s[s.len() - 4..]
} else {
s
};
// Hour and minute values are dependent on the length of the string.
// For example:
//
// - "5" -> 05:00
// - "05" -> 05:00
// - "530" -> 05:30 (the minute is the last two characters here)
// - "0530"-> 05:30
// - "0000530" -> 05:30
let (h_str, m_str) = match s.len() {
1 | 2 => (s, "0"),
3 => s.split_at(1),
4 => s.split_at(2),
_ => return None,
};
let hours = h_str.parse::<u8>().ok()?;
let minutes = m_str.parse::<u8>().ok()?;
Some((sign, hours, minutes))
})
.parse_next(input)
.and_then(|(sign, hours, minutes)| {
(sign == '-', hours, minutes)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
})
}
/// Named timezone list.
///
/// The full list of timezones can be extracted from
/// https://www.timeanddate.com/time/zones/. GNU date only supports a subset of
/// these. We support the same subset as GNU date.
fn timezone_name_to_offset(input: &str) -> ModalResult<Offset> {
let mut offset_str = match input {
"z" => Ok("+0"),
"y" => Ok("-12"),
"x" => Ok("-11"),
"wet" => Ok("+0"),
"west" => Ok("+1"),
"wat" => Ok("+1"),
"w" => Ok("-10"),
"v" => Ok("-9"),
"utc" => Ok("+0"),
"u" => Ok("-8"),
"t" => Ok("-7"),
"sst" => Ok("-11"),
"sgt" => Ok("+8"),
"sast" => Ok("+2"),
"s" => Ok("-6"),
"r" => Ok("-5"),
"q" => Ok("-4"),
"pst" => Ok("-8"),
"pdt" => Ok("-7"),
"p" => Ok("-3"),
"o" => Ok("-2"),
"nzst" => Ok("+12"),
"nzdt" => Ok("+13"),
"nst" => Ok("-3:30"),
"ndt" => Ok("-2:30"),
"n" => Ok("-1"),
"mst" => Ok("-7"),
"msk" => Ok("+3"),
"msd" => Ok("+4"),
"mdt" => Ok("-6"),
"m" => Ok("+12"),
"l" => Ok("+11"),
"k" => Ok("+10"),
"jst" => Ok("+9"),
"ist" => Ok("+5:30"),
"i" => Ok("+9"),
"hst" => Ok("-10"),
"h" => Ok("+8"),
"gst" => Ok("+4"),
"gmt" => Ok("+0"),
"g" => Ok("+7"),
"f" => Ok("+6"),
"est" => Ok("-5"),
"eet" => Ok("+2"),
"eest" => Ok("+3"),
"edt" => Ok("-4"),
"eat" => Ok("+3"),
"e" => Ok("+5"),
"d" => Ok("+4"),
"cst" => Ok("-6"),
"clt" => Ok("-4"),
"clst" => Ok("-3"),
"cet" => Ok("+1"),
"cest" => Ok("+2"),
"cdt" => Ok("-5"),
"cat" => Ok("+2"),
"c" => Ok("+3"),
"bst" => Ok("+6"),
"brt" => Ok("-3"),
"brst" => Ok("-2"),
"b" => Ok("+2"),
"ast" => Ok("-3"),
"art" => Ok("-3"),
"akst" => Ok("-9"),
"akdt" => Ok("-8"),
"adt" => Ok("+4"),
"a" => Ok("+1"),
_ => Err(ErrMode::Backtrack(ContextError::new())),
}?;
timezone_offset(&mut offset_str)
}
#[cfg(test)]
mod tests {
use super::*;
fn off(negative: bool, hours: u8, minutes: u8) -> Offset {
Offset {
negative,
hours,
minutes,
}
}
#[test]
fn timezone_offset_with_colon() {
for (input, expected) in [
("+00:00", off(false, 0, 0)), // UTC
("-00:00", off(true, 0, 0)), // UTC
("+01:00", off(false, 1, 0)), // positive offset
("-06:00", off(true, 6, 0)), // negative offset
("+05:30", off(false, 5, 30)), // positive offset with non-zero minutes
("-03:30", off(true, 3, 30)), // negative offset with non-zero minutes
("- 06:00", off(true, 6, 0)), // space after sign
("- 06 : 00", off(true, 6, 0)), // space around colon
("+5:3", off(false, 5, 3)), // single-digit hours and single-digit minutes
("+5:03", off(false, 5, 3)), // single-digit hours
("+05:3", off(false, 5, 3)), // single-digit minutes
("+00005:00030", off(false, 5, 30)), // leading zeroes in hours and minutes
("+00:00abc", off(false, 0, 0)), // space separator can be ignored if immediately followed by alphas (GNU date behavior)
] {
let mut s = input;
assert_eq!(timezone_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"+25:00", // invalid: hours > 24
"-23:61", // invalid: minutes > 60
"+24:01", // invalid: minutes > 0 when hours == 24
] {
let mut s = input;
assert!(timezone_offset(&mut s).is_err(), "{input}");
}
}
#[test]
fn timezone_offset_without_colon() {
for (input, expected) in [
("+0000", off(false, 0, 0)), // UTC
("-0000", off(true, 0, 0)), // UTC
("+0100", off(false, 1, 0)), // positive offset
("-0600", off(true, 6, 0)), // negative offset
("+0530", off(false, 5, 30)), // positive offset with non-zero minutes
("-0330", off(true, 3, 30)), // negative offset with non-zero minutes
("- 0330", off(true, 3, 30)), // space after sign
("+530", off(false, 5, 30)), // single-digit hours
("+05", off(false, 5, 0)), // double-digit hours and no minutes
("+5", off(false, 5, 0)), // single-digit hours and no minutes
("+00000530", off(false, 5, 30)), // leading zeroes
("+0000abc", off(false, 0, 0)), // space separator can be ignored if immediately followed by alphas (GNU date behavior)
] {
let mut s = input;
assert_eq!(timezone_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"+2500", // invalid: hours > 24
"-2361", // invalid: minutes > 60
"+2401", // invalid: minutes > 0 when hours == 24
"+23 days", // invalid: ambiguous with relative time parsing
] {
let mut s = input;
assert!(timezone_offset(&mut s).is_err(), "{input}");
}
}
#[test]
fn timezone_name_without_offset() {
for (input, expected) in [
("utc", off(false, 0, 0)), // UTC
("gmt", off(false, 0, 0)), // UTC
("z", off(false, 0, 0)), // UTC
("west", off(false, 1, 0)), // positive offset
("cst", off(true, 6, 0)), // negative offset
("ist", off(false, 5, 30)), // positive offset with non-zero minutes
("nst", off(true, 3, 30)), // negative offset with non-zero minutes
("z123", off(false, 0, 0)), // space separator can be ignored if immediately followed by digits (GNU date behavior)
] {
let mut s = input;
assert_eq!(timezone_name_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"abc", // invalid: non-existent timezone
"utcabc", // invalid: non-existent timezone
] {
let mut s = input;
assert!(timezone_name_offset(&mut s).is_err(), "{input}");
}
}
#[test]
fn timezone_name_with_offset() {
for (input, expected) in [
("utc+5:30", off(false, 5, 30)), // UTC with possitive offset
("utc-5:30", off(true, 5, 30)), // UTC with negative offset
("utc +5:30", off(false, 5, 30)), // space after timezone name
("utc + 5 : 30", off(false, 5, 30)), // spaces
("a+5:30", off(false, 6, 30)), // merge two positive offsets (a=+1)
("a-5:30", off(true, 4, 30)), // merge positive and negative offsets (a=+1)
("n-5:30", off(true, 6, 30)), // merge two negative offsets (n=-1)
("n+5:30", off(false, 4, 30)), // merge negative and positive offsets (n=-1)
("m+24", off(false, 36, 0)), // maximum possible positive offset (m=+12)
("y-24", off(true, 36, 0)), // maximum possible negative offset (y=-12)
] {
let mut s = input;
assert_eq!(timezone_name_offset(&mut s).unwrap(), expected, "{input}");
}
for input in [
"abc+08:00", // invalid: non-existent timezone
"utc+25", // invalid: invalid offset
"utc+23 days", // invalid: ambiguous with relative time parsing
] {
let mut s = input;
assert!(
timezone_name_offset(&mut s).is_err() || !s.is_empty(),
"{input}"
);
}
}
}
+9 -9
View File
@@ -41,21 +41,21 @@ pub(crate) enum Day {
}
#[derive(PartialEq, Eq, Debug)]
pub struct Weekday {
pub(crate) struct Weekday {
pub(crate) offset: i32,
pub(crate) day: Day,
}
impl From<Day> for chrono::Weekday {
impl From<Day> for jiff::civil::Weekday {
fn from(value: Day) -> Self {
match value {
Day::Monday => chrono::Weekday::Mon,
Day::Tuesday => chrono::Weekday::Tue,
Day::Wednesday => chrono::Weekday::Wed,
Day::Thursday => chrono::Weekday::Thu,
Day::Friday => chrono::Weekday::Fri,
Day::Saturday => chrono::Weekday::Sat,
Day::Sunday => chrono::Weekday::Sun,
Day::Monday => jiff::civil::Weekday::Monday,
Day::Tuesday => jiff::civil::Weekday::Tuesday,
Day::Wednesday => jiff::civil::Weekday::Wednesday,
Day::Thursday => jiff::civil::Weekday::Thursday,
Day::Friday => jiff::civil::Weekday::Friday,
Day::Saturday => jiff::civil::Weekday::Saturday,
Day::Sunday => jiff::civil::Weekday::Sunday,
}
}
}
+15 -19
View File
@@ -10,19 +10,15 @@
//! strings. For example, `"00"` is interpreted as `2000`, whereas `"0"`,
//! `"000"`, or `"0000"` are interpreted as `0`.
use winnow::{error::ErrMode, stream::AsChar, token::take_while, ModalResult, Parser};
use winnow::{stream::AsChar, token::take_while, ModalResult, Parser};
use super::primitive::{ctx_err, s};
pub(super) fn parse(input: &mut &str) -> ModalResult<u32> {
year_from_str(year_str(input)?).map_err(|e| ErrMode::Cut(ctx_err(e)))
}
use super::primitive::s;
// TODO: Leverage `TryFrom` trait.
pub(super) fn year_from_str(year_str: &str) -> Result<u32, &'static str> {
pub(super) fn year_from_str(year_str: &str) -> Result<u16, &'static str> {
let mut year = year_str
.parse::<u32>()
.map_err(|_| "year must be a valid number")?;
.parse::<u16>()
.map_err(|_| "year must be a valid u16 number")?;
// If year is 68 or smaller, then 2000 is added to it; otherwise, if year
// is less than 100, then 1900 is added to it.
@@ -56,23 +52,23 @@ pub(super) fn year_str<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
#[cfg(test)]
mod tests {
use super::parse;
use super::year_from_str;
#[test]
fn test_year() {
// 2-characters are converted to 19XX/20XX
assert_eq!(parse(&mut "10").unwrap(), 2010u32);
assert_eq!(parse(&mut "68").unwrap(), 2068u32);
assert_eq!(parse(&mut "69").unwrap(), 1969u32);
assert_eq!(parse(&mut "99").unwrap(), 1999u32);
assert_eq!(year_from_str("10").unwrap(), 2010u16);
assert_eq!(year_from_str("68").unwrap(), 2068u16);
assert_eq!(year_from_str("69").unwrap(), 1969u16);
assert_eq!(year_from_str("99").unwrap(), 1999u16);
// 3,4-characters are converted verbatim
assert_eq!(parse(&mut "468").unwrap(), 468u32);
assert_eq!(parse(&mut "469").unwrap(), 469u32);
assert_eq!(parse(&mut "1568").unwrap(), 1568u32);
assert_eq!(parse(&mut "1569").unwrap(), 1569u32);
assert_eq!(year_from_str("468").unwrap(), 468u16);
assert_eq!(year_from_str("469").unwrap(), 469u16);
assert_eq!(year_from_str("1568").unwrap(), 1568u16);
assert_eq!(year_from_str("1569").unwrap(), 1569u16);
// years greater than 9999 are not accepted
assert!(parse(&mut "10000").is_err());
assert!(year_from_str("10000").is_err());
}
}
+186 -202
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More