diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 7c3d79a010..07932b95fa 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -74,6 +74,20 @@ pub fn create_default_context() -> EngineState { Zip, }; + // Path + bind_command! { + Path, + PathBasename, + PathDirname, + PathExists, + PathExpand, + PathJoin, + PathParse, + PathRelativeTo, + PathSplit, + PathType, + }; + // System bind_command! { Benchmark, diff --git a/crates/nu-command/src/example_test.rs b/crates/nu-command/src/example_test.rs index ed7e9513e1..55df0f01e8 100644 --- a/crates/nu-command/src/example_test.rs +++ b/crates/nu-command/src/example_test.rs @@ -7,7 +7,7 @@ use nu_protocol::{ use crate::To; -use super::{Date, From, Into, Math, Random, Split, Str, Url}; +use super::{Date, From, Into, Math, Path, Random, Split, Str, Url}; pub fn test_examples(cmd: impl Command + 'static) { let examples = cmd.examples(); @@ -24,6 +24,7 @@ pub fn test_examples(cmd: impl Command + 'static) { working_set.add_decl(Box::new(Random)); working_set.add_decl(Box::new(Split)); working_set.add_decl(Box::new(Math)); + working_set.add_decl(Box::new(Path)); working_set.add_decl(Box::new(Date)); working_set.add_decl(Box::new(Url)); diff --git a/crates/nu-command/src/formats/to/md.rs b/crates/nu-command/src/formats/to/md.rs index b77a1e9bc8..6eef818a8d 100644 --- a/crates/nu-command/src/formats/to/md.rs +++ b/crates/nu-command/src/formats/to/md.rs @@ -430,7 +430,7 @@ mod tests { ); assert_eq!( - table(value.clone().into_pipeline_data(), true, &Config::default()), + table(value.into_pipeline_data(), true, &Config::default()), one(r#" | country | | ----------- | diff --git a/crates/nu-command/src/lib.rs b/crates/nu-command/src/lib.rs index dc547aa894..ce17687562 100644 --- a/crates/nu-command/src/lib.rs +++ b/crates/nu-command/src/lib.rs @@ -12,6 +12,7 @@ mod formats; mod hash; mod math; mod network; +mod path; mod platform; mod random; mod shells; @@ -33,6 +34,7 @@ pub use formats::*; pub use hash::*; pub use math::*; pub use network::*; +pub use path::*; pub use platform::*; pub use random::*; pub use shells::*; diff --git a/crates/nu-command/src/path/basename.rs b/crates/nu-command/src/path/basename.rs new file mode 100644 index 0000000000..5888a7035e --- /dev/null +++ b/crates/nu-command/src/path/basename.rs @@ -0,0 +1,151 @@ +use std::path::Path; + +use nu_engine::CallExt; +use nu_protocol::{engine::Command, Example, Signature, Span, Spanned, SyntaxShape, Value}; + +use super::PathSubcommandArguments; + +struct Arguments { + columns: Option>, + replace: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path basename" + } + + fn signature(&self) -> Signature { + Signature::build("path basename") + .named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + .named( + "replace", + SyntaxShape::String, + "Return original path with basename replaced by this string", + Some('r'), + ) + } + + fn usage(&self) -> &str { + "Get the final component of a path" + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + columns: call.get_flag(engine_state, stack, "columns")?, + replace: call.get_flag(engine_state, stack, "replace")?, + }; + + input.map( + move |value| super::operate(&get_basename, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Get basename of a path", + example: "'C:\\Users\\joe\\test.txt' | path basename", + result: Some(Value::test_string("test.txt")), + }, + Example { + description: "Get basename of a path in a column", + example: "ls .. | path basename -c [ name ]", + result: None, + }, + Example { + description: "Get basename of a path in a column", + example: "[[name];[C:\\Users\\Joe]] | path basename -c [ name ]", + result: Some(Value::List { + vals: vec![Value::Record { + cols: vec!["name".to_string()], + vals: vec![Value::test_string("Joe")], + span: Span::unknown(), + }], + span: Span::unknown(), + }), + }, + Example { + description: "Replace basename of a path", + example: "'C:\\Users\\joe\\test.txt' | path basename -r 'spam.png'", + result: Some(Value::test_string("C:\\Users\\joe\\spam.png")), + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Get basename of a path", + example: "'/home/joe/test.txt' | path basename", + result: Some(Value::test_string("test.txt")), + }, + Example { + description: "Get basename of a path by column", + example: "[[name];[/home/joe]] | path basename -c [ name ]", + result: Some(Value::List { + vals: vec![Value::Record { + cols: vec!["name".to_string()], + vals: vec![Value::test_string("joe")], + span: Span::unknown(), + }], + span: Span::unknown(), + }), + }, + Example { + description: "Replace basename of a path", + example: "'/home/joe/test.txt' | path basename -r 'spam.png'", + result: Some(Value::test_string("/home/joe/spam.png")), + }, + ] + } +} + +fn get_basename(path: &Path, span: Span, args: &Arguments) -> Value { + match &args.replace { + Some(r) => Value::string(path.with_file_name(r.item.clone()).to_string_lossy(), span), + None => Value::string( + match path.file_name() { + Some(n) => n.to_string_lossy(), + None => "".into(), + }, + span, + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/command.rs b/crates/nu-command/src/path/command.rs new file mode 100644 index 0000000000..4a47c6cf84 --- /dev/null +++ b/crates/nu-command/src/path/command.rs @@ -0,0 +1,56 @@ +use nu_engine::get_full_help; +use nu_protocol::{ + ast::Call, + engine::{Command, EngineState, Stack}, + IntoPipelineData, PipelineData, Signature, Value, +}; + +#[derive(Clone)] +pub struct PathCommand; + +impl Command for PathCommand { + fn name(&self) -> &str { + "path" + } + + fn signature(&self) -> Signature { + Signature::build("path") + } + + fn usage(&self) -> &str { + "Explore and manipulate paths." + } + + fn extra_usage(&self) -> &str { + r#"There are three ways to represent a path: + +* As a path literal, e.g., '/home/viking/spam.txt' +* As a structured path: a table with 'parent', 'stem', and 'extension' (and +* 'prefix' on Windows) columns. This format is produced by the 'path parse' + subcommand. +* As an inner list of path parts, e.g., '[[ / home viking spam.txt ]]'. + Splitting into parts is done by the `path split` command. + +All subcommands accept all three variants as an input. Furthermore, the 'path +join' subcommand can be used to join the structured path or path parts back into +the path literal."# + } + + fn run( + &self, + engine_state: &EngineState, + _stack: &mut Stack, + call: &Call, + _input: PipelineData, + ) -> Result { + Ok(Value::String { + val: get_full_help( + &PathCommand.signature(), + &PathCommand.examples(), + engine_state, + ), + span: call.head, + } + .into_pipeline_data()) + } +} diff --git a/crates/nu-command/src/path/dirname.rs b/crates/nu-command/src/path/dirname.rs new file mode 100644 index 0000000000..061fa0eb06 --- /dev/null +++ b/crates/nu-command/src/path/dirname.rs @@ -0,0 +1,168 @@ +use std::path::Path; + +use nu_engine::CallExt; +use nu_protocol::{engine::Command, Example, Signature, Span, Spanned, SyntaxShape, Value}; + +use super::PathSubcommandArguments; + +struct Arguments { + columns: Option>, + replace: Option>, + num_levels: Option, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path dirname" + } + + fn signature(&self) -> Signature { + Signature::build("path dirname") + .named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + .named( + "replace", + SyntaxShape::String, + "Return original path with dirname replaced by this string", + Some('r'), + ) + .named( + "num-levels", + SyntaxShape::Int, + "Number of directories to walk up", + Some('n'), + ) + } + + fn usage(&self) -> &str { + "Get the parent directory of a path" + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + columns: call.get_flag(engine_state, stack, "columns")?, + replace: call.get_flag(engine_state, stack, "replace")?, + num_levels: call.get_flag(engine_state, stack, "num-levels")?, + }; + + input.map( + move |value| super::operate(&get_dirname, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Get dirname of a path", + example: "'C:\\Users\\joe\\code\\test.txt' | path dirname", + result: Some(Value::test_string("C:\\Users\\joe\\code")), + }, + Example { + description: "Get dirname of a path in a column", + example: "ls ('.' | path expand) | path dirname -c [ name ]", + result: None, + }, + Example { + description: "Walk up two levels", + example: "'C:\\Users\\joe\\code\\test.txt' | path dirname -n 2", + result: Some(Value::test_string("C:\\Users\\joe")), + }, + Example { + description: "Replace the part that would be returned with a custom path", + example: + "'C:\\Users\\joe\\code\\test.txt' | path dirname -n 2 -r C:\\Users\\viking", + result: Some(Value::test_string("C:\\Users\\viking\\code\\test.txt")), + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Get dirname of a path", + example: "'/home/joe/code/test.txt' | path dirname", + result: Some(Value::test_string("/home/joe/code")), + }, + Example { + description: "Get dirname of a path in a column", + example: "ls ('.' | path expand) | path dirname -c [ name ]", + result: None, + }, + Example { + description: "Walk up two levels", + example: "'/home/joe/code/test.txt' | path dirname -n 2", + result: Some(Value::test_string("/home/joe")), + }, + Example { + description: "Replace the part that would be returned with a custom path", + example: "'/home/joe/code/test.txt' | path dirname -n 2 -r /home/viking", + result: Some(Value::test_string("/home/viking/code/test.txt")), + }, + ] + } +} + +fn get_dirname(path: &Path, span: Span, args: &Arguments) -> Value { + let num_levels = args.num_levels.as_ref().map_or(1, |val| *val); + + let mut dirname = path; + let mut reached_top = false; + for _ in 0..num_levels { + dirname = dirname.parent().unwrap_or_else(|| { + reached_top = true; + dirname + }); + if reached_top { + break; + } + } + + let path = match args.replace { + Some(ref newdir) => { + let remainder = path.strip_prefix(dirname).unwrap_or(dirname); + if !remainder.as_os_str().is_empty() { + Path::new(&newdir.item).join(remainder) + } else { + Path::new(&newdir.item).to_path_buf() + } + } + None => dirname.to_path_buf(), + }; + + Value::string(path.to_string_lossy(), span) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/exists.rs b/crates/nu-command/src/path/exists.rs new file mode 100644 index 0000000000..1a9f2b0adc --- /dev/null +++ b/crates/nu-command/src/path/exists.rs @@ -0,0 +1,113 @@ +use std::path::Path; + +use nu_engine::CallExt; +use nu_protocol::{engine::Command, Example, Signature, Span, SyntaxShape, Value}; + +use super::PathSubcommandArguments; + +struct Arguments { + columns: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path exists" + } + + fn signature(&self) -> Signature { + Signature::build("path exists").named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + } + + fn usage(&self) -> &str { + "Check whether a path exists" + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + columns: call.get_flag(engine_state, stack, "columns")?, + }; + + input.map( + move |value| super::operate(&exists, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Check if a file exists", + example: "'C:\\Users\\joe\\todo.txt' | path exists", + result: Some(Value::Bool { + val: false, + span: Span::unknown(), + }), + }, + Example { + description: "Check if a file exists in a column", + example: "ls | path exists -c [ name ]", + result: None, + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Check if a file exists", + example: "'/home/joe/todo.txt' | path exists", + result: Some(Value::Bool { + val: false, + span: Span::unknown(), + }), + }, + Example { + description: "Check if a file exists in a column", + example: "ls | path exists -c [ name ]", + result: None, + }, + ] + } +} + +fn exists(path: &Path, span: Span, _args: &Arguments) -> Value { + Value::Bool { + val: path.exists(), + span, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/expand.rs b/crates/nu-command/src/path/expand.rs new file mode 100644 index 0000000000..dda62aa070 --- /dev/null +++ b/crates/nu-command/src/path/expand.rs @@ -0,0 +1,136 @@ +use std::path::Path; + +use nu_engine::CallExt; +use nu_path::{canonicalize, expand_path}; +use nu_protocol::{engine::Command, Example, ShellError, Signature, Span, SyntaxShape, Value}; + +use super::PathSubcommandArguments; + +struct Arguments { + strict: bool, + columns: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path expand" + } + + fn signature(&self) -> Signature { + Signature::build("path expand") + .switch( + "strict", + "Throw an error if the path could not be expanded", + Some('s'), + ) + .named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + } + + fn usage(&self) -> &str { + "Try to expand a path to its absolute form" + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + strict: call.has_flag("strict"), + columns: call.get_flag(engine_state, stack, "columns")?, + }; + + input.map( + move |value| super::operate(&expand, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Expand an absolute path", + example: r"'C:\Users\joe\foo\..\bar' | path expand", + result: Some(Value::test_string(r"C:\Users\joe\bar")), + }, + Example { + description: "Expand a path in a column", + example: "ls | path expand -c [ name ]", + result: None, + }, + Example { + description: "Expand a relative path", + example: r"'foo\..\bar' | path expand", + result: Some(Value::test_string("bar")), + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Expand an absolute path", + example: "'/home/joe/foo/../bar' | path expand", + result: Some(Value::test_string("/home/joe/bar")), + }, + Example { + description: "Expand a path in a column", + example: "ls | path expand -c [ name ]", + result: None, + }, + Example { + description: "Expand a relative path", + example: "'foo/../bar' | path expand", + result: Some(Value::test_string("bar")), + }, + ] + } +} + +fn expand(path: &Path, span: Span, args: &Arguments) -> Value { + if let Ok(p) = canonicalize(path) { + Value::string(p.to_string_lossy(), span) + } else if args.strict { + Value::Error { + error: ShellError::LabeledError( + "Could not expand path".into(), + "could not be expanded (path might not exist, non-final \ + component is not a directory, or other cause)" + .into(), + ), + } + } else { + Value::string(expand_path(path).to_string_lossy(), span) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/join.rs b/crates/nu-command/src/path/join.rs new file mode 100644 index 0000000000..48145ca8c3 --- /dev/null +++ b/crates/nu-command/src/path/join.rs @@ -0,0 +1,270 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use nu_engine::CallExt; +use nu_protocol::{ + engine::Command, Example, PipelineData, ShellError, Signature, Span, Spanned, SyntaxShape, + Value, ValueStream, +}; + +use super::PathSubcommandArguments; + +struct Arguments { + columns: Option>, + append: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path join" + } + + fn signature(&self) -> Signature { + Signature::build("path join") + .named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + .optional( + "append", + SyntaxShape::Filepath, + "Path to append to the input", + ) + } + + fn usage(&self) -> &str { + "Join a structured path or a list of path parts." + } + + fn extra_usage(&self) -> &str { + r#"Optionally, append an additional path to the result. It is designed to accept +the output of 'path parse' and 'path split' subcommands."# + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + columns: call.get_flag(engine_state, stack, "columns")?, + append: call.opt(engine_state, stack, 0)?, + }; + + match input { + PipelineData::Value(val, md) => { + Ok(PipelineData::Value(handle_value(val, &args, head), md)) + } + PipelineData::Stream(stream, md) => Ok(PipelineData::Stream( + ValueStream::from_stream( + stream.map(move |val| handle_value(val, &args, head)), + engine_state.ctrlc.clone(), + ), + md, + )), + } + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Append a filename to a path", + example: r"'C:\Users\viking' | path join spam.txt", + result: Some(Value::test_string(r"C:\Users\viking\spam.txt")), + }, + Example { + description: "Append a filename to a path inside a column", + example: r"ls | path join spam.txt -c [ name ]", + result: None, + }, + Example { + description: "Join a list of parts into a path", + example: r"[ 'C:' '\' 'Users' 'viking' 'spam.txt' ] | path join", + result: Some(Value::test_string(r"C:\Users\viking\spam.txt")), + }, + Example { + description: "Join a structured path into a path", + example: r"[ [parent stem extension]; ['C:\Users\viking' 'spam' 'txt']] | path join", + result: Some(Value::List { + vals: vec![Value::test_string(r"C:\Users\viking\spam.txt")], + span: Span::unknown(), + }), + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Append a filename to a path", + example: r"'/home/viking' | path join spam.txt", + result: Some(Value::test_string(r"/home/viking/spam.txt")), + }, + Example { + description: "Append a filename to a path inside a column", + example: r"ls | path join spam.txt -c [ name ]", + result: None, + }, + Example { + description: "Join a list of parts into a path", + example: r"[ '/' 'home' 'viking' 'spam.txt' ] | path join", + result: Some(Value::test_string(r"/home/viking/spam.txt")), + }, + Example { + description: "Join a structured path into a path", + example: r"[[ parent stem extension ]; [ '/home/viking' 'spam' 'txt' ]] | path join", + result: Some(Value::List { + vals: vec![Value::test_string(r"/home/viking/spam.txt")], + span: Span::unknown(), + }), + }, + ] + } +} + +fn handle_value(v: Value, args: &Arguments, head: Span) -> Value { + match v { + Value::String { ref val, span } => join_single(Path::new(val), span, args), + Value::Record { cols, vals, span } => join_record(&cols, &vals, span, args), + Value::List { vals, span } => join_list(&vals, span, args), + + _ => super::handle_invalid_values(v, head), + } +} + +fn join_single(path: &Path, span: Span, args: &Arguments) -> Value { + let path = if let Some(ref append) = args.append { + path.join(Path::new(&append.item)) + } else { + path.to_path_buf() + }; + + Value::string(path.to_string_lossy(), span) +} + +fn join_list(parts: &[Value], span: Span, args: &Arguments) -> Value { + let path: Result = parts.iter().map(Value::as_string).collect(); + + match path { + Ok(ref path) => join_single(path, span, args), + Err(_) => { + let records: Result, ShellError> = parts.iter().map(Value::as_record).collect(); + match records { + Ok(vals) => { + let vals = vals + .iter() + .map(|(k, v)| join_record(k, v, span, args)) + .collect(); + + Value::List { vals, span } + } + Err(_) => Value::Error { + error: ShellError::PipelineMismatch("string or record".into(), span, span), + }, + } + } + } +} + +fn join_record(cols: &[String], vals: &[Value], span: Span, args: &Arguments) -> Value { + if args.columns.is_some() { + super::operate( + &join_single, + args, + Value::Record { + cols: cols.to_vec(), + vals: vals.to_vec(), + span, + }, + span, + ) + } else { + match merge_record(cols, vals, span) { + Ok(p) => join_single(p.as_path(), span, args), + Err(error) => Value::Error { error }, + } + } +} + +fn merge_record(cols: &[String], vals: &[Value], span: Span) -> Result { + for key in cols { + if !super::ALLOWED_COLUMNS.contains(&key.as_str()) { + let allowed_cols = super::ALLOWED_COLUMNS.join(", "); + let msg = format!( + "Column '{}' is not valid for a structured path. Allowed columns are: {}", + key, allowed_cols + ); + return Err(ShellError::UnsupportedInput(msg, span)); + } + } + + let entries: HashMap<&str, &Value> = cols.iter().map(String::as_str).zip(vals).collect(); + let mut result = PathBuf::new(); + + #[cfg(windows)] + if let Some(val) = entries.get("prefix") { + let p = val.as_string()?; + if !p.is_empty() { + result.push(p); + } + } + + if let Some(val) = entries.get("parent") { + let p = val.as_string()?; + if !p.is_empty() { + result.push(p); + } + } + + let mut basename = String::new(); + if let Some(val) = entries.get("stem") { + let p = val.as_string()?; + if !p.is_empty() { + basename.push_str(&p); + } + } + + if let Some(val) = entries.get("extension") { + let p = val.as_string()?; + if !p.is_empty() { + basename.push('.'); + basename.push_str(&p); + } + } + + if !basename.is_empty() { + result.push(basename); + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/mod.rs b/crates/nu-command/src/path/mod.rs new file mode 100644 index 0000000000..c3bd23e1fb --- /dev/null +++ b/crates/nu-command/src/path/mod.rs @@ -0,0 +1,95 @@ +mod basename; +pub mod command; +mod dirname; +mod exists; +mod expand; +mod join; +mod parse; +mod relative_to; +mod split; +mod r#type; + +use std::path::Path as StdPath; + +pub use basename::SubCommand as PathBasename; +pub use command::PathCommand as Path; +pub use dirname::SubCommand as PathDirname; +pub use exists::SubCommand as PathExists; +pub use expand::SubCommand as PathExpand; +pub use join::SubCommand as PathJoin; +pub use parse::SubCommand as PathParse; +pub use r#type::SubCommand as PathType; +pub use relative_to::SubCommand as PathRelativeTo; +pub use split::SubCommand as PathSplit; + +use nu_protocol::{ShellError, Span, Value}; + +#[cfg(windows)] +const ALLOWED_COLUMNS: [&str; 4] = ["prefix", "parent", "stem", "extension"]; +#[cfg(not(windows))] +const ALLOWED_COLUMNS: [&str; 3] = ["parent", "stem", "extension"]; + +trait PathSubcommandArguments { + fn get_columns(&self) -> Option>; +} + +fn operate(cmd: &F, args: &A, v: Value, name: Span) -> Value +where + F: Fn(&StdPath, Span, &A) -> Value + Send + Sync + 'static, + A: PathSubcommandArguments + Send + Sync + 'static, +{ + match v { + Value::String { val, span } => cmd(StdPath::new(&val), span, args), + Value::Record { cols, vals, span } => { + let col = if let Some(col) = args.get_columns() { + col + } else { + vec![] + }; + if col.is_empty() { + return Value::Error { + error: ShellError::UnsupportedInput( + String::from("when the input is a table, you must specify the columns"), + name, + ), + }; + } + + let mut output_cols = vec![]; + let mut output_vals = vec![]; + + for (k, v) in cols.iter().zip(vals) { + output_cols.push(k.clone()); + if col.contains(k) { + let new_val = match v { + Value::String { val, span } => cmd(StdPath::new(&val), span, args), + _ => return handle_invalid_values(v, name), + }; + output_vals.push(new_val); + } else { + output_vals.push(v); + } + } + + Value::Record { + cols: output_cols, + vals: output_vals, + span, + } + } + _ => handle_invalid_values(v, name), + } +} + +fn handle_invalid_values(rest: Value, name: Span) -> Value { + Value::Error { + error: err_from_value(&rest, name), + } +} + +fn err_from_value(rest: &Value, name: Span) -> ShellError { + match rest.span() { + Ok(span) => ShellError::PipelineMismatch("string, row or list".into(), name, span), + Err(error) => error, + } +} diff --git a/crates/nu-command/src/path/parse.rs b/crates/nu-command/src/path/parse.rs new file mode 100644 index 0000000000..ef5a014d00 --- /dev/null +++ b/crates/nu-command/src/path/parse.rs @@ -0,0 +1,201 @@ +use std::path::Path; + +use indexmap::IndexMap; +use nu_engine::CallExt; +use nu_protocol::{ + engine::Command, Example, ShellError, Signature, Span, Spanned, SyntaxShape, Value, +}; + +use super::PathSubcommandArguments; + +struct Arguments { + columns: Option>, + extension: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path parse" + } + + fn signature(&self) -> Signature { + Signature::build("path parse") + .named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + .named( + "extension", + SyntaxShape::String, + "Manually supply the extension (without the dot)", + Some('e'), + ) + } + + fn usage(&self) -> &str { + "Convert a path into structured data." + } + + fn extra_usage(&self) -> &str { + r#"Each path is split into a table with 'parent', 'stem' and 'extension' fields. +On Windows, an extra 'prefix' column is added."# + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + columns: call.get_flag(engine_state, stack, "columns")?, + extension: call.get_flag(engine_state, stack, "extension")?, + }; + + input.map( + move |value| super::operate(&parse, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Parse a single path", + example: r"'C:\Users\viking\spam.txt' | path parse", + result: None, + }, + Example { + description: "Replace a complex extension", + example: r"'C:\Users\viking\spam.tar.gz' | path parse -e tar.gz | update extension { 'txt' }", + result: None, + }, + Example { + description: "Ignore the extension", + example: r"'C:\Users\viking.d' | path parse -e ''", + result: None, + }, + Example { + description: "Parse all paths under the 'name' column", + example: r"ls | path parse -c [ name ]", + result: None, + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Parse a path", + example: r"'/home/viking/spam.txt' | path parse", + result: None, + }, + Example { + description: "Replace a complex extension", + example: r"'/home/viking/spam.tar.gz' | path parse -e tar.gz | update extension { 'txt' }", + result: None, + }, + Example { + description: "Ignore the extension", + example: r"'/etc/conf.d' | path parse -e ''", + result: None, + }, + Example { + description: "Parse all paths under the 'name' column", + example: r"ls | path parse -c [ name ]", + result: None, + }, + ] + } +} + +fn parse(path: &Path, span: Span, args: &Arguments) -> Value { + let mut map: IndexMap = IndexMap::new(); + + #[cfg(windows)] + { + use std::path::Component; + + let prefix = match path.components().next() { + Some(Component::Prefix(prefix_component)) => { + prefix_component.as_os_str().to_string_lossy() + } + _ => "".into(), + }; + map.insert("prefix".into(), Value::string(prefix, span)); + } + + let parent = path + .parent() + .unwrap_or_else(|| "".as_ref()) + .to_string_lossy(); + + map.insert("parent".into(), Value::string(parent, span)); + + let basename = path + .file_name() + .unwrap_or_else(|| "".as_ref()) + .to_string_lossy(); + + match &args.extension { + Some(Spanned { + item: extension, + span: extension_span, + }) => { + let ext_with_dot = [".", extension].concat(); + if basename.ends_with(&ext_with_dot) && !extension.is_empty() { + let stem = basename.trim_end_matches(&ext_with_dot); + map.insert("stem".into(), Value::string(stem, span)); + map.insert( + "extension".into(), + Value::string(extension, *extension_span), + ); + } else { + map.insert("stem".into(), Value::string(basename, span)); + map.insert("extension".into(), Value::string("", span)); + } + } + None => { + let stem = path + .file_stem() + .unwrap_or_else(|| "".as_ref()) + .to_string_lossy(); + let extension = path + .extension() + .unwrap_or_else(|| "".as_ref()) + .to_string_lossy(); + + map.insert("stem".into(), Value::string(stem, span)); + map.insert("extension".into(), Value::string(extension, span)); + } + } + + Value::from(Spanned { item: map, span }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/relative_to.rs b/crates/nu-command/src/path/relative_to.rs new file mode 100644 index 0000000000..8d2e15ec86 --- /dev/null +++ b/crates/nu-command/src/path/relative_to.rs @@ -0,0 +1,133 @@ +use std::path::Path; + +use nu_engine::CallExt; +use nu_protocol::{ + engine::Command, Example, ShellError, Signature, Span, Spanned, SyntaxShape, Value, +}; + +use super::PathSubcommandArguments; + +struct Arguments { + path: Spanned, + columns: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path relative-to" + } + + fn signature(&self) -> Signature { + Signature::build("path relative-to") + .required( + "path", + SyntaxShape::Filepath, + "Parent shared with the input path", + ) + .named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + } + + fn usage(&self) -> &str { + "Get a path as relative to another path." + } + + fn extra_usage(&self) -> &str { + r#"Can be used only when the input and the argument paths are either both +absolute or both relative. The argument path needs to be a parent of the input +path."# + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + path: call.req(engine_state, stack, 0)?, + columns: call.get_flag(engine_state, stack, "columns")?, + }; + + input.map( + move |value| super::operate(&relative_to, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Find a relative path from two absolute paths", + example: r"'C:\Users\viking' | path relative-to 'C:\Users'", + result: Some(Value::test_string(r"viking")), + }, + Example { + description: "Find a relative path from two absolute paths in a column", + example: "ls ~ | path relative-to ~ -c [ name ]", + result: None, + }, + Example { + description: "Find a relative path from two relative paths", + example: r"'eggs\bacon\sausage\spam' | path relative-to 'eggs\bacon\sausage'", + result: Some(Value::test_string(r"spam")), + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Find a relative path from two absolute paths", + example: r"'/home/viking' | path relative-to '/home'", + result: Some(Value::test_string(r"viking")), + }, + Example { + description: "Find a relative path from two absolute paths in a column", + example: "ls ~ | path relative-to ~ -c [ name ]", + result: None, + }, + Example { + description: "Find a relative path from two relative paths", + example: r"'eggs/bacon/sausage/spam' | path relative-to 'eggs/bacon/sausage'", + result: Some(Value::test_string(r"spam")), + }, + ] + } +} + +fn relative_to(path: &Path, span: Span, args: &Arguments) -> Value { + match path.strip_prefix(Path::new(&args.path.item)) { + Ok(p) => Value::string(p.to_string_lossy(), span), + Err(_) => todo!(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/split.rs b/crates/nu-command/src/path/split.rs new file mode 100644 index 0000000000..26574c23f6 --- /dev/null +++ b/crates/nu-command/src/path/split.rs @@ -0,0 +1,130 @@ +use std::path::Path; + +use nu_engine::CallExt; +use nu_protocol::{engine::Command, Example, ShellError, Signature, Span, SyntaxShape, Value}; + +use super::PathSubcommandArguments; + +struct Arguments { + columns: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path split" + } + + fn signature(&self) -> Signature { + Signature::build("path split").named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + } + + fn usage(&self) -> &str { + "Split a path into parts by a separator." + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + columns: call.get_flag(engine_state, stack, "columns")?, + }; + + input.map( + move |value| super::operate(&split, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + #[cfg(windows)] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Split a path into parts", + example: r"'C:\Users\viking\spam.txt' | path split", + result: Some(Value::List { + vals: vec![ + Value::test_string("C:"), + Value::test_string(r"\"), + Value::test_string("Users"), + Value::test_string("viking"), + Value::test_string("spam.txt"), + ], + span: Span::unknown(), + }), + }, + Example { + description: "Split all paths under the 'name' column", + example: r"ls ('.' | path expand) | path split -c [ name ]", + result: None, + }, + ] + } + + #[cfg(not(windows))] + fn examples(&self) -> Vec { + vec![ + Example { + description: "Split a path into parts", + example: r"'/home/viking/spam.txt' | path split", + result: Some(Value::List { + vals: vec![ + Value::test_string("/"), + Value::test_string("home"), + Value::test_string("viking"), + Value::test_string("spam.txt"), + ], + span: Span::unknown(), + }), + }, + Example { + description: "Split all paths under the 'name' column", + example: r"ls ('.' | path expand) | path split -c [ name ]", + result: None, + }, + ] + } +} + +fn split(path: &Path, span: Span, _: &Arguments) -> Value { + Value::List { + vals: path + .components() + .map(|comp| { + let s = comp.as_os_str().to_string_lossy(); + Value::string(s, span) + }) + .collect(), + span, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/path/type.rs b/crates/nu-command/src/path/type.rs new file mode 100644 index 0000000000..d75fd2d9df --- /dev/null +++ b/crates/nu-command/src/path/type.rs @@ -0,0 +1,122 @@ +use std::path::Path; + +use nu_engine::CallExt; +use nu_protocol::{engine::Command, Example, ShellError, Signature, Span, SyntaxShape, Value}; + +use super::PathSubcommandArguments; + +struct Arguments { + columns: Option>, +} + +impl PathSubcommandArguments for Arguments { + fn get_columns(&self) -> Option> { + self.columns.clone() + } +} + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "path type" + } + + fn signature(&self) -> Signature { + Signature::build("path type").named( + "columns", + SyntaxShape::Table, + "Optionally operate by column path", + Some('c'), + ) + } + + fn usage(&self) -> &str { + "Get the type of the object a path refers to (e.g., file, dir, symlink)" + } + + fn run( + &self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + let args = Arguments { + columns: call.get_flag(engine_state, stack, "columns")?, + }; + + input.map( + move |value| super::operate(&r#type, &args, value, head), + engine_state.ctrlc.clone(), + ) + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Show type of a filepath", + example: "'.' | path type", + result: Some(Value::test_string("Dir")), + }, + Example { + description: "Show type of a filepath in a column", + example: "ls | path type -c [ name ]", + result: None, + }, + ] + } +} + +fn r#type(path: &Path, span: Span, _: &Arguments) -> Value { + let meta = std::fs::symlink_metadata(path); + + Value::string( + match &meta { + Ok(data) => get_file_type(data), + Err(_) => "", + }, + span, + ) +} + +fn get_file_type(md: &std::fs::Metadata) -> &str { + let ft = md.file_type(); + let mut file_type = "Unknown"; + if ft.is_dir() { + file_type = "Dir"; + } else if ft.is_file() { + file_type = "File"; + } else if ft.is_symlink() { + file_type = "Symlink"; + } else { + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + if ft.is_block_device() { + file_type = "Block device"; + } else if ft.is_char_device() { + file_type = "Char device"; + } else if ft.is_fifo() { + file_type = "Pipe"; + } else if ft.is_socket() { + file_type = "Socket"; + } + } + } + file_type +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +}