diff --git a/Cargo.toml b/Cargo.toml index 26171b9..c1975a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,3 @@ anyhow = "1.0.86" [features] debug = ["winnow/debug"] - -[[bin]] -path = "bin/main.rs" -name = "parse_datetime" -test = false -bench = false diff --git a/bin/main.rs b/bin/main.rs deleted file mode 100644 index abde8f4..0000000 --- a/bin/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -use parse_datetime::parse_datetime; - -fn main() { - let date: String = std::env::args().nth(1).unwrap_or("".to_string()); - println!("{}", parse_datetime(&date).unwrap().format("%+")) -} diff --git a/fuzz/fuzz_targets/parse_datetime.rs b/fuzz/fuzz_targets/parse_datetime.rs index 82b595b..43a693f 100644 --- a/fuzz/fuzz_targets/parse_datetime.rs +++ b/fuzz/fuzz_targets/parse_datetime.rs @@ -2,7 +2,6 @@ #![allow(dead_code)] use std::fmt::{Debug, Display}; -use std::io::{self, Write}; use libfuzzer_sys::arbitrary::{self, Arbitrary}; diff --git a/src/items/combined.rs b/src/items/combined.rs index bb89995..3d02eac 100644 --- a/src/items/combined.rs +++ b/src/items/combined.rs @@ -35,11 +35,7 @@ pub struct DateTime { } pub fn parse(input: &mut &str) -> PResult { - alt(( - parse_basic, - //parse_8digits - )) - .parse_next(input) + alt((parse_basic, parse_8digits)).parse_next(input) } fn parse_basic(input: &mut &str) -> PResult { @@ -52,7 +48,6 @@ fn parse_basic(input: &mut &str) -> PResult { .parse_next(input) } -#[allow(dead_code)] fn parse_8digits(input: &mut &str) -> PResult { s(( take(2usize).and_then(dec_uint), diff --git a/src/items/date.rs b/src/items/date.rs index d22b477..23a2488 100644 --- a/src/items/date.rs +++ b/src/items/date.rs @@ -30,6 +30,8 @@ use winnow::{ ascii::{alpha1, dec_uint}, combinator::{alt, opt, preceded}, seq, + stream::AsChar, + token::take_while, trace::trace, PResult, Parser, }; @@ -97,14 +99,26 @@ fn literal2(input: &mut &str) -> PResult { } pub fn year(input: &mut &str) -> PResult { + // 2147485547 is the maximum value accepted + // by GNU, but chrono only behave like GNU + // for years in the range: [0, 9999], so we + // keep in the range [0, 9999] trace( "year", - dec_uint.try_map(|x| { - (0..=2147485547) - .contains(&x) - .then_some(x) - .ok_or(ParseDateTimeError::InvalidInput) - }), + 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_next(input) } @@ -233,9 +247,13 @@ mod tests { use super::year; // the minimun input length is 2 - assert!(year(&mut "0").is_err()); + // 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 "00").unwrap(), 2000u32); + 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); @@ -245,6 +263,6 @@ mod tests { 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); + //assert_eq!(year(&mut "1234567").unwrap(), 1234u32); } } diff --git a/src/items/mod.rs b/src/items/mod.rs index babde1d..b35142e 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -53,8 +53,8 @@ mod timezone { use chrono::NaiveDate; use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike}; -use winnow::error::ParseError; use winnow::error::ParserError; +use winnow::error::{ContextError, ErrMode, ParseError}; use winnow::trace::trace; use winnow::{ ascii::multispace0, @@ -100,6 +100,17 @@ where separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input) } +/// Check for the end of a token, without consuming the input +/// succeedes if the next character in the input is a space or +/// if the input is empty +pub(crate) fn eotoken(input: &mut &str) -> PResult<()> { + if input.is_empty() || input.chars().next().unwrap().is_space() { + return Ok(()); + } + + Err(ErrMode::Backtrack(ContextError::new())) +} + /// A hyphen or plus is ignored when it is not followed by a digit /// /// This includes being followed by a comment! Compare these inputs: @@ -141,7 +152,7 @@ where // Parse an item pub fn parse_one(input: &mut &str) -> PResult { - //eprintln!("parsing_one -> {input}"); + // eprintln!("parsing_one -> {input}"); let result = trace( "parse_one", alt(( @@ -152,11 +163,11 @@ pub fn parse_one(input: &mut &str) -> PResult { weekday::parse.map(Item::Weekday), epoch::parse.map(Item::Timestamp), timezone::parse.map(Item::TimeZone), - s(date::year).map(Item::Year), + date::year.map(Item::Year), )), ) .parse_next(input)?; - //eprintln!("parsing_one <- {input} {result:?}"); + // eprintln!("parsing_one <- {input} {result:?}"); Ok(result) } diff --git a/src/items/time.rs b/src/items/time.rs index daa11b3..e9f1a5c 100644 --- a/src/items/time.rs +++ b/src/items/time.rs @@ -42,7 +42,7 @@ use std::fmt::Display; use chrono::FixedOffset; use winnow::{ ascii::{dec_uint, float}, - combinator::{alt, opt, preceded}, + combinator::{alt, opt, preceded, terminated}, error::{AddContext, ContextError, ErrMode, StrContext}, seq, stream::AsChar, @@ -50,7 +50,7 @@ use winnow::{ PResult, Parser, }; -use super::s; +use super::{eotoken, s}; #[derive(PartialEq, Debug, Clone, Default)] pub struct Offset { @@ -206,7 +206,11 @@ fn second(input: &mut &str) -> PResult { } pub(crate) fn timezone(input: &mut &str) -> PResult { - alt((timezone_num, timezone_name_offset)).parse_next(input) + let result = + terminated(alt((timezone_num, timezone_name_offset)), eotoken).parse_next(input)?; + + // space_or_eof(input, result) + Ok(result) } /// Parse a timezone starting with `+` or `-` diff --git a/src/lib.rs b/src/lib.rs index 9c62497..ea86f1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -120,6 +120,8 @@ mod tests { mod iso_8601 { use std::env; + use chrono::{TimeZone, Utc}; + use crate::ParseDateTimeError; use crate::{parse_datetime, tests::TEST_TIME}; @@ -127,8 +129,11 @@ mod tests { fn test_t_sep() { env::set_var("TZ", "UTC"); let dt = "2021-02-15T06:37:47"; - let actual = parse_datetime(dt); - assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + let actual = parse_datetime(dt).unwrap(); + assert_eq!( + actual, + Utc.timestamp_opt(TEST_TIME, 0).unwrap().fixed_offset() + ); } #[test] @@ -400,7 +405,6 @@ mod tests { #[test] fn test_invalid_input() { let result = parse_datetime("foobar"); - println!("{result:?}"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); let result = parse_datetime("invalid 1"); @@ -618,13 +622,9 @@ mod tests { } mod test_relative { - use chrono::NaiveDate; - use crate::{items, parse_datetime}; - use std::{ - env, - io::{self, Write}, - }; + use crate::parse_datetime; + use std::env; #[test] fn test_month() { @@ -677,6 +677,11 @@ mod tests { "2024-03-29T00:00:00+00:00", ); } + } + + mod test_gnu { + use crate::parse_datetime; + fn make_gnu_date(input: &str, fmt: &str) -> String { std::process::Command::new("date") .arg("-d") @@ -684,30 +689,30 @@ mod tests { .arg(format!("+{fmt}")) .output() .map(|mut output| { - io::stdout().write_all(&output.stdout).unwrap(); + //io::stdout().write_all(&output.stdout).unwrap(); output.stdout.pop(); // remove trailing \n String::from_utf8(output.stdout).expect("from_utf8") }) .unwrap() } - #[test] - fn chrono_date() { - const FMT: &str = "%Y-%m-%d %H:%M:%S"; - let year = 262144; - let input = format!("{year}-01-01 00:00:00"); - - assert!(NaiveDate::from_ymd_opt(year, 1, 1).is_none()); - assert!(chrono::DateTime::parse_from_str(&input, FMT).is_err()); - // the parsing works, but hydration fails - assert!(items::parse(&mut input.to_string().as_str()).is_ok()); - assert!(parse_datetime(&input).is_err()); - // GNU date works - assert_eq!(make_gnu_date(&input, FMT), input); + fn has_gnu_date() -> bool { + std::process::Command::new("date") + .arg("--version") + .output() + .map(|output| String::from_utf8(output.stdout).unwrap()) + .map(|output| output.starts_with("date (GNU coreutils)")) + .unwrap_or(false) } #[test] fn gnu_compat() { + // skip if GNU date is not present + if !has_gnu_date() { + eprintln!("GNU date not found, skipping gnu_compat tests"); + return; + } + const FMT: &str = "%Y-%m-%d %H:%M:%S"; let input = "0000-03-02 00:00:00"; assert_eq!( @@ -715,7 +720,7 @@ mod tests { parse_datetime(input).unwrap().format(FMT).to_string() ); - let input = "262144-03-10 00:00:00"; + let input = "2621-03-10 00:00:00"; assert_eq!( make_gnu_date(input, FMT), parse_datetime(input) @@ -724,7 +729,7 @@ mod tests { .to_string() ); - let input = "10384-03-10 00:00:00"; + let input = "1038-03-10 00:00:00"; assert_eq!( make_gnu_date(input, FMT), parse_datetime(input)