forked from extern/nushell
Closes #15543 # Description 1. Simplify code in ``datetime.rs`` based on a suggestion in my last PR on "datetime from record" 1. Make ``into duration`` work with durations inside a record, provided as a cell path 1. Make ``into duration`` work with durations as record # User-Facing Changes ```nushell # Happy paths ~> {d: '1hr'} | into duration d ╭───┬─────╮ │ d │ 1hr │ ╰───┴─────╯ ~> {week: 10, day: 2, sign: '+'} | into duration 10wk 2day # Error paths and invalid usage ~> {week: 10, day: 2, sign: 'x'} | into duration Error: nu:🐚:incorrect_value × Incorrect value. ╭─[entry #4:1:26] 1 │ {week: 10, day: 2, sign: 'x'} | into duration · ─┬─ ──────┬────── · │ ╰── encountered here · ╰── Invalid sign. Allowed signs are +, - ╰──── ~> {week: 10, day: -2, sign: '+'} | into duration Error: nu:🐚:incorrect_value × Incorrect value. ╭─[entry #5:1:17] 1 │ {week: 10, day: -2, sign: '+'} | into duration · ─┬ ──────┬────── · │ ╰── encountered here · ╰── number should be positive ╰──── ~> {week: 10, day: '2', sign: '+'} | into duration Error: nu:🐚:only_supports_this_input_type × Input type not supported. ╭─[entry #6:1:17] 1 │ {week: 10, day: '2', sign: '+'} | into duration · ─┬─ ──────┬────── · │ ╰── only int input data is supported · ╰── input type: string ╰──── ~> {week: 10, unknown: 1} | into duration Error: nu:🐚:unsupported_input × Unsupported input ╭─[entry #7:1:1] 1 │ {week: 10, unknown: 1} | into duration · ───────────┬────────── ──────┬────── · │ ╰── Column 'unknown' is not valid for a structured duration. Allowed columns are: week, day, hour, minute, second, millisecond, microsecond, nanosecond, sign · ╰── value originates from here ╰──── ~> {week: 10, day: 2, sign: '+'} | into duration --unit sec Error: nu:🐚:incompatible_parameters × Incompatible parameters. ╭─[entry #2:1:33] 1 │ {week: 10, day: 2, sign: '+'} | into duration --unit sec · ──────┬────── ─────┬──── · │ ╰── the units should be included in the record · ╰── got a record as input ╰──── ``` # Tests + Formatting - Add examples and integration tests for ``into duration`` - Add one test for ``into duration`` # After Submitting If this is merged in time, I'll update my PR on the "datetime handling highlights" for the release notes.
485 lines
17 KiB
Rust
485 lines
17 KiB
Rust
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<Spanned<String>>,
|
|
cell_paths: Option<Vec<CellPath>>,
|
|
}
|
|
|
|
impl CmdArgument for Arguments {
|
|
fn take_cell_paths(&mut self) -> Option<Vec<CellPath>> {
|
|
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<PipelineData, ShellError> {
|
|
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::<Spanned<String>>(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<Example> {
|
|
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<Item = (&str, Span)> {
|
|
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<i64, ShellError> {
|
|
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<i64, ShellError> {
|
|
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::<f64>() {
|
|
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<Value, ShellError> {
|
|
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<i64, ShellError> {
|
|
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:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|