diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78f0235..d56e49b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index fec3930..62e9b76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,104 +12,128 @@ dependencies = [ ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "autocfg" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "cfg-if" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "autocfg" -version = "1.4.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "bumpalo" -version = "3.17.0" +name = "futures-core" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] -name = "cc" -version = "1.2.22" +name = "futures-macro" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ - "shlex", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "cfg-if" -version = "1.0.0" +name = "futures-task" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] -name = "chrono" -version = "0.4.41" +name = "futures-util" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "android-tzdata", - "iana-time-zone", - "num-traits", - "windows-link", + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "glob" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] -name = "iana-time-zone" -version = "0.1.63" +name = "hashbrown" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "equivalent", + "hashbrown", ] [[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 = "js-sys" -version = "0.3.77" +name = "jiff-static" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ - "once_cell", - "wasm-bindgen", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "libc" -version = "0.2.172" +name = "jiff-tzdb" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +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 = "log" @@ -132,22 +156,53 @@ dependencies = [ "autocfg", ] -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - [[package]] name = "parse_datetime" -version = "0.10.0" +version = "0.11.0" dependencies = [ - "chrono", + "jiff", "num-traits", "regex", + "rstest", "winnow", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -168,9 +223,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -196,156 +251,193 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] -name = "rustversion" -version = "1.0.20" +name = "relative-path" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] -name = "shlex" -version = "1.3.0" +name = "rstest" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] [[package]] -name = "syn" -version = "2.0.101" +name = "rstest_macros" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", "proc-macro2", "quote", + "regex", + "relative-path", + "rustc_version", + "syn", "unicode-ident", ] [[package]] -name = "unicode-ident" -version = "1.0.18" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "semver" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "serde_derive", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "serde_derive" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ - "bumpalo", - "log", "proc-macro2", "quote", "syn", - "wasm-bindgen-shared", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" +name = "slab" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" +name = "syn" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", + "unicode-ident", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" +name = "toml_datetime" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" [[package]] -name = "windows-core" -version = "0.61.0" +name = "toml_edit" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "indexmap", + "toml_datetime", + "winnow", ] [[package]] -name = "windows-implement" -version = "0.60.0" +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "windows-sys" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-targets", ] [[package]] -name = "windows-interface" -version = "0.59.1" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "windows-link" -version = "0.1.1" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows-result" -version = "0.3.2" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" -dependencies = [ - "windows-link", -] +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows-strings" -version = "0.4.0" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" -dependencies = [ - "windows-link", -] +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", ] diff --git a/Cargo.toml b/Cargo.toml index 2f0013f..e4f555c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "parse_datetime" description = "parsing human-readable time strings and converting them to a DateTime" -version = "0.10.0" +version = "0.11.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" @@ -10,6 +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" diff --git a/README.md b/README.md index 5a0e0df..9497ef6 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ [![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 - Parses a variety of human-readable and standard time formats. - Supports positive and negative durations. -- Allows for chaining time units (e.g., "1 hour 2 minutes" or "2 days and 2 hours"). +- 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 @@ -58,7 +58,6 @@ The `parse_datetime` and `parse_datetime_at_date` functions support absolute dat - "tomorrow" - use "ago" for the past - use "next" or "last" with `unit` (e.g., "next week", "last year") -- combined units with "and" or "," (e.g., "2 years and 1 month", "1 day, 2 hours" or "2 weeks 1 second") - unix timestamps (for example "@0" "@1344000") `num` can be a positive or negative integer. @@ -70,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)` - 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 diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index a60a071..690cfbf 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -112,6 +112,47 @@ dependencies = [ "cc", ] +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "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" @@ -138,9 +179,9 @@ checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "libfuzzer-sys" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" dependencies = [ "arbitrary", "cc", @@ -148,9 +189,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.18" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "memchr" @@ -175,37 +216,52 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.10.0" +version = "0.11.0" dependencies = [ - "chrono", + "jiff", "num-traits", "regex", "winnow", ] +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.59" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" 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" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -230,6 +286,26 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shlex" version = "1.3.0" @@ -238,9 +314,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", @@ -323,6 +399,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/src/items/builder.rs b/src/items/builder.rs new file mode 100644 index 0000000..cb87bea --- /dev/null +++ b/src/items/builder.rs @@ -0,0 +1,250 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use jiff::{civil, Span, Zoned}; + +use super::{date, epoch, error, relative, time, timezone, weekday, year}; + +/// The builder is used to construct a DateTime object from various components. +/// The parser creates a `DateTimeBuilder` object with the parsed components, +/// but without the baseline date and time. So you normally need to set the base +/// 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(crate) struct DateTimeBuilder { + base: Option, + timestamp: Option, + date: Option, + time: Option, + weekday: Option, + timezone: Option, + relative: Vec, +} + +impl DateTimeBuilder { + pub(super) fn new() -> Self { + Self::default() + } + + /// 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: Zoned) -> Self { + self.base = Some(base); + self + } + + /// 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 { + 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_date(mut self, date: date::Date) -> Result { + 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 { + 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() { + 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 { + 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: timezone::Offset) -> Result { + 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() { + 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, + ) -> Result { + if self.timestamp.is_some() { + return Err("timestamp cannot be combined with other date/time items"); + } + + self.relative.push(relative); + Ok(self) + } + + /// 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 { + 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 { + 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. + let mut dt = if self.timestamp.is_none() + && self.date.is_none() + && self.time.is_none() + && self.weekday.is_none() + && self.timezone.is_none() + { + base + } else { + base.with().time(civil::time(0, 0, 0, 0)).build()? + }; + + 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()?)?; + } + + 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 = dt.with().time(civil::time(0, 0, 0, 0)).build()?; + } + + let mut offset = offset; + let day = day.into(); + + // If the current day is not the target day, we need to adjust + // the x value to ensure we find the correct day. + // + // Consider this: + // Assuming today is Monday, next Friday is actually THIS Friday; + // but next Monday is indeed NEXT Monday. + if dt.date().weekday() != day && offset > 0 { + offset -= 1; + } + + // Calculate the delta to the target day. + // + // Assuming today is Thursday, here are some examples: + // + // Example 1: last Thursday (x = -1, day = Thursday) + // delta = (3 - 3) % 7 + (-1) * 7 = -7 + // + // Example 2: last Monday (x = -1, day = Monday) + // delta = (0 - 3) % 7 + (-1) * 7 = -3 + // + // Example 3: next Monday (x = 1, day = Monday) + // delta = (0 - 3) % 7 + (0) * 7 = 4 + // (Note that we have adjusted the x value above) + // + // Example 4: next Thursday (x = 1, day = Thursday) + // delta = (3 - 3) % 7 + (1) * 7 = 7 + 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).ok_or("multiplication overflow")?; + + dt = dt.checked_add(Span::new().try_days(delta)?)?; + } + + for rel in self.relative { + dt = dt.checked_add::(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 { + let (offset, hour_adjustment) = offset.normalize(); + dt = dt.checked_add(Span::new().hours(hour_adjustment))?; + dt = dt.datetime().to_zoned((&offset).try_into()?)?; + } + + Ok(dt) + } +} diff --git a/src/items/combined.rs b/src/items/combined.rs index c1df933..cce0a1a 100644 --- a/src/items/combined.rs +++ b/src/items/combined.rs @@ -12,7 +12,8 @@ //! > In this format, the time of day should use 24-hour notation. Fractional //! > seconds are allowed, with either comma or period preceding the fraction. //! > ISO 8601 fractional minutes and hours are not supported. Typically, hosts -//! > support nanosecond timestamp resolution; excess precision is silently discarded. +//! > support nanosecond timestamp resolution; excess precision is silently +//! > discarded. use winnow::{ combinator::{alt, trace}, seq, ModalResult, Parser, @@ -20,24 +21,20 @@ use winnow::{ use crate::items::space; -use super::{ - date::{self, Date}, - s, - time::{self, Time}, -}; +use super::{date, primitive::s, time}; #[derive(PartialEq, Debug, Clone, Default)] -pub struct DateTime { - pub(crate) date: Date, - pub(crate) time: Time, +pub(crate) struct DateTime { + pub(crate) date: date::Date, + pub(crate) time: time::Time, } -pub fn parse(input: &mut &str) -> ModalResult { +pub(crate) fn parse(input: &mut &str) -> ModalResult { seq!(DateTime { - date: trace("date iso", alt((date::iso1, date::iso2))), + date: trace("iso_date", alt((date::iso1, date::iso2))), // Note: the `T` is lowercased by the main parse function _: alt((s('t').void(), (' ', space).void())), - time: trace("time iso", time::iso), + time: trace("iso_time", time::iso), }) .parse_next(input) } @@ -58,7 +55,8 @@ mod tests { time: Time { hour: 10, minute: 10, - second: 55.0, + second: 55, + nanosecond: 0, offset: None, }, }); diff --git a/src/items/date.rs b/src/items/date.rs index 86cc992..835928b 100644 --- a/src/items/date.rs +++ b/src/items/date.rs @@ -27,142 +27,230 @@ //! > ‘September’. use winnow::{ - ascii::alpha1, - combinator::{alt, opt, preceded, trace}, - seq, + ascii::{alpha1, multispace1}, + combinator::{alt, eof, opt, preceded, terminated}, + error::ErrMode, stream::AsChar, - token::{take, take_while}, + token::take_while, ModalResult, Parser, }; -use super::{dec_uint, s}; -use crate::ParseDateTimeError; +use super::{ + primitive::{ctx_err, dec_uint, s}, + year::{year_from_str, year_str}, +}; #[derive(PartialEq, Eq, Clone, Debug, Default)] -pub struct Date { - pub day: u32, - pub month: u32, - pub year: Option, +pub(crate) struct Date { + pub(crate) day: u8, + pub(crate) month: u8, + pub(crate) year: Option, +} + +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)`. + /// + /// 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, u8, u8)) -> Result { + let (year_str, month, day) = value; + let year = year_from_str(year_str)?; + + if !(1..=12).contains(&month) { + return Err("month must be between 1 and 12"); + } + + let is_leap_year = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + + if !(1..=31).contains(&day) + || (month == 2 && day > (if is_leap_year { 29 } else { 28 })) + || ((month == 4 || month == 6 || month == 9 || month == 11) && day > 30) + { + return Err("day is not valid for the given month"); + } + + Ok(Date { + day, + month, + year: Some(year), + }) + } +} + +impl TryFrom<(u8, u8)> for Date { + type Error = &'static str; + + /// Create a `Date` from a tuple of `(month, day)`. + fn try_from((month, day): (u8, u8)) -> Result { + if !(1..=12).contains(&month) { + return Err("month must be between 1 and 12"); + } + + if !(1..=31).contains(&day) + || (month == 2 && day > 29) + || ((month == 4 || month == 6 || month == 9 || month == 11) && day > 30) + { + return Err("day is not valid for the given month"); + } + + Ok(Date { + day, + month, + year: None, + }) + } } -pub fn parse(input: &mut &str) -> ModalResult { +impl TryFrom for jiff::civil::Date { + type Error = &'static str; + + fn try_from(date: Date) -> Result { + 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 { alt((iso1, iso2, us, literal1, literal2)).parse_next(input) } -/// Parse `YYYY-MM-DD` or `YY-MM-DD` +/// Parse `[year]-[month]-[day]` /// /// This is also used by [`combined`](super::combined). -pub fn iso1(input: &mut &str) -> ModalResult { - seq!(Date { - year: year.map(Some), - _: s('-'), - month: month, - _: s('-'), - day: day, - }) - .parse_next(input) +pub(super) fn iso1(input: &mut &str) -> ModalResult { + let (year, _, month, _, day) = + (year_str, s('-'), s(dec_uint), s('-'), s(dec_uint)).parse_next(input)?; + + (year, month, day) + .try_into() + .map_err(|e| ErrMode::Cut(ctx_err(e))) } -/// Parse `YYYYMMDD` +/// Parse `[year][month][day]` /// /// This is also used by [`combined`](super::combined). -pub fn iso2(input: &mut &str) -> ModalResult { - s(( - take(4usize).try_map(|s: &str| s.parse::()), - take(2usize).try_map(|s: &str| s.parse::()), - take(2usize).try_map(|s: &str| s.parse::()), - )) - .map(|(year, month, day): (u32, u32, u32)| Date { - day, - month, - year: Some(year), - }) - .parse_next(input) +pub(super) fn iso2(input: &mut &str) -> ModalResult { + 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 = month_from_str(&date_str[len - 4..len - 2])?; + let day = day_from_str(&date_str[len - 2..])?; + + (year, month, day) + .try_into() + .map_err(|e| ErrMode::Cut(ctx_err(e))) } -/// Parse `MM/DD/YYYY`, `MM/DD/YY` or `MM/DD` +/// Parse `[year]/[month]/[day]` or `[month]/[day]/[year]` or `[month]/[day]`. fn us(input: &mut &str) -> ModalResult { - seq!(Date { - month: month, - _: s('/'), - day: day, - year: opt(preceded(s('/'), year)), - }) - .parse_next(input) + let (s1, _, n, s2) = ( + s(take_while(1.., AsChar::is_dec_digit)), + s('/'), + s(dec_uint), + opt(preceded(s('/'), s(take_while(1.., AsChar::is_dec_digit)))), + ) + .parse_next(input)?; + + match s2 { + Some(s2) if s1.len() >= 4 => { + // [year]/[month]/[day] + // + // GNU quirk: interpret as [year]/[month]/[day] if the first part is at + // least 4 characters long. + 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 = month_from_str(s1)?; + (s2, month, n) + .try_into() + .map_err(|e| ErrMode::Cut(ctx_err(e))) + } + None => { + // [month]/[day] + let month = month_from_str(s1)?; + (month, n).try_into().map_err(|e| ErrMode::Cut(ctx_err(e))) + } + } } -/// Parse `14 November 2022`, `14 Nov 2022`, "14nov2022", "14-nov-2022", "14-nov2022", "14nov-2022" +/// Parse `14 November 2022`, `14 Nov 2022`, "14nov2022", "14-nov-2022", +/// "14-nov2022", "14nov-2022". fn literal1(input: &mut &str) -> ModalResult { - seq!(Date { - day: day, - _: opt(s('-')), - month: literal_month, - year: opt(preceded(opt(s('-')), year)), - }) - .parse_next(input) -} + let (day, _, month, year) = ( + s(dec_uint), + opt(s('-')), + s(literal_month), + opt(terminated( + preceded(opt(s('-')), year_str), + // The year must be followed by a space or end of input. + alt((multispace1, eof)), + )), + ) + .parse_next(input)?; -/// Parse `November 14, 2022` and `Nov 14, 2022` -fn literal2(input: &mut &str) -> ModalResult { - seq!(Date { - month: literal_month, - day: day, - // FIXME: GNU requires _some_ space between the day and the year, - // probably to distinguish with floats. - year: opt(preceded(s(","), year)), - }) - .parse_next(input) + match year { + Some(year) => (year, month, day) + .try_into() + .map_err(|e| ErrMode::Cut(ctx_err(e))), + None => (month, day) + .try_into() + .map_err(|e| ErrMode::Cut(ctx_err(e))), + } } -pub fn year(input: &mut &str) -> ModalResult { - // 2147485547 is the maximum value accepted - // by GNU, but chrono only behaves like GNU - // for years in the range: [0, 9999], so we - // keep in the range [0, 9999] - trace( - "year", - s( - take_while(1..=4, AsChar::is_dec_digit).map(|number_str: &str| { - let year = number_str.parse::().unwrap(); - if number_str.len() == 2 { - if year <= 68 { - year + 2000 - } else { - year + 1900 - } - } else { - year - } - }), - ), +/// Parse `November 14, 2022`, `Nov 14, 2022`, and `Nov 14 2022`. +fn literal2(input: &mut &str) -> ModalResult { + let (month, day, year) = ( + s(literal_month), + s(dec_uint), + opt(terminated( + preceded( + // GNU quirk: for formats like `Nov 14, 2022`, there must be some + // space between the comma and the year. This is probably to + // distinguish with floats. + opt(s(terminated(',', multispace1))), + year_str, + ), + // The year must be followed by a space or end of input. + alt((multispace1, eof)), + )), ) - .parse_next(input) -} + .parse_next(input)?; -fn month(input: &mut &str) -> ModalResult { - s(dec_uint) - .try_map(|x| { - (1..=12) - .contains(&x) - .then_some(x) - .ok_or(ParseDateTimeError::InvalidInput) - }) - .parse_next(input) -} - -fn day(input: &mut &str) -> ModalResult { - s(dec_uint) - .try_map(|x| { - (1..=31) - .contains(&x) - .then_some(x) - .ok_or(ParseDateTimeError::InvalidInput) - }) - .parse_next(input) + match year { + Some(year) => (year, month, day) + .try_into() + .map_err(|e| ErrMode::Cut(ctx_err(e))), + None => (month, day) + .try_into() + .map_err(|e| ErrMode::Cut(ctx_err(e))), + } } /// Parse the name of a month (case-insensitive) -fn literal_month(input: &mut &str) -> ModalResult { +fn literal_month(input: &mut &str) -> ModalResult { s(alpha1) .verify_map(|s: &str| { Some(match s { @@ -184,6 +272,16 @@ fn literal_month(input: &mut &str) -> ModalResult { .parse_next(input) } +fn month_from_str(s: &str) -> ModalResult { + s.parse::() + .map_err(|_| ErrMode::Cut(ctx_err("month must be a valid u8 number"))) +} + +fn day_from_str(s: &str) -> ModalResult { + s.parse::() + .map_err(|_| ErrMode::Cut(ctx_err("day must be a valid u8 number"))) +} + #[cfg(test)] mod tests { use super::{parse, Date}; @@ -202,6 +300,252 @@ mod tests { // 14nov2022 // ``` + #[test] + fn iso1() { + let reference = Date { + year: Some(1), + month: 2, + day: 3, + }; + + for mut s in ["1-2-3", "1 - 2 - 3", "1-02-03", "1-002-003", "001-02-03"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + // GNU quirk: when year string is 2 characters long and year is 68 or + // smaller, 2000 is added to it. + let reference = Date { + year: Some(2001), + month: 2, + day: 3, + }; + + for mut s in ["01-2-3", "01-02-03"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + // GNU quirk: when year string is 2 characters long and year is less + // than 100, 1900 is added to it. + let reference = Date { + year: Some(1970), + month: 2, + day: 3, + }; + + for mut s in ["70-2-3", "70-02-03"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + for mut s in ["01-00-01", "01-13-01", "01-01-32", "01-02-29", "01-04-31"] { + let old_s = s.to_owned(); + assert!(parse(&mut s).is_err(), "Format string: {old_s}"); + } + } + + #[test] + fn iso2() { + let reference = Date { + year: Some(1), + month: 2, + day: 3, + }; + + for mut s in ["10203", "0010203", "00010203", "000010203"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + // GNU quirk: when year string is 2 characters long and year is 68 or + // smaller, 2000 is added to it. + let reference = Date { + year: Some(2001), + month: 2, + day: 3, + }; + + let mut s = "010203"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + + // GNU quirk: when year string is 2 characters long and year is less + // than 100, 1900 is added to it. + let reference = Date { + year: Some(1970), + month: 2, + day: 3, + }; + + let mut s = "700203"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + + for mut s in ["010001", "011301", "010132", "010229", "010431"] { + let old_s = s.to_owned(); + assert!(parse(&mut s).is_err(), "Format string: {old_s}"); + } + } + + #[test] + fn us() { + let reference = Date { + year: Some(1), + month: 2, + day: 3, + }; + + for mut s in ["2/3/1", "2 / 3 / 1", "02/03/ 001", "0001/2/3"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + let reference = Date { + year: None, + month: 2, + day: 3, + }; + + for mut s in ["2/3", "2 / 3"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + // GNU quirk: when year string is 2 characters long and year is 68 or + // smaller, 2000 is added to it. + let reference = Date { + year: Some(2001), + month: 2, + day: 3, + }; + + let mut s = "2/3/01"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + + // GNU quirk: when year string is 2 characters long and year is less + // than 100, 1900 is added to it. + let reference = Date { + year: Some(1970), + month: 2, + day: 3, + }; + + let mut s = "2/3/70"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + + for mut s in ["00/01/01", "13/01/01", "01/32/01", "02/30/01", "04/31/01"] { + let old_s = s.to_owned(); + assert!(parse(&mut s).is_err(), "Format string: {old_s}"); + } + } + + #[test] + fn literal1() { + let reference = Date { + year: Some(2022), + month: 11, + day: 14, + }; + + for mut s in [ + "14 november 2022", + "14 nov 2022", + "14-nov-2022", + "14-nov2022", + "14nov2022", + "14nov 2022", + ] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + let reference = Date { + year: None, + month: 11, + day: 14, + }; + + for mut s in ["14 november", "14 nov", "14-nov", "14nov"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + let reference = Date { + year: None, + month: 11, + day: 14, + }; + + // Year must be followed by a space or end of input. + let mut s = "14 nov 2022a"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + assert_eq!(s, " 2022a"); + + let mut s = "14 nov-2022a"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + assert_eq!(s, "-2022a"); + } + + #[test] + fn literal2() { + let reference = Date { + year: Some(2022), + month: 11, + day: 14, + }; + + for mut s in [ + "november 14 2022", + "november 14, 2022", + "november 14 , 2022", + "nov 14 2022", + "nov14 2022", + "nov14, 2022", + ] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + let reference = Date { + year: None, + month: 11, + day: 14, + }; + + for mut s in ["november 14", "nov 14", "nov14"] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + + let reference = Date { + year: None, + month: 11, + day: 14, + }; + + // There must be some space between the comma and the year. + let mut s = "november 14,2022"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + assert_eq!(s, ",2022"); + + // Year must be followed by a space or end of input. + let mut s = "november 14 2022a"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + assert_eq!(s, " 2022a"); + + let mut s = "november 14, 2022a"; + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + assert_eq!(s, ", 2022a"); + } + #[test] fn with_year() { let reference = Date { @@ -257,28 +601,4 @@ mod tests { assert_eq!(parse(&mut s).unwrap(), reference); } } - - #[test] - fn test_year() { - use super::year; - - // the minimun input length is 2 - // assert!(year(&mut "0").is_err()); - // -> GNU accepts year 0 - // test $(date -d '1-1-1' '+%Y') -eq '0001' - - // test $(date -d '68-1-1' '+%Y') -eq '2068' - // 2-characters are converted to 19XX/20XX - assert_eq!(year(&mut "10").unwrap(), 2010u32); - assert_eq!(year(&mut "68").unwrap(), 2068u32); - assert_eq!(year(&mut "69").unwrap(), 1969u32); - assert_eq!(year(&mut "99").unwrap(), 1999u32); - // 3,4-characters are converted verbatim - assert_eq!(year(&mut "468").unwrap(), 468u32); - assert_eq!(year(&mut "469").unwrap(), 469u32); - assert_eq!(year(&mut "1568").unwrap(), 1568u32); - assert_eq!(year(&mut "1569").unwrap(), 1569u32); - // consumes at most 4 characters from the input - //assert_eq!(year(&mut "1234567").unwrap(), 1234u32); - } } diff --git a/src/items/epoch.rs b/src/items/epoch.rs new file mode 100644 index 0000000..60edc8f --- /dev/null +++ b/src/items/epoch.rs @@ -0,0 +1,137 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! 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 winnow::{ + ascii::digit1, + combinator::{opt, preceded}, + token::one_of, + ModalResult, Parser, +}; + +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 for jiff::Timestamp { + type Error = &'static str; + + fn try_from(ts: Timestamp) -> Result { + 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 { + (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::().ok()?.checked_mul(multiplier)?)) + } + None => Some((sec, 0)), + }) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(second: i64, nanosecond: u32) -> Timestamp { + Timestamp { second, nanosecond } + } + + #[test] + 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}"); + } + + 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}"); + } + } + + #[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}"); + } + } +} diff --git a/src/items/error.rs b/src/items/error.rs new file mode 100644 index 0000000..51c6ae2 --- /dev/null +++ b/src/items/error.rs @@ -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> for Error { + fn from(err: ErrMode) -> Self { + Error::ParseError(err.to_string()) + } +} + +impl From for Error { + fn from(err: jiff::Error) -> Self { + Error::ParseError(err.to_string()) + } +} diff --git a/src/items/mod.rs b/src/items/mod.rs index 9a9794a..b3da0c9 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -18,257 +18,240 @@ //! > - pure numbers. //! //! We put all of those in separate modules: +//! - [`combined`] //! - [`date`] +//! - [`epoch`] +//! - [`pure`] +//! - [`relative`] //! - [`time`] -//! - [`time_zone`] -//! - [`combined`] +//! - [`timezone`] //! - [`weekday`] -//! - [`relative`] -//! - [`number] +//! - [`year`] -#![allow(deprecated)] +// date and time items mod combined; mod date; -mod ordinal; +mod epoch; +mod pure; mod relative; mod time; +mod timezone; mod weekday; -mod epoch { - use winnow::{combinator::preceded, ModalResult, Parser}; +mod year; - use super::{dec_int, s}; - pub fn parse(input: &mut &str) -> ModalResult { - s(preceded("@", dec_int)).parse_next(input) - } -} -mod timezone { - use super::time; - use winnow::ModalResult; - - pub(crate) fn parse(input: &mut &str) -> ModalResult { - time::timezone(input) - } -} +// utility modules +mod builder; +mod ordinal; +mod primitive; -use chrono::NaiveDate; -use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike}; +pub(crate) mod error; -use winnow::error::{StrContext, StrContextValue}; +use jiff::Zoned; +use primitive::space; use winnow::{ - ascii::{digit1, multispace0}, - combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated, trace}, - error::{ContextError, ErrMode, ParserError}, - stream::AsChar, - token::{none_of, one_of, take_while}, + combinator::{alt, eof, 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 enum Item { - Timestamp(i32), - 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), } -/// Allow spaces and comments before a parser -/// -/// Every token parser should be wrapped in this to allow spaces and comments. -/// It is only preceding, because that allows us to check mandatory whitespace -/// after running the parser. -fn s<'a, O, E>(p: impl Parser<&'a str, O, E>) -> impl Parser<&'a str, O, E> -where - E: ParserError<&'a str>, -{ - preceded(space, p) +/// Parse a date and time string based on a specific date. +pub(crate) fn parse_at_date + Clone>(base: Zoned, input: S) -> Result { + 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()), + } } -/// Parse the space in-between tokens -/// -/// You probably want to use the [`s`] combinator instead. -fn space<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> -where - E: ParserError<&'a str>, -{ - separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input) +/// Parse a date and time string based on the current local time. +pub(crate) fn parse_at_local + Clone>(input: S) -> Result { + let input = input.as_ref().to_ascii_lowercase(); + match parse(&mut input.as_str()) { + Ok(builder) => builder.build(), + Err(e) => Err(e.into()), + } } -/// A hyphen or plus is ignored when it is not followed by a digit +/// Parse a date and time string. /// -/// This includes being followed by a comment! Compare these inputs: -/// ```txt -/// - 12 weeks -/// - (comment) 12 weeks -/// ``` -/// The last comment should be ignored. +/// Grammar: /// -/// The plus is undocumented, but it seems to be ignored. -fn ignored_hyphen_or_plus<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> -where - E: ParserError<&'a str>, -{ - ( - alt(('-', '+')), - multispace0, - peek(not(take_while(1, AsChar::is_dec_digit))), - ) - .void() - .parse_next(input) -} - -/// Parse a comment +/// ```ebnf +/// spec = timestamp | items ; /// -/// A comment is given between parentheses, which must be balanced. Any other -/// tokens can be within the comment. -fn comment<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> -where - E: ParserError<&'a str>, -{ - delimited( - '(', - repeat(0.., alt((none_of(['(', ')']).void(), comment))), - ')', - ) - .parse_next(input) -} - -/// Parse a signed decimal integer. +/// timestamp = "@" , float ; /// -/// Rationale for not using `winnow::ascii::dec_int`: When upgrading winnow from -/// 0.5 to 0.7, we discovered that `winnow::ascii::dec_int` now accepts only the -/// following two forms: +/// items = item , { item } ; +/// item = datetime | date | time | relative | weekday | timezone | pure ; /// -/// - 0 -/// - [+-][1-9][0-9]* +/// datetime = date , [ "t" | whitespace ] , iso_time ; /// -/// Inputs like [+-]0[0-9]* (e.g., `+012`) are therefore rejected. We provide a -/// custom implementation to support such zero-prefixed integers. -fn dec_int<'a, E>(input: &mut &'a str) -> winnow::Result -where - E: ParserError<&'a str>, -{ - (opt(one_of(['+', '-'])), digit1) - .void() - .take() - .verify_map(|s: &str| s.parse().ok()) - .parse_next(input) -} - -/// Parse an unsigned decimal integer. +/// date = iso_date | us_date | literal1_date | literal2_date ; +/// +/// iso_date = year , [ iso_date_delim ] , month , [ iso_date_delim ] , day ; +/// iso_date_delim = optional_whitespace , "-" , optional_whitespace ; +/// +/// us_date = month , [ us_date_delim ] , day , [ us_date_delim , year ]; +/// us_date_delim = optional_whitespace , "/" , optional_whitespace ; +/// +/// literal1_date = day , [ literal1_date_delim ] , literal_month , [ literal1_date_delim , year ] ; +/// literal1_date_delim = (optional_whitespace , "-" , optional_whitespace) | optional_whitespace ; +/// +/// literal2_date = literal_month , optional_whitespace , day , [ literal2_date_delim , year ] ; +/// literal2_date_delim = (optional_whitespace , "," , optional_whitespace) | optional_whitespace ; +/// +/// year = dec_uint ; +/// month = dec_uint ; +/// day = dec_uint ; +/// +/// literal_month = "january" | "jan" +/// | "february" | "feb" +/// | "march" | "mar" +/// | "april" | "apr" +/// | "may" +/// | "june" | "jun" +/// | "july" | "jul" +/// | "august" | "aug" +/// | "september" | "sept" | "sep" +/// | "october" | "oct" +/// | "november" | "nov" +/// | "december" | "dec" ; +/// +/// time = iso_time | meridiem_time ; +/// +/// iso_time = hour24 , [ ":" , minute , [ ":" , second ] ] , [ time_offset ] ; +/// +/// meridiem_time = hour12 , [ ":" , minute , [ ":" , second ] ] , meridiem ; +/// meridiem = "am" | "pm" | "a.m." | "p.m." ; +/// +/// hour24 = dec_uint ; +/// hour12 = dec_uint ; +/// minute = dec_uint ; +/// second = dec_uint ; +/// +/// time_offset = ( "+" | "-" ) , dec_uint , [ ":" , dec_uint ] ; +/// +/// relative = [ numeric_ordinal ] , unit , [ "ago" ] | day_shift ; +/// +/// unit = "year" | "years" +/// | "month" | "months" +/// | "fortnight" | "fortnights" +/// | "week" | "weeks" +/// | "day" | "days" +/// | "hour" | "hours" +/// | "minute" | "minutes" | "min" | "mins" +/// | "second" | "seconds" | "sec" | "secs" ; +/// +/// day_shift = "tomorrow" | "yesterday" | "today" | "now" ; /// -/// See the rationale for `dec_int` for why we don't use -/// `winnow::ascii::dec_uint`. -fn dec_uint<'a, E>(input: &mut &'a str) -> winnow::Result -where - E: ParserError<&'a str>, -{ - digit1 - .void() - .take() - .verify_map(|s: &str| s.parse().ok()) - .parse_next(input) +/// weekday = [ ordinal ] , day , [ "," ] ; +/// +/// ordinal = numeric_ordinal | text_ordinal ; +/// numeric_ordinal = [ "+" | "-" ] , dec_uint ; +/// text_ordinal = "last" | "this" | "next" | "first" +/// | "third" | "fourth" | "fifth" | "sixth" +/// | "seventh" | "eighth" | "ninth" | "tenth" +/// | "eleventh" | "twelfth" ; +/// +/// day = "monday" | "mon" | "mon." +/// | "tuesday" | "tue" | "tue." | "tues" +/// | "wednesday" | "wed" | "wed." | "wednes" +/// | "thursday" | "thu" | "thu." | "thur" | "thurs" +/// | "friday" | "fri" | "fri." +/// | "saturday" | "sat" | "sat." +/// | "sunday" | "sun" | "sun." ; +/// +/// timezone = named_zone , [ time_offset ] ; +/// +/// pure = { digit } +/// +/// optional_whitespace = { whitespace } ; +/// ``` +fn parse(input: &mut &str) -> ModalResult { + trace("parse", alt((parse_timestamp, parse_items))).parse_next(input) } -// Parse an item -pub fn parse_one(input: &mut &str) -> ModalResult { +/// Parse a timestamp. +/// +/// From the GNU docs: +/// +/// > (Timestamp) Such a number cannot be combined with any other date item, as +/// > it specifies a complete timestamp. +fn parse_timestamp(input: &mut &str) -> ModalResult { trace( - "parse_one", - alt(( - combined::parse.map(Item::DateTime), - date::parse.map(Item::Date), - time::parse.map(Item::Time), - relative::parse.map(Item::Relative), - weekday::parse.map(Item::Weekday), - epoch::parse.map(Item::Timestamp), - timezone::parse.map(Item::TimeZone), - date::year.map(Item::Year), - )), + "parse_timestamp", + terminated(epoch::parse.map(Item::Timestamp), eof), ) + .verify_map(|ts: Item| { + if let Item::Timestamp(ts) = ts { + DateTimeBuilder::new().set_timestamp(ts).ok() + } else { + None + } + }) .parse_next(input) } -pub fn parse(input: &mut &str) -> ModalResult> { - let mut items = Vec::new(); - let mut date_seen = false; - let mut time_seen = false; - let mut year_seen = false; - let mut tz_seen = false; +/// Parse a sequence of items. +fn parse_items(input: &mut &str) -> ModalResult { + let mut builder = DateTimeBuilder::new(); loop { - match parse_one.parse_next(input) { - Ok(item) => { - match item { - Item::DateTime(ref dt) => { - if date_seen || time_seen { - let mut ctx_err = ContextError::new(); - ctx_err.push(StrContext::Expected( - winnow::error::StrContextValue::Description( - "date or time cannot appear more than once", - ), - )); - return Err(ErrMode::Backtrack(ctx_err)); - } - - date_seen = true; - time_seen = true; - if dt.date.year.is_some() { - year_seen = true; - } - } - Item::Date(ref d) => { - if date_seen { - let mut ctx_err = ContextError::new(); - ctx_err.push(StrContext::Expected(StrContextValue::Description( - "date cannot appear more than once", - ))); - return Err(ErrMode::Backtrack(ctx_err)); - } - - date_seen = true; - if d.year.is_some() { - year_seen = true; - } - } - Item::Time(_) => { - if time_seen { - let mut ctx_err = ContextError::new(); - ctx_err.push(StrContext::Expected(StrContextValue::Description( - "time cannot appear more than once", - ))); - return Err(ErrMode::Backtrack(ctx_err)); - } - time_seen = true; - } - Item::Year(_) => { - if year_seen { - let mut ctx_err = ContextError::new(); - ctx_err.push(StrContext::Expected(StrContextValue::Description( - "year cannot appear more than once", - ))); - return Err(ErrMode::Backtrack(ctx_err)); - } - year_seen = true; - } - Item::TimeZone(_) => { - if tz_seen { - let mut ctx_err = ContextError::new(); - ctx_err.push(StrContext::Expected(StrContextValue::Description( - "timezone cannot appear more than once", - ))); - return Err(ErrMode::Backtrack(ctx_err)); - } - tz_seen = true; - } - _ => {} + 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::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))?; } - items.push(item); - } + 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) + .map_err(|e| expect_error(input, e))?; + } + Item::Pure(pure) => { + builder = builder.set_pure(pure).map_err(|e| expect_error(input, e))?; + } + }, Err(ErrMode::Backtrack(_)) => break, Err(e) => return Err(e), } @@ -276,201 +259,50 @@ pub fn parse(input: &mut &str) -> ModalResult> { space.parse_next(input)?; if !input.is_empty() { - return Err(ErrMode::Backtrack(ContextError::new())); + return Err(expect_error(input, "unexpected input")); } - Ok(items) + Ok(builder) } -fn new_date( - year: i32, - month: u32, - day: u32, - hour: u32, - minute: u32, - second: u32, - offset: FixedOffset, -) -> Option> { - let newdate = NaiveDate::from_ymd_opt(year, month, day) - .and_then(|naive| naive.and_hms_opt(hour, minute, second))?; - - Some(DateTime::::from_local(newdate, offset)) +/// Parse an item. +fn parse_item(input: &mut &str) -> ModalResult { + trace( + "parse_item", + alt(( + combined::parse.map(Item::DateTime), + date::parse.map(Item::Date), + time::parse.map(Item::Time), + relative::parse.map(Item::Relative), + weekday::parse.map(Item::Weekday), + timezone::parse.map(Item::TimeZone), + pure::parse.map(Item::Pure), + )), + ) + .parse_next(input) } -/// 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, -) -> Option> { - 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())?; - Some(x) +/// Create an error with context for unexpected input. +fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode { + ErrMode::Cut(ContextError::new()).add_context( + input, + &input.checkpoint(), + StrContext::Expected(StrContextValue::Description(reason)), + ) } -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() -} +#[cfg(test)] +mod tests { + use jiff::{civil::DateTime, tz::TimeZone, ToSpan, Zoned}; -fn at_date_inner(date: Vec, mut d: DateTime) -> Option> { - d = d.with_hour(0).unwrap(); - d = d.with_minute(0).unwrap(); - d = d.with_second(0).unwrap(); - d = d.with_nanosecond(0).unwrap(); - - for item in date { - match item { - Item::Timestamp(ts) => { - d = chrono::Utc - .timestamp_opt(ts.into(), 0) - .unwrap() - .with_timezone(&d.timezone()) - } - Item::Date(date::Date { day, month, year }) => { - d = new_date( - year.map(|x| x as i32).unwrap_or(d.year()), - month, - day, - d.hour(), - d.minute(), - d.second(), - *d.offset(), - )?; - } - Item::DateTime(combined::DateTime { - date: date::Date { day, month, year }, - time: - time::Time { - hour, - minute, - second, - offset, - }, - .. - }) => { - let offset = offset - .and_then(|o| chrono::FixedOffset::try_from(o).ok()) - .unwrap_or(*d.offset()); - - d = new_date( - year.map(|x| x as i32).unwrap_or(d.year()), - month, - day, - hour, - minute, - second as u32, - offset, - )?; - } - Item::Year(year) => d = d.with_year(year as i32).unwrap_or(d), - Item::Time(time::Time { - hour, - minute, - second, - offset, - }) => { - let offset = offset - .and_then(|o| chrono::FixedOffset::try_from(o).ok()) - .unwrap_or(*d.offset()); - - d = new_date( - d.year(), - d.month(), - d.day(), - hour, - minute, - second as u32, - offset, - )?; - } - Item::Weekday(weekday::Weekday { - offset: _, // TODO: use the offset - day, - }) => { - let mut beginning_of_day = d - .with_hour(0) - .unwrap() - .with_minute(0) - .unwrap() - .with_second(0) - .unwrap() - .with_nanosecond(0) - .unwrap(); - let day = day.into(); - - while beginning_of_day.weekday() != day { - beginning_of_day += chrono::Duration::days(1); - } + use super::{parse, DateTimeBuilder}; - d = beginning_of_day - } - Item::Relative(relative::Relative::Years(x)) => { - d = d.with_year(d.year() + x)?; - } - Item::Relative(relative::Relative::Months(x)) => { - // *NOTE* This is done in this way to conform to - // GNU behavior. - let days = last_day_of_month(d.year(), d.month()); - if x >= 0 { - d += d - .date_naive() - .checked_add_days(chrono::Days::new((days * x as u32) as u64))? - .signed_duration_since(d.date_naive()); - } else { - d += d - .date_naive() - .checked_sub_days(chrono::Days::new((days * -x as u32) as u64))? - .signed_duration_since(d.date_naive()); - } - } - Item::Relative(relative::Relative::Days(x)) => d += chrono::Duration::days(x.into()), - Item::Relative(relative::Relative::Hours(x)) => d += chrono::Duration::hours(x.into()), - Item::Relative(relative::Relative::Minutes(x)) => { - d += chrono::Duration::minutes(x.into()); - } - // Seconds are special because they can be given as a float - Item::Relative(relative::Relative::Seconds(x)) => { - d += chrono::Duration::seconds(x as i64); - } - Item::TimeZone(offset) => { - d = with_timezone_restore(offset, d)?; - } - } + fn at_date(builder: DateTimeBuilder, base: Zoned) -> Zoned { + builder.set_base(base).build().unwrap() } - Some(d) -} - -pub(crate) fn at_date( - date: Vec, - d: DateTime, -) -> Result, ParseDateTimeError> { - at_date_inner(date, d).ok_or(ParseDateTimeError::InvalidInput) -} - -pub(crate) fn at_local(date: Vec) -> Result, ParseDateTimeError> { - at_date(date, chrono::Local::now().into()) -} - -#[cfg(test)] -mod tests { - use super::{at_date, date::Date, parse, time::Time, Item}; - use chrono::{DateTime, FixedOffset}; - - fn at_utc(date: Vec) -> DateTime { - at_date(date, chrono::Utc::now().fixed_offset()).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 { @@ -479,27 +311,15 @@ 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() } #[test] fn date_and_time() { assert_eq!( - parse(&mut " 10:10 2022-12-12 "), - Ok(vec![ - Item::Time(Time { - hour: 10, - minute: 10, - second: 0.0, - offset: None, - }), - Item::Date(Date { - day: 12, - month: 12, - year: Some(2022) - }) - ]) + "2022-12-12", + test_eq_fmt("%Y-%m-%d", " 10:10 2022-12-12 ") ); // format, expected output, input @@ -535,9 +355,213 @@ mod tests { test_eq_fmt("%Y-%m-%d %H:%M:%S %:z", "Jul 17 06:14:49 2024 GMT"), ); + assert_eq!( + "2024-07-17 06:14:49.567 +00:00", + test_eq_fmt("%Y-%m-%d %H:%M:%S%.f %:z", "Jul 17 06:14:49.567 2024 GMT"), + ); + + assert_eq!( + "2024-07-17 06:14:49.567 +00:00", + test_eq_fmt("%Y-%m-%d %H:%M:%S%.f %:z", "Jul 17 06:14:49,567 2024 GMT"), + ); + assert_eq!( "2024-07-17 06:14:49 -03:00", test_eq_fmt("%Y-%m-%d %H:%M:%S %:z", "Jul 17 06:14:49 2024 BRT"), ); } + + #[test] + fn invalid() { + let result = parse(&mut "2025-05-19 2024-05-20 06:14:49"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("date cannot appear more than once")); + + let result = parse(&mut "2025-05-19 2024-05-20"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("date cannot appear more than once")); + + let result = parse(&mut "06:14:49 06:14:49"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("time 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()); + assert!(result + .unwrap_err() + .to_string() + .contains("timezone cannot appear more than once")); + + 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 = "2025-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + + assert_eq!( + 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.clone()), now); + assert_eq!( + 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.clone()), + now.checked_sub(6.days()).unwrap() + ); + assert_eq!( + 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.clone()), + now.checked_add(1.days()).unwrap() + ); + assert_eq!( + 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.clone()), + now.checked_add(1.days()).unwrap() + ); + assert_eq!( + 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.clone()), + now.checked_add(8.days()).unwrap() + ); + } + + #[test] + fn relative_date_time() { + let now = Zoned::now().with_time_zone(TimeZone::UTC); + + 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.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.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.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); + 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); + } } diff --git a/src/items/ordinal.rs b/src/items/ordinal.rs index 2762999..afba61f 100644 --- a/src/items/ordinal.rs +++ b/src/items/ordinal.rs @@ -1,14 +1,15 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use super::s; use winnow::{ - ascii::{alpha1, dec_uint}, + ascii::alpha1, combinator::{alt, opt}, ModalResult, Parser, }; -pub fn ordinal(input: &mut &str) -> ModalResult { +use super::primitive::{dec_uint, s}; + +pub(super) fn ordinal(input: &mut &str) -> ModalResult { alt((text_ordinal, number_ordinal)).parse_next(input) } diff --git a/src/items/primitive.rs b/src/items/primitive.rs new file mode 100644 index 0000000..d4e13b3 --- /dev/null +++ b/src/items/primitive.rs @@ -0,0 +1,144 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Primitive combinators. + +use std::str::FromStr; + +use winnow::{ + ascii::{digit1, multispace0, Uint}, + combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated}, + error::{ContextError, ParserError, StrContext, StrContextValue}, + stream::AsChar, + token::{none_of, one_of, take_while}, + Parser, +}; + +/// Allow spaces and comments before a parser +/// +/// Every token parser should be wrapped in this to allow spaces and comments. +/// It is only preceding, because that allows us to check mandatory whitespace +/// after running the parser. +pub(super) fn s<'a, O, E>(p: impl Parser<&'a str, O, E>) -> impl Parser<&'a str, O, E> +where + E: ParserError<&'a str>, +{ + preceded(space, p) +} + +/// Parse the space in-between tokens +/// +/// You probably want to use the [`s`] combinator instead. +pub(super) fn space<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> +where + E: ParserError<&'a str>, +{ + separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input) +} + +/// A hyphen or plus is ignored when it is not followed by a digit +/// +/// This includes being followed by a comment! Compare these inputs: +/// ```txt +/// - 12 weeks +/// - (comment) 12 weeks +/// ``` +/// The last comment should be ignored. +/// +/// The plus is undocumented, but it seems to be ignored. +fn ignored_hyphen_or_plus<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> +where + E: ParserError<&'a str>, +{ + ( + alt(('-', '+')), + multispace0, + peek(not(take_while(1, AsChar::is_dec_digit))), + ) + .void() + .parse_next(input) +} + +/// Parse a comment +/// +/// A comment is given between parentheses, which must be balanced. Any other +/// tokens can be within the comment. +fn comment<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> +where + E: ParserError<&'a str>, +{ + delimited( + '(', + repeat(0.., alt((none_of(['(', ')']).void(), comment))), + ')', + ) + .parse_next(input) +} + +/// Parse a signed decimal integer. +/// +/// Rationale for not using `winnow::ascii::dec_int`: When upgrading winnow from +/// 0.5 to 0.7, we discovered that `winnow::ascii::dec_int` now accepts only the +/// following two forms: +/// +/// - 0 +/// - [+-]?[1-9][0-9]* +/// +/// Inputs like [+-]?0[0-9]* (e.g., `+012`) are therefore rejected. We provide a +/// custom implementation to support such zero-prefixed integers. +#[allow(unused)] +pub(super) fn dec_int<'a, E>(input: &mut &'a str) -> winnow::Result +where + E: ParserError<&'a str>, +{ + (opt(one_of(['+', '-'])), digit1) + .void() + .take() + .verify_map(|s: &str| s.parse().ok()) + .parse_next(input) +} + +/// Parse an unsigned decimal integer. +/// +/// See the rationale for `dec_int` for why we don't use +/// `winnow::ascii::dec_uint`. +pub(super) fn dec_uint<'a, O, E>(input: &mut &'a str) -> winnow::Result +where + O: Uint + FromStr, + E: ParserError<&'a str>, +{ + dec_uint_str + .verify_map(|s: &str| s.parse().ok()) + .parse_next(input) +} + +/// 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>, +{ + 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 +where + E: ParserError<&'a str>, +{ + s(alt(('+', '-'))).parse_next(input) +} + +/// Create a context error with a reason. +pub(super) fn ctx_err(reason: &'static str) -> ContextError { + let mut err = ContextError::new(); + err.push(StrContext::Expected(StrContextValue::Description(reason))); + err +} diff --git a/src/items/pure.rs b/src/items/pure.rs new file mode 100644 index 0000000..4f0e84d --- /dev/null +++ b/src/items/pure.rs @@ -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 { + s(dec_uint_str) + .map(|s: &str| s.to_owned()) + .parse_next(input) +} diff --git a/src/items/relative.rs b/src/items/relative.rs index 3a605a7..021f580 100644 --- a/src/items/relative.rs +++ b/src/items/relative.rs @@ -32,38 +32,42 @@ //! > ‘this thursday’. use winnow::{ - ascii::{alpha1, float}, + ascii::alpha1, combinator::{alt, opt}, ModalResult, Parser, }; -use super::{ordinal::ordinal, 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 { - fn mul(self, n: i32) -> Self { - match self { - Self::Years(x) => Self::Years(n * x), - Self::Months(x) => Self::Months(n * x), - Self::Days(x) => Self::Days(n * x), - Self::Hours(x) => Self::Hours(n * x), - Self::Minutes(x) => Self::Minutes(n * x), - Self::Seconds(x) => Self::Seconds(f64::from(n) * x), +impl TryFrom for jiff::Span { + type Error = &'static str; + + fn try_from(relative: Relative) -> Result { + 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 { +pub(super) fn parse(input: &mut &str) -> ModalResult { alt(( s("tomorrow").value(Relative::Days(1)), s("yesterday").value(Relative::Days(-1)), @@ -71,24 +75,48 @@ pub fn parse(input: &mut &str) -> ModalResult { s("today").value(Relative::Days(0)), s("now").value(Relative::Days(0)), seconds, - other, + displacement, )) .parse_next(input) } fn seconds(input: &mut &str) -> ModalResult { ( - 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 { - (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 { + (opt(ordinal), s(alpha1), ago) + .verify_map(|(n, unit, ago): (Option, &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) } @@ -96,23 +124,6 @@ fn ago(input: &mut &str) -> ModalResult { opt(s("ago")).map(|o| o.is_some()).parse_next(input) } -fn integer_unit(input: &mut &str) -> ModalResult { - 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}; @@ -121,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)), @@ -176,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)), diff --git a/src/items/time.rs b/src/items/time.rs index 7fa8d3f..bbad9a8 100644 --- a/src/items/time.rs +++ b/src/items/time.rs @@ -3,7 +3,7 @@ // spell-checker:ignore shhmm colonless -//! Parse a time item (without a date) +//! Parse a time item (without a date). //! //! The GNU docs state: //! @@ -37,136 +37,77 @@ //! > //! > Either ‘am’/‘pm’ or a time zone correction may be specified, but not both. -use std::fmt::Display; - -use chrono::FixedOffset; use winnow::{ - ascii::{digit1, float}, - combinator::{alt, opt, peek, preceded}, - error::{ContextError, ErrMode, StrContext, StrContextValue}, - seq, - stream::AsChar, - token::take_while, + combinator::{alt, opt, preceded}, + error::ErrMode, ModalResult, Parser, }; -use crate::ParseDateTimeError; - -use super::{dec_uint, relative, s}; - -#[derive(PartialEq, Debug, Clone, Default)] -pub struct Offset { - pub(crate) negative: bool, - pub(crate) hours: u32, - pub(crate) minutes: u32, -} +use super::{ + epoch::sec_and_nsec, + primitive::{colon, ctx_err, dec_uint, s}, + timezone::{timezone_offset, Offset}, +}; #[derive(PartialEq, Clone, Debug, Default)] -pub struct Time { - pub hour: u32, - pub minute: u32, - pub second: f64, - pub offset: Option, +pub(crate) struct Time { + pub(crate) hour: u8, + pub(crate) minute: u8, + pub(crate) second: u8, + pub(crate) nanosecond: u32, + pub(super) offset: Option, } -impl Offset { - fn merge(self, offset: Offset) -> Option { - fn combine(a: u32, neg_a: bool, b: u32, neg_b: bool) -> (u32, bool) { - if neg_a == neg_b { - (a + b, neg_a) - } else if a > b { - (a - b, neg_a) - } else { - (b - a, neg_b) - } - } - let (hours_minutes, negative) = combine( - self.hours * 60 + self.minutes, - self.negative, - offset.hours * 60 + offset.minutes, - offset.negative, - ); - let hours = hours_minutes / 60; - let minutes = hours_minutes % 60; - - Some(Offset { - negative, - hours, - minutes, - }) - } -} - -impl TryFrom for chrono::FixedOffset { - type Error = ParseDateTimeError; - - fn try_from( - Offset { - negative, - hours, - minutes, - }: Offset, - ) -> Result { - let secs = hours * 3600 + minutes * 60; - - let offset = if negative { - FixedOffset::west_opt( - secs.try_into() - .map_err(|_| ParseDateTimeError::InvalidInput)?, - ) - .ok_or(ParseDateTimeError::InvalidInput)? - } else { - FixedOffset::east_opt( - secs.try_into() - .map_err(|_| ParseDateTimeError::InvalidInput)?, - ) - .ok_or(ParseDateTimeError::InvalidInput)? - }; - - Ok(offset) - } -} +impl TryFrom