use nu_cmd_base::input_handler::{operate, CmdArgument}; use nu_engine::command_prelude::*; use nu_parser::{parse_unit_value, DURATION_UNIT_GROUPS}; use nu_protocol::{ast::Expr, Unit}; const NS_PER_US: i64 = 1_000; const NS_PER_MS: i64 = 1_000_000; const NS_PER_SEC: i64 = 1_000_000_000; const NS_PER_MINUTE: i64 = 60 * NS_PER_SEC; const NS_PER_HOUR: i64 = 60 * NS_PER_MINUTE; const NS_PER_DAY: i64 = 24 * NS_PER_HOUR; const NS_PER_WEEK: i64 = 7 * NS_PER_DAY; const ALLOWED_COLUMNS: [&str; 9] = [ "week", "day", "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", "sign", ]; const ALLOWED_SIGNS: [&str; 2] = ["+", "-"]; #[derive(Clone, Debug)] struct Arguments { unit: Option>, cell_paths: Option>, } impl CmdArgument for Arguments { fn take_cell_paths(&mut self) -> Option> { self.cell_paths.take() } } #[derive(Clone)] pub struct IntoDuration; impl Command for IntoDuration { fn name(&self) -> &str { "into duration" } fn signature(&self) -> Signature { Signature::build("into duration") .input_output_types(vec![ (Type::Int, Type::Duration), (Type::Float, Type::Duration), (Type::String, Type::Duration), (Type::Duration, Type::Duration), // FIXME: https://github.com/nushell/nushell/issues/15485 // 'record -> any' was added as a temporary workaround to avoid type inference issues. The Any arm needs to be appear first. (Type::record(), Type::Any), (Type::record(), Type::record()), (Type::record(), Type::Duration), (Type::table(), Type::table()), ]) .allow_variants_without_examples(true) .named( "unit", SyntaxShape::String, "Unit to convert number into (will have an effect only with integer input)", Some('u'), ) .rest( "rest", SyntaxShape::CellPath, "For a data structure input, convert data at the given cell paths.", ) .category(Category::Conversions) } fn description(&self) -> &str { "Convert value to duration." } fn extra_description(&self) -> &str { "Max duration value is i64::MAX nanoseconds; max duration time unit is wk (weeks)." } fn search_terms(&self) -> Vec<&str> { vec!["convert", "time", "period"] } fn run( &self, engine_state: &EngineState, stack: &mut Stack, call: &Call, input: PipelineData, ) -> Result { let cell_paths = call.rest(engine_state, stack, 0)?; let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths); let span = match input.span() { Some(t) => t, None => call.head, }; let unit = match call.get_flag::>(engine_state, stack, "unit")? { Some(spanned_unit) => { if ["ns", "us", "µs", "ms", "sec", "min", "hr", "day", "wk"] .contains(&spanned_unit.item.as_str()) { Some(spanned_unit) } else { return Err(ShellError::CantConvertToDuration { details: spanned_unit.item, dst_span: span, src_span: span, help: Some( "supported units are ns, us/µs, ms, sec, min, hr, day, and wk" .to_string(), ), }); } } None => None, }; let args = Arguments { unit, cell_paths }; operate(action, args, input, call.head, engine_state.signals()) } fn examples(&self) -> Vec { vec![ Example { description: "Convert duration string to duration value", example: "'7min' | into duration", result: Some(Value::test_duration(7 * 60 * NS_PER_SEC)), }, Example { description: "Convert compound duration string to duration value", example: "'1day 2hr 3min 4sec' | into duration", result: Some(Value::test_duration( (((((/* 1 * */24) + 2) * 60) + 3) * 60 + 4) * NS_PER_SEC, )), }, Example { description: "Convert table of duration strings to table of duration values", example: "[[value]; ['1sec'] ['2min'] ['3hr'] ['4day'] ['5wk']] | into duration value", result: Some(Value::test_list(vec![ Value::test_record(record! { "value" => Value::test_duration(NS_PER_SEC), }), Value::test_record(record! { "value" => Value::test_duration(2 * 60 * NS_PER_SEC), }), Value::test_record(record! { "value" => Value::test_duration(3 * 60 * 60 * NS_PER_SEC), }), Value::test_record(record! { "value" => Value::test_duration(4 * 24 * 60 * 60 * NS_PER_SEC), }), Value::test_record(record! { "value" => Value::test_duration(5 * 7 * 24 * 60 * 60 * NS_PER_SEC), }), ])), }, Example { description: "Convert duration to duration", example: "420sec | into duration", result: Some(Value::test_duration(7 * 60 * NS_PER_SEC)), }, Example { description: "Convert a number of ns to duration", example: "1_234_567 | into duration", result: Some(Value::test_duration(1_234_567)), }, Example { description: "Convert a number of an arbitrary unit to duration", example: "1_234 | into duration --unit ms", result: Some(Value::test_duration(1_234 * 1_000_000)), }, Example { description: "Convert a floating point number of an arbitrary unit to duration", example: "1.234 | into duration --unit sec", result: Some(Value::test_duration(1_234 * 1_000_000)), }, Example { description: "Convert a record to a duration", example: "{day: 10, hour: 2, minute: 6, second: 50, sign: '+'} | into duration", result: Some(Value::duration( 10 * NS_PER_DAY + 2 * NS_PER_HOUR + 6 * NS_PER_MINUTE + 50 * NS_PER_SEC, Span::test_data(), )), }, ] } } fn split_whitespace_indices(s: &str, span: Span) -> impl Iterator { s.split_whitespace().map(move |sub| { // Gets the offset of the `sub` substring inside the string `s`. // `wrapping_` operations are necessary because the pointers can // overflow on 32-bit platforms. The result will not overflow, because // `sub` is within `s`, and the end of `s` has to be a valid memory // address. // // XXX: this should be replaced with `str::substr_range` from the // standard library when it's stabilized. let start_offset = span .start .wrapping_add(sub.as_ptr() as usize) .wrapping_sub(s.as_ptr() as usize); (sub, Span::new(start_offset, start_offset + sub.len())) }) } fn compound_to_duration(s: &str, span: Span) -> Result { let mut duration_ns: i64 = 0; for (substring, substring_span) in split_whitespace_indices(s, span) { let sub_ns = string_to_duration(substring, substring_span)?; duration_ns += sub_ns; } Ok(duration_ns) } fn string_to_duration(s: &str, span: Span) -> Result { if let Some(Ok(expression)) = parse_unit_value( s.as_bytes(), span, DURATION_UNIT_GROUPS, Type::Duration, |x| x, ) { if let Expr::ValueWithUnit(value) = expression.expr { if let Expr::Int(x) = value.expr.expr { match value.unit.item { Unit::Nanosecond => return Ok(x), Unit::Microsecond => return Ok(x * 1000), Unit::Millisecond => return Ok(x * 1000 * 1000), Unit::Second => return Ok(x * NS_PER_SEC), Unit::Minute => return Ok(x * 60 * NS_PER_SEC), Unit::Hour => return Ok(x * 60 * 60 * NS_PER_SEC), Unit::Day => return Ok(x * 24 * 60 * 60 * NS_PER_SEC), Unit::Week => return Ok(x * 7 * 24 * 60 * 60 * NS_PER_SEC), _ => {} } } } } Err(ShellError::CantConvertToDuration { details: s.to_string(), dst_span: span, src_span: span, help: Some("supported units are ns, us/µs, ms, sec, min, hr, day, and wk".to_string()), }) } fn action(input: &Value, args: &Arguments, head: Span) -> Value { let value_span = input.span(); let unit_option = &args.unit; if let Value::Record { .. } | Value::Duration { .. } = input { if let Some(unit) = unit_option { return Value::error( ShellError::IncompatibleParameters { left_message: "got a record as input".into(), left_span: head, right_message: "the units should be included in the record".into(), right_span: unit.span, }, head, ); } } let unit: &str = match unit_option { Some(unit) => &unit.item, None => "ns", }; match input { Value::Duration { .. } => input.clone(), Value::Record { val, .. } => { merge_record(val, head, value_span).unwrap_or_else(|err| Value::error(err, value_span)) } Value::String { val, .. } => { if let Ok(num) = val.parse::() { let ns = unit_to_ns_factor(unit); return Value::duration((num * (ns as f64)) as i64, head); } match compound_to_duration(val, value_span) { Ok(val) => Value::duration(val, head), Err(error) => Value::error(error, head), } } Value::Float { val, .. } => { let ns = unit_to_ns_factor(unit); Value::duration((*val * (ns as f64)) as i64, head) } Value::Int { val, .. } => { let ns = unit_to_ns_factor(unit); Value::duration(*val * ns, head) } // Propagate errors by explicitly matching them before the final case. Value::Error { .. } => input.clone(), other => Value::error( ShellError::OnlySupportsThisInputType { exp_input_type: "string or duration".into(), wrong_type: other.get_type().to_string(), dst_span: head, src_span: other.span(), }, head, ), } } fn merge_record(record: &Record, head: Span, span: Span) -> Result { if let Some(invalid_col) = record .columns() .find(|key| !ALLOWED_COLUMNS.contains(&key.as_str())) { let allowed_cols = ALLOWED_COLUMNS.join(", "); return Err(ShellError::UnsupportedInput { msg: format!( "Column '{invalid_col}' is not valid for a structured duration. Allowed columns are: {allowed_cols}" ), input: "value originates from here".into(), msg_span: head, input_span: span } ); }; let mut duration: i64 = 0; if let Some(col_val) = record.get("week") { let week = parse_number_from_record(col_val, &head)?; duration += week * NS_PER_WEEK; }; if let Some(col_val) = record.get("day") { let day = parse_number_from_record(col_val, &head)?; duration += day * NS_PER_DAY; }; if let Some(col_val) = record.get("hour") { let hour = parse_number_from_record(col_val, &head)?; duration += hour * NS_PER_HOUR; }; if let Some(col_val) = record.get("minute") { let minute = parse_number_from_record(col_val, &head)?; duration += minute * NS_PER_MINUTE; }; if let Some(col_val) = record.get("second") { let second = parse_number_from_record(col_val, &head)?; duration += second * NS_PER_SEC; }; if let Some(col_val) = record.get("millisecond") { let millisecond = parse_number_from_record(col_val, &head)?; duration += millisecond * NS_PER_MS; }; if let Some(col_val) = record.get("microsecond") { let microsecond = parse_number_from_record(col_val, &head)?; duration += microsecond * NS_PER_US; }; if let Some(col_val) = record.get("nanosecond") { let nanosecond = parse_number_from_record(col_val, &head)?; duration += nanosecond; }; if let Some(sign) = record.get("sign") { match sign { Value::String { val, .. } => { if !ALLOWED_SIGNS.contains(&val.as_str()) { let allowed_signs = ALLOWED_SIGNS.join(", "); return Err(ShellError::IncorrectValue { msg: format!("Invalid sign. Allowed signs are {}", allowed_signs) .to_string(), val_span: sign.span(), call_span: head, }); } if val == "-" { duration = -duration; } } other => { return Err(ShellError::OnlySupportsThisInputType { exp_input_type: "int".to_string(), wrong_type: other.get_type().to_string(), dst_span: head, src_span: other.span(), }); } } }; Ok(Value::duration(duration, span)) } fn parse_number_from_record(col_val: &Value, head: &Span) -> Result { let value = match col_val { Value::Int { val, .. } => { if *val < 0 { return Err(ShellError::IncorrectValue { msg: "number should be positive".to_string(), val_span: col_val.span(), call_span: *head, }); } *val } other => { return Err(ShellError::OnlySupportsThisInputType { exp_input_type: "int".to_string(), wrong_type: other.get_type().to_string(), dst_span: *head, src_span: other.span(), }); } }; Ok(value) } fn unit_to_ns_factor(unit: &str) -> i64 { match unit { "ns" => 1, "us" | "µs" => NS_PER_US, "ms" => NS_PER_MS, "sec" => NS_PER_SEC, "min" => NS_PER_MINUTE, "hr" => NS_PER_HOUR, "day" => NS_PER_DAY, "wk" => NS_PER_WEEK, _ => 0, } } #[cfg(test)] mod test { use super::*; use rstest::rstest; #[test] fn test_examples() { use crate::test_examples; test_examples(IntoDuration {}) } const NS_PER_SEC: i64 = 1_000_000_000; #[rstest] #[case("3ns", 3)] #[case("4us", 4 * NS_PER_US)] #[case("4\u{00B5}s", 4 * NS_PER_US)] // micro sign #[case("4\u{03BC}s", 4 * NS_PER_US)] // mu symbol #[case("5ms", 5 * NS_PER_MS)] #[case("1sec", NS_PER_SEC)] #[case("7min", 7 * NS_PER_MINUTE)] #[case("42hr", 42 * NS_PER_HOUR)] #[case("123day", 123 * NS_PER_DAY)] #[case("3wk", 3 * NS_PER_WEEK)] #[case("86hr 26ns", 86 * 3600 * NS_PER_SEC + 26)] // compound duration string #[case("14ns 3hr 17sec", 14 + 3 * NS_PER_HOUR + 17 * NS_PER_SEC)] // compound string with units in random order fn turns_string_to_duration(#[case] phrase: &str, #[case] expected_duration_val: i64) { let args = Arguments { unit: Some(Spanned { item: "ns".to_string(), span: Span::test_data(), }), cell_paths: None, }; let actual = action(&Value::test_string(phrase), &args, Span::test_data()); match actual { Value::Duration { val: observed_val, .. } => { assert_eq!(expected_duration_val, observed_val, "expected != observed") } other => { panic!("Expected Value::Duration, observed {other:?}"); } } } }