diff --git a/Cargo.lock b/Cargo.lock index 9ae5810..8c7e695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1409,6 +1409,7 @@ dependencies = [ "nu-cmd-lang", "nu-command", "nu-engine", + "nu-glob", "nu-parser", "nu-path", "nu-protocol", diff --git a/Cargo.toml b/Cargo.toml index d42d4cc..1098dfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ nu-engine = { git = "https://github.com/90-008/nushell", default-features = fals nu-parser = { git = "https://github.com/90-008/nushell", default-features = false } nu-protocol = { git = "https://github.com/90-008/nushell", default-features = false } nu-path = { git = "https://github.com/90-008/nushell", default-features = false } +nu-glob = { git = "https://github.com/90-008/nushell", default-features = false } nu-cmd-base = { git = "https://github.com/90-008/nushell", default-features = false } nu-cmd-lang = { git = "https://github.com/90-008/nushell", default-features = false } nu-cmd-extra = { git = "https://github.com/90-008/nushell", default-features = false } diff --git a/src/cmd/glob.rs b/src/cmd/glob.rs new file mode 100644 index 0000000..b0c53d8 --- /dev/null +++ b/src/cmd/glob.rs @@ -0,0 +1,282 @@ +use std::sync::Arc; + +use crate::globals::{get_pwd, get_vfs}; +use nu_engine::CallExt; +use nu_glob::Pattern; +use nu_protocol::{ + Category, ListStream, PipelineData, ShellError, Signature, SyntaxShape, Type, Value, + engine::{Command, EngineState, Stack}, +}; +use vfs::VfsFileType; + +/// Options for glob matching +pub struct GlobOptions { + pub max_depth: Option, + pub no_dirs: bool, + pub no_files: bool, +} + +impl Default for GlobOptions { + fn default() -> Self { + Self { + max_depth: None, + no_dirs: false, + no_files: false, + } + } +} + +/// Match files and directories using a glob pattern. +/// Returns a vector of relative paths (relative to the base path) that match the pattern. +pub fn glob_match( + pattern_str: &str, + base_path: Arc, + options: GlobOptions, +) -> Result, ShellError> { + if pattern_str.is_empty() { + return Err(ShellError::GenericError { + error: "glob pattern must not be empty".into(), + msg: "glob pattern is empty".into(), + span: None, + help: Some("add characters to the glob pattern".into()), + inner: vec![], + }); + } + + // Parse the pattern + let pattern = Pattern::new(pattern_str).map_err(|e| ShellError::GenericError { + error: "error with glob pattern".into(), + msg: format!("{}", e), + span: None, + help: None, + inner: vec![], + })?; + + // Determine max depth + let max_depth = if let Some(d) = options.max_depth { + d + } else if pattern_str.contains("**") { + usize::MAX + } else { + // Count number of / in pattern to determine depth + pattern_str.split('/').count() + }; + + // Normalize pattern: remove leading / for relative matching + let normalized_pattern = pattern_str.trim_start_matches('/'); + let is_recursive = normalized_pattern.contains("**"); + + // Collect matching paths + let mut matches = Vec::new(); + + fn walk_directory( + current_path: Arc, + current_relative_path: String, + pattern: &Pattern, + normalized_pattern: &str, + current_depth: usize, + max_depth: usize, + matches: &mut Vec, + no_dirs: bool, + no_files: bool, + is_recursive: bool, + ) -> Result<(), ShellError> { + if current_depth > max_depth { + return Ok(()); + } + + // Walk through directory entries + if let Ok(entries) = current_path.read_dir() { + for entry in entries { + let filename = entry.filename(); + let entry_path = current_path.join(&filename) + .map_err(|e| ShellError::GenericError { + error: "path error".into(), + msg: e.to_string(), + span: None, + help: None, + inner: vec![], + })?; + + // Build relative path from base + let new_relative = if current_relative_path.is_empty() { + filename.clone() + } else { + format!("{}/{}", current_relative_path, filename) + }; + + let metadata = entry_path.metadata().map_err(|e| ShellError::GenericError { + error: "path error".into(), + msg: e.to_string(), + span: None, + help: None, + inner: vec![], + })?; + + // Check if this path matches the pattern + // For patterns without path separators, match just the filename + // For patterns with path separators, match the full relative path + let path_to_match = if normalized_pattern.contains('/') { + &new_relative + } else { + &filename + }; + + if pattern.matches(path_to_match) { + let should_include = match metadata.file_type { + VfsFileType::Directory => !no_dirs, + VfsFileType::File => !no_files, + }; + if should_include { + matches.push(new_relative.clone()); + } + } + + // Recursively walk into subdirectories + if metadata.file_type == VfsFileType::Directory { + // Continue if: recursive pattern, or we haven't reached max depth, or pattern has more components + let should_recurse = is_recursive + || current_depth < max_depth + || (normalized_pattern.contains('/') && current_depth < normalized_pattern.split('/').count()); + + if should_recurse { + walk_directory( + Arc::new(entry_path), + new_relative, + pattern, + normalized_pattern, + current_depth + 1, + max_depth, + matches, + no_dirs, + no_files, + is_recursive, + )?; + } + } + } + } + + Ok(()) + } + + // Start walking from base path + walk_directory( + base_path, + String::new(), + &pattern, + normalized_pattern, + 0, + max_depth, + &mut matches, + options.no_dirs, + options.no_files, + is_recursive, + )?; + + Ok(matches) +} + +#[derive(Clone)] +pub struct Glob; + +impl Command for Glob { + fn name(&self) -> &str { + "glob" + } + + fn signature(&self) -> Signature { + Signature::build("glob") + .required( + "pattern", + SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::GlobPattern]), + "The glob expression.", + ) + .named( + "depth", + SyntaxShape::Int, + "directory depth to search", + Some('d'), + ) + .switch( + "no-dir", + "Whether to filter out directories from the returned paths", + Some('D'), + ) + .switch( + "no-file", + "Whether to filter out files from the returned paths", + Some('F'), + ) + .input_output_type(Type::Nothing, Type::List(Box::new(Type::String))) + .category(Category::FileSystem) + } + + fn description(&self) -> &str { + "Creates a list of files and/or folders based on the glob pattern provided." + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &nu_protocol::engine::Call, + _input: PipelineData, + ) -> Result { + let span = call.head; + let pattern_value: Value = call.req(engine_state, stack, 0)?; + let pattern_span = pattern_value.span(); + let depth: Option = call.get_flag(engine_state, stack, "depth")?; + let no_dirs = call.has_flag(engine_state, stack, "no-dir")?; + let no_files = call.has_flag(engine_state, stack, "no-file")?; + + let pattern_str = match pattern_value { + Value::String { val, .. } | Value::Glob { val, .. } => val, + _ => { + return Err(ShellError::IncorrectValue { + msg: "Incorrect glob pattern supplied to glob. Please use string or glob only." + .to_string(), + val_span: call.head, + call_span: pattern_span, + }); + } + }; + + if pattern_str.is_empty() { + return Err(ShellError::GenericError { + error: "glob pattern must not be empty".into(), + msg: "glob pattern is empty".into(), + span: Some(pattern_span), + help: Some("add characters to the glob pattern".into()), + inner: vec![], + }); + } + + // Determine if pattern is absolute (starts with /) + let is_absolute = pattern_str.starts_with('/'); + let base_path = if is_absolute { + get_vfs() + } else { + get_pwd() + }; + + // Use the glob_match function + let options = GlobOptions { + max_depth: depth.map(|d| d as usize), + no_dirs, + no_files, + }; + + let matches = glob_match(&pattern_str, base_path, options)?; + + // Convert matches to Value stream + let signals = engine_state.signals().clone(); + let values = matches.into_iter().map(move |path| Value::string(path, span)); + + Ok(PipelineData::list_stream( + ListStream::new(values, span, signals.clone()), + None, + )) + } +} + diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index d9bab39..1a45eba 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,6 +1,7 @@ pub mod cd; pub mod eval; pub mod fetch; +pub mod glob; pub mod job; pub mod job_kill; pub mod job_list; @@ -19,6 +20,7 @@ pub mod sys; pub use cd::Cd; pub use eval::Eval; pub use fetch::Fetch; +pub use glob::Glob; pub use job::Job; pub use job_kill::JobKill; pub use job_list::JobList; diff --git a/src/cmd/source_file.rs b/src/cmd/source_file.rs index 0642900..348d9b7 100644 --- a/src/cmd/source_file.rs +++ b/src/cmd/source_file.rs @@ -1,11 +1,13 @@ use crate::{ + cmd::glob::glob_match, error::{CommandError, to_shell_err}, - globals::{get_pwd, print_to_console, set_pwd}, + globals::{get_pwd, get_vfs, print_to_console, set_pwd}, }; +use std::sync::Arc; use nu_engine::{CallExt, get_eval_block_with_early_return}; use nu_parser::parse; use nu_protocol::{ - Category, PipelineData, ShellError, Signature, SyntaxShape, Type, + Category, PipelineData, ShellError, Signature, SyntaxShape, Type, Value, engine::{Command, EngineState, Stack, StateWorkingSet}, }; @@ -19,7 +21,11 @@ impl Command for SourceFile { fn signature(&self) -> Signature { Signature::build(self.name()) - .required("path", SyntaxShape::Filepath, "the file to source") + .required( + "path", + SyntaxShape::OneOf(vec![SyntaxShape::Filepath, SyntaxShape::GlobPattern]), + "the file to source", + ) .input_output_type(Type::Nothing, Type::Nothing) .category(Category::Core) } @@ -36,31 +42,83 @@ impl Command for SourceFile { _input: PipelineData, ) -> Result { let span = call.arguments_span(); - let path: String = call.req(engine_state, stack, 0)?; + let path: Value = call.req(engine_state, stack, 0)?; - let pwd = get_pwd(); - - let path = pwd.join(&path).map_err(to_shell_err(span))?; - let contents = path.read_to_string().map_err(to_shell_err(span))?; - - set_pwd(path.parent().into()); - let res = eval(engine_state, stack, &contents, Some(&path.filename())); - set_pwd(pwd); - - match res { - Ok(d) => Ok(d), - Err(err) => { - let msg: String = err.into(); - print_to_console(&msg, true); - Err(ShellError::GenericError { - error: "source error".into(), - msg: "can't source file".into(), + // Check if path is a glob pattern + let path_str = match &path { + Value::String { val, .. } | Value::Glob { val, .. } => val.clone(), + _ => { + return Err(ShellError::GenericError { + error: "not a path or glob pattern".into(), + msg: String::new(), span: Some(span), help: None, inner: vec![], - }) + }); + } + }; + + let pwd = get_pwd(); + let is_absolute = path_str.starts_with('/'); + let base_path: Arc = if is_absolute { + get_vfs() + } else { + pwd.clone() + }; + + // Check if it's a glob pattern (contains *, ?, [, or **) + let is_glob = path_str.contains('*') + || path_str.contains('?') + || path_str.contains('[') + || path_str.contains("**"); + + let paths_to_source = if is_glob { + // Expand glob pattern + let options = crate::cmd::glob::GlobOptions { + max_depth: None, + no_dirs: true, // Only source files, not directories + no_files: false, + }; + glob_match(&path_str, base_path.clone(), options)? + } else { + // Single file path + vec![path_str] + }; + + // Source each matching file + for rel_path in paths_to_source { + let full_path = base_path.join(&rel_path).map_err(to_shell_err(span))?; + + let metadata = full_path.metadata().map_err(to_shell_err(span))?; + if metadata.file_type != vfs::VfsFileType::File { + continue; + } + + let contents = full_path.read_to_string().map_err(to_shell_err(span))?; + + set_pwd(full_path.parent().into()); + let res = eval(engine_state, stack, &contents, Some(&full_path.filename())); + set_pwd(pwd.clone()); + + match res { + Ok(p) => { + print_to_console(&p.collect_string("\n", &engine_state.config)?, true); + } + Err(err) => { + let msg: String = err.into(); + print_to_console(&msg, true); + return Err(ShellError::GenericError { + error: "source error".into(), + msg: format!("can't source file: {}", rel_path), + span: Some(span), + help: None, + inner: vec![], + }); + } } } + + Ok(PipelineData::Empty) } } diff --git a/src/lib.rs b/src/lib.rs index de46e68..1d1d252 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,7 +28,7 @@ pub mod memory_fs; use crate::{ cmd::{ - Cd, Eval, Fetch, Job, JobKill, JobList, Ls, Mkdir, Mv, Open, Print, Pwd, Random, Rm, Save, + Cd, Eval, Fetch, Glob, Job, JobKill, JobList, Ls, Mkdir, Mv, Open, Print, Pwd, Random, Rm, Save, SourceFile, Sys, }, default_context::add_shell_command_context, @@ -102,7 +102,7 @@ async fn init_engine_internal() -> Result<(), String> { engine_state = add_extra_command_context(engine_state); let mut working_set = StateWorkingSet::new(&engine_state); - let decls: [Box; 17] = [ + let decls: [Box; 18] = [ Box::new(Ls), Box::new(Open), Box::new(Save), @@ -120,6 +120,7 @@ async fn init_engine_internal() -> Result<(), String> { Box::new(Sys), Box::new(Random), Box::new(Print), + Box::new(Glob), ]; for decl in decls { working_set.add_decl(decl);