diff --git a/README.md b/README.md index 48fae584a5..bf6b65585b 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,7 @@ Nu adheres closely to a set of goals that make up its design philosophy. As feat | from-ini | Parse text as .ini and create table | | from-json | Parse text as .json and create table | | from-sqlite | Parse binary data as sqlite .db and create table | +| from-ssv | Parse text as whitespace-separated values and create table| | from-toml | Parse text as .toml and create table | | from-tsv | Parse text as .tsv and create table | | from-url | Parse urlencoded string and create a table | diff --git a/src/cli.rs b/src/cli.rs index 16dc983540..0182ad1002 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -282,6 +282,7 @@ pub async fn cli() -> Result<(), Box> { whole_stream_command(Env), whole_stream_command(FromCSV), whole_stream_command(FromTSV), + whole_stream_command(FromSSV), whole_stream_command(FromINI), whole_stream_command(FromBSON), whole_stream_command(FromJSON), diff --git a/src/commands.rs b/src/commands.rs index 4eb733edd4..61a45dbb3a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -22,6 +22,7 @@ pub(crate) mod from_csv; pub(crate) mod from_ini; pub(crate) mod from_json; pub(crate) mod from_sqlite; +pub(crate) mod from_ssv; pub(crate) mod from_toml; pub(crate) mod from_tsv; pub(crate) mod from_url; @@ -92,6 +93,7 @@ pub(crate) use from_ini::FromINI; pub(crate) use from_json::FromJSON; pub(crate) use from_sqlite::FromDB; pub(crate) use from_sqlite::FromSQLite; +pub(crate) use from_ssv::FromSSV; pub(crate) use from_toml::FromTOML; pub(crate) use from_tsv::FromTSV; pub(crate) use from_url::FromURL; diff --git a/src/commands/from_ssv.rs b/src/commands/from_ssv.rs new file mode 100644 index 0000000000..1be9b4567a --- /dev/null +++ b/src/commands/from_ssv.rs @@ -0,0 +1,187 @@ +use crate::commands::WholeStreamCommand; +use crate::data::{Primitive, TaggedDictBuilder, Value}; +use crate::prelude::*; + +pub struct FromSSV; + +#[derive(Deserialize)] +pub struct FromSSVArgs { + headerless: bool, +} + +const STRING_REPRESENTATION: &str = "from-ssv"; + +impl WholeStreamCommand for FromSSV { + fn name(&self) -> &str { + STRING_REPRESENTATION + } + + fn signature(&self) -> Signature { + Signature::build(STRING_REPRESENTATION).switch("headerless") + } + + fn usage(&self) -> &str { + "Parse text as whitespace-separated values and create a table." + } + + fn run( + &self, + args: CommandArgs, + registry: &CommandRegistry, + ) -> Result { + args.process(registry, from_ssv)?.run() + } +} + +fn string_to_table(s: &str, headerless: bool) -> Option>> { + let mut lines = s.lines().filter(|l| !l.trim().is_empty()); + + let headers = lines + .next()? + .split_whitespace() + .map(|s| s.to_owned()) + .collect::>(); + + let header_row = if headerless { + (1..=headers.len()) + .map(|i| format!("Column{}", i)) + .collect::>() + } else { + headers + }; + + Some( + lines + .map(|l| { + header_row + .iter() + .zip(l.split_whitespace()) + .map(|(a, b)| (String::from(a), String::from(b))) + .collect() + }) + .collect(), + ) +} + +fn from_ssv_string_to_value( + s: &str, + headerless: bool, + tag: impl Into, +) -> Option> { + let tag = tag.into(); + let rows = string_to_table(s, headerless)? + .iter() + .map(|row| { + let mut tagged_dict = TaggedDictBuilder::new(&tag); + for (col, entry) in row { + tagged_dict.insert_tagged( + col, + Value::Primitive(Primitive::String(String::from(entry))).tagged(&tag), + ) + } + tagged_dict.into_tagged_value() + }) + .collect(); + + Some(Value::Table(rows).tagged(&tag)) +} + +fn from_ssv( + FromSSVArgs { headerless }: FromSSVArgs, + RunnableContext { input, name, .. }: RunnableContext, +) -> Result { + let stream = async_stream! { + let values: Vec> = input.values.collect().await; + let mut concat_string = String::new(); + let mut latest_tag: Option = None; + + for value in values { + let value_tag = value.tag(); + latest_tag = Some(value_tag.clone()); + match value.item { + Value::Primitive(Primitive::String(s)) => { + concat_string.push_str(&s); + } + _ => yield Err(ShellError::labeled_error_with_secondary ( + "Expected a string from pipeline", + "requires string input", + &name, + "value originates from here", + &value_tag + )), + } + } + + match from_ssv_string_to_value(&concat_string, headerless, name.clone()) { + Some(x) => match x { + Tagged { item: Value::Table(list), ..} => { + for l in list { yield ReturnSuccess::value(l) } + } + x => yield ReturnSuccess::value(x) + }, + None => if let Some(tag) = latest_tag { + yield Err(ShellError::labeled_error_with_secondary( + "Could not parse as SSV", + "input cannot be parsed ssv", + &name, + "value originates from here", + &tag, + )) + }, + } + }; + + Ok(stream.to_output_stream()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn owned(x: &str, y: &str) -> (String, String) { + (String::from(x), String::from(y)) + } + + #[test] + fn it_trims_empty_and_whitespace_only_lines() { + let input = r#" + + a b + + 1 2 + + 3 4 + "#; + let result = string_to_table(input, false); + assert_eq!( + result, + Some(vec![ + vec![owned("a", "1"), owned("b", "2")], + vec![owned("a", "3"), owned("b", "4")] + ]) + ); + } + + #[test] + fn it_ignores_headers_when_headerless() { + let input = r#" + a b + 1 2 + 3 4 + "#; + let result = string_to_table(input, true); + assert_eq!( + result, + Some(vec![ + vec![owned("Column1", "1"), owned("Column2", "2")], + vec![owned("Column1", "3"), owned("Column2", "4")] + ]) + ); + } + + #[test] + fn it_returns_none_given_an_empty_string() { + let input = ""; + let result = string_to_table(input, true); + assert_eq!(result, None); + } +} diff --git a/tests/filters_test.rs b/tests/filters_test.rs index f994fa4494..ed841af4ca 100644 --- a/tests/filters_test.rs +++ b/tests/filters_test.rs @@ -355,6 +355,62 @@ fn converts_from_tsv_text_skipping_headers_to_structured_table() { }) } +#[test] +fn converts_from_ssv_text_to_structured_table() { + Playground::setup("filter_from_ssv_test_1", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "oc_get_svc.txt", + r#" + NAME LABELS SELECTOR IP PORT(S) + docker-registry docker-registry=default docker-registry=default 172.30.78.158 5000/TCP + kubernetes component=apiserver,provider=kubernetes 172.30.0.2 443/TCP + kubernetes-ro component=apiserver,provider=kubernetes 172.30.0.1 80/TCP + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), h::pipeline( + r#" + open oc_get_svc.txt + | from-ssv + | nth 0 + | get IP + | echo $it + "# + )); + + assert_eq!(actual, "172.30.78.158"); + }) +} + +#[test] +fn converts_from_ssv_text_skipping_headers_to_structured_table() { + Playground::setup("filter_from_ssv_test_2", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "oc_get_svc.txt", + r#" + NAME LABELS SELECTOR IP PORT(S) + docker-registry docker-registry=default docker-registry=default 172.30.78.158 5000/TCP + kubernetes component=apiserver,provider=kubernetes 172.30.0.2 443/TCP + kubernetes-ro component=apiserver,provider=kubernetes 172.30.0.1 80/TCP + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), h::pipeline( + r#" + open oc_get_svc.txt + | from-ssv --headerless + | nth 2 + | get Column2 + | echo $it + "# + )); + + assert_eq!(actual, "component=apiserver,provider=kubernetes"); + }) +} + #[test] fn can_convert_table_to_bson_and_back_into_table() { let actual = nu!(