From 2573441e2875cb9c2075efe1f6ade35aeb87ebe3 Mon Sep 17 00:00:00 2001 From: Darren Schroeder <343840+fdncred@users.noreply.github.com> Date: Mon, 12 Oct 2020 08:03:00 -0500 Subject: [PATCH] xpath command for nushell (#2656) * xpath prototype * new xpath engine is finally working * nearly there * closer * working with list, started to add test, code cleanup * broken again * working again - time for some cleanup * cleaned up code, added error handling and test * update example, fix clippy * removed commented char --- Cargo.lock | 29 +++++ crates/nu-cli/Cargo.toml | 2 + crates/nu-cli/src/cli.rs | 1 + crates/nu-cli/src/commands.rs | 2 + crates/nu-cli/src/commands/xpath.rs | 150 ++++++++++++++++++++++++++ crates/nu-cli/tests/commands/xpath.rs | 41 +++++++ 6 files changed, 225 insertions(+) create mode 100644 crates/nu-cli/src/commands/xpath.rs create mode 100644 crates/nu-cli/tests/commands/xpath.rs diff --git a/Cargo.lock b/Cargo.lock index 8530a5d156..b133732c62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2974,6 +2974,8 @@ dependencies = [ "sha2 0.9.1", "shellexpand", "strip-ansi-escapes", + "sxd-document", + "sxd-xpath", "tempfile", "term", "term_size", @@ -3728,6 +3730,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "peresil" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57" + [[package]] name = "pest" version = "2.1.3" @@ -5053,6 +5061,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "sxd-document" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078" +dependencies = [ + "peresil", + "typed-arena", +] + +[[package]] +name = "sxd-xpath" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea" +dependencies = [ + "peresil", + "quick-error", + "sxd-document", +] + [[package]] name = "syn" version = "1.0.39" diff --git a/crates/nu-cli/Cargo.toml b/crates/nu-cli/Cargo.toml index 9b55e51cf8..ed15aacfce 100644 --- a/crates/nu-cli/Cargo.toml +++ b/crates/nu-cli/Cargo.toml @@ -79,6 +79,8 @@ serde_yaml = "0.8.13" sha2 = "0.9.1" shellexpand = "2.0.0" strip-ansi-escapes = "0.1.0" +sxd-xpath = "0.4.2" +sxd-document = "0.3.2" tempfile = "3.1.0" term = {version = "0.6.1", optional = true} term_size = "0.3.2" diff --git a/crates/nu-cli/src/cli.rs b/crates/nu-cli/src/cli.rs index f38205b8e2..90a90a2dcd 100644 --- a/crates/nu-cli/src/cli.rs +++ b/crates/nu-cli/src/cli.rs @@ -245,6 +245,7 @@ pub fn create_default_context(interactive: bool) -> Result, +} + +#[async_trait] +impl WholeStreamCommand for XPath { + fn name(&self) -> &str { + "xpath" + } + + fn signature(&self) -> Signature { + Signature::build("xpath").required("query", SyntaxShape::String, "xpath query") + } + + fn usage(&self) -> &str { + "execute xpath query on xml" + } + + fn examples(&self) -> Vec { + vec![Example { + description: "find items with name attribute", + example: r#"echo '
' | from xml | to xml | xpath '//nushell/@rocks'"#, + result: None, + }] + } + + async fn run( + &self, + args: CommandArgs, + registry: &CommandRegistry, + ) -> Result { + let tag = args.call_info.name_tag.clone(); + let (XPathArgs { query }, input) = args.process(®istry).await?; + + let query_string = query.as_str(); + let input_string = input.collect_string(tag.clone()).await?.item; + let result_string = execute_xpath_query(input_string, query_string.to_string()); + + match result_string { + Some(r) => Ok( + futures::stream::iter(r.into_iter().map(ReturnSuccess::value)).to_output_stream(), + ), + None => Err(ShellError::labeled_error( + "xpath query error", + "xpath query error", + query.tag(), + )), + } + } +} + +pub fn execute_xpath_query(input_string: String, query_string: String) -> Option> { + let xpath = build_xpath(&query_string); + let package = parser::parse(&input_string).expect("failed to parse xml"); + let document = package.as_document(); + let context = Context::new(); + + // leaving this here for augmentation at some point + // build_variables(&arguments, &mut context); + // build_namespaces(&arguments, &mut context); + + let res = xpath.evaluate(&context, document.root()); + + // Some xpath statements can be long, so let's truncate it with ellipsis + let mut key = query_string.clone(); + if query_string.len() >= 20 { + key.truncate(17); + key += "..."; + } else { + key = query_string; + }; + + match res { + Ok(r) => { + let rows: Vec = match r { + sxd_xpath::Value::Nodeset(ns) => ns + .into_iter() + .map(|a| { + let mut row = TaggedDictBuilder::new(Tag::unknown()); + row.insert_value(&key, UntaggedValue::string(a.string_value())); + row.into_value() + }) + .collect::>(), + sxd_xpath::Value::Boolean(b) => { + let mut row = TaggedDictBuilder::new(Tag::unknown()); + row.insert_value(&key, UntaggedValue::boolean(b)); + vec![row.into_value()] + } + sxd_xpath::Value::Number(n) => { + let mut row = TaggedDictBuilder::new(Tag::unknown()); + row.insert_value( + &key, + UntaggedValue::decimal(BigDecimal::from_f64(n).expect("error with f64")) + .into_untagged_value(), + ); + + vec![row.into_value()] + } + sxd_xpath::Value::String(s) => { + let mut row = TaggedDictBuilder::new(Tag::unknown()); + row.insert_value(&key, UntaggedValue::string(s)); + vec![row.into_value()] + } + }; + + if !rows.is_empty() { + Some(rows) + } else { + None + } + } + Err(_) => None, + } +} + +fn build_xpath(xpath_str: &str) -> sxd_xpath::XPath { + let factory = Factory::new(); + + factory + .build(xpath_str) + .unwrap_or_else(|e| panic!("Unable to compile XPath {}: {}", xpath_str, e)) + .expect("error with building the xpath factory") +} + +#[cfg(test)] +mod tests { + use super::ShellError; + use super::XPath; + + #[test] + fn examples_work_as_expected() -> Result<(), ShellError> { + use crate::examples::test as test_examples; + + Ok(test_examples(XPath {})?) + } +} diff --git a/crates/nu-cli/tests/commands/xpath.rs b/crates/nu-cli/tests/commands/xpath.rs new file mode 100644 index 0000000000..27ebd54763 --- /dev/null +++ b/crates/nu-cli/tests/commands/xpath.rs @@ -0,0 +1,41 @@ +use nu_test_support::fs::Stub::FileWithContentToBeTrimmed; +use nu_test_support::playground::Playground; +use nu_test_support::{nu, pipeline}; + +#[test] +fn position_function_in_predicate() { + let actual = nu!( + cwd: ".", pipeline( + r#" + echo "" | from xml | to xml | xpath "count(//a/*[position() = 2])" + "# + )); + + assert_eq!(actual.out, "1.0000"); +} + +#[test] +fn functions_implicitly_coerce_argument_types() { + let actual = nu!( + cwd: ".", pipeline( + r#" + echo "true" | from xml | to xml | xpath "count(//*[contains(., true)])" + "# + )); + + assert_eq!(actual.out, "1.0000"); +} + +#[test] +fn find_guid_permilink_is_true() { + let actual = nu!( + cwd: "tests/fixtures/formats", pipeline( + r#" + open jonathan.xml + | to xml + | xpath '//guid/@isPermaLink' + "# + )); + + assert_eq!(actual.out, "true"); +}