Add into cell-path for dynamic cell-path creation (#11322)

# 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 '^(?<key>[^=]+)=(?<value>.*)'
| 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:🐚: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

- 🟢 `toolkit fmt`
- 🟢 `toolkit clippy`
- 🟢 `toolkit test`
- 🟢 `toolkit test stdlib`

# After Submitting

Automatic documentation updates
This commit is contained in:
Eric Hodel 2024-01-24 14:20:46 -08:00 committed by GitHub
parent 0aabe84460
commit 2a65d43c13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 361 additions and 10 deletions

View file

@ -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<PipelineData, ShellError> {
into_cell_path(call, input)
}
fn examples(&self) -> Vec<Example> {
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<PipelineData, ShellError> {
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<PathMember, ShellError> {
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<Value, ShellError> {
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<PathMember, ShellError> {
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<Value, ShellError> {
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<PathMember, ShellError> {
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 {})
}
}

View file

@ -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;

View file

@ -293,6 +293,7 @@ pub fn add_shell_command_context(mut engine_state: EngineState) -> EngineState {
Into,
IntoBool,
IntoBinary,
IntoCellPath,
IntoDatetime,
IntoDuration,
IntoFloat,

View file

@ -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<std::cmp::Ordering> {
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<PathMember>,
@ -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<PathMember>,
}
#[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))
);
}
}