diff --git a/cli/tests/cases/builtins/compgen.yaml b/cli/tests/cases/builtins/compgen.yaml new file mode 100644 index 00000000..383ba30c --- /dev/null +++ b/cli/tests/cases/builtins/compgen.yaml @@ -0,0 +1,53 @@ +name: "Builtins: compgen" +cases: + - name: "compgen -A alias" + stdin: | + alias myalias="ls" + alias myalias2="echo hi" + + compgen -A alias myalias + + - name: "compgen -A builtin" + stdin: | + compgen -A builtin cd + + - name: "compgen -A directory" + known_failure: true + stdin: | + touch somefile + mkdir somedir + mkdir somedir2 + + compgen -A directory some + + - name: "compgen -A file" + known_failure: true + stdin: | + touch somefile + mkdir somedir + mkdir somedir2 + + compgen -A file some + + - name: "compgen -A function" + stdin: | + myfunc() { + echo hi + } + + myfunc2() { + echo hello + } + + compgen -A function myfunc + + - name: "compgen -A keyword" + stdin: | + compgen -A keyword esa + + - name: "compgen -A variable" + stdin: | + declare myvar=10 + declare myvar2=11 + + compgen -A variable myvar diff --git a/shell/Cargo.toml b/shell/Cargo.toml index d176559c..7e834177 100644 --- a/shell/Cargo.toml +++ b/shell/Cargo.toml @@ -45,7 +45,7 @@ whoami = "1.5.1" [target.'cfg(unix)'.dependencies] exec = "0.3.1" -nix = { version = "0.28.0", features = ["fs", "process", "signal"] } +nix = { version = "0.28.0", features = ["fs", "process", "signal", "user"] } tokio-command-fds = "0.2.1" uzers = "0.12.0" diff --git a/shell/src/builtins/complete.rs b/shell/src/builtins/complete.rs index 77f357ea..b6d6c3a9 100644 --- a/shell/src/builtins/complete.rs +++ b/shell/src/builtins/complete.rs @@ -477,21 +477,23 @@ pub(crate) struct CompOptCommand { impl BuiltinCommand for CompOptCommand { async fn execute( &self, - _context: crate::context::CommandExecutionContext<'_>, + context: crate::context::CommandExecutionContext<'_>, ) -> Result { - if self.update_default { - return error::unimp("compopt -D"); - } - if self.update_empty { - return error::unimp("compopt -E"); - } - if self.update_initial_word { - return error::unimp("compopt -I"); - } if !self.names.is_empty() { + tracing::debug!("UNIMPLEMENTED: compopt with names"); return error::unimp("compopt with names"); } + let target_spec = if self.update_default { + Some(&mut context.shell.completion_config.default) + } else if self.update_empty { + Some(&mut context.shell.completion_config.empty_line) + } else if self.update_initial_word { + Some(&mut context.shell.completion_config.initial_word) + } else { + None + }; + let mut options = HashMap::new(); for option in &self.disabled_options { options.insert(option.clone(), false); @@ -500,12 +502,18 @@ impl BuiltinCommand for CompOptCommand { options.insert(option.clone(), true); } - // TODO: implement options - for (option, value) in options { - if value { - tracing::debug!("compopt: enabling {option:?}"); - } else { - tracing::debug!("compopt: disabling {option:?}"); + if let Some(Some(target_spec)) = target_spec { + for (option, value) in options { + match option { + CompleteOption::BashDefault => target_spec.bash_default = value, + CompleteOption::Default => target_spec.default = value, + CompleteOption::DirNames => target_spec.dir_names = value, + CompleteOption::FileNames => target_spec.file_names = value, + CompleteOption::NoQuote => target_spec.no_quote = value, + CompleteOption::NoSort => target_spec.no_sort = value, + CompleteOption::NoSpace => target_spec.no_space = value, + CompleteOption::PlusDirs => target_spec.plus_dirs = value, + } } } diff --git a/shell/src/builtins/typ.rs b/shell/src/builtins/typ.rs index b1fd8051..469b974d 100644 --- a/shell/src/builtins/typ.rs +++ b/shell/src/builtins/typ.rs @@ -4,6 +4,7 @@ use std::{io::Write, sync::Arc}; use clap::Parser; use parser::ast; +use crate::keywords; use crate::{ builtin::{BuiltinCommand, BuiltinExitCode}, Shell, @@ -134,7 +135,7 @@ impl TypeCommand { } // Check for keywords. - if is_keyword(shell, name) { + if keywords::is_keyword(shell, name) { types.push(ResolvedType::Keyword); } @@ -165,30 +166,3 @@ impl TypeCommand { types } } - -fn is_keyword(shell: &Shell, name: &str) -> bool { - match name { - "!" => true, - "{" => true, - "}" => true, - "case" => true, - "do" => true, - "done" => true, - "elif" => true, - "else" => true, - "esac" => true, - "fi" => true, - "for" => true, - "if" => true, - "in" => true, - "then" => true, - "until" => true, - "while" => true, - // N.B. Some shells also treat the following as reserved. - "[[" if !shell.options.sh_mode => true, - "]]" if !shell.options.sh_mode => true, - "function" if !shell.options.sh_mode => true, - "select" if !shell.options.sh_mode => true, - _ => false, - } -} diff --git a/shell/src/completion.rs b/shell/src/completion.rs index 99ef8f61..c6a3856c 100644 --- a/shell/src/completion.rs +++ b/shell/src/completion.rs @@ -1,10 +1,10 @@ use clap::ValueEnum; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, path::{Path, PathBuf}, }; -use crate::{env, error, patterns, variables::ShellValueLiteral, Shell}; +use crate::{env, error, namedoptions, patterns, users, variables::ShellValueLiteral, Shell}; #[derive(Clone, Debug, ValueEnum)] pub enum CompleteAction { @@ -183,19 +183,36 @@ impl CompletionSpec { candidates.push(name.to_string()); } } - CompleteAction::ArrayVar => tracing::debug!("UNIMPLEMENTED: complete -A arrayvar"), + CompleteAction::ArrayVar => { + for (name, var) in shell.env.iter() { + if var.value().is_array() { + candidates.push(name.to_owned()); + } + } + } CompleteAction::Binding => tracing::debug!("UNIMPLEMENTED: complete -A binding"), CompleteAction::Builtin => { let mut builtin_names = shell.get_builtin_names(); candidates.append(&mut builtin_names); } - CompleteAction::Command => tracing::debug!("UNIMPLEMENTED: complete -A command"), + CompleteAction::Command => { + let mut command_completions = get_command_completions(shell, context); + candidates.append(&mut command_completions); + } CompleteAction::Directory => { let mut file_completions = get_file_completions(shell, context, true); candidates.append(&mut file_completions); } - CompleteAction::Disabled => tracing::debug!("UNIMPLEMENTED: complete -A disabled"), - CompleteAction::Enabled => tracing::debug!("UNIMPLEMENTED: complete -A enabled"), + CompleteAction::Disabled => { + // For now, no builtins are disabled. + tracing::debug!("UNIMPLEMENTED: complete -A disabled"); + } + CompleteAction::Enabled => { + // For now, all builtins are enabled + tracing::debug!("UNIMPLEMENTED: complete -A enabled"); + let mut builtin_names = shell.get_builtin_names(); + candidates.append(&mut builtin_names); + } CompleteAction::Export => { for (key, value) in shell.env.iter() { if value.is_exported() { @@ -212,20 +229,43 @@ impl CompletionSpec { candidates.push(name.to_owned()); } } - CompleteAction::Group => tracing::debug!("UNIMPLEMENTED: complete -A group"), + CompleteAction::Group => { + let mut names = users::get_all_groups()?; + candidates.append(&mut names); + } CompleteAction::HelpTopic => { tracing::debug!("UNIMPLEMENTED: complete -A helptopic"); } - CompleteAction::HostName => tracing::debug!("UNIMPLEMENTED: complete -A hostname"), + CompleteAction::HostName => { + // N.B. We only retrieve one hostname. + if let Ok(name) = hostname::get() { + candidates.push(name.to_string_lossy().to_string()); + } + } CompleteAction::Job => tracing::debug!("UNIMPLEMENTED: complete -A job"), - CompleteAction::Keyword => tracing::debug!("UNIMPLEMENTED: complete -A keyword"), + CompleteAction::Keyword => { + for keyword in shell.get_keywords() { + candidates.push(keyword.clone()); + } + } CompleteAction::Running => tracing::debug!("UNIMPLEMENTED: complete -A running"), CompleteAction::Service => tracing::debug!("UNIMPLEMENTED: complete -A service"), - CompleteAction::SetOpt => tracing::debug!("UNIMPLEMENTED: complete -A setopt"), - CompleteAction::ShOpt => tracing::debug!("UNIMPLEMENTED: complete -A shopt"), + CompleteAction::SetOpt => { + for (name, _) in namedoptions::SET_O_OPTIONS.iter() { + candidates.push((*name).to_owned()); + } + } + CompleteAction::ShOpt => { + for (name, _) in namedoptions::SHOPT_OPTIONS.iter() { + candidates.push((*name).to_owned()); + } + } CompleteAction::Signal => tracing::debug!("UNIMPLEMENTED: complete -A signal"), CompleteAction::Stopped => tracing::debug!("UNIMPLEMENTED: complete -A stopped"), - CompleteAction::User => tracing::debug!("UNIMPLEMENTED: complete -A user"), + CompleteAction::User => { + let mut names = users::get_all_users()?; + candidates.append(&mut names); + } CompleteAction::Variable => { for (key, _) in shell.env.iter() { candidates.push(key.to_owned()); @@ -235,10 +275,18 @@ impl CompletionSpec { } if let Some(glob_pattern) = &self.glob_pattern { - tracing::debug!("UNIMPLEMENTED: complete -G({glob_pattern})"); + let pattern = patterns::Pattern::from(glob_pattern.as_str()); + let mut expansions = pattern.expand( + shell.working_dir.as_path(), + shell.parser_options().enable_extended_globbing, + Some(&patterns::Pattern::accept_all_expand_filter), + )?; + + candidates.append(&mut expansions); } if let Some(word_list) = &self.word_list { - tracing::debug!("UNIMPLEMENTED: complete -W({word_list})"); + let mut words = split_string_using_ifs(word_list, shell); + candidates.append(&mut words); } if let Some(function_name) = &self.function_name { let call_result = self @@ -262,7 +310,9 @@ impl CompletionSpec { // Apply filter pattern, if present. if let Some(filter_pattern) = &self.filter_pattern { - tracing::debug!("UNIMPLEMENTED: complete -X (filter pattern): {filter_pattern}"); + if !filter_pattern.is_empty() { + tracing::debug!("UNIMPLEMENTED: complete -X (filter pattern): '{filter_pattern}'"); + } } // Add prefix and/or suffix, if present. @@ -535,15 +585,13 @@ fn get_file_completions( ) -> Vec { let glob = std::format!("{}*", context.token_to_complete); - let metadata_filter = |metadata: Option<&std::fs::Metadata>| { - !must_be_dir || metadata.is_some_and(|md| md.is_dir()) - }; + let path_filter = |path: &Path| !must_be_dir || path.is_dir(); // TODO: Pass through quoting. if let Ok(mut candidates) = patterns::Pattern::from(glob).expand( shell.working_dir.as_path(), shell.options.extended_globbing, - Some(&metadata_filter), + Some(&path_filter), ) { for candidate in &mut candidates { if Path::new(candidate.as_str()).is_dir() { @@ -556,6 +604,19 @@ fn get_file_completions( } } +fn get_command_completions(shell: &Shell, context: &CompletionContext) -> Vec { + let mut candidates = HashSet::new(); + let glob_pattern = std::format!("{}*", context.token_to_complete); + + for path in shell.find_executables_in_path(&glob_pattern) { + if let Some(file_name) = path.file_name() { + candidates.insert(file_name.to_string_lossy().to_string()); + } + } + + candidates.into_iter().collect() +} + fn get_completions_using_basic_lookup( shell: &Shell, context: &CompletionContext, @@ -571,21 +632,8 @@ fn get_completions_using_basic_lookup( && !context.token_to_complete.is_empty() && !context.token_to_complete.contains('/') { - let glob_pattern = std::format!("{}*", context.token_to_complete); - - for path in shell.find_executables_in_path(&glob_pattern) { - if let Some(file_name) = path.file_name() { - candidates.push(file_name.to_string_lossy().to_string()); - } - } - } - - if context.token_index + 1 >= context.tokens.len() { - for candidate in &mut candidates { - if !candidate.ends_with('/') { - candidate.push(' '); - } - } + let mut command_completions = get_command_completions(shell, context); + candidates.append(&mut command_completions); } #[cfg(windows)] @@ -598,3 +646,11 @@ fn get_completions_using_basic_lookup( CompletionResult::Candidates(candidates) } + +fn split_string_using_ifs>(s: S, shell: &Shell) -> Vec { + let ifs_chars: Vec = shell.get_ifs().chars().collect(); + s.as_ref() + .split(ifs_chars.as_slice()) + .map(|s| s.to_owned()) + .collect() +} diff --git a/shell/src/keywords.rs b/shell/src/keywords.rs new file mode 100644 index 00000000..07681af1 --- /dev/null +++ b/shell/src/keywords.rs @@ -0,0 +1,45 @@ +use std::collections::HashSet; + +use crate::Shell; + +fn get_keywords(sh_mode_only: bool) -> HashSet { + let mut keywords = HashSet::new(); + keywords.insert(String::from("!")); + keywords.insert(String::from("{")); + keywords.insert(String::from("}")); + keywords.insert(String::from("case")); + keywords.insert(String::from("do")); + keywords.insert(String::from("done")); + keywords.insert(String::from("elif")); + keywords.insert(String::from("else")); + keywords.insert(String::from("esac")); + keywords.insert(String::from("fi")); + keywords.insert(String::from("for")); + keywords.insert(String::from("if")); + keywords.insert(String::from("in")); + keywords.insert(String::from("then")); + keywords.insert(String::from("until")); + keywords.insert(String::from("while")); + + if !sh_mode_only { + keywords.insert(String::from("[[")); + keywords.insert(String::from("]]")); + keywords.insert(String::from("function")); + keywords.insert(String::from("select")); + } + + keywords +} + +lazy_static::lazy_static! { + pub(crate) static ref SH_MODE_KEYWORDS: HashSet = get_keywords(true); + pub(crate) static ref KEYWORDS: HashSet = get_keywords(false); +} + +pub fn is_keyword(shell: &Shell, name: &str) -> bool { + if shell.options.sh_mode { + SH_MODE_KEYWORDS.contains(name) + } else { + KEYWORDS.contains(name) + } +} diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 26d4e007..c3a61e8a 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -11,6 +11,7 @@ mod expansion; mod extendedtests; mod interp; mod jobs; +mod keywords; mod namedoptions; mod openfiles; mod options; diff --git a/shell/src/patterns.rs b/shell/src/patterns.rs index e55717f8..15ea4195 100644 --- a/shell/src/patterns.rs +++ b/shell/src/patterns.rs @@ -61,19 +61,19 @@ impl Pattern { self.pieces.iter().all(|p| p.as_str().is_empty()) } - pub(crate) fn accept_all_expand_filter(_metadata: Option<&std::fs::Metadata>) -> bool { + pub(crate) fn accept_all_expand_filter(_path: &Path) -> bool { true } #[allow(clippy::too_many_lines)] - pub(crate) fn expand( + pub(crate) fn expand( &self, working_dir: &Path, enable_extended_globbing: bool, - metadata_filter: Option<&MF>, + path_filter: Option<&PF>, ) -> Result, error::Error> where - MF: Fn(Option<&std::fs::Metadata>) -> bool, + PF: Fn(&Path) -> bool, { if self.is_empty() { return Ok(vec![]); @@ -81,6 +81,13 @@ impl Pattern { matches!(piece, PatternPiece::Pattern(_)) && requires_expansion(piece.as_str()) }) { let concatenated: String = self.pieces.iter().map(|piece| piece.as_str()).collect(); + + if let Some(filter) = path_filter { + if !filter(Path::new(&concatenated)) { + return Ok(vec![]); + } + } + return Ok(vec![concatenated]); } @@ -150,22 +157,10 @@ impl Pattern { let subpattern = Pattern::from(&component); let regex = subpattern.to_regex(true, true, enable_extended_globbing)?; - let matches_criteria = |dir_entry: &std::fs::DirEntry| { - if !regex + let matches_regex = |dir_entry: &std::fs::DirEntry| { + regex .is_match(dir_entry.file_name().to_string_lossy().as_ref()) .unwrap_or(false) - { - return false; - } - - if let Some(filter) = &metadata_filter { - let metadata_result = dir_entry.metadata(); - if !filter(metadata_result.as_ref().ok()) { - return false; - } - } - - true }; let mut matching_paths_in_dir: Vec<_> = current_path @@ -173,7 +168,7 @@ impl Pattern { .map_or_else(|_| vec![], |dir| dir.into_iter().collect()) .into_iter() .filter_map(|result| result.ok()) - .filter(matches_criteria) + .filter(matches_regex) .map(|entry| entry.path()) .collect(); @@ -185,7 +180,13 @@ impl Pattern { let results: Vec<_> = paths_so_far .into_iter() - .map(|path| { + .filter_map(|path| { + if let Some(filter) = path_filter { + if !filter(path.as_path()) { + return None; + } + } + let path_str = path.to_string_lossy(); let mut path_ref = path_str.as_ref(); @@ -193,7 +194,7 @@ impl Pattern { path_ref = path_ref.strip_prefix(prefix_to_remove).unwrap(); } - path_ref.to_string() + Some(path_ref.to_string()) }) .collect(); diff --git a/shell/src/shell.rs b/shell/src/shell.rs index 94dca819..cd1a96d4 100644 --- a/shell/src/shell.rs +++ b/shell/src/shell.rs @@ -1,12 +1,11 @@ -use faccess::PathExt; use std::borrow::Cow; use std::collections::{HashMap, VecDeque}; use std::io::Write; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::env::{EnvironmentLookup, EnvironmentScope, ShellEnvironment}; -use crate::expansion; use crate::interp::{self, Execute, ExecutionParameters, ExecutionResult}; use crate::jobs; use crate::openfiles; @@ -17,6 +16,7 @@ use crate::{builtins, error}; use crate::{commands, patterns}; use crate::{completion, users}; use crate::{context, env}; +use crate::{expansion, keywords}; pub struct Shell { // @@ -705,6 +705,11 @@ impl Shell { #[allow(clippy::manual_flatten)] pub fn find_executables_in_path(&self, required_glob_pattern: &str) -> Vec { + let is_executable = |path: &Path| { + path.metadata() + .is_ok_and(|md| md.permissions().mode() & 0o111 != 0) + }; + let mut executables = vec![]; for dir_str in self.env.get_str("PATH").unwrap_or_default().split(':') { let pattern = std::format!("{dir_str}/{required_glob_pattern}"); @@ -712,13 +717,10 @@ impl Shell { if let Ok(entries) = patterns::Pattern::from(pattern).expand( &self.working_dir, self.options.extended_globbing, - Some(&patterns::Pattern::accept_all_expand_filter), + Some(&is_executable), ) { for entry in entries { - let path = Path::new(&entry); - if path.executable() { - executables.push(path.to_path_buf()); - } + executables.push(PathBuf::from(entry)); } } } @@ -815,4 +817,12 @@ impl Shell { pub fn get_builtin_names(&self) -> Vec { builtins::get_all_builtin_names() } + + pub fn get_keywords(&self) -> Vec { + if self.options.sh_mode { + keywords::SH_MODE_KEYWORDS.iter().cloned().collect() + } else { + keywords::KEYWORDS.iter().cloned().collect() + } + } } diff --git a/shell/src/users.rs b/shell/src/users.rs index bda46de7..45a89798 100644 --- a/shell/src/users.rs +++ b/shell/src/users.rs @@ -26,6 +26,22 @@ pub(crate) fn get_current_username() -> Result { Ok(username.to_string_lossy().to_string()) } +#[allow(clippy::unnecessary_wraps)] +#[cfg(unix)] +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO: implement this + tracing::debug!("UNIMPLEMENTED: get_all_users"); + Ok(vec![]) +} + +#[allow(clippy::unnecessary_wraps)] +#[cfg(unix)] +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO: implement this + tracing::debug!("UNIMPLEMENTED: get_all_groups"); + Ok(vec![]) +} + #[cfg(windows)] pub(crate) fn get_user_home_dir() -> Option { homedir::get_my_home().unwrap_or_default() @@ -41,3 +57,15 @@ pub(crate) fn is_root() -> bool { pub(crate) fn get_current_username() -> Result { Ok(whoami::username()) } + +#[cfg(windows)] +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO: implement some version of this for Windows + Ok(vec![]) +} + +#[cfg(windows)] +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO: implement some version of this for Windows + Ok(vec![]) +} diff --git a/shell/src/variables.rs b/shell/src/variables.rs index ad4884dd..211284e8 100644 --- a/shell/src/variables.rs +++ b/shell/src/variables.rs @@ -491,6 +491,17 @@ pub enum FormatStyle { } impl ShellValue { + pub fn is_array(&self) -> bool { + matches!( + self, + ShellValue::IndexedArray(_) + | ShellValue::AssociativeArray(_) + | ShellValue::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::AssociativeArray + ) + ) + } + pub fn indexed_array_from_slice(values: &[&str]) -> Self { let mut owned_values = BTreeMap::new(); for (i, value) in values.iter().enumerate() {