use super::{ExpandedField, pattern};
use crate::env::ShellEnv;
pub fn expand(_env: &ShellEnv, fields: Vec<ExpandedField>) -> Vec<ExpandedField> {
let mut result = Vec::new();
for field in fields {
if has_unquoted_glob_chars(&field) {
let matches = glob_match(&field.value);
if matches.is_empty() {
result.push(field);
} else {
for m in matches {
result.push(ExpandedField::all_quoted(m));
}
}
} else {
result.push(field);
}
}
result
}
fn has_unquoted_glob_chars(field: &ExpandedField) -> bool {
let bytes = field.value.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if !field.is_quoted(i) && matches!(b, b'*' | b'?' | b'[') {
return true;
}
}
false
}
fn glob_match(pattern: &str) -> Vec<String> {
if !pattern.contains('/') {
let mut matches = glob_in_dir(".", pattern);
matches.sort();
return matches;
}
let mut matches = glob_path(pattern);
matches.sort();
matches
}
fn glob_path(pattern: &str) -> Vec<String> {
let (base, components) = if let Some(stripped) = pattern.strip_prefix('/') {
("/".to_string(), stripped.split('/').collect::<Vec<_>>())
} else {
(String::new(), pattern.split('/').collect::<Vec<_>>())
};
expand_components(base, &components)
}
fn expand_components(dir: String, components: &[&str]) -> Vec<String> {
if components.is_empty() {
return if dir.is_empty() { vec![] } else { vec![dir] };
}
let component = components[0];
let rest = &components[1..];
let is_glob = component.contains(['*', '?', '[']);
if is_glob {
let search_dir = if dir.is_empty() { "." } else { &dir };
let entries = glob_in_dir(search_dir, component);
let mut result = Vec::new();
for entry in entries {
let full = join_path(&dir, &entry);
if rest.is_empty() {
result.push(full);
} else {
if std::fs::metadata(&full)
.map(|m| m.is_dir())
.unwrap_or(false)
{
result.extend(expand_components(full, rest));
}
}
}
result
} else {
let full = join_path(&dir, component);
if rest.is_empty() {
if std::path::Path::new(&full).exists() {
vec![full]
} else {
vec![]
}
} else {
expand_components(full, rest)
}
}
}
fn join_path(dir: &str, name: &str) -> String {
match dir {
"" => name.to_string(),
"." => name.to_string(),
"/" => format!("/{}", name),
d => format!("{}/{}", d, name),
}
}
fn glob_in_dir(dir: &str, pattern: &str) -> Vec<String> {
let read_dir = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let skip_hidden = !pattern.starts_with('.');
let mut matches = Vec::new();
for entry in read_dir.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if skip_hidden && name_str.starts_with('.') {
continue;
}
if pattern::matches(pattern, &name_str) {
matches.push(name_str.into_owned());
}
}
matches
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::ShellEnv;
fn make_env() -> ShellEnv {
ShellEnv::new("yosh", vec![])
}
fn unquoted(s: &str) -> ExpandedField {
let mut f = ExpandedField::new();
f.push_unquoted(s);
f
}
fn quoted_field(s: &str) -> ExpandedField {
let mut f = ExpandedField::new();
f.push_quoted(s);
f
}
fn values(fields: Vec<ExpandedField>) -> Vec<String> {
fields.into_iter().map(|f| f.value).collect()
}
#[test]
fn test_no_glob_passthrough() {
let env = make_env();
let input = vec![unquoted("hello")];
assert_eq!(values(expand(&env, input)), vec!["hello"]);
}
#[test]
fn test_quoted_glob_not_expanded() {
let env = make_env();
let input = vec![quoted_field("*.rs")];
let result = expand(&env, input);
assert_eq!(values(result), vec!["*.rs"]);
}
#[test]
fn test_glob_src_files() {
let shell_env = make_env();
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let pattern = std::path::Path::new(manifest_dir)
.join("src")
.join("*.rs")
.to_string_lossy()
.into_owned();
let input = vec![unquoted(&pattern)];
let result = values(expand(&shell_env, input));
assert!(
result.iter().any(|p| p.ends_with("main.rs")),
"expected main.rs in {:?}",
result
);
}
#[test]
fn test_no_match_keeps_pattern() {
let env = make_env();
let input = vec![unquoted("nonexistent_*.xyz")];
let result = values(expand(&env, input));
assert_eq!(result, vec!["nonexistent_*.xyz"]);
}
#[test]
fn test_star_does_not_match_dotfiles() {
let _env = make_env();
let matches = glob_in_dir(".", "*");
for m in &matches {
assert!(
!m.starts_with('.'),
"glob '*' should not match dotfile: {}",
m
);
}
}
#[test]
fn test_has_unquoted_glob_chars_true() {
assert!(has_unquoted_glob_chars(&unquoted("*.rs")));
assert!(has_unquoted_glob_chars(&unquoted("file?.txt")));
assert!(has_unquoted_glob_chars(&unquoted("[abc]")));
}
#[test]
fn test_has_unquoted_glob_chars_false_quoted() {
assert!(!has_unquoted_glob_chars("ed_field("*.rs")));
}
#[test]
fn test_has_unquoted_glob_chars_false_no_meta() {
assert!(!has_unquoted_glob_chars(&unquoted("hello.rs")));
}
}