99 Commits

Author SHA1 Message Date
Sylvestre Ledru 46a5ef89c9 Merge pull request #195 from cakebaker/bump_version_to_0_11
Bump version to `0.11.0`
2025-08-01 09:05:34 +02:00
Daniel Hofstetter 226d6a0588 Bump version to 0.11.0 2025-08-01 08:38:55 +02:00
Sylvestre Ledru 28b9f11f2e Merge pull request #179 from cakebaker/remove_unnecessary_option
chore: remove unnecessary `Option` as return value
2025-08-01 08:25:34 +02:00
Sylvestre Ledru b68c325abd Merge pull request #162 from cakebaker/set_timezone_for_tests
tests: set timezone to `UTC0` in integration tests
2025-08-01 08:25:18 +02:00
Daniel Hofstetter 27a626d3d5 Merge pull request #194 from uutils/renovate/rstest-0.x-lockfile
chore(deps): update rust crate rstest to v0.26.1
2025-07-27 16:43:53 +02:00
renovate[bot] 1630fd025e chore(deps): update rust crate rstest to v0.26.1 2025-07-27 14:39:43 +00:00
Daniel Hofstetter cf5770838c Merge pull request #193 from uutils/renovate/rstest-0.x
chore(deps): update rust crate rstest to 0.26
2025-07-27 10:26:19 +02:00
renovate[bot] db9ff29c02 chore(deps): update rust crate rstest to 0.26 2025-07-26 21:25:06 +00:00
Daniel Hofstetter 4cfe8db9be Merge pull request #192 from yuankunzhang/update-readme
Update readme
2025-07-24 16:26:03 +02:00
yuankunzhang 1e7a541528 docs: update readme 2025-07-24 14:21:24 +00:00
Daniel Hofstetter b2bb6dfe91 Merge pull request #191 from yuankunzhang/update-comments
docs: update comments
2025-07-24 16:20:22 +02:00
yuankunzhang d71c6ea41c docs: update comments 2025-07-24 14:09:45 +00:00
Daniel Hofstetter 3b6362e7c8 Merge pull request #190 from yuankunzhang/fix-base-datetime-handling
fix base datetime handling in the builder
2025-07-24 10:21:28 +02:00
yuankunzhang 897a50f4d5 fix base datetime handling in the builder 2025-07-24 10:09:25 +02:00
Daniel Hofstetter 96f477121f Merge pull request #189 from yuankunzhang/support-float-timestamp
fix: support float timestamp values
2025-07-24 10:03:56 +02:00
yuankunzhang c47ce6870a fix: support float timestamp values 2025-07-24 09:34:02 +02:00
Daniel Hofstetter 77e1ab2d00 Merge pull request #188 from yuankunzhang/update-comments
Update comments
2025-07-23 17:35:43 +02:00
yuankunzhang a48ad6aec3 chore: update comments 2025-07-23 17:30:32 +02:00
Daniel Hofstetter 0ddeec70b4 Merge pull request #187 from yuankunzhang/year-module
feat: add a year module
2025-07-23 17:26:43 +02:00
yuankunzhang 2bb8ac8203 feat: add a year module 2025-07-23 13:53:46 +00:00
Daniel Hofstetter 5b52929c91 Merge pull request #186 from yuankunzhang/improve-literal-date-parser
feat: improve the literal date parsers
2025-07-23 14:42:54 +02:00
yuankunzhang 3d6f79d826 feat: improve the literal date parsers 2025-07-23 12:31:33 +00:00
Daniel Hofstetter 324d0bca34 Merge pull request #184 from yuankunzhang/improve-us-date-parser
Improve the `us()` date parser
2025-07-22 15:35:14 +02:00
yuankunzhang 9f75f895e0 feat: improve the us() date parser 2025-07-22 15:30:13 +02:00
Yuankun Zhang 5ba4b18655 Improve the iso1() and iso2() date parsers (#183)
* feat: improve date parsing with GNU compatibility and validation

- Add `TryFrom<(&str, u32, u32)>` implementation for `Date` struct
  with validation.
- Add comprehensive date validation for months, days, taking leap
  year into account.
- Refactor `iso1()` and `iso2()` date parsers to use the new
  validation logic.

* fix typo

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>

* remove unnecessary if condition

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>

---------

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>
2025-07-22 14:49:36 +02:00
Daniel Hofstetter 5d869b5264 Merge pull request #182 from yuankunzhang/add-ctx-err
feat: add a `ctx_err` util function
2025-07-20 14:07:21 +02:00
yuankunzhang 793d18dce7 feat: add a ctx_err util function 2025-07-20 03:28:52 +00:00
Daniel Hofstetter 7182760266 Merge pull request #181 from yuankunzhang/support-leading-zeros-in-timezone-colonless
feat: support leading zeros in `timezone_colonless()`
2025-07-19 17:30:07 +02:00
yuankunzhang e7a8a52a21 feat: support leading zeros in timezone_colonless() 2025-07-19 17:18:39 +02:00
Daniel Hofstetter 0674254bc6 Merge pull request #180 from yuankunzhang/fix-time-offset
fix: time offset can only appear after time or timezone
2025-07-19 16:16:17 +02:00
yuankunzhang 8bebfab576 fix: time offset can only appear after time or timezone 2025-07-19 13:57:46 +00:00
Daniel Hofstetter 6feeddfea1 chore: remove unnecessary Option as return value 2025-07-17 10:12:03 +02:00
Daniel Hofstetter 6499842e15 Merge pull request #178 from yuankunzhang/improve-items-mod
chore: re-organize items/mod.rs
2025-07-17 09:44:47 +02:00
yuankunzhang 949e0613d6 chore: re-organize items/mod.rs 2025-07-17 09:54:24 +08:00
Daniel Hofstetter 6e6ffc2dff tests: set timezone to UTC0 in integration tests 2025-07-16 10:13:20 +02:00
Daniel Hofstetter 67364fc6ba Merge pull request #174 from yuankunzhang/exclusive-timestamp
fix: timestamp cannot be combined with other items
2025-07-16 10:09:47 +02:00
Daniel Hofstetter d92f5eeb27 Merge pull request #176 from uutils/renovate/winnow-0.x-lockfile
fix(deps): update rust crate winnow to v0.7.12
2025-07-16 10:07:03 +02:00
renovate[bot] 5c4a60fadf fix(deps): update rust crate winnow to v0.7.12 2025-07-16 08:01:37 +00:00
Daniel Hofstetter 45bda33a6b Merge pull request #175 from uutils/renovate/libfuzzer-sys-0.x-lockfile
fix(deps): update rust crate libfuzzer-sys to v0.4.10
2025-07-04 08:06:44 +02:00
renovate[bot] 64069cdbf2 fix(deps): update rust crate libfuzzer-sys to v0.4.10 2025-07-03 23:25:47 +00:00
yuankunzhang 4d6568b326 fix: timestamp cannot be combined with other items 2025-07-01 13:40:04 +00:00
Daniel Hofstetter f5c1a5e399 Merge pull request #173 from yuankunzhang/date-time-builder
refactor: introduce a builder and simplify the date time composition
2025-07-01 14:52:49 +02:00
yuankunzhang 706ab45937 refactor: introduce a builder and simplify the date time composition 2025-06-30 13:51:25 +00:00
Daniel Hofstetter 4d9afba2d4 Merge pull request #172 from yuankunzhang/minor-refactor
refactor: put the epoch and timezone module into separate files
2025-06-30 15:10:34 +02:00
yuankunzhang 05677a803d refactor: put the epoch and timezone module into separate files 2025-06-30 13:05:10 +00:00
Daniel Hofstetter 98d4931b53 Merge pull request #169 from yuankunzhang/hour-with-meridiem
fix: hour must be greater than 0 when meridiem is specified
2025-06-28 17:03:48 +02:00
yuankunzhang 0b5d3ffdf3 fix: hour must be greater than 0 when meridiem is specified 2025-06-28 16:48:09 +02:00
Daniel Hofstetter 24fa78a927 Merge pull request #168 from yuankunzhang/nanoseconds
feat: support nanoseconds
2025-06-28 16:45:22 +02:00
yuankunzhang c0ada32ce1 feat: support nanoseconds 2025-06-28 16:30:24 +02:00
Daniel Hofstetter b5b1fd2b5e Merge pull request #171 from yuankunzhang/check-overflows
fix: check overflows during date time composition
2025-06-28 16:18:41 +02:00
yuankunzhang 8d78de85a5 fix: check overflows during date time composition 2025-06-27 15:24:00 +00:00
Sylvestre Ledru 33ac3b75cc Merge pull request #170 from cakebaker/clippy_fix_warning
clippy: fix warning from `uninlined_format_args`
2025-06-27 09:54:14 +02:00
Daniel Hofstetter 832942c019 clippy: fix warning from uninlined_format_args 2025-06-27 09:45:11 +02:00
Daniel Hofstetter e031c8ce49 Merge pull request #167 from Maximkaaa/integration_tests
test: Integration tests for time parsing
2025-06-26 16:59:28 +02:00
Maxim 856a13ddb2 test: Integration tests for date parsing 2025-06-25 05:15:41 +00:00
Sylvestre Ledru f1f2c47120 Merge pull request #163 from cakebaker/tests_fix_typos
tests: fix typos
2025-06-24 14:47:21 +02:00
Sylvestre Ledru 2c0fa5a264 Merge pull request #164 from cakebaker/tests_add_license_header
tests: add license header to integration tests
2025-06-24 14:47:08 +02:00
Daniel Hofstetter 937572d557 tests: add license header to integration tests 2025-06-24 14:43:56 +02:00
Daniel Hofstetter a7d9d1ab59 tests: fix typos
aphabetical -> alphabetical, ommititing -> omitting
2025-06-24 13:55:37 +02:00
Sylvestre Ledru 3be37c4d82 Merge pull request #161 from Maximkaaa/integration_tests
Integration tests for date parsing
2025-06-24 12:36:55 +02:00
Daniel Hofstetter 95897465b2 Merge pull request #159 from yuankunzhang/weekday-optional-comma
fix: weekday may be followed by an optional comma
2025-06-21 07:42:03 +02:00
Maxim 7260b0ae73 test: Integration tests for date parsing 2025-06-20 09:16:48 +00:00
Maxim 92daee09de chore: Remove empty source file 2025-06-20 15:31:40 +09:00
yuankunzhang 73f69b1da0 fix: weekday may be followed by an optional comma 2025-06-17 22:08:35 +08:00
Daniel Hofstetter 30cb26d75c Merge pull request #157 from yuankunzhang/simplify-mod-rs
Simplify items/mod.rs by moving the fundamental combinators to their own module
2025-06-15 17:04:49 +02:00
yuankunzhang 6e64f21967 refactor: move fundamental combinators from mod.rs to the primitive module 2025-06-15 21:15:28 +08:00
yuankunzhang 9b67307512 fix: relative date time handling 2025-06-15 21:13:31 +08:00
Sylvestre Ledru 76d8f17350 Merge pull request #155 from yuankunzhang/main
fix: use custom dec_uint in ordinal parsing
2025-06-15 11:05:09 +02:00
Yuankun Zhang c05b8217e0 Merge branch 'main' into main 2025-06-15 12:22:16 +08:00
yuankunzhang 41a53a2610 fix: use custom dec_uint in ordinal parsing 2025-06-15 12:21:07 +08:00
Daniel Hofstetter 03cff2ce1e Merge pull request #150 from yuankunzhang/minor-fix
fix: check whether the parsed time contains timezone
2025-06-14 15:10:44 +02:00
Daniel Hofstetter 12f73c3d57 Merge pull request #153 from uutils/renovate/winnow-0.x-lockfile
fix(deps): update rust crate winnow to v0.7.11
2025-06-10 18:47:50 +02:00
renovate[bot] c694587fdd fix(deps): update rust crate winnow to v0.7.11 2025-06-10 16:41:00 +00:00
yuankunzhang 3fe06c0b94 fix: check whether the parsed time contains timezone 2025-06-04 16:42:32 +08:00
Sylvestre Ledru 1a6a6f688a Merge pull request #143 from yuankunzhang/main
use proper error mode in items::parse()
2025-06-04 09:11:30 +02:00
yuankunzhang 294bd5eeb2 use proper error mode in items::parse() 2025-05-28 23:21:41 +08:00
yuankunzhang 60745300ad use a custom float parser 2025-05-28 23:19:10 +08:00
Sylvestre Ledru 1552608bce Merge pull request #145 from yuankunzhang/fallible-offset-conversion
fallible offset conversion
2025-05-26 18:08:50 +02:00
Sylvestre Ledru ee9beb07d3 prepare release 0.10.0 2025-05-26 16:50:31 +02:00
Daniel Hofstetter 6f073addf5 Merge pull request #146 from drinkcat/fix-tests
Fix/improve tests
2025-05-26 13:59:44 +02:00
Nicolas Boichat a9676626be src/items: Fix timezone tests
The current tests look wrong ("%Z" shouldn't print an offset, but
this accidentally works due to the way the parsing logic operates),
fix that.
2025-05-25 21:34:22 +02:00
Nicolas Boichat da0af495e8 test: Replace %+ format with explicit one
%+ is an extension that jiff doesn't support (if we still want to
convert parse_datetime in the future, and since these are tests,
it's probably better to use an explicit format.
2025-05-25 21:34:22 +02:00
Nicolas Boichat e00555cd96 src/lib: Add end-to-end timezone test
Also make the offset test a little bit smarter.
2025-05-25 21:34:22 +02:00
yuankunzhang ab91b2b6d7 fallible offset conversion 2025-05-20 21:30:35 +08:00
Terts Diepraam cc7c143b21 Merge pull request #141 from yuankunzhang/main
Upgrade winnow to 0.7.10
2025-05-19 15:05:43 +02:00
Daniel Hofstetter 7721ae2cd8 Merge pull request #142 from yanganto/msrv
Specify MSRV 1.71.1
2025-05-14 16:06:27 +02:00
Antonio Yang 557c3a3ae3 Specify MSRV 1.71.1 2025-05-14 00:59:47 +08:00
yuankunzhang bea8ec2641 upgrade winnow to 0.7.10 2025-05-12 19:46:33 +08:00
Terts Diepraam 852e49e224 Merge pull request #138 from yuankunzhang/code-cleanup
Code cleanup
2025-05-09 13:31:02 +02:00
yuankunzhang 2390ef0b25 code cleanup 2025-05-09 18:55:10 +08:00
Terts Diepraam 77156f46d2 Merge pull request #137 from yuankunzhang/replace-the-regex-parser
Replace the regex parser with winnow-based parse
2025-05-09 11:35:48 +02:00
yuankunzhang bc6b70bc9e code fixup to pass all tests 2025-05-07 21:54:42 +08:00
Daniel Hilst 2479c61dd5 Find a compatibility intersection between chrono and GNU 2025-04-30 19:53:34 +08:00
Daniel Hilst 4c3d2221da Restore the old API and add the missing combinators 2025-04-30 19:53:34 +08:00
Terts Diepraam 07d4b80ea5 start parsing date with winnow 2025-04-30 19:53:34 +08:00
Daniel Hofstetter b481a2bdec Merge pull request #136 from uutils/renovate/chrono-0.x-lockfile
fix(deps): update rust crate chrono to v0.4.41
2025-04-29 18:28:41 +02:00
renovate[bot] a387463373 fix(deps): update rust crate chrono to v0.4.41 2025-04-29 15:59:06 +00:00
Daniel Hofstetter b6795d9b2e Merge pull request #134 from dan-hipschman/handle-uppercase
Allow uppercase for words like "today", "next", etc.
2025-04-26 17:56:24 +02:00
Dan Hipschman 3bdafeb2cf Allow uppercase for words like "today", "next", etc. 2025-04-26 08:07:10 -07:00
26 changed files with 3734 additions and 1928 deletions
+14
View File
@@ -4,6 +4,7 @@ name: Basic CI
env:
CARGO_TERM_COLOR: always
RUST_MIN_SRV: "1.71.1"
jobs:
check:
@@ -49,6 +50,19 @@ jobs:
- run: rustup component add clippy
- run: cargo clippy --all-targets -- -D warnings
min_version:
name: Minimum Supported Rust Version
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_MIN_SRV }}
- run: cargo test
coverage:
name: Code Coverage
runs-on: ${{ matrix.job.os }}
Generated
+287 -120
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,13 +1,18 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a DateTime"
version = "0.9.0"
version = "0.11.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
readme = "README.md"
rust-version = "1.71.1"
[dependencies]
regex = "1.10.4"
chrono = { version="0.4.38", default-features=false, features=["std", "alloc", "clock"] }
nom = "8.0.0"
winnow = "0.7.10"
num-traits = "0.2.19"
[dev-dependencies]
rstest = "0.26"
+1 -2
View File
@@ -10,7 +10,7 @@ A Rust crate for parsing human-readable relative time strings and human-readable
- 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
@@ -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.
+18 -17
View File
@@ -63,9 +63,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.40"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
@@ -138,9 +138,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",
@@ -158,20 +158,11 @@ version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.15"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
@@ -184,11 +175,12 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
[[package]]
name = "parse_datetime"
version = "0.8.0"
version = "0.10.0"
dependencies = [
"chrono",
"nom",
"num-traits",
"regex",
"winnow",
]
[[package]]
@@ -394,3 +386,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [
"memchr",
]
+320
View File
@@ -0,0 +1,320 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike};
use super::{date, relative, time, weekday};
/// 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 struct DateTimeBuilder {
base: Option<DateTime<FixedOffset>>,
timestamp: Option<f64>,
date: Option<date::Date>,
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
timezone: Option<time::Offset>,
relative: Vec<relative::Relative>,
}
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: DateTime<FixedOffset>) -> Self {
self.base = Some(base);
self
}
/// Timestamp value is exclusive to other date/time components. Caller of
/// the builder must ensure that it is not combined with other items.
pub(super) fn set_timestamp(mut self, ts: f64) -> Result<Self, &'static str> {
self.timestamp = Some(ts);
Ok(self)
}
pub(super) fn set_year(mut self, year: u32) -> Result<Self, &'static str> {
if let Some(date) = self.date.as_mut() {
if date.year.is_some() {
Err("year cannot appear more than once")
} else {
date.year = Some(year);
Ok(self)
}
} else {
self.date = Some(date::Date {
day: 1,
month: 1,
year: Some(year),
});
Ok(self)
}
}
pub(super) fn set_date(mut self, date: date::Date) -> Result<Self, &'static str> {
if self.date.is_some() || self.timestamp.is_some() {
Err("date cannot appear more than once")
} else {
self.date = Some(date);
Ok(self)
}
}
pub(super) fn set_time(mut self, time: time::Time) -> Result<Self, &'static str> {
if self.time.is_some() || self.timestamp.is_some() {
Err("time cannot appear more than once")
} else if self.timezone.is_some() && time.offset.is_some() {
Err("time offset and timezone are mutually exclusive")
} else {
self.time = Some(time);
Ok(self)
}
}
pub(super) fn set_weekday(mut self, weekday: weekday::Weekday) -> Result<Self, &'static str> {
if self.weekday.is_some() {
Err("weekday cannot appear more than once")
} else {
self.weekday = Some(weekday);
Ok(self)
}
}
pub(super) fn set_timezone(mut self, timezone: time::Offset) -> Result<Self, &'static str> {
if self.timezone.is_some() {
Err("timezone cannot appear more than once")
} else if self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() {
Err("time offset and timezone are mutually exclusive")
} else {
self.timezone = Some(timezone);
Ok(self)
}
}
pub(super) fn push_relative(mut self, relative: relative::Relative) -> Self {
self.relative.push(relative);
self
}
pub(super) fn build(self) -> Option<DateTime<FixedOffset>> {
let base = self.base.unwrap_or_else(|| chrono::Local::now().into());
// 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 {
new_date(
base.year(),
base.month(),
base.day(),
0,
0,
0,
0,
*base.offset(),
)?
};
if let Some(ts) = self.timestamp {
// TODO: How to make the fract -> nanosecond conversion more precise?
// Maybe considering using the
// [rust_decimal](https://crates.io/crates/rust_decimal) crate?
match chrono::Utc.timestamp_opt(ts as i64, (ts.fract() * 10f64.powi(9)).round() as u32)
{
chrono::MappedLocalTime::Single(t) => {
// If the timestamp is valid, we can use it directly.
dt = t.with_timezone(&dt.timezone());
}
chrono::MappedLocalTime::Ambiguous(earliest, _latest) => {
// TODO: When there is a fold in the local time, which value
// do we choose? For now, we use the earliest one.
dt = earliest.with_timezone(&dt.timezone());
}
chrono::MappedLocalTime::None => {
return None; // Invalid timestamp
}
}
}
if let Some(date::Date { year, month, day }) = self.date {
dt = new_date(
year.map(|x| x as i32).unwrap_or(dt.year()),
month,
day,
dt.hour(),
dt.minute(),
dt.second(),
dt.nanosecond(),
*dt.offset(),
)?;
}
if let Some(time::Time {
hour,
minute,
second,
ref offset,
}) = self.time
{
let offset = offset
.clone()
.and_then(|o| chrono::FixedOffset::try_from(o).ok())
.unwrap_or(*dt.offset());
dt = new_date(
dt.year(),
dt.month(),
dt.day(),
hour,
minute,
second as u32,
(second.fract() * 10f64.powi(9)).round() as u32,
offset,
)?;
}
if let Some(weekday::Weekday { offset, day }) = self.weekday {
if self.time.is_none() {
dt = new_date(dt.year(), dt.month(), dt.day(), 0, 0, 0, 0, *dt.offset())?;
}
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.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.num_days_from_monday() as i32
- dt.weekday().num_days_from_monday() as i32)
.rem_euclid(7)
+ offset.checked_mul(7)?;
dt = if delta < 0 {
dt.checked_sub_days(chrono::Days::new((-delta) as u64))?
} else {
dt.checked_add_days(chrono::Days::new(delta as u64))?
}
}
for rel in self.relative {
// TODO: Handle potential overflows in the addition operations.
match rel {
relative::Relative::Years(x) => {
dt = dt.with_year(dt.year() + x)?;
}
relative::Relative::Months(x) => {
// *NOTE* This is done in this way to conform to
// GNU behavior.
let days = last_day_of_month(dt.year(), dt.month());
if x >= 0 {
dt += dt
.date_naive()
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
.signed_duration_since(dt.date_naive());
} else {
dt += dt
.date_naive()
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
.signed_duration_since(dt.date_naive());
}
}
relative::Relative::Days(x) => dt += chrono::Duration::days(x.into()),
relative::Relative::Hours(x) => dt += chrono::Duration::hours(x.into()),
relative::Relative::Minutes(x) => {
dt += chrono::Duration::try_minutes(x.into())?;
}
// Seconds are special because they can be given as a float.
relative::Relative::Seconds(x) => {
dt += chrono::Duration::try_seconds(x as i64)?;
}
}
}
if let Some(offset) = self.timezone {
dt = with_timezone_restore(offset, dt)?;
}
Some(dt)
}
}
#[allow(clippy::too_many_arguments)]
fn new_date(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
nano: u32,
offset: FixedOffset,
) -> Option<DateTime<FixedOffset>> {
let newdate = NaiveDate::from_ymd_opt(year, month, day)
.and_then(|naive| naive.and_hms_nano_opt(hour, minute, second, nano))?;
Some(DateTime::<FixedOffset>::from_local(newdate, offset))
}
/// Restores year, month, day, etc after applying the timezone
/// returns None if timezone overflows the date
fn with_timezone_restore(
offset: time::Offset,
at: DateTime<FixedOffset>,
) -> Option<DateTime<FixedOffset>> {
let offset: FixedOffset = chrono::FixedOffset::try_from(offset).ok()?;
let copy = at;
let x = at
.with_timezone(&offset)
.with_day(copy.day())?
.with_month(copy.month())?
.with_year(copy.year())?
.with_hour(copy.hour())?
.with_minute(copy.minute())?
.with_second(copy.second())?
.with_nanosecond(copy.nanosecond())?;
Some(x)
}
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()
}
+79
View File
@@ -0,0 +1,79 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Parse an ISO 8601 date and time item
//!
//! The GNU docs state:
//!
//! > The ISO 8601 date and time of day extended format consists of an ISO 8601
//! > date, a T character separator, and an ISO 8601 time of day. This format
//! > is also recognized if the T is replaced by a space.
//! >
//! > 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.
use winnow::{
combinator::{alt, trace},
seq, ModalResult, Parser,
};
use crate::items::space;
use super::{
date::{self, Date},
primitive::s,
time::{self, Time},
};
#[derive(PartialEq, Debug, Clone, Default)]
pub(crate) struct DateTime {
pub(crate) date: Date,
pub(crate) time: Time,
}
pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTime> {
seq!(DateTime {
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("iso_time", time::iso),
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::{parse, DateTime};
use crate::items::{date::Date, time::Time};
#[test]
fn some_date() {
let reference = Some(DateTime {
date: Date {
day: 10,
month: 10,
year: Some(2022),
},
time: Time {
hour: 10,
minute: 10,
second: 55.0,
offset: None,
},
});
for mut s in [
"2022-10-10t10:10:55",
"2022-10-10 10:10:55",
"2022-10-10 t 10:10:55",
"2022-10-10 10:10:55",
"2022-10-10 (A comment!) t 10:10:55",
"2022-10-10 (A comment!) 10:10:55",
] {
let old_s = s.to_owned();
assert_eq!(parse(&mut s).ok(), reference, "Failed string: {old_s}")
}
}
}
+583
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use winnow::{combinator::preceded, ModalResult, Parser};
use super::primitive::{float, s};
/// Parse a timestamp in the form of `@1234567890`.
pub fn parse(input: &mut &str) -> ModalResult<f64> {
s(preceded("@", float)).parse_next(input)
}
#[cfg(test)]
mod tests {
use super::parse;
fn float_eq(a: f64, b: f64) -> bool {
(a - b).abs() < f64::EPSILON
}
#[test]
fn float() {
let mut input = "@1234567890";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.0));
let mut input = "@1234567890.12345";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.12345));
let mut input = "@1234567890,12345";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.12345));
let mut input = "@-1234567890.12345";
assert_eq!(parse(&mut input).unwrap(), -1234567890.12345);
}
}
+501
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use winnow::{
ascii::alpha1,
combinator::{alt, opt},
ModalResult, Parser,
};
use super::primitive::{dec_uint, s};
pub(super) fn ordinal(input: &mut &str) -> ModalResult<i32> {
alt((text_ordinal, number_ordinal)).parse_next(input)
}
fn number_ordinal(input: &mut &str) -> ModalResult<i32> {
let sign = opt(alt(('+'.value(1), '-'.value(-1)))).map(|s| s.unwrap_or(1));
(s(sign), s(dec_uint))
.verify_map(|(s, u): (i32, u32)| {
let i: i32 = u.try_into().ok()?;
Some(s * i)
})
.parse_next(input)
}
fn text_ordinal(input: &mut &str) -> ModalResult<i32> {
s(alpha1)
.verify_map(|s: &str| {
Some(match s {
"last" => -1,
"this" => 0,
"next" | "first" => 1,
"third" => 3,
"fourth" => 4,
"fifth" => 5,
"sixth" => 6,
"seventh" => 7,
"eight" => 8,
"ninth" => 9,
"tenth" => 10,
"eleventh" => 11,
"twelfth" => 12,
_ => return None,
})
})
.parse_next(input)
}
+140
View File
@@ -0,0 +1,140 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Primitive combinators.
use winnow::{
ascii::{digit1, multispace0},
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<i32, E>
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, E>(input: &mut &'a str) -> winnow::Result<u32, E>
where
E: ParserError<&'a str>,
{
digit1
.void()
.take()
.verify_map(|s: &str| s.parse().ok())
.parse_next(input)
}
/// Parse a float number.
///
/// Rationale for not using `winnow::ascii::float`: the `float` parser provided
/// by winnow accepts E-notation numbers (e.g., `1.23e4`), whereas GNU date
/// rejects such numbers. To remain compatible with GNU date, we provide a
/// custom implementation that only accepts inputs like [+-]?[0-9]+(\.[0-9]+)?.
pub(super) fn float<'a, E>(input: &mut &'a str) -> winnow::Result<f64, E>
where
E: ParserError<&'a str>,
{
(
opt(one_of(['+', '-'])),
digit1,
opt(preceded(one_of(['.', ',']), digit1)),
)
.void()
.take()
.verify_map(|s: &str| s.replace(",", ".").parse().ok())
.parse_next(input)
}
/// 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
}
+195
View File
@@ -0,0 +1,195 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Parse a relative datetime item
//!
//! The GNU docs state:
//!
//! > The unit of time displacement may be selected by the string year or
//! > month for moving by whole years or months. These are fuzzy units, as
//! > years and months are not all of equal duration. More precise units are
//! > fortnight which is worth 14 days, week worth 7 days, day worth 24
//! > hours, hour worth 60 minutes, minute or min worth 60 seconds, and
//! > second or sec worth one second. An s suffix on these units is
//! > accepted and ignored.
//! >
//! > The unit of time may be preceded by a multiplier, given as an optionally
//! > signed number. Unsigned numbers are taken as positively signed. No number
//! > at all implies 1 for a multiplier. Following a relative item by the
//! > string ago is equivalent to preceding the unit by a multiplier with
//! > value -1.
//! >
//! > The string tomorrow is worth one day in the future (equivalent to
//! > day), the string yesterday is worth one day in the past (equivalent
//! > to day ago).
//! >
//! > The strings now or today are relative items corresponding to
//! > zero-valued time displacement, these strings come from the fact a
//! > zero-valued time displacement represents the current time when not
//! > otherwise changed by previous items. They may be used to stress other
//! > items, like in 12:00 today. The string this also has the meaning of a
//! > zero-valued time displacement, but is preferred in date strings like
//! > this thursday.
use winnow::{
ascii::alpha1,
combinator::{alt, opt},
ModalResult, Parser,
};
use super::{
ordinal::ordinal,
primitive::{float, s},
};
#[derive(Clone, Copy, Debug, PartialEq)]
pub 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),
}
impl Relative {
// TODO: determine how to handle multiplication overflows,
// using saturating_mul for now.
fn mul(self, n: i32) -> Self {
match self {
Self::Years(x) => Self::Years(n.saturating_mul(x)),
Self::Months(x) => Self::Months(n.saturating_mul(x)),
Self::Days(x) => Self::Days(n.saturating_mul(x)),
Self::Hours(x) => Self::Hours(n.saturating_mul(x)),
Self::Minutes(x) => Self::Minutes(n.saturating_mul(x)),
Self::Seconds(x) => Self::Seconds(f64::from(n) * x),
}
}
}
pub fn parse(input: &mut &str) -> ModalResult<Relative> {
alt((
s("tomorrow").value(Relative::Days(1)),
s("yesterday").value(Relative::Days(-1)),
// For "today" and "now", the unit is arbitrary
s("today").value(Relative::Days(0)),
s("now").value(Relative::Days(0)),
seconds,
other,
))
.parse_next(input)
}
fn seconds(input: &mut &str) -> ModalResult<Relative> {
(
opt(alt((s(float), ordinal.map(|x| x as f64)))),
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 }))
.parse_next(input)
}
fn other(input: &mut &str) -> ModalResult<Relative> {
(opt(ordinal), integer_unit, ago)
.map(|(n, unit, ago)| unit.mul(n.unwrap_or(1) * if ago { -1 } else { 1 }))
.parse_next(input)
}
fn ago(input: &mut &str) -> ModalResult<bool> {
opt(s("ago")).map(|o| o.is_some()).parse_next(input)
}
fn integer_unit(input: &mut &str) -> ModalResult<Relative> {
s(alpha1)
.verify_map(|s: &str| {
Some(match s.strip_suffix('s').unwrap_or(s) {
"year" => Relative::Years(1),
"month" => Relative::Months(1),
"fortnight" => Relative::Days(14),
"week" => Relative::Days(7),
"day" => Relative::Days(1),
"hour" => Relative::Hours(1),
"minute" | "min" => Relative::Minutes(1),
_ => return None,
})
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::{parse, Relative};
#[test]
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)),
// Minutes
("minute", Relative::Minutes(1)),
("minutes", Relative::Minutes(1)),
("min", Relative::Minutes(1)),
("mins", Relative::Minutes(1)),
("10 minutes", Relative::Minutes(10)),
("-10 minutes", Relative::Minutes(-10)),
("10 minutes ago", Relative::Minutes(-10)),
("-10 minutes ago", Relative::Minutes(10)),
// Hours
("hour", Relative::Hours(1)),
("hours", Relative::Hours(1)),
("10 hours", Relative::Hours(10)),
("+10 hours", Relative::Hours(10)),
("-10 hours", Relative::Hours(-10)),
("10 hours ago", Relative::Hours(-10)),
("-10 hours ago", Relative::Hours(10)),
// Days
("day", Relative::Days(1)),
("days", Relative::Days(1)),
("10 days", Relative::Days(10)),
("+10 days", Relative::Days(10)),
("-10 days", Relative::Days(-10)),
("10 days ago", Relative::Days(-10)),
("-10 days ago", Relative::Days(10)),
// Multiple days
("fortnight", Relative::Days(14)),
("fortnights", Relative::Days(14)),
("2 fortnights ago", Relative::Days(-28)),
("+2 fortnights ago", Relative::Days(-28)),
("week", Relative::Days(7)),
("weeks", Relative::Days(7)),
("2 weeks ago", Relative::Days(-14)),
// Other
("year", Relative::Years(1)),
("years", Relative::Years(1)),
("month", Relative::Months(1)),
("months", Relative::Months(1)),
// Special
("yesterday", Relative::Days(-1)),
("tomorrow", Relative::Days(1)),
("today", Relative::Days(0)),
("now", Relative::Days(0)),
// This something
("this day", Relative::Days(0)),
("this second", Relative::Seconds(0.0)),
("this year", Relative::Years(0)),
// Weird stuff
("next week ago", Relative::Days(-7)),
("last week ago", Relative::Days(7)),
("this week ago", Relative::Days(0)),
] {
let mut t = s;
assert_eq!(parse(&mut t).ok(), Some(rel), "Failed string: {s}")
}
}
}
+865
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use winnow::ModalResult;
use super::time;
pub(crate) fn parse(input: &mut &str) -> ModalResult<time::Offset> {
time::timezone(input)
}
+156
View File
@@ -0,0 +1,156 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore wednes
//! The GNU docs state:
//!
//! > The explicit mention of a day of the week will forward the date (only if
//! > necessary) to reach that day of the week in the future.
//! >
//! > Days of the week may be spelled out in full: Sunday, Monday,
//! > Tuesday, Wednesday, Thursday, Friday or Saturday. Days may be
//! > abbreviated to their first three letters, optionally followed by a
//! > period. The special abbreviations Tues for Tuesday, Wednes for
//! > Wednesday and Thur or Thurs for Thursday are also allowed.
//! >
//! > A number may precede a day of the week item to move forward supplementary
//! > weeks. It is best used in expression like third monday. In this
//! > context, last day or next day is also acceptable; they move one week
//! > before or after the day that day by itself would represent.
//! >
//! > A comma following a day of the week item is ignored.
use winnow::{
ascii::alpha1,
combinator::{opt, terminated},
seq, ModalResult, Parser,
};
use super::{ordinal::ordinal, primitive::s};
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub(crate) enum Day {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
#[derive(PartialEq, Eq, Debug)]
pub struct Weekday {
pub(crate) offset: i32,
pub(crate) day: Day,
}
impl From<Day> for chrono::Weekday {
fn from(value: Day) -> Self {
match value {
Day::Monday => chrono::Weekday::Mon,
Day::Tuesday => chrono::Weekday::Tue,
Day::Wednesday => chrono::Weekday::Wed,
Day::Thursday => chrono::Weekday::Thu,
Day::Friday => chrono::Weekday::Fri,
Day::Saturday => chrono::Weekday::Sat,
Day::Sunday => chrono::Weekday::Sun,
}
}
}
/// Parse a weekday item.
pub(super) fn parse(input: &mut &str) -> ModalResult<Weekday> {
seq!(Weekday {
offset: opt(ordinal).map(|o| o.unwrap_or_default()),
day: terminated(day, opt(s(","))),
})
.parse_next(input)
}
fn day(input: &mut &str) -> ModalResult<Day> {
s(alpha1)
.verify_map(|s: &str| {
Some(match s {
"monday" | "mon" | "mon." => Day::Monday,
"tuesday" | "tue" | "tue." | "tues" => Day::Tuesday,
"wednesday" | "wed" | "wed." | "wednes" => Day::Wednesday,
"thursday" | "thu" | "thu." | "thur" | "thurs" => Day::Thursday,
"friday" | "fri" | "fri." => Day::Friday,
"saturday" | "sat" | "sat." => Day::Saturday,
"sunday" | "sun" | "sun." => Day::Sunday,
_ => return None,
})
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::{parse, Day, Weekday};
#[test]
fn this_monday() {
for mut s in [
"monday",
"mon",
"mon.",
"this monday",
"this mon",
"this mon.",
"this monday",
"this - monday",
"0 monday",
] {
assert_eq!(
parse(&mut s).unwrap(),
Weekday {
offset: 0,
day: Day::Monday,
}
);
}
}
#[test]
fn next_tuesday() {
for s in ["tuesday", "tue", "tue.", "tues"] {
let s = format!("next {s}");
assert_eq!(
parse(&mut s.as_ref()).unwrap(),
Weekday {
offset: 1,
day: Day::Tuesday,
}
);
}
}
#[test]
fn last_wednesday() {
for s in ["wednesday", "wed", "wed.", "wednes"] {
let s = format!("last {s}");
assert_eq!(
parse(&mut s.as_ref()).unwrap(),
Weekday {
offset: -1,
day: Day::Wednesday,
}
);
}
}
#[test]
fn optional_comma() {
for mut s in ["monday,", "mon,", "mon.,", "mon. ,"] {
assert_eq!(
parse(&mut s).unwrap(),
Weekday {
offset: 0,
day: Day::Monday,
}
);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Parse a year from a string.
//!
//! The year must be parsed to a string first, this is to handle a specific GNU
//! compatibility quirk. According to the GNU documentation: "if the year is 68
//! or smaller, then 2000 is added to it; otherwise, if year is less than 100,
//! then 1900 is added to it." This adjustment only applies to two-digit year
//! strings. For example, `"00"` is interpreted as `2000`, whereas `"0"`,
//! `"000"`, or `"0000"` are interpreted as `0`.
use winnow::{error::ErrMode, stream::AsChar, token::take_while, ModalResult, Parser};
use super::primitive::{ctx_err, s};
pub(super) fn parse(input: &mut &str) -> ModalResult<u32> {
year_from_str(year_str(input)?).map_err(|e| ErrMode::Cut(ctx_err(e)))
}
// TODO: Leverage `TryFrom` trait.
pub(super) fn year_from_str(year_str: &str) -> Result<u32, &'static str> {
let mut year = year_str
.parse::<u32>()
.map_err(|_| "year must be a valid number")?;
// If year is 68 or smaller, then 2000 is added to it; otherwise, if year
// is less than 100, then 1900 is added to it.
//
// GNU quirk: this only applies to two-digit years. For example,
// "98-01-01" will be parsed as "1998-01-01", whereas "098-01-01" will be
// parsed as "0098-01-01".
if year_str.len() == 2 {
if year <= 68 {
year += 2000
} else {
year += 1900
}
}
// 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].
//
// See discussion in https://github.com/uutils/parse_datetime/issues/160.
if year > 9999 {
return Err("year must be no greater than 9999");
}
Ok(year)
}
pub(super) fn year_str<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
s(take_while(1.., AsChar::is_dec_digit)).parse_next(input)
}
#[cfg(test)]
mod tests {
use super::parse;
#[test]
fn test_year() {
// 2-characters are converted to 19XX/20XX
assert_eq!(parse(&mut "10").unwrap(), 2010u32);
assert_eq!(parse(&mut "68").unwrap(), 2068u32);
assert_eq!(parse(&mut "69").unwrap(), 1969u32);
assert_eq!(parse(&mut "99").unwrap(), 1999u32);
// 3,4-characters are converted verbatim
assert_eq!(parse(&mut "468").unwrap(), 468u32);
assert_eq!(parse(&mut "469").unwrap(), 469u32);
assert_eq!(parse(&mut "1568").unwrap(), 1568u32);
assert_eq!(parse(&mut "1569").unwrap(), 1569u32);
// years greater than 9999 are not accepted
assert!(parse(&mut "10000").is_err());
}
}
+144 -343
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-208
View File
@@ -1,208 +0,0 @@
use chrono::{DateTime, FixedOffset, Local, NaiveTime, TimeZone};
use regex::Regex;
mod time_only_formats {
pub const HH_MM: &str = "%R";
pub const HH_MM_SS: &str = "%T";
pub const TWELVE_HOUR: &str = "%r";
}
/// Convert a military time zone string to a time zone offset.
///
/// Military time zones are the letters A through Z except J. They are
/// described in RFC 5322.
fn to_offset(tz: &str) -> Option<FixedOffset> {
let hour = match tz {
"A" => 1,
"B" => 2,
"C" => 3,
"D" => 4,
"E" => 5,
"F" => 6,
"G" => 7,
"H" => 8,
"I" => 9,
"K" => 10,
"L" => 11,
"M" => 12,
"N" => -1,
"O" => -2,
"P" => -3,
"Q" => -4,
"R" => -5,
"S" => -6,
"T" => -7,
"U" => -8,
"V" => -9,
"W" => -10,
"X" => -11,
"Y" => -12,
"Z" => 0,
_ => return None,
};
let offset_in_sec = hour * 3600;
FixedOffset::east_opt(offset_in_sec)
}
/// Parse a time string without an offset and apply an offset to it.
///
/// Multiple formats are attempted when parsing the string.
fn parse_time_with_offset_multi(
date: DateTime<Local>,
offset: FixedOffset,
s: &str,
) -> Option<DateTime<FixedOffset>> {
for fmt in [
time_only_formats::HH_MM,
time_only_formats::HH_MM_SS,
time_only_formats::TWELVE_HOUR,
] {
let Ok(parsed) = NaiveTime::parse_from_str(s, fmt) else {
continue;
};
let parsed_dt = date.date_naive().and_time(parsed);
if let Some(dt) = offset.from_local_datetime(&parsed_dt).single() {
return Some(dt);
}
}
None
}
pub(crate) fn parse_time_only(date: DateTime<Local>, s: &str) -> Option<DateTime<FixedOffset>> {
let re =
Regex::new(r"^(?<time>.*?)(?:(?<sign>\+|-)(?<h>[0-9]{1,2}):?(?<m>[0-9]{0,2}))?$").unwrap();
let captures = re.captures(s)?;
// Parse the sign, hour, and minute to get a `FixedOffset`, if possible.
let parsed_offset = match captures.name("h") {
Some(hours) if !(hours.as_str().is_empty()) => {
let mut offset_in_sec = hours.as_str().parse::<i32>().unwrap() * 3600;
match captures.name("m") {
Some(minutes) if !(minutes.as_str().is_empty()) => {
offset_in_sec += minutes.as_str().parse::<i32>().unwrap() * 60;
}
_ => (),
}
offset_in_sec *= if &captures["sign"] == "-" { -1 } else { 1 };
FixedOffset::east_opt(offset_in_sec)
}
_ => None,
};
// Parse the time and apply the parsed offset.
let s = captures["time"].trim();
let offset = match parsed_offset {
Some(offset) => offset,
None => *date.offset(),
};
if let Some(result) = parse_time_with_offset_multi(date, offset, s) {
return Some(result);
}
// Military time zones are specified in RFC 5322, Section 4.3
// "Obsolete Date and Time".
// <https://datatracker.ietf.org/doc/html/rfc5322>
//
// We let the parsing above handle "5:00 AM" so at this point we
// should be guaranteed that we don't have an AM/PM suffix. That
// way, we can safely parse "5:00M" here without interference.
let re = Regex::new(r"(?<time>.*?)(?<tz>[A-IKLMN-YZ])").unwrap();
let captures = re.captures(s)?;
if let Some(tz) = captures.name("tz") {
let s = captures["time"].trim();
let offset = match to_offset(tz.as_str()) {
Some(offset) => offset,
None => *date.offset(),
};
if let Some(result) = parse_time_with_offset_multi(date, offset, s) {
return Some(result);
}
}
None
}
#[cfg(test)]
mod tests {
use crate::parse_time_only_str::parse_time_only;
use chrono::{DateTime, Local, TimeZone};
use std::env;
fn get_test_date() -> DateTime<Local> {
Local.with_ymd_and_hms(2024, 3, 3, 0, 0, 0).unwrap()
}
#[test]
fn test_time_only() {
env::set_var("TZ", "UTC");
let parsed_time = parse_time_only(get_test_date(), "21:04")
.unwrap()
.timestamp();
assert_eq!(parsed_time, 1709499840);
}
#[test]
fn test_military_time_zones() {
env::set_var("TZ", "UTC");
let date = get_test_date();
let actual = parse_time_only(date, "05:00C").unwrap().timestamp();
// Computed via `date -u -d "2024-03-03 05:00:00C" +%s`, using a
// version of GNU date after v8.32 (earlier versions had a bug).
let expected = 1709431200;
assert_eq!(actual, expected);
}
#[test]
fn test_time_with_offset() {
env::set_var("TZ", "UTC");
let parsed_time = parse_time_only(get_test_date(), "21:04 +0530")
.unwrap()
.timestamp();
assert_eq!(parsed_time, 1709480040);
}
#[test]
fn test_time_with_hour_only_offset() {
env::set_var("TZ", "UTC");
let parsed_time = parse_time_only(get_test_date(), "22:04 +01")
.unwrap()
.timestamp();
assert_eq!(parsed_time, 1709499840);
}
#[test]
fn test_time_with_hour_only_neg_offset() {
env::set_var("TZ", "UTC");
let parsed_time = parse_time_only(get_test_date(), "17:04 -04")
.unwrap()
.timestamp();
assert_eq!(parsed_time, 1709499840);
}
#[test]
fn test_time_with_seconds() {
env::set_var("TZ", "UTC");
let parsed_time = parse_time_only(get_test_date(), "21:04:30")
.unwrap()
.timestamp();
assert_eq!(parsed_time, 1709499870);
}
#[test]
fn test_time_with_seconds_with_offset() {
env::set_var("TZ", "UTC");
let parsed_time = parse_time_only(get_test_date(), "21:04:30 +0530")
.unwrap()
.timestamp();
assert_eq!(parsed_time, 1709480070);
}
#[test]
fn test_twelve_hour_time() {
env::set_var("TZ", "UTC");
let parsed_time = parse_time_only(get_test_date(), "9:04:00 PM")
.unwrap()
.timestamp();
assert_eq!(parsed_time, 1709499840);
}
}

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