use crate::compsys::ported::_description::description_byname;
use crate::compsys::ported::_requested::requested_byname;
use crate::compsys::ported::_tags::tags_byname;
use crate::compsys::ported::shared::{FnScope, LocalScope, PM_ARRAY};
use crate::ported::modules::zutil::lookupstyle;
use crate::ported::params::{getaparam, getiparam, getsparam, paramtab, setaparam};
use crate::ported::utils::{errflag, noerrs_lock, quotestring};
use crate::ported::zle::compcore::{get_compstate_str, set_compstate_str};
use crate::ported::zle::complete::bin_compadd;
use crate::ported::zsh_h::{
isset, options, ERRFLAG_ERROR, MAX_OPS, MULTIOS, QT_BACKSLASH, RECEXACT,
};
use std::path::Path;
fn make_ops() -> options {
options {
ind: [0u8; MAX_OPS],
args: Vec::new(),
argscount: 0,
argsalloc: 0,
}
}
pub fn _expand() -> i32 {
_expand_with(&[])
}
pub fn _expand_with(args: &[String]) -> i32 {
let _fn_scope = FnScope::enter("_expand");
if getiparam("_matcher_num") > 1 {
return 1;
}
let _scope = LocalScope::declare(&["exp", "dir", "space", "normal", "dstr"], PM_ARRAY);
let mut continue_: i32 = 0;
let mut force = String::new();
for arg in args {
if let Some(letters) = arg.strip_prefix('-') {
for c in letters.chars() {
if matches!(c, 'g' | 's' | 'c' | 'o') {
force.push(c);
}
}
}
}
let iprefix = getsparam("IPREFIX").unwrap_or_default();
let prefix = getsparam("PREFIX").unwrap_or_default();
let suffix = getsparam("SUFFIX").unwrap_or_default();
let isuffix = getsparam("ISUFFIX").unwrap_or_default();
let word = if caller_is_prefix() {
format!("{}{}{}", iprefix, prefix, suffix) } else {
format!("{}{}{}{}", iprefix, prefix, suffix, isuffix) };
let curcontext = getsparam("curcontext").unwrap_or_default();
let ctx = format!(":completion:{}:", curcontext);
if ends_in_unterminated_dollar(&word) || ends_in_unknown_parameter(&word) {
return 1;
}
if style_true_or_unset(&ctx, "suffix")
&& looks_like_prefix(&word)
&& !has_unescaped_glob_meta(&substitute_params(&word))
{
return 1;
}
let mut tmp = {
let vals = lookupstyle(&ctx, "accept-exact");
if !vals.is_empty() {
vals.join(" ")
} else if isset(RECEXACT) {
"1".to_string()
} else {
String::new()
}
};
if !matches!(tmp.as_str(), "yes" | "true" | "on" | "1") {
if is_bare_tilde_form(&word) {
return 1;
}
if is_ambiguous_prefix(&word) {
continue_ = 1;
}
if continue_ == 1 && tmp != "continue" {
return 1;
}
}
let mut exp: Vec<String> = vec![word.clone()];
if force.contains('s') || style_true_or_unset(&ctx, "substitute") {
exp = exp
.iter()
.map(|e| escape_whitespace(&substitute_params(e)))
.collect();
} else {
exp = exp.iter().map(|e| e.replacen("\\$", "$", 1)).collect();
}
if exp.join(" ").is_empty() {
exp = vec![word.clone()];
}
let subd = exp.clone();
let orig_exp = exp.clone();
let mut done_quote = false; if force.contains('g') || style_true_or_unset(&ctx, "glob") {
let (globbed, failed) = eval_quietly(|| {
orig_exp
.iter()
.flat_map(|e| glob_subst(&unescape_ws_and_quotes(e)))
.collect::<Vec<String>>()
});
if !failed && !globbed.is_empty() {
exp = globbed
.iter()
.map(|s| quotestring(s, QT_BACKSLASH))
.collect();
done_quote = true;
}
}
if !done_quote {
exp = eval_quietly(|| {
orig_exp
.iter()
.map(|e| quotestring(&unescape_ws_and_quotes(e), QT_BACKSLASH))
.collect::<Vec<String>>()
})
.0;
}
if exp.is_empty() {
exp = subd.clone();
}
if exp.len() == 1 {
let got = exp[0].replace('\\', "");
let want = word.replace('\\', "");
if got == want || got == format!("{}(N)", want) {
return 1;
}
}
let subd_joined = subd.join(" ");
let exp_joined = exp.join(" ");
let glob_changed_nothing =
subd_joined == exp_joined || subd_joined == format!("{}(N)", exp_joined);
if (force.contains('o') || style_true(&ctx, "subst-globs-only")) && glob_changed_nothing {
return 1;
}
let mut opre = String::new();
let mut pre = String::new();
let keep_prefix = {
let vals = lookupstyle(&ctx, "keep-prefix");
if vals.is_empty() {
"changed".to_string() } else {
vals.join(" ")
}
};
if has_expandable_prefix(&word)
&& matches!(
keep_prefix.as_str(),
"yes" | "true" | "on" | "1" | "changed"
)
{
opre = if word.contains('$') {
dollar_prefix(&word) } else {
word.split('/').next().unwrap_or_default().to_string() };
let epre = match eval_quietly(|| glob_subst(&substitute_params(&opre))) {
(v, false) => v,
(_, true) => Vec::new(),
};
if epre.len() == 1 && !epre[0].is_empty() {
pre = quotestring(&epre[0], QT_BACKSLASH); let unchanged = format!(
"{}{}",
opre,
exp[0].strip_prefix(pre.as_str()).unwrap_or(&exp[0])
) == word;
if (keep_prefix != "changed" || exp.len() > 1 || !unchanged)
&& exp[0].starts_with(pre.as_str())
{
exp = exp
.iter()
.map(|e| format!("{}{}", opre, e.strip_prefix(pre.as_str()).unwrap_or(e)))
.collect();
}
}
if exp.len() == 1 && exp[0] == word {
return 1;
}
}
let sort = lookupstyle(&ctx, "sort").join(" ");
if matches!(sort.as_str(), "yes" | "true" | "1" | "on") {
exp.sort();
}
let mut asp = String::new();
{
let vals = lookupstyle(&ctx, "add-space");
if !vals.is_empty() {
tmp = vals.join(" ");
if !tmp.contains("subst")
|| !word.contains('$')
|| exp.first().map(|e| e.contains('$')).unwrap_or(false)
{
if tmp.contains("file") {
asp = "file".to_string(); }
if ["yes", "true", "1", "on", "subst"]
.iter()
.any(|k| tmp.contains(k))
{
asp = format!("yes{}", asp); }
}
} else {
asp = "file".to_string(); }
}
let mut suf = " ".to_string();
if exp.len() == 1 {
let j = replace_first(&exp[0], &opre, &pre); if Path::new(&j).is_dir() && !exp[0].ends_with('/') {
suf = "/".to_string(); } else if asp.starts_with("yes") || (asp.ends_with("file") && Path::new(&j).is_file()) {
suf = " ".to_string(); } else {
suf = String::new(); }
}
if get_compstate_str("insert").unwrap_or_default().is_empty() {
setaparam("exp", exp.clone());
let _ = description_byname(&description_args(&sort, "expansions", "expansions", &word));
let mut argv = getaparam("expl").unwrap_or_default();
argv.extend([
"-UQ".to_string(),
"-qS".to_string(),
suf.clone(),
"-a".to_string(),
"exp".to_string(),
]);
let _ = bin_compadd("compadd", &argv, &make_ops(), 0); } else {
let _ = tags_byname(&[
"all-expansions".to_string(),
"expansions".to_string(),
"original".to_string(),
]);
if !exp.is_empty() && requested_byname(&["expansions".to_string()]) == 0 {
let _ = description_byname(&description_args(&sort, "expansions", "expansions", &word));
let mut normal: Vec<String> = Vec::new();
let mut space: Vec<String> = Vec::new();
let mut dir: Vec<String> = Vec::new();
for i in &exp {
let j = replace_first(i, &opre, &pre); if Path::new(&j).is_dir() && !i.ends_with('/') {
dir.push(i.clone()); } else if asp.starts_with("yes")
|| (asp.ends_with("file") && Path::new(&j).is_file())
{
space.push(i.clone()); } else {
normal.push(i.clone()); }
}
let pref = if word.starts_with('~') || word.starts_with('/') {
"/".to_string()
} else {
format!("{}/", getsparam("PWD").unwrap_or_default())
};
let expl = getaparam("expl").unwrap_or_default();
if !dir.is_empty() {
setaparam("dir", dir);
let _ = bin_compadd(
"compadd",
&partition_argv(&expl, &pref, "/", "dir"),
&make_ops(),
0,
);
}
if !space.is_empty() {
setaparam("space", space);
let _ = bin_compadd(
"compadd",
&partition_argv(&expl, &pref, " ", "space"),
&make_ops(),
0,
);
}
if !normal.is_empty() {
setaparam("normal", normal);
let _ = bin_compadd(
"compadd",
&partition_argv(&expl, &pref, "", "normal"),
&make_ops(),
0,
);
}
}
if requested_byname(&["all-expansions".to_string()]) == 0 {
let _ = description_byname(&description_args(
&sort,
"all-expansions",
"all expansions",
&word,
));
let mut disp: Vec<String> = Vec::new();
let columns = getiparam("COLUMNS");
let joined_len = exp.join(" ").chars().count() as i64; if columns > 5 && joined_len >= columns {
setaparam(
"dstr",
vec![format!(
"{} ...",
right_pad_or_truncate(&exp.join(" "), (columns - 5) as usize)
)], );
disp = vec!["-ld".to_string(), "dstr".to_string()]; }
if isset(MULTIOS) {
let redirect = get_compstate_str("redirect").unwrap_or_default();
let mut rebuilt: Vec<String> = Vec::new();
if let Some(first) = exp.first() {
rebuilt.push(first.clone());
}
rebuilt.extend(exp.iter().skip(1).map(|e| format!("{}{}", redirect, e)));
exp = rebuilt;
}
let mut argv = disp;
argv.extend(getaparam("expl").unwrap_or_default());
argv.extend([
"-UQ".to_string(),
"-qS".to_string(),
suf.clone(),
"-".to_string(),
exp.join(" "),
]);
let _ = bin_compadd("compadd", &argv, &make_ops(), 0);
}
if requested_byname(&[
"original".to_string(),
"expl".to_string(),
"original".to_string(),
]) == 0
{
let mut argv = getaparam("expl").unwrap_or_default();
argv.extend(["-UQ".to_string(), "-".to_string(), word.clone()]);
let _ = bin_compadd("compadd", &argv, &make_ops(), 0);
}
set_compstate_str("insert", "menu");
}
continue_
}
fn caller_is_prefix() -> bool {
crate::ported::modules::parameter::funcstackgetfn(std::ptr::null_mut())
.get(1)
.map(|n| n == "_prefix")
.unwrap_or(false)
}
fn ends_in_unterminated_dollar(word: &str) -> bool {
word.char_indices()
.filter(|(_, c)| *c == '$')
.any(|(i, _)| {
let tail = &word[i + 1..];
tail.is_empty() || (tail.starts_with('{') && !tail.contains('}'))
})
}
fn ends_in_unknown_parameter(word: &str) -> bool {
let Some(i) = word.rfind('$') else {
return false;
};
let name = &word[i + 1..];
if name.is_empty() || !name.chars().all(is_param_name_char) {
return false;
}
!parameter_exists(name)
}
fn is_param_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn parameter_exists(name: &str) -> bool {
paramtab()
.read()
.map(|t| t.get(name).is_some())
.unwrap_or(false)
}
fn looks_like_prefix(word: &str) -> bool {
tilde_then_slash(word) || dollar_name_then_separator(word) || brace_param_then_one_char(word)
}
fn tilde_then_slash(word: &str) -> bool {
word.strip_prefix('~')
.map(|rest| rest.contains('/'))
.unwrap_or(false)
}
fn dollar_name_then_separator(word: &str) -> bool {
let ch: Vec<char> = word.chars().collect();
let is_name = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '[' | ']');
let Some(&last) = ch.last() else {
return false;
};
if is_name(last) {
return false;
}
let mut i = ch.len() - 1; let run_end = i;
while i > 0 && is_name(ch[i - 1]) {
i -= 1;
}
if i == run_end {
return false; }
if i > 0 && matches!(ch[i - 1], '=' | '~' | '#' | '^' | '+') {
i -= 1; }
i > 0 && ch[i - 1] == '$'
}
fn brace_param_then_one_char(word: &str) -> bool {
let ch: Vec<char> = word.chars().collect();
if ch.len() < 4 {
return false;
}
if ch[ch.len() - 2] != '}' {
return false;
}
ch[..ch.len() - 2].iter().collect::<String>().contains("${")
}
fn has_unescaped_glob_meta(s: &str) -> bool {
const META: &[char] = &[']', '[', '^', '*', '?', '(', ')', '<', '>', '{', '}', '|'];
let ch: Vec<char> = s.chars().collect();
ch.iter()
.enumerate()
.any(|(i, c)| META.contains(c) && (i == 0 || ch[i - 1] != '\\'))
}
fn is_bare_tilde_form(word: &str) -> bool {
if matches!(word, "~" | "~-" | "~+") {
return true;
}
if let Some(rest) = word.strip_prefix("~-").or_else(|| word.strip_prefix("~+")) {
if !rest.is_empty() && !rest.starts_with('0') && rest.chars().all(|c| c.is_ascii_digit()) {
let n: i64 = rest.parse().unwrap_or(i64::MAX);
let depth = getaparam("dirstack").map(|d| d.len()).unwrap_or(0) as i64;
if n <= depth {
return true;
}
}
}
if let Some(rest) = word.strip_prefix("~[") {
if let Some(close) = rest.find(']') {
return rest[close + 1..].starts_with('/');
}
}
false
}
fn is_ambiguous_prefix(word: &str) -> bool {
if let Some(stem) = word.strip_prefix('~') {
let hits =
assoc_keys_with_prefix("userdirs", stem) + assoc_keys_with_prefix("nameddirs", stem);
if hits > 1 {
return true;
}
}
if let Some(i) = word.rfind('$') {
let stem = &word[i + 1..];
if !stem.is_empty() && stem.chars().all(is_param_name_char) {
let hits = paramtab()
.read()
.map(|t| t.keys().filter(|k| k.starts_with(stem)).count())
.unwrap_or(0);
if hits != 1 {
return true;
}
}
}
false
}
fn assoc_keys_with_prefix(name: &str, prefix: &str) -> usize {
getaparam(name)
.unwrap_or_default()
.chunks(2)
.filter_map(|kv| kv.first())
.filter(|k| k.starts_with(prefix))
.count()
}
fn eval_quietly<T>(f: impl FnOnce() -> T) -> (T, bool) {
use std::sync::atomic::Ordering::SeqCst;
let saved_errflag = errflag.load(SeqCst);
errflag.fetch_and(!ERRFLAG_ERROR, SeqCst);
let saved_noerrs = *noerrs_lock().lock().unwrap();
*noerrs_lock().lock().unwrap() = saved_noerrs + 1;
let value = f();
*noerrs_lock().lock().unwrap() = saved_noerrs;
let failed = (errflag.load(SeqCst) & ERRFLAG_ERROR) != 0;
errflag.store(saved_errflag, SeqCst);
(value, failed)
}
fn substitute_params(s: &str) -> String {
let ch: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < ch.len() {
if ch[i] == '\\' && i + 1 < ch.len() {
out.push(ch[i]);
out.push(ch[i + 1]);
i += 2;
continue;
}
if ch[i] != '$' || i + 1 >= ch.len() {
out.push(ch[i]);
i += 1;
continue;
}
let (name, next) = if ch[i + 1] == '{' {
match ch[i + 2..].iter().position(|&c| c == '}') {
Some(rel) => (
ch[i + 2..i + 2 + rel].iter().collect::<String>(),
i + 3 + rel,
),
None => {
out.push('$');
i += 1;
continue;
}
}
} else {
let mut j = i + 1;
while j < ch.len() && is_param_name_char(ch[j]) {
j += 1;
}
if j == i + 1 {
out.push('$');
i += 1;
continue;
}
(ch[i + 1..j].iter().collect::<String>(), j)
};
if let Some(v) = getsparam(&name).or_else(|| std::env::var(&name).ok()) {
out.push_str(&v);
}
i = next;
}
out
}
fn escape_whitespace(s: &str) -> String {
let is_ws = |c: char| matches!(c, ' ' | '\t' | '\n');
let mut out = String::with_capacity(s.len() + 8);
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
if c == '\\' {
match it.peek() {
Some(&w) if is_ws(w) => {
out.push('\\');
out.push(w);
it.next();
}
_ => out.push('\\'),
}
} else if is_ws(c) {
out.push('\\');
out.push(c);
} else {
out.push(c);
}
}
out
}
fn unescape_ws_and_quotes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
if c == '\\' {
if let Some(&n) = it.peek() {
if matches!(n, ' ' | '\t' | '"' | '\'' | '\n') {
out.push(n);
it.next();
continue;
}
}
}
out.push(c);
}
out
}
fn glob_subst(s: &str) -> Vec<String> {
let expanded = tilde_expand(s);
if !has_glob_meta(&expanded) {
return vec![expanded];
}
let hits = crate::ported::glob::glob_path(&expanded);
if hits.is_empty() {
vec![expanded]
} else {
hits
}
}
fn tilde_expand(s: &str) -> String {
if !s.starts_with('~') {
return s.to_string();
}
let tokenized = format!("\u{98}{}", &s[1..]);
crate::ported::subst::filesubstr(&tokenized, false).unwrap_or_else(|| s.to_string())
}
fn has_glob_meta(s: &str) -> bool {
let ch: Vec<char> = s.chars().collect();
ch.iter()
.enumerate()
.any(|(i, c)| matches!(*c, '*' | '?' | '[' | '(') && (i == 0 || ch[i - 1] != '\\'))
}
fn has_expandable_prefix(word: &str) -> bool {
if tilde_then_slash(word) {
return true;
}
match word.find('$') {
Some(i) => word[i..].contains('/'),
None => false,
}
}
fn dollar_prefix(word: &str) -> String {
let ch: Vec<char> = word.chars().collect();
let mut best: Option<usize> = None;
for (k, c) in ch.iter().enumerate() {
if *c != '/' {
continue;
}
let mut j = k;
while j > 0 && ch[j - 1] != '/' && ch[j - 1] != '$' {
j -= 1;
}
if j < k && j > 0 && ch[j - 1] == '$' {
best = Some(k);
}
}
match best {
Some(k) => ch[..=k].iter().collect(),
None => String::new(),
}
}
fn replace_first(s: &str, pat: &str, rep: &str) -> String {
if pat.is_empty() {
return s.to_string();
}
s.replacen(pat, rep, 1)
}
fn description_args(sort: &str, tag: &str, descr: &str, word: &str) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
if sort != "menu" {
args.push("-V".to_string());
}
args.push(tag.to_string());
args.push("expl".to_string());
args.push(descr.to_string());
args.push(format!("o:{}", word));
args
}
fn partition_argv(expl: &[String], pref: &str, suf: &str, array: &str) -> Vec<String> {
let mut argv: Vec<String> = expl.to_vec();
argv.extend([
"-fW".to_string(),
pref.to_string(),
"-UQ".to_string(),
"-qS".to_string(),
suf.to_string(),
"-a".to_string(),
array.to_string(),
]);
argv
}
fn right_pad_or_truncate(s: &str, n: usize) -> String {
let len = s.chars().count();
if len >= n {
s.chars().take(n).collect()
} else {
format!("{}{}", s, " ".repeat(n - len))
}
}
fn style_true_or_unset(ctx: &str, style: &str) -> bool {
match lookupstyle(ctx, style).first() {
Some(v) => matches!(v.as_str(), "true" | "yes" | "on" | "1"),
None => true,
}
}
fn style_true(ctx: &str, style: &str) -> bool {
matches!(
lookupstyle(ctx, style).first().map(|v| v.as_str()),
Some("true" | "yes" | "on" | "1")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ported::params::{setiparam, setsparam};
#[test]
fn matcher_num_gt_one_returns_one() {
let _g = crate::test_util::global_state_lock();
setiparam("_matcher_num", 5);
assert_eq!(_expand(), 1);
setiparam("_matcher_num", 0);
}
#[test]
fn plain_word_no_substitution_returns_one() {
let _g = crate::test_util::global_state_lock();
setiparam("_matcher_num", 1);
let _ = setsparam("PREFIX", "plain");
let _ = setsparam("SUFFIX", "");
let _ = setsparam("IPREFIX", "");
let _ = setsparam("ISUFFIX", "");
assert_eq!(_expand(), 1);
}
#[test]
fn suffix_guard_matches_zsh() {
assert!(looks_like_prefix("~/"));
assert!(looks_like_prefix("~/Do"));
assert!(looks_like_prefix("~/*"));
assert!(looks_like_prefix("~[x]/y"));
assert!(looks_like_prefix("$HOME/"));
assert!(looks_like_prefix("$a[1]/"));
assert!(looks_like_prefix("$=foo/"));
assert!(looks_like_prefix("${foo}x"));
assert!(!looks_like_prefix("~"));
assert!(!looks_like_prefix("$HOME/x"));
assert!(!looks_like_prefix("${foo}xy"));
assert!(!looks_like_prefix("x$y"));
assert!(!looks_like_prefix("a$b=c/"));
assert!(!has_unescaped_glob_meta("~/"));
assert!(!has_unescaped_glob_meta("~/Do"));
assert!(has_unescaped_glob_meta("~/*"));
assert!(has_unescaped_glob_meta("~[x]/y"));
assert!(!has_unescaped_glob_meta("a\\*b"));
assert!(has_unescaped_glob_meta("*"));
}
#[test]
fn unfinished_parameter_reference_bails() {
assert!(ends_in_unterminated_dollar("a$"));
assert!(ends_in_unterminated_dollar("a${b"));
assert!(ends_in_unterminated_dollar("a${b$c"));
assert!(!ends_in_unterminated_dollar("${a}"));
assert!(!ends_in_unterminated_dollar("plain"));
}
#[test]
fn dollar_prefix_matches_zsh() {
assert_eq!(dollar_prefix("$HOME/x"), "$HOME/");
assert_eq!(dollar_prefix("a$FOO/b/c"), "a$FOO/");
assert_eq!(dollar_prefix("no/dollar/here"), "");
}
#[test]
fn all_expansions_display_is_padded_then_cut() {
assert_eq!(right_pad_or_truncate("aaa bbb ccc", 15), "aaa bbb ccc ");
assert_eq!(
right_pad_or_truncate("aaaaaaaaaa bbbbbbbbbb cccccccccc", 15),
"aaaaaaaaaa bbbb"
);
}
#[test]
fn eval_quietly_reports_the_error_and_swallows_the_flag() {
let _g = crate::test_util::global_state_lock();
use std::sync::atomic::Ordering::SeqCst;
errflag.store(0, SeqCst);
let (value, failed) = eval_quietly(|| {
crate::ported::utils::zerr("bad pattern: *(");
7
});
assert_eq!(value, 7);
assert!(failed, "a zerr inside the eval is an eval failure");
assert_eq!(
errflag.load(SeqCst) & ERRFLAG_ERROR,
0,
"the error bit must not outlive the eval"
);
assert!(!eval_quietly(|| ()).1, "a clean eval never reports failure");
}
#[test]
fn bare_tilde_forms_bail() {
assert!(is_bare_tilde_form("~"));
assert!(is_bare_tilde_form("~+"));
assert!(is_bare_tilde_form("~-"));
assert!(!is_bare_tilde_form("~/"));
assert!(!is_bare_tilde_form("~user"));
}
}