diff --git a/Cargo.lock b/Cargo.lock index f3f829129..80ece1a46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,7 +373,7 @@ dependencies = [ "codepage", "encoding_rs", "log", - "quick-xml", + "quick-xml 0.19.0", "serde", "zip", ] @@ -1623,6 +1623,7 @@ dependencies = [ "num 0.4.0", "polars", "pretty-hex", + "quick-xml 0.22.0", "rand", "rayon", "regex", @@ -2278,6 +2279,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.10" diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index 9d78c3cd7..93368e57a 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -56,6 +56,7 @@ zip = { version="0.5.9", optional=true } lazy_static = "1.4.0" strip-ansi-escapes = "0.1.1" crossterm = "0.22.1" +quick-xml = "0.22" num = {version="0.4.0", optional=true} diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 31f536732..0ecbff13f 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -166,6 +166,8 @@ pub fn create_default_context() -> EngineState { ToCsv, ToHtml, ToMd, + ToYaml, + ToXml, Touch, Uniq, Use, diff --git a/crates/nu-command/src/formats/to/mod.rs b/crates/nu-command/src/formats/to/mod.rs index 22de63f0a..6dab22236 100644 --- a/crates/nu-command/src/formats/to/mod.rs +++ b/crates/nu-command/src/formats/to/mod.rs @@ -7,6 +7,8 @@ mod md; mod toml; mod tsv; mod url; +mod xml; +mod yaml; pub use self::csv::ToCsv; pub use self::toml::ToToml; @@ -16,3 +18,5 @@ pub use html::ToHtml; pub use json::ToJson; pub use md::ToMd; pub use tsv::ToTsv; +pub use xml::ToXml; +pub use yaml::ToYaml; diff --git a/crates/nu-command/src/formats/to/xml.rs b/crates/nu-command/src/formats/to/xml.rs new file mode 100644 index 000000000..157c193bf --- /dev/null +++ b/crates/nu-command/src/formats/to/xml.rs @@ -0,0 +1,202 @@ +use indexmap::IndexMap; +use nu_engine::CallExt; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{ + Category, Config, Example, IntoPipelineData, PipelineData, ShellError, Signature, Span, + Spanned, SyntaxShape, Value, +}; +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; +use std::collections::HashSet; +use std::io::Cursor; +use std::io::Write; + +#[derive(Clone)] +pub struct ToXml; + +impl Command for ToXml { + fn name(&self) -> &str { + "to xml" + } + + fn signature(&self) -> Signature { + Signature::build("to xml") + .named( + "pretty", + SyntaxShape::Int, + "Formats the XML text with the provided indentation setting", + Some('p'), + ) + .category(Category::Formats) + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Outputs an XML string representing the contents of this table", + example: r#"{ "note": { "children": [{ "remember": {"attributes" : {}, "children": [Event]}}], "attributes": {} } } | to xml"#, + result: Some(Value::test_string( + "Event", + )), + }, + Example { + description: "Optionally, formats the text with a custom indentation setting", + example: r#"{ "note": { "children": [{ "remember": {"attributes" : {}, "children": [Event]}}], "attributes": {} } } | to xml -p 3"#, + result: Some(Value::test_string( + "\n Event\n", + )), + }, + ] + } + + fn usage(&self) -> &str { + "Convert table into .xml text" + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + let head = call.head; + let config = stack.get_config()?; + let pretty: Option> = call.get_flag(engine_state, stack, "pretty")?; + to_xml(input, head, pretty, &config) + } +} + +pub fn add_attributes<'a>( + element: &mut quick_xml::events::BytesStart<'a>, + attributes: &'a IndexMap, +) { + for (k, v) in attributes { + element.push_attribute((k.as_str(), v.as_str())); + } +} + +pub fn get_attributes(row: &Value, config: &Config) -> Option> { + if let Value::Record { .. } = row { + if let Some(Value::Record { cols, vals, .. }) = row.get_data_by_key("attributes") { + let mut h = IndexMap::new(); + for (k, v) in cols.iter().zip(vals.iter()) { + h.insert(k.clone(), v.clone().into_abbreviated_string(config)); + } + return Some(h); + } + } + None +} + +pub fn get_children(row: &Value) -> Option> { + if let Value::Record { .. } = row { + if let Some(Value::List { vals, .. }) = row.get_data_by_key("children") { + return Some(vals); + } + } + None +} + +pub fn is_xml_row(row: &Value) -> bool { + if let Value::Record { cols, .. } = &row { + let keys: HashSet<&String> = cols.iter().collect(); + let children: String = "children".to_string(); + let attributes: String = "attributes".to_string(); + return keys.contains(&children) && keys.contains(&attributes) && keys.len() == 2; + } + false +} + +pub fn write_xml_events( + current: Value, + writer: &mut quick_xml::Writer, + config: &Config, +) -> Result<(), ShellError> { + match current { + Value::Record { cols, vals, span } => { + for (k, v) in cols.iter().zip(vals.iter()) { + let mut e = BytesStart::owned(k.as_bytes(), k.len()); + if !is_xml_row(v) { + return Err(ShellError::SpannedLabeledError( + "Expected a row with 'children' and 'attributes' columns".to_string(), + "missing 'children' and 'attributes' columns ".to_string(), + span, + )); + } + let a = get_attributes(v, config); + if let Some(ref a) = a { + add_attributes(&mut e, a); + } + writer + .write_event(Event::Start(e)) + .expect("Couldn't open XML node"); + let c = get_children(v); + if let Some(c) = c { + for v in c { + write_xml_events(v, writer, config)?; + } + } + writer + .write_event(Event::End(BytesEnd::borrowed(k.as_bytes()))) + .expect("Couldn't close XML node"); + } + } + Value::List { vals, .. } => { + for v in vals { + write_xml_events(v, writer, config)?; + } + } + _ => { + let s = current.clone().into_abbreviated_string(config); + writer + .write_event(Event::Text(BytesText::from_plain_str(s.as_str()))) + .expect("Couldn't write XML text"); + } + } + Ok(()) +} + +fn to_xml( + input: PipelineData, + head: Span, + pretty: Option>, + config: &Config, +) -> Result { + let mut w = pretty.as_ref().map_or_else( + || quick_xml::Writer::new(Cursor::new(Vec::new())), + |p| quick_xml::Writer::new_with_indent(Cursor::new(Vec::new()), b' ', p.item as usize), + ); + + let value = input.into_value(head); + let value_type = value.get_type(); + + match write_xml_events(value, &mut w, config) { + Ok(_) => { + let b = w.into_inner().into_inner(); + let s = if let Ok(s) = String::from_utf8(b) { + s + } else { + return Err(ShellError::NonUtf8(head)); + }; + Ok(Value::string(s, head).into_pipeline_data()) + } + Err(_) => Err(ShellError::CantConvert( + "XML".into(), + value_type.to_string(), + head, + )), + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(ToXml {}) + } +} diff --git a/crates/nu-command/src/formats/to/yaml.rs b/crates/nu-command/src/formats/to/yaml.rs new file mode 100644 index 000000000..8525a8a84 --- /dev/null +++ b/crates/nu-command/src/formats/to/yaml.rs @@ -0,0 +1,122 @@ +use nu_protocol::ast::{Call, PathMember}; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{ + Category, Example, IntoPipelineData, PipelineData, ShellError, Signature, Span, Value, +}; + +#[derive(Clone)] +pub struct ToYaml; + +impl Command for ToYaml { + fn name(&self) -> &str { + "to yaml" + } + + fn signature(&self) -> Signature { + Signature::build("to yaml").category(Category::Formats) + } + + fn usage(&self) -> &str { + "Convert table into .yaml/.yml text" + } + + fn examples(&self) -> Vec { + vec![Example { + description: "Outputs an YAML string representing the contents of this table", + example: r#"[[foo bar]; ["1" "2"]] | to yaml"#, + result: Some(Value::test_string("---\n- foo: \"1\"\n bar: \"2\"\n")), + }] + } + + fn run( + &self, + _engine_state: &EngineState, + _stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + let head = call.head; + to_yaml(input, head) + } +} + +pub fn value_to_yaml_value(v: &Value) -> Result { + Ok(match &v { + Value::Bool { val, .. } => serde_yaml::Value::Bool(*val), + Value::Int { val, .. } => serde_yaml::Value::Number(serde_yaml::Number::from(*val)), + Value::Filesize { val, .. } => serde_yaml::Value::Number(serde_yaml::Number::from(*val)), + Value::Duration { val, .. } => serde_yaml::Value::String(val.to_string()), + Value::Date { val, .. } => serde_yaml::Value::String(val.to_string()), + Value::Range { .. } => serde_yaml::Value::Null, + Value::Float { val, .. } => serde_yaml::Value::Number(serde_yaml::Number::from(*val)), + Value::String { val, .. } => serde_yaml::Value::String(val.clone()), + Value::Record { cols, vals, .. } => { + let mut m = serde_yaml::Mapping::new(); + for (k, v) in cols.iter().zip(vals.iter()) { + m.insert( + serde_yaml::Value::String(k.clone()), + value_to_yaml_value(v)?, + ); + } + serde_yaml::Value::Mapping(m) + } + Value::List { vals, .. } => { + let mut out = vec![]; + + for value in vals { + out.push(value_to_yaml_value(value)?); + } + + serde_yaml::Value::Sequence(out) + } + Value::Block { .. } => serde_yaml::Value::Null, + Value::Nothing { .. } => serde_yaml::Value::Null, + Value::Error { error } => return Err(error.clone()), + Value::Binary { val, .. } => serde_yaml::Value::Sequence( + val.iter() + .map(|x| serde_yaml::Value::Number(serde_yaml::Number::from(*x))) + .collect(), + ), + Value::CellPath { val, .. } => serde_yaml::Value::Sequence( + val.members + .iter() + .map(|x| match &x { + PathMember::String { val, .. } => Ok(serde_yaml::Value::String(val.clone())), + PathMember::Int { val, .. } => { + Ok(serde_yaml::Value::Number(serde_yaml::Number::from(*val))) + } + }) + .collect::, ShellError>>()?, + ), + Value::CustomValue { .. } => serde_yaml::Value::Null, + }) +} + +fn to_yaml(input: PipelineData, head: Span) -> Result { + let value = input.into_value(head); + + let yaml_value = value_to_yaml_value(&value)?; + match serde_yaml::to_string(&yaml_value) { + Ok(serde_yaml_string) => Ok(Value::String { + val: serde_yaml_string, + span: head, + } + .into_pipeline_data()), + _ => Ok(Value::Error { + error: ShellError::CantConvert("YAML".into(), value.get_type().to_string(), head), + } + .into_pipeline_data()), + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(ToYaml {}) + } +}