mirror of
https://github.com/nushell/nushell.git
synced 2024-11-23 00:43:33 +01:00
Port str datetime to into datetime (#424)
* Port str datetime to into datetime * Fix the span issue and some other small cleanups
This commit is contained in:
parent
bef138232c
commit
df5ac9b71c
@ -24,6 +24,7 @@ sysinfo = "0.20.4"
|
|||||||
chrono = { version = "0.4.19", features = ["serde"] }
|
chrono = { version = "0.4.19", features = ["serde"] }
|
||||||
chrono-humanize = "0.2.1"
|
chrono-humanize = "0.2.1"
|
||||||
chrono-tz = "0.6.0"
|
chrono-tz = "0.6.0"
|
||||||
|
dtparse = "1.2.0"
|
||||||
terminal_size = "0.1.17"
|
terminal_size = "0.1.17"
|
||||||
indexmap = { version="1.7", features=["serde-1"] }
|
indexmap = { version="1.7", features=["serde-1"] }
|
||||||
lscolors = { version = "0.8.0", features = ["crossterm"] }
|
lscolors = { version = "0.8.0", features = ["crossterm"] }
|
||||||
|
393
crates/nu-command/src/conversions/into/datetime.rs
Normal file
393
crates/nu-command/src/conversions/into/datetime.rs
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
use chrono::{DateTime, FixedOffset, Local, LocalResult, Offset, TimeZone, Utc};
|
||||||
|
use nu_engine::CallExt;
|
||||||
|
use nu_protocol::ast::Call;
|
||||||
|
use nu_protocol::ast::CellPath;
|
||||||
|
use nu_protocol::engine::{Command, EngineState, Stack};
|
||||||
|
use nu_protocol::{
|
||||||
|
Category, Example, PipelineData, ShellError, Signature, Span, Spanned, SyntaxShape, Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Arguments {
|
||||||
|
timezone: Option<Spanned<String>>,
|
||||||
|
offset: Option<Spanned<i64>>,
|
||||||
|
format: Option<String>,
|
||||||
|
column_paths: Vec<CellPath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case it may be confused with chrono::TimeZone
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum Zone {
|
||||||
|
Utc,
|
||||||
|
Local,
|
||||||
|
East(u8),
|
||||||
|
West(u8),
|
||||||
|
Error, // we want the nullshell to cast it instead of rust
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Zone {
|
||||||
|
fn new(i: i64) -> Self {
|
||||||
|
if i.abs() <= 12 {
|
||||||
|
// guanranteed here
|
||||||
|
if i >= 0 {
|
||||||
|
Self::East(i as u8) // won't go out of range
|
||||||
|
} else {
|
||||||
|
Self::West(-i as u8) // same here
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self::Error // Out of range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn from_string(s: String) -> Self {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"utc" | "u" => Self::Utc,
|
||||||
|
"local" | "l" => Self::Local,
|
||||||
|
_ => Self::Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SubCommand;
|
||||||
|
|
||||||
|
impl Command for SubCommand {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"into datetime"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> Signature {
|
||||||
|
Signature::build("into datetime")
|
||||||
|
.named(
|
||||||
|
"timezone",
|
||||||
|
SyntaxShape::String,
|
||||||
|
"Specify timezone if the input is timestamp, like 'UTC/u' or 'LOCAL/l'",
|
||||||
|
Some('z'),
|
||||||
|
)
|
||||||
|
.named(
|
||||||
|
"offset",
|
||||||
|
SyntaxShape::Int,
|
||||||
|
"Specify timezone by offset if the input is timestamp, like '+8', '-4', prior than timezone",
|
||||||
|
Some('o'),
|
||||||
|
)
|
||||||
|
.named(
|
||||||
|
"format",
|
||||||
|
SyntaxShape::String,
|
||||||
|
"Specify date and time formatting",
|
||||||
|
Some('f'),
|
||||||
|
)
|
||||||
|
.rest(
|
||||||
|
"rest",
|
||||||
|
SyntaxShape::CellPath,
|
||||||
|
"optionally convert text into datetime by column paths",
|
||||||
|
)
|
||||||
|
.category(Category::Conversions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
engine_state: &EngineState,
|
||||||
|
stack: &mut Stack,
|
||||||
|
call: &Call,
|
||||||
|
input: PipelineData,
|
||||||
|
) -> Result<PipelineData, ShellError> {
|
||||||
|
operate(engine_state, stack, call, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage(&self) -> &str {
|
||||||
|
"converts text into datetime"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples(&self) -> Vec<Example> {
|
||||||
|
vec![
|
||||||
|
Example {
|
||||||
|
description: "Convert to datetime",
|
||||||
|
example: "'16.11.1984 8:00 am +0000' | into datetime",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
Example {
|
||||||
|
description: "Convert to datetime",
|
||||||
|
example: "'2020-08-04T16:39:18+00:00' | into datetime",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
Example {
|
||||||
|
description: "Convert to datetime using a custom format",
|
||||||
|
example: "'20200904_163918+0000' | into datetime -f '%Y%m%d_%H%M%S%z'",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
Example {
|
||||||
|
description: "Convert timestamp (no larger than 8e+12) to datetime using a specified timezone",
|
||||||
|
example: "'1614434140' | into datetime -z 'UTC'",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
Example {
|
||||||
|
description:
|
||||||
|
"Convert timestamp (no larger than 8e+12) to datetime using a specified timezone offset (between -12 and 12)",
|
||||||
|
example: "'1614434140' | into datetime -o '+9'",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DatetimeFormat(String);
|
||||||
|
|
||||||
|
fn operate(
|
||||||
|
engine_state: &EngineState,
|
||||||
|
stack: &mut Stack,
|
||||||
|
call: &Call,
|
||||||
|
input: PipelineData,
|
||||||
|
) -> Result<PipelineData, ShellError> {
|
||||||
|
let head = call.head;
|
||||||
|
|
||||||
|
let options = Arguments {
|
||||||
|
timezone: call.get_flag(engine_state, stack, "timezone")?,
|
||||||
|
offset: call.get_flag(engine_state, stack, "offset")?,
|
||||||
|
format: call.get_flag(engine_state, stack, "format")?,
|
||||||
|
column_paths: call.rest(engine_state, stack, 0)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// if zone-offset is specified, then zone will be neglected
|
||||||
|
let zone_options = match &options.offset {
|
||||||
|
Some(zone_offset) => Some(Spanned {
|
||||||
|
item: Zone::new(zone_offset.item),
|
||||||
|
span: zone_offset.span,
|
||||||
|
}),
|
||||||
|
None => options.timezone.as_ref().map(|zone| Spanned {
|
||||||
|
item: Zone::from_string(zone.item.clone()),
|
||||||
|
span: zone.span,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let format_options = options
|
||||||
|
.format
|
||||||
|
.as_ref()
|
||||||
|
.map(|fmt| DatetimeFormat(fmt.to_string()));
|
||||||
|
|
||||||
|
input.map(
|
||||||
|
move |v| {
|
||||||
|
if options.column_paths.is_empty() {
|
||||||
|
action(&v, &zone_options, &format_options, head)
|
||||||
|
} else {
|
||||||
|
let mut ret = v;
|
||||||
|
for path in &options.column_paths {
|
||||||
|
let zone_options = zone_options.clone();
|
||||||
|
let format_options = format_options.clone();
|
||||||
|
let r = ret.update_cell_path(
|
||||||
|
&path.members,
|
||||||
|
Box::new(move |old| action(old, &zone_options, &format_options, head)),
|
||||||
|
);
|
||||||
|
if let Err(error) = r {
|
||||||
|
return Value::Error { error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
},
|
||||||
|
engine_state.ctrlc.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action(
|
||||||
|
input: &Value,
|
||||||
|
timezone: &Option<Spanned<Zone>>,
|
||||||
|
dateformat: &Option<DatetimeFormat>,
|
||||||
|
head: Span,
|
||||||
|
) -> Value {
|
||||||
|
match input {
|
||||||
|
Value::String { val: s, span, .. } => {
|
||||||
|
let ts = s.parse::<i64>();
|
||||||
|
// if timezone if specified, first check if the input is a timestamp.
|
||||||
|
if let Some(tz) = timezone {
|
||||||
|
const TIMESTAMP_BOUND: i64 = 8.2e+12 as i64;
|
||||||
|
// Since the timestamp method of chrono itself don't throw an error (it just panicked)
|
||||||
|
// We have to manually guard it.
|
||||||
|
if let Ok(t) = ts {
|
||||||
|
if t.abs() > TIMESTAMP_BOUND {
|
||||||
|
return Value::Error{error: ShellError::UnsupportedInput(
|
||||||
|
"Given timestamp is out of range, it should between -8e+12 and 8e+12".to_string(),
|
||||||
|
head,
|
||||||
|
)};
|
||||||
|
}
|
||||||
|
const HOUR: i32 = 3600;
|
||||||
|
let stampout = match tz.item {
|
||||||
|
Zone::Utc => Value::Date {
|
||||||
|
val: Utc.timestamp(t, 0).into(),
|
||||||
|
span: head,
|
||||||
|
},
|
||||||
|
Zone::Local => Value::Date {
|
||||||
|
val: Local.timestamp(t, 0).into(),
|
||||||
|
span: head,
|
||||||
|
},
|
||||||
|
Zone::East(i) => {
|
||||||
|
let eastoffset = FixedOffset::east((i as i32) * HOUR);
|
||||||
|
Value::Date {
|
||||||
|
val: eastoffset.timestamp(t, 0),
|
||||||
|
span: head,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Zone::West(i) => {
|
||||||
|
let westoffset = FixedOffset::west((i as i32) * HOUR);
|
||||||
|
Value::Date {
|
||||||
|
val: westoffset.timestamp(t, 0),
|
||||||
|
span: head,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Zone::Error => Value::Error {
|
||||||
|
error: ShellError::UnsupportedInput(
|
||||||
|
"Cannot convert given timezone or offset to timestamp".to_string(),
|
||||||
|
tz.span,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return stampout;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// if it's not, continue and negelect the timezone option.
|
||||||
|
let out = match dateformat {
|
||||||
|
Some(dt) => match DateTime::parse_from_str(s, &dt.0) {
|
||||||
|
Ok(d) => Value::Date { val: d, span: head },
|
||||||
|
Err(reason) => {
|
||||||
|
return Value::Error {
|
||||||
|
error: ShellError::CantConvert(
|
||||||
|
format!("could not parse as datetime using format '{}'", dt.0),
|
||||||
|
reason.to_string(),
|
||||||
|
head,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => match dtparse::parse(s) {
|
||||||
|
Ok((native_dt, fixed_offset)) => {
|
||||||
|
let offset = match fixed_offset {
|
||||||
|
Some(fo) => fo,
|
||||||
|
None => FixedOffset::east(0).fix(),
|
||||||
|
};
|
||||||
|
match offset.from_local_datetime(&native_dt) {
|
||||||
|
LocalResult::Single(d) => Value::Date { val: d, span: head },
|
||||||
|
LocalResult::Ambiguous(d, _) => Value::Date { val: d, span: head },
|
||||||
|
LocalResult::None => {
|
||||||
|
return Value::Error {
|
||||||
|
error: ShellError::CantConvert(
|
||||||
|
"could not convert to a timezone-aware datetime"
|
||||||
|
.to_string(),
|
||||||
|
"local time representation is invalid".to_string(),
|
||||||
|
head,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Value::Error {
|
||||||
|
error: ShellError::UnsupportedInput(
|
||||||
|
"Cannot convert input string as datetime. Might be missing timezone or offset".to_string(),
|
||||||
|
*span,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let got = format!("Expected string, got {} instead", other.get_type());
|
||||||
|
Value::Error {
|
||||||
|
error: ShellError::UnsupportedInput(got, head),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use super::{action, DatetimeFormat, SubCommand, Zone};
|
||||||
|
use nu_protocol::Type::Error;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_examples() {
|
||||||
|
use crate::test_examples;
|
||||||
|
|
||||||
|
test_examples(SubCommand {})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_a_date_format() {
|
||||||
|
let date_str = Value::test_string("16.11.1984 8:00 am +0000");
|
||||||
|
let fmt_options = Some(DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string()));
|
||||||
|
let actual = action(&date_str, &None, &fmt_options, Span::unknown());
|
||||||
|
let expected = Value::Date {
|
||||||
|
val: DateTime::parse_from_str("16.11.1984 8:00 am +0000", "%d.%m.%Y %H:%M %P %z")
|
||||||
|
.unwrap(),
|
||||||
|
span: Span::unknown(),
|
||||||
|
};
|
||||||
|
assert_eq!(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_iso8601_date_format() {
|
||||||
|
let date_str = Value::test_string("2020-08-04T16:39:18+00:00");
|
||||||
|
let actual = action(&date_str, &None, &None, Span::unknown());
|
||||||
|
let expected = Value::Date {
|
||||||
|
val: DateTime::parse_from_str("2020-08-04T16:39:18+00:00", "%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
.unwrap(),
|
||||||
|
span: Span::unknown(),
|
||||||
|
};
|
||||||
|
assert_eq!(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_timestamp_offset() {
|
||||||
|
let date_str = Value::test_string("1614434140");
|
||||||
|
let timezone_option = Some(Spanned {
|
||||||
|
item: Zone::East(8),
|
||||||
|
span: Span::unknown(),
|
||||||
|
});
|
||||||
|
let actual = action(&date_str, &timezone_option, &None, Span::unknown());
|
||||||
|
let expected = Value::Date {
|
||||||
|
val: DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z")
|
||||||
|
.unwrap(),
|
||||||
|
span: Span::unknown(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_timestamp() {
|
||||||
|
let date_str = Value::test_string("1614434140");
|
||||||
|
let timezone_option = Some(Spanned {
|
||||||
|
item: Zone::Local,
|
||||||
|
span: Span::unknown(),
|
||||||
|
});
|
||||||
|
let actual = action(&date_str, &timezone_option, &None, Span::unknown());
|
||||||
|
let expected = Value::Date {
|
||||||
|
val: Local.timestamp(1614434140, 0).into(),
|
||||||
|
span: Span::unknown(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_invalid_timestamp() {
|
||||||
|
let date_str = Value::test_string("10440970000000");
|
||||||
|
let timezone_option = Some(Spanned {
|
||||||
|
item: Zone::Utc,
|
||||||
|
span: Span::unknown(),
|
||||||
|
});
|
||||||
|
let actual = action(&date_str, &timezone_option, &None, Span::unknown());
|
||||||
|
|
||||||
|
assert_eq!(actual.get_type(), Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn communicates_parsing_error_given_an_invalid_datetimelike_string() {
|
||||||
|
let date_str = Value::test_string("16.11.1984 8:00 am Oops0000");
|
||||||
|
let fmt_options = Some(DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string()));
|
||||||
|
let actual = action(&date_str, &None, &fmt_options, Span::unknown());
|
||||||
|
|
||||||
|
assert_eq!(actual.get_type(), Error);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
mod binary;
|
mod binary;
|
||||||
mod command;
|
mod command;
|
||||||
|
mod datetime;
|
||||||
mod decimal;
|
mod decimal;
|
||||||
mod filesize;
|
mod filesize;
|
||||||
mod int;
|
mod int;
|
||||||
@ -8,6 +9,7 @@ mod string;
|
|||||||
pub use self::filesize::SubCommand as IntoFilesize;
|
pub use self::filesize::SubCommand as IntoFilesize;
|
||||||
pub use binary::SubCommand as IntoBinary;
|
pub use binary::SubCommand as IntoBinary;
|
||||||
pub use command::Into;
|
pub use command::Into;
|
||||||
|
pub use datetime::SubCommand as IntoDatetime;
|
||||||
pub use decimal::SubCommand as IntoDecimal;
|
pub use decimal::SubCommand as IntoDecimal;
|
||||||
pub use int::SubCommand as IntoInt;
|
pub use int::SubCommand as IntoInt;
|
||||||
pub use string::SubCommand as IntoString;
|
pub use string::SubCommand as IntoString;
|
||||||
|
@ -81,6 +81,7 @@ pub fn create_default_context() -> EngineState {
|
|||||||
If,
|
If,
|
||||||
Into,
|
Into,
|
||||||
IntoBinary,
|
IntoBinary,
|
||||||
|
IntoDatetime,
|
||||||
IntoDecimal,
|
IntoDecimal,
|
||||||
IntoFilesize,
|
IntoFilesize,
|
||||||
IntoInt,
|
IntoInt,
|
||||||
|
Loading…
Reference in New Issue
Block a user