From 2a65d43c1378353def719e5acc0545974a3b03ff Mon Sep 17 00:00:00 2001 From: Eric Hodel Date: Wed, 24 Jan 2024 14:20:46 -0800 Subject: [PATCH] Add `into cell-path` for dynamic cell-path creation (#11322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description The `cell-path` is a type that can be created statically with `$.nested.structure.5`, but can't be created from user input. This makes it difficult to take advantage of commands that accept a cell-path to operate on data structures. This PR adds `into cell-path` for dynamic cell-path creation. `into cell-path` accepts the following input shapes: * Bare integer (equivalent to `$.1`) * List of strings and integers * List of records with entries `value` and `optional` * String (parsed into a cell-path) ## Example usage An example of where `into cell-path` can be used is in working with `git config --list`. The git configuration has a tree structure that maps well to nushell records. With dynamic cell paths it is easy to convert `git config list` to a record: ```nushell git config --list | lines | parse -r '^(?[^=]+)=(?.*)' | reduce --fold {} {|entry, result| let path = $entry.key | into cell-path $result | upsert $path {|| $entry.value } } | select remote ``` Output: ``` ╭────────┬──────────────────────────────────────────────────────────────────╮ │ │ ╭──────────┬───────────────────────────────────────────────────╮ │ │ remote │ │ │ ╭───────┬───────────────────────────────────────╮ │ │ │ │ │ upstream │ │ url │ git@github.com:nushell/nushell.git │ │ │ │ │ │ │ │ fetch │ +refs/heads/*:refs/remotes/upstream/* │ │ │ │ │ │ │ ╰───────┴───────────────────────────────────────╯ │ │ │ │ │ │ ╭───────┬─────────────────────────────────────╮ │ │ │ │ │ origin │ │ url │ git@github.com:drbrain/nushell │ │ │ │ │ │ │ │ fetch │ +refs/heads/*:refs/remotes/origin/* │ │ │ │ │ │ │ ╰───────┴─────────────────────────────────────╯ │ │ │ │ ╰──────────┴───────────────────────────────────────────────────╯ │ ╰────────┴──────────────────────────────────────────────────────────────────╯ ``` ## Errors `lex()` + `parse_cell_path()` are forgiving about what is allowed in a cell-path so it will allow what appears to be nonsense to become a cell-path: ```nushell let table = [["!@$%^&*" value]; [key value]] $table | get ("!@$%^&*.0" | into cell-path) # => key ``` But it will reject bad cell-paths: ``` ❯ "a b" | into cell-path Error: nu::shell::cant_convert × Can't convert to cell-path. ╭─[entry #14:1:1] 1 │ "a b" | into cell-path · ───────┬────── · ╰── can't convert string to cell-path ╰──── help: "a b" is not a valid cell-path (Parse mismatch during operation.) ``` # User-Facing Changes New conversion command `into cell-path` # Tests + Formatting - :green_circle: `toolkit fmt` - :green_circle: `toolkit clippy` - :green_circle: `toolkit test` - :green_circle: `toolkit test stdlib` # After Submitting Automatic documentation updates --- .../src/conversions/into/cell_path.rs | 225 ++++++++++++++++++ crates/nu-command/src/conversions/into/mod.rs | 2 + crates/nu-command/src/default_context.rs | 1 + crates/nu-protocol/src/ast/cell_path.rs | 143 ++++++++++- 4 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 crates/nu-command/src/conversions/into/cell_path.rs diff --git a/crates/nu-command/src/conversions/into/cell_path.rs b/crates/nu-command/src/conversions/into/cell_path.rs new file mode 100644 index 0000000000..5f59b72b8f --- /dev/null +++ b/crates/nu-command/src/conversions/into/cell_path.rs @@ -0,0 +1,225 @@ +use nu_protocol::{ + ast::{Call, CellPath, PathMember}, + engine::{Command, EngineState, Stack}, + Category, Example, IntoPipelineData, PipelineData, Record, ShellError, Signature, Span, Type, + Value, +}; + +#[derive(Clone)] +pub struct IntoCellPath; + +impl Command for IntoCellPath { + fn name(&self) -> &str { + "into cell-path" + } + + fn signature(&self) -> nu_protocol::Signature { + Signature::build("into cell-path") + .input_output_types(vec![ + (Type::Int, Type::CellPath), + (Type::List(Box::new(Type::Any)), Type::CellPath), + ( + Type::List(Box::new(Type::Record(vec![ + ("value".into(), Type::Any), + ("optional".into(), Type::Bool), + ]))), + Type::CellPath, + ), + ]) + .category(Category::Conversions) + .allow_variants_without_examples(true) + } + + fn usage(&self) -> &str { + "Convert value to a cell-path." + } + + fn search_terms(&self) -> Vec<&str> { + vec!["convert"] + } + + fn extra_usage(&self) -> &str { + "Converting a string directly into a cell path is intentionally not supported." + } + + fn run( + &self, + _engine_state: &EngineState, + _stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + into_cell_path(call, input) + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Convert integer into cell path", + example: "5 | into cell-path", + result: Some(Value::test_cell_path(CellPath { + members: vec![PathMember::test_int(5, false)], + })), + }, + Example { + description: "Convert string into cell path", + example: "'some.path' | split row '.' | into cell-path", + result: Some(Value::test_cell_path(CellPath { + members: vec![ + PathMember::test_string("some".into(), false), + PathMember::test_string("path".into(), false), + ], + })), + }, + Example { + description: "Convert list into cell path", + example: "[5 c 7 h] | into cell-path", + result: Some(Value::test_cell_path(CellPath { + members: vec![ + PathMember::test_int(5, false), + PathMember::test_string("c".into(), false), + PathMember::test_int(7, false), + PathMember::test_string("h".into(), false), + ], + })), + }, + Example { + description: "Convert table into cell path", + example: "[[value, optional]; [5 true] [c false]] | into cell-path", + result: Some(Value::test_cell_path(CellPath { + members: vec![ + PathMember::test_int(5, true), + PathMember::test_string("c".into(), false), + ], + })), + }, + ] + } +} + +fn into_cell_path(call: &Call, input: PipelineData) -> Result { + let head = call.head; + + match input { + PipelineData::Value(value, _) => Ok(value_to_cell_path(&value, head)?.into_pipeline_data()), + PipelineData::ListStream(stream, ..) => { + let list: Vec<_> = stream.collect(); + Ok(list_to_cell_path(&list, head)?.into_pipeline_data()) + } + PipelineData::ExternalStream { span, .. } => Err(ShellError::OnlySupportsThisInputType { + exp_input_type: "list, int".into(), + wrong_type: "raw data".into(), + dst_span: head, + src_span: span, + }), + PipelineData::Empty => Err(ShellError::PipelineEmpty { dst_span: head }), + } +} + +fn int_to_cell_path(val: i64, span: Span) -> Value { + let member = match int_to_path_member(val, span) { + Ok(m) => m, + Err(e) => { + return Value::error(e, span); + } + }; + + let path = CellPath { + members: vec![member], + }; + + Value::cell_path(path, span) +} + +fn int_to_path_member(val: i64, span: Span) -> Result { + let Ok(val) = val.try_into() else { + return Err(ShellError::NeedsPositiveValue { span }); + }; + + Ok(PathMember::int(val, false, span)) +} + +fn list_to_cell_path(vals: &[Value], span: Span) -> Result { + let mut members = vec![]; + + for val in vals { + members.push(value_to_path_member(val, span)?); + } + + let path = CellPath { members }; + + Ok(Value::cell_path(path, span)) +} + +fn record_to_path_member( + record: &Record, + val_span: Span, + span: Span, +) -> Result { + let Some(value) = record.get("value") else { + return Err(ShellError::CantFindColumn { + col_name: "value".into(), + span: val_span, + src_span: span, + }); + }; + + let mut member = value_to_path_member(value, span)?; + + if let Some(optional) = record.get("optional") { + if optional.as_bool()? { + member.make_optional(); + } + }; + + Ok(member) +} + +fn value_to_cell_path(value: &Value, span: Span) -> Result { + match value { + Value::Int { val, .. } => Ok(int_to_cell_path(*val, span)), + Value::List { vals, .. } => list_to_cell_path(vals, span), + other => Err(ShellError::OnlySupportsThisInputType { + exp_input_type: "int, list".into(), + wrong_type: other.get_type().to_string(), + dst_span: span, + src_span: other.span(), + }), + } +} + +fn value_to_path_member(val: &Value, span: Span) -> Result { + let member = match val { + Value::Int { + val, + internal_span: span, + } => int_to_path_member(*val, *span)?, + Value::String { + val, + internal_span: span, + } => PathMember::string(val.into(), false, *span), + Value::Record { val, internal_span } => record_to_path_member(val, *internal_span, span)?, + other => { + return Err(ShellError::CantConvert { + to_type: "int or string".to_string(), + from_type: other.get_type().to_string(), + span: val.span(), + help: None, + }) + } + }; + + Ok(member) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(IntoCellPath {}) + } +} diff --git a/crates/nu-command/src/conversions/into/mod.rs b/crates/nu-command/src/conversions/into/mod.rs index eaf847fac5..5e4e602b40 100644 --- a/crates/nu-command/src/conversions/into/mod.rs +++ b/crates/nu-command/src/conversions/into/mod.rs @@ -1,5 +1,6 @@ mod binary; mod bool; +mod cell_path; mod command; mod datetime; mod duration; @@ -13,6 +14,7 @@ mod value; pub use self::bool::SubCommand as IntoBool; pub use self::filesize::SubCommand as IntoFilesize; pub use binary::SubCommand as IntoBinary; +pub use cell_path::IntoCellPath; pub use command::Into; pub use datetime::SubCommand as IntoDatetime; pub use duration::SubCommand as IntoDuration; diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 85c6185df9..f89be8deb4 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -293,6 +293,7 @@ pub fn add_shell_command_context(mut engine_state: EngineState) -> EngineState { Into, IntoBool, IntoBinary, + IntoCellPath, IntoDatetime, IntoDuration, IntoFloat, diff --git a/crates/nu-protocol/src/ast/cell_path.rs b/crates/nu-protocol/src/ast/cell_path.rs index e9047db9dd..3de37f3992 100644 --- a/crates/nu-protocol/src/ast/cell_path.rs +++ b/crates/nu-protocol/src/ast/cell_path.rs @@ -1,9 +1,9 @@ use super::Expression; use crate::Span; use serde::{Deserialize, Serialize}; -use std::fmt::Display; +use std::{cmp::Ordering, fmt::Display}; -#[derive(Debug, Clone, PartialOrd, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum PathMember { String { val: String, @@ -17,6 +17,51 @@ pub enum PathMember { }, } +impl PathMember { + pub fn int(val: usize, optional: bool, span: Span) -> Self { + PathMember::Int { + val, + span, + optional, + } + } + + pub fn string(val: String, optional: bool, span: Span) -> Self { + PathMember::String { + val, + span, + optional, + } + } + + pub fn test_int(val: usize, optional: bool) -> Self { + PathMember::Int { + val, + optional, + span: Span::test_data(), + } + } + + pub fn test_string(val: String, optional: bool) -> Self { + PathMember::String { + val, + optional, + span: Span::test_data(), + } + } + + pub fn make_optional(&mut self) { + match self { + PathMember::String { + ref mut optional, .. + } => *optional = true, + PathMember::Int { + ref mut optional, .. + } => *optional = true, + } + } +} + impl PartialEq for PathMember { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -49,6 +94,55 @@ impl PartialEq for PathMember { } } +impl PartialOrd for PathMember { + fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + ( + PathMember::String { + val: l_val, + optional: l_opt, + .. + }, + PathMember::String { + val: r_val, + optional: r_opt, + .. + }, + ) => { + let val_ord = Some(l_val.cmp(r_val)); + + if let Some(Ordering::Equal) = val_ord { + Some(l_opt.cmp(r_opt)) + } else { + val_ord + } + } + ( + PathMember::Int { + val: l_val, + optional: l_opt, + .. + }, + PathMember::Int { + val: r_val, + optional: r_opt, + .. + }, + ) => { + let val_ord = Some(l_val.cmp(r_val)); + + if let Some(Ordering::Equal) = val_ord { + Some(l_opt.cmp(r_opt)) + } else { + val_ord + } + } + (PathMember::Int { .. }, PathMember::String { .. }) => Some(Ordering::Greater), + (PathMember::String { .. }, PathMember::Int { .. }) => Some(Ordering::Less), + } + } +} + #[derive(Debug, Clone, PartialEq, PartialOrd, Serialize, Deserialize)] pub struct CellPath { pub members: Vec, @@ -57,14 +151,7 @@ pub struct CellPath { impl CellPath { pub fn make_optional(&mut self) { for member in &mut self.members { - match member { - PathMember::String { - ref mut optional, .. - } => *optional = true, - PathMember::Int { - ref mut optional, .. - } => *optional = true, - } + member.make_optional(); } } } @@ -89,3 +176,39 @@ pub struct FullCellPath { pub head: Expression, pub tail: Vec, } + +#[cfg(test)] +mod test { + use super::*; + use std::cmp::Ordering::Greater; + + #[test] + fn path_member_partial_ord() { + assert_eq!( + Some(Greater), + PathMember::test_int(5, true).partial_cmp(&PathMember::test_string("e".into(), true)) + ); + + assert_eq!( + Some(Greater), + PathMember::test_int(5, true).partial_cmp(&PathMember::test_int(5, false)) + ); + + assert_eq!( + Some(Greater), + PathMember::test_int(6, true).partial_cmp(&PathMember::test_int(5, true)) + ); + + assert_eq!( + Some(Greater), + PathMember::test_string("e".into(), true) + .partial_cmp(&PathMember::test_string("e".into(), false)) + ); + + assert_eq!( + Some(Greater), + PathMember::test_string("f".into(), true) + .partial_cmp(&PathMember::test_string("e".into(), true)) + ); + } +}