···2626 }
2727}
28282929+/// Expand a path (glob pattern or regular path) into a list of matching paths.
3030+/// If the path is not a glob pattern, returns a single-item list.
3131+/// Returns a vector of relative paths (relative to the base path).
3232+pub fn expand_path(
3333+ path_str: &str,
3434+ base_path: Arc<vfs::VfsPath>,
3535+ options: GlobOptions,
3636+) -> Result<Vec<String>, ShellError> {
3737+ // Check if it's a glob pattern
3838+ let is_glob = path_str.contains('*')
3939+ || path_str.contains('?')
4040+ || path_str.contains('[')
4141+ || path_str.contains("**");
4242+4343+ if is_glob {
4444+ glob_match(path_str, base_path, options)
4545+ } else {
4646+ // Single path: return as single-item list
4747+ Ok(vec![path_str.trim_start_matches('/').to_string()])
4848+ }
4949+}
5050+2951/// Match files and directories using a glob pattern.
3052/// Returns a vector of relative paths (relative to the base path) that match the pattern.
3153pub fn glob_match(
+74-51
src/cmd/ls.rs
···11-use std::{
22- sync::Arc,
33- time::{SystemTime, UNIX_EPOCH},
44-};
11+use std::time::{SystemTime, UNIX_EPOCH};
5266-use crate::{error::to_shell_err, globals::get_pwd};
33+use crate::{
44+ cmd::glob::{expand_path, GlobOptions},
55+ error::to_shell_err,
66+ globals::{get_pwd, get_vfs},
77+};
88+use std::sync::Arc;
79use jacquard::chrono;
810use nu_engine::CallExt;
911use nu_protocol::{
···21232224 fn signature(&self) -> Signature {
2325 Signature::build("ls")
2424- .optional("path", SyntaxShape::Filepath, "the path to list")
2626+ .optional(
2727+ "path",
2828+ SyntaxShape::OneOf(vec![SyntaxShape::Filepath, SyntaxShape::GlobPattern]),
2929+ "the path to list",
3030+ )
2531 .switch(
2632 "all",
2733 "include hidden paths (that start with a dot)",
···4854 call: &nu_protocol::engine::Call,
4955 _input: PipelineData,
5056 ) -> Result<PipelineData, ShellError> {
5151- let path_arg: Option<String> = call.opt(engine_state, stack, 0)?;
5757+ let path_arg: Option<Value> = call.opt(engine_state, stack, 0)?;
5258 let all = call.has_flag(engine_state, stack, "all")?;
5359 let long = call.has_flag(engine_state, stack, "long")?;
5460 let full_paths = call.has_flag(engine_state, stack, "full-paths")?;
55615662 let pwd = get_pwd();
5757- // web_sys::console::log_1(&JsValue::from_str(&format!("{pwd:?}")));
5858- // web_sys::console::log_1(&JsValue::from_str(&format!(
5959- // "{:?}",
6060- // pwd.read_dir().map(|a| a.collect::<Vec<_>>())
6161- // )));
6262- let mut target_dir = pwd.clone();
6363- if let Some(path) = path_arg {
6464- target_dir = Arc::new(
6565- target_dir
6666- .join(path.trim_end_matches('/'))
6767- .map_err(to_shell_err(call.arguments_span()))?,
6868- );
6969- }
6363+ let span = call.head;
6464+6565+ // If no path provided, list current directory
6666+ let (matches, base_path) = if let Some(path_val) = &path_arg {
6767+ let path_str = match path_val {
6868+ Value::String { val, .. } | Value::Glob { val, .. } => val,
6969+ _ => {
7070+ return Err(ShellError::GenericError {
7171+ error: "invalid path".into(),
7272+ msg: "path must be a string or glob pattern".into(),
7373+ span: Some(call.arguments_span()),
7474+ help: None,
7575+ inner: vec![],
7676+ });
7777+ }
7878+ };
7979+8080+ let is_absolute = path_str.starts_with('/');
8181+ let base_path: Arc<vfs::VfsPath> = if is_absolute {
8282+ get_vfs()
8383+ } else {
8484+ pwd.clone()
8585+ };
8686+8787+ let options = GlobOptions {
8888+ max_depth: None,
8989+ no_dirs: false,
9090+ no_files: false,
9191+ };
9292+9393+ let matches = expand_path(path_str, base_path.clone(), options)?;
9494+ (matches, base_path)
9595+ } else {
9696+ // No path: list current directory entries
9797+ let entries = pwd.read_dir().map_err(to_shell_err(span))?;
9898+ let matches: Vec<String> = entries.map(|e| e.filename()).collect();
9999+ (matches, pwd.clone())
100100+ };
101101+102102+ let make_record = move |rel_path: &str| {
103103+ let full_path = base_path.join(rel_path).map_err(to_shell_err(span))?;
104104+ let metadata = full_path.metadata().map_err(to_shell_err(span))?;
701057171- let span = call.head;
7272- let entries = target_dir.read_dir().map_err(to_shell_err(span))?;
106106+ // Filter hidden files if --all is not set
107107+ let filename = rel_path.split('/').last().unwrap_or(rel_path);
108108+ if filename.starts_with('.') && !all {
109109+ return Ok(None);
110110+ }
731117474- let make_record = move |name: &str, metadata: &vfs::VfsMetadata| {
75112 let type_str = match metadata.file_type {
76113 vfs::VfsFileType::Directory => "dir",
77114 vfs::VfsFileType::File => "file",
78115 };
7911680117 let mut record = Record::new();
8181- record.push("name", Value::string(name, span));
118118+ let display_name = if full_paths {
119119+ full_path.as_str().to_string()
120120+ } else {
121121+ rel_path.to_string()
122122+ };
123123+ record.push("name", Value::string(display_name, span));
82124 record.push("type", Value::string(type_str, span));
83125 record.push("size", Value::filesize(metadata.len as i64, span));
84126 let mut add_timestamp = |field: &str, timestamp: Option<SystemTime>| {
···100142 add_timestamp("created", metadata.created);
101143 add_timestamp("accessed", metadata.accessed);
102144 }
103103- Value::record(record, span)
145145+ Ok(Some(Value::record(record, span)))
104146 };
105147106106- let entries = entries.into_iter().flat_map(move |entry| {
107107- let do_map = || {
108108- let name = entry.filename();
109109- if name.starts_with('.') && !all {
110110- return Ok(None);
111111- }
112112- let metadata = entry.metadata().map_err(to_shell_err(span))?;
113113-114114- let name = if full_paths {
115115- format!("{path}/{name}", path = target_dir.as_str())
116116- } else {
117117- let path = target_dir
118118- .as_str()
119119- .trim_start_matches(pwd.as_str())
120120- .trim_start_matches("/");
121121- format!(
122122- "{path}{sep}{name}",
123123- sep = path.is_empty().then_some("").unwrap_or("/"),
124124- )
125125- };
126126- Ok(Some(make_record(&name, &metadata)))
127127- };
128128- do_map()
129129- .transpose()
130130- .map(|res| res.unwrap_or_else(|err| Value::error(err, span)))
131131- });
148148+ let entries = matches
149149+ .into_iter()
150150+ .flat_map(move |rel_path| {
151151+ make_record(&rel_path)
152152+ .transpose()
153153+ .map(|res| res.unwrap_or_else(|err| Value::error(err, span)))
154154+ });
132155133156 let signals = engine_state.signals().clone();
134157 Ok(PipelineData::list_stream(
+72-17
src/cmd/mv.rs
···11use std::io::{Read, Write};
2233-use crate::{error::to_shell_err, globals::get_pwd};
33+use crate::{
44+ cmd::glob::{expand_path, GlobOptions},
55+ error::to_shell_err,
66+ globals::{get_pwd, get_vfs},
77+};
88+use std::sync::Arc;
49use nu_engine::CallExt;
510use nu_protocol::{
66- Category, PipelineData, ShellError, Signature, SyntaxShape, Type,
1111+ Category, PipelineData, ShellError, Signature, SyntaxShape, Type, Value,
712 engine::{Command, EngineState, Stack},
813};
914use vfs::{VfsError, VfsFileType};
···2025 Signature::build("mv")
2126 .required(
2227 "source",
2323- SyntaxShape::Filepath,
2828+ SyntaxShape::OneOf(vec![SyntaxShape::Filepath, SyntaxShape::GlobPattern]),
2429 "path to the file or directory to move",
2530 )
2631 .required(
···4348 call: &nu_protocol::engine::Call,
4449 _input: PipelineData,
4550 ) -> Result<PipelineData, ShellError> {
4646- let source_path: String = call.req(engine_state, stack, 0)?;
5151+ let source_value: Value = call.req(engine_state, stack, 0)?;
4752 let dest_path: String = call.req(engine_state, stack, 1)?;
48535454+ let source_str = match source_value {
5555+ Value::String { val, .. } | Value::Glob { val, .. } => val,
5656+ _ => {
5757+ return Err(ShellError::GenericError {
5858+ error: "invalid source path".into(),
5959+ msg: "source must be a string or glob pattern".into(),
6060+ span: Some(call.arguments_span()),
6161+ help: None,
6262+ inner: vec![],
6363+ });
6464+ }
6565+ };
6666+4967 // Prevent moving root
5050- if source_path == "/" {
6868+ if source_str == "/" {
5169 return Err(ShellError::GenericError {
5270 error: "cannot move root".to_string(),
5371 msg: "refusing to move root directory".to_string(),
···5775 });
5876 }
59776060- // Resolve source relative to PWD (or absolute if path starts with '/')
6161- let source = get_pwd()
6262- .join(source_path.trim_end_matches('/'))
6363- .map_err(to_shell_err(call.arguments_span()))?;
7878+ // Expand source path (glob or single) into list of paths
7979+ let is_absolute = source_str.starts_with('/');
8080+ let base_path: Arc<vfs::VfsPath> = if is_absolute {
8181+ get_vfs()
8282+ } else {
8383+ get_pwd()
8484+ };
8585+8686+ let options = GlobOptions {
8787+ max_depth: None,
8888+ no_dirs: false,
8989+ no_files: false,
9090+ };
9191+9292+ let matches = expand_path(&source_str, base_path.clone(), options)?;
9393+ let is_glob = matches.len() > 1 || source_str.contains('*') || source_str.contains('?') || source_str.contains('[') || source_str.contains("**");
64946565- // Resolve destination relative to PWD (or absolute if path starts with '/')
9595+ // Resolve destination
6696 let dest = get_pwd()
6797 .join(dest_path.trim_end_matches('/'))
6898 .map_err(to_shell_err(call.arguments_span()))?;
69997070- // Check that source exists
7171- let meta = source
7272- .metadata()
7373- .map_err(to_shell_err(call.arguments_span()))?;
100100+ // For glob patterns, destination must be a directory
101101+ if is_glob {
102102+ let dest_meta = dest.metadata().map_err(to_shell_err(call.arguments_span()))?;
103103+ if dest_meta.file_type != VfsFileType::Directory {
104104+ return Err(ShellError::GenericError {
105105+ error: "destination must be a directory".to_string(),
106106+ msg: "when using glob patterns, destination must be a directory".to_string(),
107107+ span: Some(call.arguments_span()),
108108+ help: None,
109109+ inner: vec![],
110110+ });
111111+ }
112112+ }
741137575- match meta.file_type {
7676- VfsFileType::File => move_file(&source, &dest, call.arguments_span())?,
7777- VfsFileType::Directory => move_directory(&source, &dest, call.arguments_span())?,
114114+ // Move each matching file/directory
115115+ for rel_path in matches {
116116+ let source = base_path.join(&rel_path).map_err(to_shell_err(call.arguments_span()))?;
117117+ let source_meta = source.metadata().map_err(to_shell_err(call.arguments_span()))?;
118118+119119+ // Determine destination path
120120+ let dest_entry = if is_glob {
121121+ // For glob patterns, use filename in destination directory
122122+ let filename = rel_path.split('/').last().unwrap_or(&rel_path);
123123+ dest.join(filename).map_err(to_shell_err(call.arguments_span()))?
124124+ } else {
125125+ // For single path, use destination as-is
126126+ dest.clone()
127127+ };
128128+129129+ match source_meta.file_type {
130130+ VfsFileType::File => move_file(&source, &dest_entry, call.arguments_span())?,
131131+ VfsFileType::Directory => move_directory(&source, &dest_entry, call.arguments_span())?,
132132+ }
78133 }
7913480135 Ok(PipelineData::Empty)
+124-30
src/cmd/open.rs
···11use std::ops::Not;
2233-use crate::{error::to_shell_err, globals::get_pwd};
33+use crate::{
44+ cmd::glob::{expand_path, GlobOptions},
55+ globals::{get_pwd, get_vfs},
66+};
77+use std::sync::Arc;
48use nu_command::{FromCsv, FromJson, FromOds, FromToml, FromTsv, FromXlsx, FromXml, FromYaml};
59use nu_engine::CallExt;
610use nu_protocol::{
77- ByteStream, Category, PipelineData, ShellError, Signature, SyntaxShape, Type,
1111+ ByteStream, Category, ListStream, PipelineData, ShellError, Signature, SyntaxShape, Type, Value,
812 engine::{Command, EngineState, Stack},
913};
1014···18221923 fn signature(&self) -> Signature {
2024 Signature::build("open")
2121- .required("path", SyntaxShape::Filepath, "path to the file")
2525+ .required(
2626+ "path",
2727+ SyntaxShape::OneOf(vec![SyntaxShape::Filepath, SyntaxShape::GlobPattern]),
2828+ "path to the file",
2929+ )
2230 .switch(
2331 "raw",
2432 "output content as raw string/binary without parsing",
···3947 call: &nu_protocol::engine::Call,
4048 _input: PipelineData,
4149 ) -> Result<PipelineData, ShellError> {
4242- let path: String = call.req(engine_state, stack, 0)?;
5050+ let path_value: Value = call.req(engine_state, stack, 0)?;
4351 let raw_flag = call.has_flag(engine_state, stack, "raw")?;
44524545- let target_file = get_pwd().join(&path).map_err(to_shell_err(call.head))?;
5353+ let path_str = match path_value {
5454+ Value::String { val, .. } | Value::Glob { val, .. } => val,
5555+ _ => {
5656+ return Err(ShellError::GenericError {
5757+ error: "invalid path".into(),
5858+ msg: "path must be a string or glob pattern".into(),
5959+ span: Some(call.head),
6060+ help: None,
6161+ inner: vec![],
6262+ });
6363+ }
6464+ };
6565+6666+ // Expand path (glob or single) into list of paths
6767+ let is_absolute = path_str.starts_with('/');
6868+ let base_path: Arc<vfs::VfsPath> = if is_absolute {
6969+ get_vfs()
7070+ } else {
7171+ get_pwd()
7272+ };
7373+7474+ let options = GlobOptions {
7575+ max_depth: None,
7676+ no_dirs: true, // Only open files, not directories
7777+ no_files: false,
7878+ };
46794747- let parse_cmd = raw_flag
4848- .not()
4949- .then(|| {
5050- target_file
5151- .extension()
5252- .and_then(|ext| get_cmd_for_ext(&ext))
5353- })
5454- .flatten();
8080+ let matches = expand_path(&path_str, base_path.clone(), options)?;
55815656- target_file
5757- .open_file()
5858- .map_err(to_shell_err(call.head))
5959- .and_then(|f| {
6060- let data = PipelineData::ByteStream(
6161- ByteStream::read(
6262- f,
6363- call.head,
6464- engine_state.signals().clone(),
6565- nu_protocol::ByteStreamType::String,
6666- ),
6767- None,
6868- );
6969- if let Some(cmd) = parse_cmd {
7070- return cmd.run(engine_state, stack, call, data);
8282+ let span = call.head;
8383+ let signals = engine_state.signals().clone();
8484+8585+ // Open each matching file
8686+ let mut results = Vec::new();
8787+ for rel_path in matches {
8888+ let target_file = match base_path.join(&rel_path) {
8989+ Ok(p) => p,
9090+ Err(e) => {
9191+ results.push(Value::error(
9292+ ShellError::GenericError {
9393+ error: "path error".into(),
9494+ msg: e.to_string(),
9595+ span: Some(span),
9696+ help: None,
9797+ inner: vec![],
9898+ },
9999+ span,
100100+ ));
101101+ continue;
71102 }
7272- Ok(data)
7373- })
103103+ };
104104+105105+ let parse_cmd = raw_flag
106106+ .not()
107107+ .then(|| {
108108+ target_file
109109+ .extension()
110110+ .and_then(|ext| get_cmd_for_ext(&ext))
111111+ })
112112+ .flatten();
113113+114114+ match target_file.open_file() {
115115+ Ok(f) => {
116116+ let data = PipelineData::ByteStream(
117117+ ByteStream::read(
118118+ f,
119119+ span,
120120+ signals.clone(),
121121+ nu_protocol::ByteStreamType::String,
122122+ ),
123123+ None,
124124+ );
125125+126126+ let value = if let Some(cmd) = parse_cmd {
127127+ match cmd.run(engine_state, stack, call, data) {
128128+ Ok(pipeline_data) => {
129129+ // Convert pipeline data to value
130130+ pipeline_data.into_value(span).unwrap_or_else(|e| {
131131+ Value::error(e, span)
132132+ })
133133+ }
134134+ Err(e) => Value::error(e, span),
135135+ }
136136+ } else {
137137+ data.into_value(span).unwrap_or_else(|e| Value::error(e, span))
138138+ };
139139+ results.push(value);
140140+ }
141141+ Err(e) => {
142142+ results.push(Value::error(
143143+ ShellError::GenericError {
144144+ error: "io error".into(),
145145+ msg: format!("failed to open file {}: {}", rel_path, e),
146146+ span: Some(span),
147147+ help: None,
148148+ inner: vec![],
149149+ },
150150+ span,
151151+ ));
152152+ }
153153+ }
154154+ }
155155+156156+ // If single file, return the single result directly (for backward compatibility)
157157+ if results.len() == 1 && !path_str.contains('*') && !path_str.contains('?') && !path_str.contains('[') && !path_str.contains("**") {
158158+ match results.into_iter().next().unwrap() {
159159+ Value::Error { error, .. } => Err(*error),
160160+ val => Ok(PipelineData::Value(val, None)),
161161+ }
162162+ } else {
163163+ Ok(PipelineData::list_stream(
164164+ ListStream::new(results.into_iter(), span, signals.clone()),
165165+ None,
166166+ ))
167167+ }
74168 }
75169}
76170
+56-24
src/cmd/rm.rs
···11-use crate::{error::to_shell_err, globals::get_pwd};
11+use crate::{
22+ cmd::glob::{expand_path, GlobOptions},
33+ error::to_shell_err,
44+ globals::{get_pwd, get_vfs},
55+};
66+use std::sync::Arc;
27use nu_engine::CallExt;
38use nu_protocol::{
44- Category, PipelineData, ShellError, Signature, SyntaxShape, Type,
99+ Category, PipelineData, ShellError, Signature, SyntaxShape, Type, Value,
510 engine::{Command, EngineState, Stack},
611};
712use vfs::VfsFileType;
···1823 Signature::build("rm")
1924 .required(
2025 "path",
2121- SyntaxShape::String,
2626+ SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::GlobPattern]),
2227 "path to file or directory to remove",
2328 )
2429 .switch(
···4146 call: &nu_protocol::engine::Call,
4247 _input: PipelineData,
4348 ) -> Result<PipelineData, ShellError> {
4444- let path: String = call.req(engine_state, stack, 0)?;
4949+ let path_value: Value = call.req(engine_state, stack, 0)?;
4550 let recursive = call.has_flag(engine_state, stack, "recursive")?;
46515252+ let path_str = match path_value {
5353+ Value::String { val, .. } | Value::Glob { val, .. } => val,
5454+ _ => {
5555+ return Err(ShellError::GenericError {
5656+ error: "invalid path".into(),
5757+ msg: "path must be a string or glob pattern".into(),
5858+ span: Some(call.head),
5959+ help: None,
6060+ inner: vec![],
6161+ });
6262+ }
6363+ };
6464+4765 // Prevent removing root
4848- if path == "/" {
6666+ if path_str == "/" {
4967 return Err(ShellError::GenericError {
5068 error: "cannot remove root".to_string(),
5169 msg: "refusing to remove root directory".to_string(),
···5573 });
5674 }
57755858- // Resolve target relative to PWD (or absolute if path starts with '/')
5959- let target = get_pwd()
6060- .join(path.trim_end_matches('/'))
6161- .map_err(to_shell_err(call.head))?;
7676+ // Expand path (glob or single) into list of paths
7777+ let is_absolute = path_str.starts_with('/');
7878+ let base_path: Arc<vfs::VfsPath> = if is_absolute {
7979+ get_vfs()
8080+ } else {
8181+ get_pwd()
8282+ };
62836363- let meta = target.metadata().map_err(to_shell_err(call.head))?;
6464- match meta.file_type {
6565- VfsFileType::File => {
6666- target.remove_file().map_err(to_shell_err(call.head))?;
6767- Ok(PipelineData::Empty)
6868- }
6969- VfsFileType::Directory => {
7070- (if recursive {
7171- target.remove_dir_all()
7272- } else {
7373- // non-recursive: attempt to remove directory (will fail if not empty)
7474- target.remove_dir()
7575- })
7676- .map_err(to_shell_err(call.head))
7777- .map(|_| PipelineData::Empty)
8484+ let options = GlobOptions {
8585+ max_depth: None,
8686+ no_dirs: false,
8787+ no_files: false,
8888+ };
8989+9090+ let matches = expand_path(&path_str, base_path.clone(), options)?;
9191+9292+ // Remove all matching paths
9393+ for rel_path in matches {
9494+ let target = base_path.join(&rel_path).map_err(to_shell_err(call.head))?;
9595+ let meta = target.metadata().map_err(to_shell_err(call.head))?;
9696+ match meta.file_type {
9797+ VfsFileType::File => {
9898+ target.remove_file().map_err(to_shell_err(call.head))?;
9999+ }
100100+ VfsFileType::Directory => {
101101+ (if recursive {
102102+ target.remove_dir_all()
103103+ } else {
104104+ target.remove_dir()
105105+ })
106106+ .map_err(to_shell_err(call.head))?;
107107+ }
78108 }
79109 }
110110+111111+ Ok(PipelineData::Empty)
80112 }
81113}