making ls and du supports rest parameters. (#12327)

# Description
Close: #12147
Close: #11796 

About the change: it make pattern handling into a function:
`ls_for_one_pattern`(for ls), `du_for_one_pattern`(for du). Then
iterates on user input pattern, call these core function, and chaining
these iterator to one pipelinedata.
This commit is contained in:
Wind 2024-04-13 23:03:17 +08:00 committed by GitHub
parent 56cdee1fd8
commit 0110345755
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 462 additions and 332 deletions

View File

@ -1,9 +1,11 @@
use super::util::opt_for_glob_pattern;
use super::util::get_rest_for_glob_pattern;
use crate::{DirBuilder, DirInfo, FileInfo};
use nu_engine::{command_prelude::*, current_dir};
use nu_glob::Pattern;
use nu_protocol::NuGlob;
use serde::Deserialize;
use std::path::Path;
use std::sync::{atomic::AtomicBool, Arc};
#[derive(Clone)]
pub struct Du;
@ -33,7 +35,7 @@ impl Command for Du {
Signature::build("du")
.input_output_types(vec![(Type::Nothing, Type::Table(vec![]))])
.allow_variants_without_examples(true)
.optional(
.rest(
"path",
SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]),
"Starting directory.",
@ -93,81 +95,59 @@ impl Command for Du {
});
}
}
let all = call.has_flag(engine_state, stack, "all")?;
let deref = call.has_flag(engine_state, stack, "deref")?;
let exclude = call.get_flag(engine_state, stack, "exclude")?;
let current_dir = current_dir(engine_state, stack)?;
let args = DuArgs {
path: opt_for_glob_pattern(engine_state, stack, call, 0)?,
all: call.has_flag(engine_state, stack, "all")?,
deref: call.has_flag(engine_state, stack, "deref")?,
exclude: call.get_flag(engine_state, stack, "exclude")?,
max_depth,
min_size,
let paths = get_rest_for_glob_pattern(engine_state, stack, call, 0)?;
let paths = if call.rest_iter(0).count() == 0 {
None
} else {
Some(paths)
};
let exclude = args.exclude.map_or(Ok(None), move |x| {
Pattern::new(x.item.as_ref())
.map(Some)
.map_err(|e| ShellError::InvalidGlobPattern {
msg: e.msg.into(),
span: x.span,
})
})?;
let include_files = args.all;
let mut paths = match args.path {
Some(p) => nu_engine::glob_from(&p, &current_dir, call.head, None),
// The * pattern should never fail.
None => nu_engine::glob_from(
&Spanned {
item: NuGlob::Expand("*".into()),
span: Span::unknown(),
},
&current_dir,
call.head,
None,
),
}
.map(|f| f.1)?
.filter(move |p| {
if include_files {
true
} else {
matches!(p, Ok(f) if f.is_dir())
match paths {
None => {
let args = DuArgs {
path: None,
all,
deref,
exclude,
max_depth,
min_size,
};
Ok(
du_for_one_pattern(args, &current_dir, tag, engine_state.ctrlc.clone())?
.into_pipeline_data(engine_state.ctrlc.clone()),
)
}
});
let all = args.all;
let deref = args.deref;
let max_depth = args.max_depth.map(|f| f.item as u64);
let min_size = args.min_size.map(|f| f.item as u64);
let params = DirBuilder {
tag,
min: min_size,
deref,
exclude,
all,
};
let mut output: Vec<Value> = vec![];
for p in paths.by_ref() {
match p {
Ok(a) => {
if a.is_dir() {
output.push(
DirInfo::new(a, &params, max_depth, engine_state.ctrlc.clone()).into(),
);
} else if let Ok(v) = FileInfo::new(a, deref, tag) {
output.push(v.into());
}
}
Err(e) => {
output.push(Value::error(e, tag));
Some(paths) => {
let mut result_iters = vec![];
for p in paths {
let args = DuArgs {
path: Some(p),
all,
deref,
exclude: exclude.clone(),
max_depth,
min_size,
};
result_iters.push(du_for_one_pattern(
args,
&current_dir,
tag,
engine_state.ctrlc.clone(),
)?)
}
// chain all iterators on result.
Ok(result_iters
.into_iter()
.flatten()
.into_pipeline_data(engine_state.ctrlc.clone()))
}
}
Ok(output.into_pipeline_data(engine_state.ctrlc.clone()))
}
fn examples(&self) -> Vec<Example> {
@ -179,6 +159,75 @@ impl Command for Du {
}
}
fn du_for_one_pattern(
args: DuArgs,
current_dir: &Path,
call_span: Span,
ctrl_c: Option<Arc<AtomicBool>>,
) -> Result<impl Iterator<Item = Value> + Send, ShellError> {
let exclude = args.exclude.map_or(Ok(None), move |x| {
Pattern::new(x.item.as_ref())
.map(Some)
.map_err(|e| ShellError::InvalidGlobPattern {
msg: e.msg.into(),
span: x.span,
})
})?;
let include_files = args.all;
let mut paths = match args.path {
Some(p) => nu_engine::glob_from(&p, current_dir, call_span, None),
// The * pattern should never fail.
None => nu_engine::glob_from(
&Spanned {
item: NuGlob::Expand("*".into()),
span: Span::unknown(),
},
current_dir,
call_span,
None,
),
}
.map(|f| f.1)?
.filter(move |p| {
if include_files {
true
} else {
matches!(p, Ok(f) if f.is_dir())
}
});
let all = args.all;
let deref = args.deref;
let max_depth = args.max_depth.map(|f| f.item as u64);
let min_size = args.min_size.map(|f| f.item as u64);
let params = DirBuilder {
tag: call_span,
min: min_size,
deref,
exclude,
all,
};
let mut output: Vec<Value> = vec![];
for p in paths.by_ref() {
match p {
Ok(a) => {
if a.is_dir() {
output.push(DirInfo::new(a, &params, max_depth, ctrl_c.clone()).into());
} else if let Ok(v) = FileInfo::new(a, deref, call_span) {
output.push(v.into());
}
}
Err(e) => {
output.push(Value::error(e, call_span));
}
}
}
Ok(output.into_iter())
}
#[cfg(test)]
mod tests {
use super::Du;

View File

@ -1,4 +1,4 @@
use super::util::opt_for_glob_pattern;
use super::util::get_rest_for_glob_pattern;
use crate::{DirBuilder, DirInfo};
use chrono::{DateTime, Local, LocalResult, TimeZone, Utc};
use nu_engine::{command_prelude::*, env::current_dir};
@ -18,6 +18,18 @@ use std::{
#[derive(Clone)]
pub struct Ls;
#[derive(Clone, Copy)]
struct Args {
all: bool,
long: bool,
short_names: bool,
full_paths: bool,
du: bool,
directory: bool,
use_mime_type: bool,
call_span: Span,
}
impl Command for Ls {
fn name(&self) -> &str {
"ls"
@ -36,7 +48,7 @@ impl Command for Ls {
.input_output_types(vec![(Type::Nothing, Type::Table(vec![]))])
// LsGlobPattern is similar to string, it won't auto-expand
// and we use it to track if the user input is quoted.
.optional("pattern", SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]), "The glob pattern to use.")
.rest("pattern", SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]), "The glob pattern to use.")
.switch("all", "Show hidden files", Some('a'))
.switch(
"long",
@ -81,241 +93,55 @@ impl Command for Ls {
let call_span = call.head;
let cwd = current_dir(engine_state, stack)?;
let pattern_arg = opt_for_glob_pattern(engine_state, stack, call, 0)?;
let pattern_arg = {
if let Some(path) = pattern_arg {
// it makes no sense to list an empty string.
if path.item.as_ref().is_empty() {
return Err(ShellError::FileNotFoundCustom {
msg: "empty string('') directory or file does not exist".to_string(),
span: path.span,
});
}
match path.item {
NuGlob::DoNotExpand(p) => Some(Spanned {
item: NuGlob::DoNotExpand(nu_utils::strip_ansi_string_unlikely(p)),
span: path.span,
}),
NuGlob::Expand(p) => Some(Spanned {
item: NuGlob::Expand(nu_utils::strip_ansi_string_unlikely(p)),
span: path.span,
}),
}
} else {
pattern_arg
}
let args = Args {
all,
long,
short_names,
full_paths,
du,
directory,
use_mime_type,
call_span,
};
// it indicates we need to append an extra '*' after pattern for listing given directory
// Example: 'ls directory' -> 'ls directory/*'
let mut extra_star_under_given_directory = false;
let (path, p_tag, absolute_path, quoted) = match pattern_arg {
Some(pat) => {
let p_tag = pat.span;
let expanded = nu_path::expand_path_with(
pat.item.as_ref(),
&cwd,
matches!(pat.item, NuGlob::Expand(..)),
);
// Avoid checking and pushing "*" to the path when directory (do not show contents) flag is true
if !directory && expanded.is_dir() {
if permission_denied(&expanded) {
#[cfg(unix)]
let error_msg = format!(
"The permissions of {:o} do not allow access for this user",
expanded
.metadata()
.expect(
"this shouldn't be called since we already know there is a dir"
)
.permissions()
.mode()
& 0o0777
);
#[cfg(not(unix))]
let error_msg = String::from("Permission denied");
return Err(ShellError::GenericError {
error: "Permission denied".into(),
msg: error_msg,
span: Some(p_tag),
help: None,
inner: vec![],
});
}
if is_empty_dir(&expanded) {
return Ok(Value::list(vec![], call_span).into_pipeline_data());
}
extra_star_under_given_directory = true;
}
// it's absolute path if:
// 1. pattern is absolute.
// 2. pattern can be expanded, and after expands to real_path, it's absolute.
// here `expand_to_real_path` call is required, because `~/aaa` should be absolute
// path.
let absolute_path = Path::new(pat.item.as_ref()).is_absolute()
|| (pat.item.is_expand()
&& expand_to_real_path(pat.item.as_ref()).is_absolute());
(
expanded,
p_tag,
absolute_path,
matches!(pat.item, NuGlob::DoNotExpand(_)),
)
}
None => {
// Avoid pushing "*" to the default path when directory (do not show contents) flag is true
if directory {
(PathBuf::from("."), call_span, false, false)
} else if is_empty_dir(current_dir(engine_state, stack)?) {
return Ok(Value::list(vec![], call_span).into_pipeline_data());
} else {
(PathBuf::from("*"), call_span, false, false)
}
}
};
let hidden_dir_specified = is_hidden_dir(&path);
// when it's quoted, we need to escape our glob pattern(but without the last extra
// start which may be added under given directory)
// so we can do ls for a file or directory like `a[123]b`
let path = if quoted {
let p = path.display().to_string();
let mut glob_escaped = Pattern::escape(&p);
if extra_star_under_given_directory {
glob_escaped.push(std::path::MAIN_SEPARATOR);
glob_escaped.push('*');
}
glob_escaped
} else {
let mut p = path.display().to_string();
if extra_star_under_given_directory {
p.push(std::path::MAIN_SEPARATOR);
p.push('*');
}
p
};
let glob_path = Spanned {
// use NeedExpand, the relative escaping logic is handled previously
item: NuGlob::Expand(path.clone()),
span: p_tag,
};
let glob_options = if all {
let pattern_arg = get_rest_for_glob_pattern(engine_state, stack, call, 0)?;
let input_pattern_arg = if call.rest_iter(0).count() == 0 {
None
} else {
let glob_options = MatchOptions {
recursive_match_hidden_dir: false,
..Default::default()
};
Some(glob_options)
Some(pattern_arg)
};
let (prefix, paths) = nu_engine::glob_from(&glob_path, &cwd, call_span, glob_options)?;
let mut paths_peek = paths.peekable();
if paths_peek.peek().is_none() {
return Err(ShellError::GenericError {
error: format!("No matches found for {}", &path),
msg: "Pattern, file or folder not found".into(),
span: Some(p_tag),
help: Some("no matches found".into()),
inner: vec![],
});
}
let mut hidden_dirs = vec![];
Ok(paths_peek
.filter_map(move |x| match x {
Ok(path) => {
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => Some(metadata),
Err(_) => None,
};
if path_contains_hidden_folder(&path, &hidden_dirs) {
return None;
}
if !all && !hidden_dir_specified && is_hidden_dir(&path) {
if path.is_dir() {
hidden_dirs.push(path);
}
return None;
}
let display_name = if short_names {
path.file_name().map(|os| os.to_string_lossy().to_string())
} else if full_paths || absolute_path {
Some(path.to_string_lossy().to_string())
} else if let Some(prefix) = &prefix {
if let Ok(remainder) = path.strip_prefix(prefix) {
if directory {
// When the path is the same as the cwd, path_diff should be "."
let path_diff =
if let Some(path_diff_not_dot) = diff_paths(&path, &cwd) {
let path_diff_not_dot = path_diff_not_dot.to_string_lossy();
if path_diff_not_dot.is_empty() {
".".to_string()
} else {
path_diff_not_dot.to_string()
}
} else {
path.to_string_lossy().to_string()
};
Some(path_diff)
} else {
let new_prefix = if let Some(pfx) = diff_paths(prefix, &cwd) {
pfx
} else {
prefix.to_path_buf()
};
Some(new_prefix.join(remainder).to_string_lossy().to_string())
}
} else {
Some(path.to_string_lossy().to_string())
}
} else {
Some(path.to_string_lossy().to_string())
}
.ok_or_else(|| ShellError::GenericError {
error: format!("Invalid file name: {:}", path.to_string_lossy()),
msg: "invalid file name".into(),
span: Some(call_span),
help: None,
inner: vec![],
});
match display_name {
Ok(name) => {
let entry = dir_entry_dict(
&path,
&name,
metadata.as_ref(),
call_span,
long,
du,
ctrl_c.clone(),
use_mime_type,
);
match entry {
Ok(value) => Some(value),
Err(err) => Some(Value::error(err, call_span)),
}
}
Err(err) => Some(Value::error(err, call_span)),
}
match input_pattern_arg {
None => Ok(ls_for_one_pattern(None, args, ctrl_c.clone(), cwd)?
.into_pipeline_data_with_metadata(
PipelineMetadata {
data_source: DataSource::Ls,
},
ctrl_c,
)),
Some(pattern) => {
let mut result_iters = vec![];
for pat in pattern {
result_iters.push(ls_for_one_pattern(
Some(pat),
args,
ctrl_c.clone(),
cwd.clone(),
)?)
}
_ => Some(Value::nothing(call_span)),
})
.into_pipeline_data_with_metadata(
PipelineMetadata {
data_source: DataSource::Ls,
},
engine_state.ctrlc.clone(),
))
// Here nushell needs to use
// use `flatten` to chain all iterators into one.
Ok(result_iters
.into_iter()
.flatten()
.into_pipeline_data_with_metadata(
PipelineMetadata {
data_source: DataSource::Ls,
},
ctrl_c,
))
}
}
}
fn examples(&self) -> Vec<Example> {
@ -365,6 +191,248 @@ impl Command for Ls {
}
}
fn ls_for_one_pattern(
pattern_arg: Option<Spanned<NuGlob>>,
args: Args,
ctrl_c: Option<Arc<AtomicBool>>,
cwd: PathBuf,
) -> Result<Box<dyn Iterator<Item = Value> + Send>, ShellError> {
let Args {
all,
long,
short_names,
full_paths,
du,
directory,
use_mime_type,
call_span,
} = args;
let pattern_arg = {
if let Some(path) = pattern_arg {
// it makes no sense to list an empty string.
if path.item.as_ref().is_empty() {
return Err(ShellError::FileNotFoundCustom {
msg: "empty string('') directory or file does not exist".to_string(),
span: path.span,
});
}
match path.item {
NuGlob::DoNotExpand(p) => Some(Spanned {
item: NuGlob::DoNotExpand(nu_utils::strip_ansi_string_unlikely(p)),
span: path.span,
}),
NuGlob::Expand(p) => Some(Spanned {
item: NuGlob::Expand(nu_utils::strip_ansi_string_unlikely(p)),
span: path.span,
}),
}
} else {
pattern_arg
}
};
// it indicates we need to append an extra '*' after pattern for listing given directory
// Example: 'ls directory' -> 'ls directory/*'
let mut extra_star_under_given_directory = false;
let (path, p_tag, absolute_path, quoted) = match pattern_arg {
Some(pat) => {
let p_tag = pat.span;
let expanded = nu_path::expand_path_with(
pat.item.as_ref(),
&cwd,
matches!(pat.item, NuGlob::Expand(..)),
);
// Avoid checking and pushing "*" to the path when directory (do not show contents) flag is true
if !directory && expanded.is_dir() {
if permission_denied(&expanded) {
#[cfg(unix)]
let error_msg = format!(
"The permissions of {:o} do not allow access for this user",
expanded
.metadata()
.expect("this shouldn't be called since we already know there is a dir")
.permissions()
.mode()
& 0o0777
);
#[cfg(not(unix))]
let error_msg = String::from("Permission denied");
return Err(ShellError::GenericError {
error: "Permission denied".into(),
msg: error_msg,
span: Some(p_tag),
help: None,
inner: vec![],
});
}
if is_empty_dir(&expanded) {
return Ok(Box::new(vec![].into_iter()));
}
extra_star_under_given_directory = true;
}
// it's absolute path if:
// 1. pattern is absolute.
// 2. pattern can be expanded, and after expands to real_path, it's absolute.
// here `expand_to_real_path` call is required, because `~/aaa` should be absolute
// path.
let absolute_path = Path::new(pat.item.as_ref()).is_absolute()
|| (pat.item.is_expand() && expand_to_real_path(pat.item.as_ref()).is_absolute());
(
expanded,
p_tag,
absolute_path,
matches!(pat.item, NuGlob::DoNotExpand(_)),
)
}
None => {
// Avoid pushing "*" to the default path when directory (do not show contents) flag is true
if directory {
(PathBuf::from("."), call_span, false, false)
} else if is_empty_dir(&cwd) {
return Ok(Box::new(vec![].into_iter()));
} else {
(PathBuf::from("*"), call_span, false, false)
}
}
};
let hidden_dir_specified = is_hidden_dir(&path);
// when it's quoted, we need to escape our glob pattern(but without the last extra
// start which may be added under given directory)
// so we can do ls for a file or directory like `a[123]b`
let path = if quoted {
let p = path.display().to_string();
let mut glob_escaped = Pattern::escape(&p);
if extra_star_under_given_directory {
glob_escaped.push(std::path::MAIN_SEPARATOR);
glob_escaped.push('*');
}
glob_escaped
} else {
let mut p = path.display().to_string();
if extra_star_under_given_directory {
p.push(std::path::MAIN_SEPARATOR);
p.push('*');
}
p
};
let glob_path = Spanned {
// use NeedExpand, the relative escaping logic is handled previously
item: NuGlob::Expand(path.clone()),
span: p_tag,
};
let glob_options = if all {
None
} else {
let glob_options = MatchOptions {
recursive_match_hidden_dir: false,
..Default::default()
};
Some(glob_options)
};
let (prefix, paths) = nu_engine::glob_from(&glob_path, &cwd, call_span, glob_options)?;
let mut paths_peek = paths.peekable();
if paths_peek.peek().is_none() {
return Err(ShellError::GenericError {
error: format!("No matches found for {}", &path),
msg: "Pattern, file or folder not found".into(),
span: Some(p_tag),
help: Some("no matches found".into()),
inner: vec![],
});
}
let mut hidden_dirs = vec![];
let one_ctrl_c = ctrl_c.clone();
Ok(Box::new(paths_peek.filter_map(move |x| match x {
Ok(path) => {
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => Some(metadata),
Err(_) => None,
};
if path_contains_hidden_folder(&path, &hidden_dirs) {
return None;
}
if !all && !hidden_dir_specified && is_hidden_dir(&path) {
if path.is_dir() {
hidden_dirs.push(path);
}
return None;
}
let display_name = if short_names {
path.file_name().map(|os| os.to_string_lossy().to_string())
} else if full_paths || absolute_path {
Some(path.to_string_lossy().to_string())
} else if let Some(prefix) = &prefix {
if let Ok(remainder) = path.strip_prefix(prefix) {
if directory {
// When the path is the same as the cwd, path_diff should be "."
let path_diff = if let Some(path_diff_not_dot) = diff_paths(&path, &cwd) {
let path_diff_not_dot = path_diff_not_dot.to_string_lossy();
if path_diff_not_dot.is_empty() {
".".to_string()
} else {
path_diff_not_dot.to_string()
}
} else {
path.to_string_lossy().to_string()
};
Some(path_diff)
} else {
let new_prefix = if let Some(pfx) = diff_paths(prefix, &cwd) {
pfx
} else {
prefix.to_path_buf()
};
Some(new_prefix.join(remainder).to_string_lossy().to_string())
}
} else {
Some(path.to_string_lossy().to_string())
}
} else {
Some(path.to_string_lossy().to_string())
}
.ok_or_else(|| ShellError::GenericError {
error: format!("Invalid file name: {:}", path.to_string_lossy()),
msg: "invalid file name".into(),
span: Some(call_span),
help: None,
inner: vec![],
});
match display_name {
Ok(name) => {
let entry = dir_entry_dict(
&path,
&name,
metadata.as_ref(),
call_span,
long,
du,
one_ctrl_c.clone(),
use_mime_type,
);
match entry {
Ok(value) => Some(value),
Err(err) => Some(Value::error(err, call_span)),
}
}
Err(err) => Some(Value::error(err, call_span)),
}
}
_ => Some(Value::nothing(call_span)),
})))
}
fn permission_denied(dir: impl AsRef<Path>) -> bool {
match dir.as_ref().read_dir() {
Err(e) => matches!(e.kind(), std::io::ErrorKind::PermissionDenied),

View File

@ -131,34 +131,3 @@ pub fn get_rest_for_glob_pattern(
Ok(output)
}
/// Get optional arguments from given `call` with position `pos`.
///
/// It's similar to `call.opt`, except that it always returns NuGlob.
pub fn opt_for_glob_pattern(
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
pos: usize,
) -> Result<Option<Spanned<NuGlob>>, ShellError> {
if let Some(expr) = call.positional_nth(pos) {
let eval_expression = get_eval_expression(engine_state);
let result = eval_expression(engine_state, stack, expr)?;
let result_span = result.span();
let result = match result {
Value::String { val, .. }
if matches!(
&expr.expr,
Expr::FullCellPath(_) | Expr::StringInterpolation(_)
) =>
{
// should quote if given input type is not glob.
Value::glob(val, expr.ty != Type::Glob, result_span)
}
other => other,
};
FromValue::from_value(result).map(Some)
} else {
Ok(None)
}
}

View File

@ -50,7 +50,7 @@ fn test_du_flag_max_depth() {
#[case("a[bc]d")]
#[case("a][c")]
fn du_files_with_glob_metachars(#[case] src_name: &str) {
Playground::setup("umv_test_16", |dirs, sandbox| {
Playground::setup("du_test_16", |dirs, sandbox| {
sandbox.with_files(vec![EmptyFile(src_name)]);
let src = dirs.test().join(src_name);
@ -82,3 +82,21 @@ fn du_files_with_glob_metachars(#[case] src_name: &str) {
fn du_files_with_glob_metachars_nw(#[case] src_name: &str) {
du_files_with_glob_metachars(src_name);
}
#[test]
fn du_with_multiple_path() {
let actual = nu!(cwd: "tests/fixtures", "du cp formats | get path | path basename");
assert!(actual.out.contains("cp"));
assert!(actual.out.contains("formats"));
assert!(!actual.out.contains("lsp"));
assert!(actual.status.success());
// report errors if one path not exists
let actual = nu!(cwd: "tests/fixtures", "du cp asdf | get path | path basename");
assert!(actual.err.contains("directory not found"));
assert!(!actual.status.success());
// du with spreading empty list should returns nothing.
let actual = nu!(cwd: "tests/fixtures", "du ...[] | length");
assert_eq!(actual.out, "0");
}

View File

@ -733,3 +733,29 @@ fn list_with_tilde() {
assert!(actual.out.contains("~tilde"));
})
}
#[test]
fn list_with_multiple_path() {
Playground::setup("ls_multiple_path", |dirs, sandbox| {
sandbox.with_files(vec![
EmptyFile("f1.txt"),
EmptyFile("f2.txt"),
EmptyFile("f3.txt"),
]);
let actual = nu!(cwd: dirs.test(), "ls f1.txt f2.txt");
assert!(actual.out.contains("f1.txt"));
assert!(actual.out.contains("f2.txt"));
assert!(!actual.out.contains("f3.txt"));
assert!(actual.status.success());
// report errors if one path not exists
let actual = nu!(cwd: dirs.test(), "ls asdf f1.txt");
assert!(actual.err.contains("directory not found"));
assert!(!actual.status.success());
// ls with spreading empty list should returns nothing.
let actual = nu!(cwd: dirs.test(), "ls ...[] | length");
assert_eq!(actual.out, "0");
})
}