From 12a1eefe736853b961afac1e2c0207ff6c136052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Riegel?= <96702577+LoicRiegel@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:44:55 +0200 Subject: [PATCH] Move human date parsing into new command `date from-human` (#15495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No related issue. Decided in nushell's weekly meeting: see [meeting notes](https://hackmd.io/rA1YecqjRh6I5m8dTq7BHw) # Description Converting a date as a human readable string to a datetime: - currently: using the ``into datetime`` command - after this change: using ``date from-human`` command Also moved the ``--list-human`` flag to the new command. # User-Facing Changes - Users have to use a new command for parsing human readable datetimes. Result: ```nushell ~> date from-human --list ╭────┬───────────────────────────────────┬──────────────╮ │ # │ parseable human datetime examples │ result │ ├────┼───────────────────────────────────┼──────────────┤ │ 0 │ Today 18:30 │ in 6 hours │ │ 1 │ 2022-11-07 13:25:30 │ 2 years ago │ │ 2 │ 15:20 Friday │ in 6 days │ │ 3 │ This Friday 17:00 │ in 6 days │ │ 4 │ 13:25, Next Tuesday │ in 3 days │ │ 5 │ Last Friday at 19:45 │ 16 hours ago │ │ 6 │ In 3 days │ in 2 days │ │ 7 │ In 2 hours │ in 2 hours │ │ 8 │ 10 hours and 5 minutes ago │ 10 hours ago │ │ 9 │ 1 years ago │ a year ago │ │ 10 │ A year ago │ a year ago │ │ 11 │ A month ago │ a month ago │ │ 12 │ A week ago │ a week ago │ │ 13 │ A day ago │ a day ago │ │ 14 │ An hour ago │ an hour ago │ │ 15 │ A minute ago │ a minute ago │ │ 16 │ A second ago │ now │ │ 17 │ Now │ now │ ╰────┴───────────────────────────────────┴──────────────╯ ~> "2 days ago" | date from-human Thu, 3 Apr 2025 12:03:33 +0200 (2 days ago) ~> "2 days ago" | into datetime Error: nu::shell::datetime_parse_error × Unable to parse datetime: [2 days ago]. ╭─[entry #5:1:1] 1 │ "2 days ago" | into datetime · ──────┬───── · ╰── datetime parsing failed ╰──── help: Examples of supported inputs: * "5 pm" * "2020/12/4" * "2020.12.04 22:10 +2" * "2020-04-12 22:10:57 +02:00" * "2020-04-12T22:10:57.213231+02:00" * "Tue, 1 Jul 2003 10:52:37 +0200" ``` # Tests + Formatting Fmt, clippy 🆗 Tests 🆗 > Note: I was able to reactivate one unit test in the ``into datetime`` command # After Submitting Here since the user facing changes are significant, I think we should communicate in the released notes. Otherwise the automatically generated documentation should be enough IMO. --- .../src/conversions/into/datetime.rs | 125 +-------- crates/nu-command/src/date/from_human.rs | 259 ++++++++++++++++++ crates/nu-command/src/date/mod.rs | 2 + crates/nu-command/src/default_context.rs | 1 + 4 files changed, 265 insertions(+), 122 deletions(-) create mode 100644 crates/nu-command/src/date/from_human.rs diff --git a/crates/nu-command/src/conversions/into/datetime.rs b/crates/nu-command/src/conversions/into/datetime.rs index 1ff2d55522..e503f32653 100644 --- a/crates/nu-command/src/conversions/into/datetime.rs +++ b/crates/nu-command/src/conversions/into/datetime.rs @@ -1,6 +1,5 @@ use crate::{generate_strftime_list, parse_date_from_string}; use chrono::{DateTime, FixedOffset, Local, NaiveDateTime, TimeZone, Utc}; -use human_date_parser::{from_human_time, ParseResult}; use nu_cmd_base::input_handler::{operate, CmdArgument}; use nu_engine::command_prelude::*; @@ -98,11 +97,6 @@ impl Command for IntoDatetime { "Show all possible variables for use in --format flag", Some('l'), ) - .switch( - "list-human", - "Show human-readable datetime parsing examples", - Some('n'), - ) .rest( "rest", SyntaxShape::CellPath, @@ -120,8 +114,6 @@ impl Command for IntoDatetime { ) -> Result { if call.has_flag(engine_state, stack, "list")? { Ok(generate_strftime_list(call.head, true).into_pipeline_data()) - } else if call.has_flag(engine_state, stack, "list-human")? { - Ok(list_human_readable_examples(call.head).into_pipeline_data()) } else { let cell_paths = call.rest(engine_state, stack, 0)?; let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths); @@ -256,21 +248,6 @@ impl Command for IntoDatetime { Span::test_data(), )), }, - Example { - description: "Parsing human readable datetimes", - example: "'Today at 18:30' | into datetime", - result: None, - }, - Example { - description: "Parsing human readable datetimes", - example: "'Last Friday at 19:45' | into datetime", - result: None, - }, - Example { - description: "Parsing human readable datetimes", - example: "'In 5 minutes and 30 seconds' | into datetime", - result: None, - }, ] } } @@ -291,60 +268,9 @@ fn action(input: &Value, args: &Arguments, head: Span) -> Value { if matches!(input, Value::String { .. }) && dateformat.is_none() { let span = input.span(); if let Ok(input_val) = input.coerce_str() { - match parse_date_from_string(&input_val, span) { - Ok(date) => return Value::date(date, span), - Err(_) => { - if let Ok(date) = from_human_time(&input_val, Local::now().naive_local()) { - match date { - ParseResult::Date(date) => { - let time = Local::now().time(); - let combined = date.and_time(time); - let local_offset = *Local::now().offset(); - let dt_fixed = - TimeZone::from_local_datetime(&local_offset, &combined) - .single() - .unwrap_or_default(); - return Value::date(dt_fixed, span); - } - ParseResult::DateTime(date) => { - let local_offset = *Local::now().offset(); - let dt_fixed = match local_offset.from_local_datetime(&date) { - chrono::LocalResult::Single(dt) => dt, - chrono::LocalResult::Ambiguous(_, _) => { - return Value::error( - ShellError::DatetimeParseError { - msg: "Ambiguous datetime".to_string(), - span, - }, - span, - ); - } - chrono::LocalResult::None => { - return Value::error( - ShellError::DatetimeParseError { - msg: "Invalid datetime".to_string(), - span, - }, - span, - ); - } - }; - return Value::date(dt_fixed, span); - } - ParseResult::Time(time) => { - let date = Local::now().date_naive(); - let combined = date.and_time(time); - let local_offset = *Local::now().offset(); - let dt_fixed = - TimeZone::from_local_datetime(&local_offset, &combined) - .single() - .unwrap_or_default(); - return Value::date(dt_fixed, span); - } - } - } - } - }; + if let Ok(date) = parse_date_from_string(&input_val, span) { + return Value::date(date, span); + } } } @@ -524,44 +450,6 @@ fn action(input: &Value, args: &Arguments, head: Span) -> Value { } } -fn list_human_readable_examples(span: Span) -> Value { - let examples: Vec = vec![ - "Today 18:30".into(), - "2022-11-07 13:25:30".into(), - "15:20 Friday".into(), - "This Friday 17:00".into(), - "13:25, Next Tuesday".into(), - "Last Friday at 19:45".into(), - "In 3 days".into(), - "In 2 hours".into(), - "10 hours and 5 minutes ago".into(), - "1 years ago".into(), - "A year ago".into(), - "A month ago".into(), - "A week ago".into(), - "A day ago".into(), - "An hour ago".into(), - "A minute ago".into(), - "A second ago".into(), - "Now".into(), - ]; - - let records = examples - .iter() - .map(|s| { - Value::record( - record! { - "parseable human datetime examples" => Value::test_string(s.to_string()), - "result" => action(&Value::test_string(s.to_string()), &Arguments { zone_options: None, format_options: None, cell_paths: None }, span) - }, - span, - ) - }) - .collect::>(); - - Value::list(records, span) -} - #[cfg(test)] mod tests { use super::*; @@ -593,14 +481,7 @@ mod tests { } #[test] - #[ignore] fn takes_a_date_format_without_timezone() { - // Ignoring this test for now because we changed the human-date-parser to use - // the users timezone instead of UTC. We may continue to tweak this behavior. - // Another hacky solution is to set the timezone to UTC in the test, which works - // on MacOS and Linux but hasn't been tested on Windows. Plus it kind of defeats - // the purpose of a "without_timezone" test. - // std::env::set_var("TZ", "UTC"); let date_str = Value::test_string("16.11.1984 8:00 am"); let fmt_options = Some(DatetimeFormat("%d.%m.%Y %H:%M %P".to_string())); let args = Arguments { diff --git a/crates/nu-command/src/date/from_human.rs b/crates/nu-command/src/date/from_human.rs new file mode 100644 index 0000000000..f924b4d6d5 --- /dev/null +++ b/crates/nu-command/src/date/from_human.rs @@ -0,0 +1,259 @@ +use chrono::{Local, TimeZone}; +use human_date_parser::{from_human_time, ParseResult}; +use nu_engine::command_prelude::*; + +#[derive(Clone)] +pub struct DateFromHuman; + +impl Command for DateFromHuman { + fn name(&self) -> &str { + "date from-human" + } + + fn signature(&self) -> Signature { + Signature::build("date from-human") + .input_output_types(vec![ + (Type::String, Type::Date), + (Type::Nothing, Type::table()), + ]) + .allow_variants_without_examples(true) + .switch( + "list", + "Show human-readable datetime parsing examples", + Some('l'), + ) + .category(Category::Date) + } + + fn description(&self) -> &str { + "Convert a human readable datetime string to a datetime." + } + + fn search_terms(&self) -> Vec<&str> { + vec![ + "relative", + "now", + "today", + "tomorrow", + "yesterday", + "weekday", + "weekday_name", + "timezone", + ] + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + if call.has_flag(engine_state, stack, "list")? { + return Ok(list_human_readable_examples(call.head).into_pipeline_data()); + } + let head = call.head; + // This doesn't match explicit nulls + if matches!(input, PipelineData::Empty) { + return Err(ShellError::PipelineEmpty { dst_span: head }); + } + input.map(move |value| helper(value, head), engine_state.signals()) + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Parsing human readable datetime", + example: "'Today at 18:30' | date from-human", + result: None, + }, + Example { + description: "Parsing human readable datetime", + example: "'Last Friday at 19:45' | date from-human", + result: None, + }, + Example { + description: "Parsing human readable datetime", + example: "'In 5 minutes and 30 seconds' | date from-human", + result: None, + }, + Example { + description: "PShow human-readable datetime parsing examples", + example: "date from-human --list", + result: None, + }, + ] + } +} + +fn helper(value: Value, head: Span) -> Value { + let span = value.span(); + let input_val = match value { + Value::String { val, .. } => val, + other => { + return Value::error( + ShellError::OnlySupportsThisInputType { + exp_input_type: "string".to_string(), + wrong_type: other.get_type().to_string(), + dst_span: head, + src_span: span, + }, + span, + ) + } + }; + + if let Ok(date) = from_human_time(&input_val, Local::now().naive_local()) { + match date { + ParseResult::Date(date) => { + let time = Local::now().time(); + let combined = date.and_time(time); + let local_offset = *Local::now().offset(); + let dt_fixed = TimeZone::from_local_datetime(&local_offset, &combined) + .single() + .unwrap_or_default(); + return Value::date(dt_fixed, span); + } + ParseResult::DateTime(date) => { + let local_offset = *Local::now().offset(); + let dt_fixed = match local_offset.from_local_datetime(&date) { + chrono::LocalResult::Single(dt) => dt, + chrono::LocalResult::Ambiguous(_, _) => { + return Value::error( + ShellError::DatetimeParseError { + msg: "Ambiguous datetime".to_string(), + span, + }, + span, + ); + } + chrono::LocalResult::None => { + return Value::error( + ShellError::DatetimeParseError { + msg: "Invalid datetime".to_string(), + span, + }, + span, + ); + } + }; + return Value::date(dt_fixed, span); + } + ParseResult::Time(time) => { + let date = Local::now().date_naive(); + let combined = date.and_time(time); + let local_offset = *Local::now().offset(); + let dt_fixed = TimeZone::from_local_datetime(&local_offset, &combined) + .single() + .unwrap_or_default(); + return Value::date(dt_fixed, span); + } + } + } + + match from_human_time(&input_val, Local::now().naive_local()) { + Ok(date) => match date { + ParseResult::Date(date) => { + let time = Local::now().time(); + let combined = date.and_time(time); + let local_offset = *Local::now().offset(); + let dt_fixed = TimeZone::from_local_datetime(&local_offset, &combined) + .single() + .unwrap_or_default(); + Value::date(dt_fixed, span) + } + ParseResult::DateTime(date) => { + let local_offset = *Local::now().offset(); + let dt_fixed = match local_offset.from_local_datetime(&date) { + chrono::LocalResult::Single(dt) => dt, + chrono::LocalResult::Ambiguous(_, _) => { + return Value::error( + ShellError::DatetimeParseError { + msg: "Ambiguous datetime".to_string(), + span, + }, + span, + ); + } + chrono::LocalResult::None => { + return Value::error( + ShellError::DatetimeParseError { + msg: "Invalid datetime".to_string(), + span, + }, + span, + ); + } + }; + Value::date(dt_fixed, span) + } + ParseResult::Time(time) => { + let date = Local::now().date_naive(); + let combined = date.and_time(time); + let local_offset = *Local::now().offset(); + let dt_fixed = TimeZone::from_local_datetime(&local_offset, &combined) + .single() + .unwrap_or_default(); + Value::date(dt_fixed, span) + } + }, + Err(_) => Value::error( + ShellError::IncorrectValue { + msg: "Cannot parse as humanized date".to_string(), + val_span: head, + call_span: span, + }, + span, + ), + } +} + +fn list_human_readable_examples(span: Span) -> Value { + let examples: Vec = vec![ + "Today 18:30".into(), + "2022-11-07 13:25:30".into(), + "15:20 Friday".into(), + "This Friday 17:00".into(), + "13:25, Next Tuesday".into(), + "Last Friday at 19:45".into(), + "In 3 days".into(), + "In 2 hours".into(), + "10 hours and 5 minutes ago".into(), + "1 years ago".into(), + "A year ago".into(), + "A month ago".into(), + "A week ago".into(), + "A day ago".into(), + "An hour ago".into(), + "A minute ago".into(), + "A second ago".into(), + "Now".into(), + ]; + + let records = examples + .iter() + .map(|s| { + Value::record( + record! { + "parseable human datetime examples" => Value::test_string(s.to_string()), + "result" => helper(Value::test_string(s.to_string()), span), + }, + span, + ) + }) + .collect::>(); + + Value::list(records, span) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(DateFromHuman {}) + } +} diff --git a/crates/nu-command/src/date/mod.rs b/crates/nu-command/src/date/mod.rs index 385420c911..e95acef3dd 100644 --- a/crates/nu-command/src/date/mod.rs +++ b/crates/nu-command/src/date/mod.rs @@ -1,4 +1,5 @@ mod date_; +mod from_human; mod humanize; mod list_timezone; mod now; @@ -7,6 +8,7 @@ mod to_timezone; mod utils; pub use date_::Date; +pub use from_human::DateFromHuman; pub use humanize::DateHumanize; pub use list_timezone::DateListTimezones; pub use now::DateNow; diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 0c1b38182a..d4cd7e22f5 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -272,6 +272,7 @@ pub fn add_shell_command_context(mut engine_state: EngineState) -> EngineState { // Date bind_command! { Date, + DateFromHuman, DateHumanize, DateListTimezones, DateNow,