use super::*;
use crate::ast::Range;
use crate::embed::{DiagnosticCategory, DiagnosticKind};
use crate::parser::Parser;
use super::frontend::{emit_diagnostic, range_for_line};
fn emit_expand_error(state: &ShellState, code: &'static str, message: &str, range: Option<Range>) {
emit_diagnostic(
state,
DiagnosticKind::Error,
DiagnosticCategory::Expansion,
code,
message,
None,
range,
);
}
pub(super) fn expand_word<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
word: &Word,
) -> Vec<String> {
if let Word::Parameter(parameter) = word
&& let Some(fields) = expand_parameter_special_fields(
state,
parameter.name(),
parameter.op(),
parameter.colon(),
parameter.arg(),
)
{
return fields;
}
if let Word::List(list) = word
&& list.double_quoted()
{
let children = list.children();
if children.len() == 1 && is_quoted_at_expansion(&children[0]) {
return state.frame.iter().skip(1).cloned().collect();
}
let mut fields = vec![String::new()];
for child in children {
let parts = expand_double_quoted_child(state, runtime, child);
if parts.is_empty() {
continue;
}
if is_quoted_at_expansion(child) && parts.len() > 1 {
let prefix = fields.pop().unwrap_or_default();
fields.push(format!("{prefix}{}", parts[0]));
fields.extend(parts.into_iter().skip(1));
continue;
}
let joined = parts.join("");
if let Some(last) = fields.last_mut() {
last.push_str(&joined);
} else {
fields.push(joined);
}
}
return fields;
}
if let Word::List(list) = word
&& !list.double_quoted()
{
return expand_unquoted_word_list(state, runtime, list.children());
}
let raw = expand_word_nosplit(state, runtime, word);
let should_split = match word {
Word::String(string) => !string.single_quoted() && string.split_fields(),
Word::List(_) => false,
Word::Command(_) | Word::Arithmetic(_) | Word::Parameter(_) => true,
};
if should_split {
if raw.is_empty() {
Vec::new()
} else {
split_fields(state, &raw)
}
} else {
vec![raw]
}
}
fn expand_unquoted_word_list<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
children: &[Word],
) -> Vec<String> {
let mut fields = vec![String::new()];
let mut produced_any = false;
for child in children {
let parts = expand_word(state, runtime, child);
if parts.is_empty() {
continue;
}
produced_any = true;
let mut parts = parts.into_iter();
let first = parts.next().unwrap_or_default();
if let Some(last) = fields.last_mut() {
last.push_str(&first);
} else {
fields.push(first);
}
fields.extend(parts);
}
if produced_any { fields } else { Vec::new() }
}
fn is_quoted_at_expansion(word: &Word) -> bool {
matches!(
word,
Word::Parameter(parameter)
if parameter.name() == "@" && parameter.op() == ParameterOp::None
)
}
fn is_quoted_dollar_at(word: &Word) -> bool {
matches!(
word,
Word::List(list)
if list.double_quoted()
&& list.children().len() == 1
&& is_quoted_at_expansion(&list.children()[0])
)
}
fn expand_parameter_special_fields(
state: &mut ShellState,
name: &str,
op: ParameterOp,
colon: bool,
arg: Option<&Word>,
) -> Option<Vec<String>> {
let arg_word = arg?;
if !is_quoted_dollar_at(arg_word) {
return None;
}
let value = get_parameter_value(state, name);
let is_set = value.is_some();
let is_nonempty = value.as_ref().is_some_and(|v| !v.is_empty());
let treat_as_unset = if colon { !is_nonempty } else { !is_set };
match op {
ParameterOp::Minus if treat_as_unset => Some(state.frame.iter().skip(1).cloned().collect()),
ParameterOp::Plus if !treat_as_unset => Some(state.frame.iter().skip(1).cloned().collect()),
_ => None,
}
}
fn expand_double_quoted_child<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
word: &Word,
) -> Vec<String> {
match word {
Word::Parameter(parameter)
if parameter.name() == "@" && parameter.op() == ParameterOp::None =>
{
state.frame.iter().skip(1).cloned().collect()
}
Word::Parameter(parameter)
if parameter.name() == "*" && parameter.op() == ParameterOp::None =>
{
vec![quoted_star_value(state)]
}
_ => vec![expand_word_nosplit(state, runtime, word)],
}
}
pub(super) fn quoted_star_value(state: &ShellState) -> String {
join_positional_parameters(state)
}
pub(super) fn expand_word_nosplit<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
word: &Word,
) -> String {
match word {
Word::String(string) => string.value().to_string(),
Word::Parameter(parameter) => expand_parameter(
state,
runtime,
parameter.name(),
parameter.op(),
parameter.colon(),
parameter.arg(),
),
Word::Command(command) => {
let output = run_command_substitution(
state,
runtime,
command.program(),
command.source(),
command.range.begin.line,
);
output.trim_end_matches('\n').to_string()
}
Word::Arithmetic(arithm) => match eval_arithm(state, arithm.body()) {
Ok(val) => val.to_string(),
Err(message) => {
let rendered = format!("arithmetic expansion: {message}");
let _ = state.stderr_fd.write_line(&rendered);
emit_expand_error(state, "expand.arithmetic", &rendered, Some(arithm.range));
state.record_expansion_error(2);
if !state.interactive {
state.set_exit_code(2);
}
String::new()
}
},
Word::List(list) => {
let mut result = String::new();
for child in list.children() {
result.push_str(&expand_word_nosplit(state, runtime, child));
}
result
}
}
}
fn expand_parameter<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
name: &str,
op: ParameterOp,
colon: bool,
arg: Option<&Word>,
) -> String {
let value = get_parameter_value(state, name);
if op == ParameterOp::None
&& value.is_none()
&& state.has_option(OPT_NOUNSET)
&& !is_special_parameter_name(name)
{
let message = format!("{name}: parameter not set");
let _ = state.stderr_fd.write_line(&message);
emit_expand_error(state, "expand.parameter_unset", &message, None);
state.set_exit_code(1);
return String::new();
}
let is_set = value.is_some();
let is_nonempty = value.as_ref().is_some_and(|v| !v.is_empty());
let val = value.unwrap_or_default();
let treat_as_unset = if colon { !is_nonempty } else { !is_set };
match op {
ParameterOp::None => val,
ParameterOp::LeadingHash => val.len().to_string(),
ParameterOp::Minus => {
if treat_as_unset {
arg.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_default()
} else {
val
}
}
ParameterOp::Equal => {
if treat_as_unset {
let default = arg
.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_default();
if state.env_set(name, default.clone(), 0) {
default
} else {
readonly_assignment_status(state, name, "", true);
String::new()
}
} else {
val
}
}
ParameterOp::QMark => {
if treat_as_unset {
let msg = arg
.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_else(|| "parameter null or not set".to_string());
let message = format!("{name}: {msg}");
let _ = state.stderr_fd.write_line(&message);
emit_expand_error(state, "expand.parameter_error", &message, None);
state.exit_code = 1;
String::new()
} else {
val
}
}
ParameterOp::Plus => {
if treat_as_unset {
String::new()
} else {
arg.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_default()
}
}
ParameterOp::Percent => {
let pat = arg
.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_default();
strip_suffix(&val, &pat, false)
}
ParameterOp::DoublePercent => {
let pat = arg
.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_default();
strip_suffix(&val, &pat, true)
}
ParameterOp::Hash => {
let pat = arg
.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_default();
strip_prefix(&val, &pat, false)
}
ParameterOp::DoubleHash => {
let pat = arg
.map(|w| expand_word_nosplit(state, runtime, w))
.unwrap_or_default();
strip_prefix(&val, &pat, true)
}
}
}
pub(super) fn is_special_parameter_name(name: &str) -> bool {
matches!(name, "@" | "*" | "#" | "?" | "-" | "$" | "!")
|| name == "0"
|| name.parse::<usize>().is_ok()
}
pub(super) fn word_begin_line(word: &Word) -> Option<u32> {
match word {
Word::String(string) => Some(string.range.begin.line),
Word::Command(command) => Some(command.range.begin.line),
Word::Arithmetic(arithm) => Some(arithm.range.begin.line),
Word::Parameter(parameter) => Some(parameter.dollar_pos().line),
Word::List(list) => list.children().first().and_then(word_begin_line),
}
}
pub(super) fn word_contains_command_substitution(word: &Word) -> bool {
match word {
Word::Command(_) => true,
Word::List(list) => list
.children()
.iter()
.any(word_contains_command_substitution),
Word::Parameter(parameter) => parameter
.arg()
.is_some_and(word_contains_command_substitution),
Word::String(_) | Word::Arithmetic(_) => false,
}
}
pub(super) fn get_parameter_value(state: &ShellState, name: &str) -> Option<String> {
match name {
"@" | "*" => Some(join_positional_parameters(state)),
"#" => Some((state.frame.len().saturating_sub(1)).to_string()),
"?" => Some(state.last_status.to_string()),
"-" => Some(format_options_with_schema(
state.options,
&state.definition.option_schema,
)),
"$" => Some(std::process::id().to_string()),
"!" => state.last_bg_pid.map(|pid| pid.to_string()),
"0" => state.frame.first().cloned(),
_ => {
if let Ok(n) = name.parse::<usize>() {
state.frame.get(n).cloned()
} else {
state.env_get(name).map(String::from)
}
}
}
}
fn join_positional_parameters(state: &ShellState) -> String {
let params: Vec<&str> = state.frame.iter().skip(1).map(|s| s.as_str()).collect();
let sep = match state.env_get("IFS").unwrap_or(" \t\n").chars().next() {
Some(sep) => sep.to_string(),
None => String::new(),
};
params.join(&sep)
}
pub(super) fn split_fields(state: &ShellState, s: &str) -> Vec<String> {
let ifs = state.env_get("IFS").unwrap_or(" \t\n");
if ifs.is_empty() {
return vec![s.to_string()];
}
let mut fields = Vec::new();
let mut current = String::new();
for ch in s.chars() {
if ifs.contains(ch) {
if !current.is_empty() || !ch.is_whitespace() {
fields.push(std::mem::take(&mut current));
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
fields.push(current);
}
fields
}
fn readonly_assignment_status(
state: &mut ShellState,
name: &str,
context: &str,
expansion_context: bool,
) -> i32 {
let message = if context.is_empty() {
format!("{name}: readonly variable")
} else {
format!("{context}: {name}: readonly variable")
};
shell_errln(state, &message);
if expansion_context {
state.record_expansion_error(1);
if !state.interactive {
state.exit_code = 1;
}
}
1
}
pub(super) fn pattern_match(pattern: &str, text: &str) -> bool {
pattern_match_inner(pattern.as_bytes(), text.as_bytes())
}
fn pattern_match_inner(pat: &[u8], txt: &[u8]) -> bool {
if pat.is_empty() {
return txt.is_empty();
}
match pat[0] {
b'*' => (0..=txt.len()).any(|i| pattern_match_inner(&pat[1..], &txt[i..])),
b'?' => !txt.is_empty() && pattern_match_inner(&pat[1..], &txt[1..]),
b'[' => {
if txt.is_empty() {
return false;
}
let Some((matched, consumed)) = match_bracket_class(pat, txt[0]) else {
return false;
};
matched && pattern_match_inner(&pat[consumed..], &txt[1..])
}
b'\\' if pat.len() > 1 => {
!txt.is_empty() && txt[0] == pat[1] && pattern_match_inner(&pat[2..], &txt[1..])
}
ch => !txt.is_empty() && txt[0] == ch && pattern_match_inner(&pat[1..], &txt[1..]),
}
}
pub(super) fn match_bracket_class(pat: &[u8], byte: u8) -> Option<(bool, usize)> {
if pat.first().copied() != Some(b'[') {
return None;
}
let mut i = 1usize;
if i >= pat.len() {
return None;
}
let mut negate = false;
if pat[i] == b'!' || pat[i] == b'^' {
negate = true;
i += 1;
}
let mut matched = false;
while i < pat.len() {
if pat[i] == b']' && i > 1 {
return Some(((if negate { !matched } else { matched }), i + 1));
}
if i + 2 < pat.len() && pat[i + 1] == b'-' && pat[i + 2] != b']' {
let start = pat[i];
let end = pat[i + 2];
if start <= byte && byte <= end {
matched = true;
}
i += 3;
continue;
}
if pat[i] == byte {
matched = true;
}
i += 1;
}
None
}
pub(super) fn strip_suffix(value: &str, pattern: &str, longest: bool) -> String {
let bytes = value.as_bytes();
if longest {
for i in 0..bytes.len() {
if pattern_match(pattern, &value[i..]) {
return value[..i].to_string();
}
}
} else {
for i in (0..bytes.len()).rev() {
if pattern_match(pattern, &value[i..]) {
return value[..i].to_string();
}
}
}
value.to_string()
}
pub(super) fn strip_prefix(value: &str, pattern: &str, longest: bool) -> String {
let bytes = value.as_bytes();
if longest {
for i in (0..=bytes.len()).rev() {
if pattern_match(pattern, &value[..i]) {
return value[i..].to_string();
}
}
} else {
for i in 0..=bytes.len() {
if pattern_match(pattern, &value[..i]) {
return value[i..].to_string();
}
}
}
value.to_string()
}
pub(super) fn expand_tilde(state: &ShellState, s: &str) -> String {
if !s.starts_with('~') {
return s.to_string();
}
let rest = &s[1..];
if (rest.is_empty() || rest.starts_with('/'))
&& let Some(home) = state.env_get("HOME")
{
return format!("{home}{rest}");
}
let (user, suffix) = match rest.find('/') {
Some(slash_pos) => (&rest[..slash_pos], &rest[slash_pos..]),
None => (rest, ""),
};
if !user.is_empty()
&& let Some(home) = user_home_dir(user)
{
return format!("{home}{suffix}");
}
s.to_string()
}
fn user_home_dir(user: &str) -> Option<String> {
let c_user = CString::new(user).ok()?;
let passwd = unsafe { libc::getpwnam(c_user.as_ptr()) };
if passwd.is_null() {
return None;
}
let dir = unsafe { (*passwd).pw_dir };
if dir.is_null() {
return None;
}
Some(
unsafe { CStr::from_ptr(dir) }
.to_string_lossy()
.into_owned(),
)
}
fn run_command_substitution<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
program: &Program,
source_text: Option<&str>,
source_line: u32,
) -> String {
let pipe = match sys::OsPipe::new() {
Ok(p) => p,
Err(_) => return String::new(),
};
let text = source_text
.map(str::to_owned)
.unwrap_or_else(|| program.to_canonical());
let aliases = state.aliases_snapshot();
let aliases_for_error = state.aliases_snapshot();
let reparse_text = format!("{text})");
let mut parser = Parser::from_string(&reparse_text);
parser.set_alias_inserted_rparen_is_data(true);
crate::parser::configure_parser_for_language(&mut parser, &state.definition.language);
parser.set_alias_func(crate::parser::AliasFn::new(move |name| {
aliases.get(name).cloned()
}));
let reparsed = match parser.parse_command_substitution_reparse() {
Ok(program) => program,
Err(err) => {
let source = state
.current_source
.as_deref()
.unwrap_or(state.shell_name());
let message = format!(
"{source}: command substitution: line {source_line}: {}",
err.message
);
let _ = state.stderr_fd.write_line(&message);
emit_expand_error(
state,
"expand.command_substitution_parse",
&err.message,
Some(range_for_line(source_line)),
);
let excerpt = text
.split_whitespace()
.next()
.and_then(|name| aliases_for_error.get(name))
.map(|expanded| format!("{expanded} "))
.unwrap_or_else(|| {
let mut excerpt = text[..err.pos.offset.min(text.len())].to_string();
if let Some(ch) = text[err.pos.offset.min(text.len())..].chars().next() {
excerpt.push(ch);
excerpt.push(' ');
}
excerpt
});
let _ = state.stderr_fd.write_line(&format!(
"{source}: command substitution: line {source_line}: `{excerpt}'"
));
state.last_status = 1;
state.record_expansion_error(1);
if !state.interactive {
state.exit_code = 1;
}
pipe.write_fd.close();
return String::new();
}
};
let mut sub_runtime = match runtime.fork() {
Ok(runtime) => runtime,
Err(err) => {
let source = state
.current_source
.as_deref()
.unwrap_or(state.shell_name());
let message = format!("{source}: command substitution: failed to fork runtime: {err}");
let _ = state.stderr_fd.write_line(&message);
emit_expand_error(
state,
"expand.command_substitution_fork",
&message,
Some(range_for_line(source_line)),
);
state.last_status = 1;
state.record_expansion_error(1);
if !state.interactive {
state.exit_code = 1;
}
pipe.write_fd.close();
return String::new();
}
};
let mut child_state = state.fork_session();
child_state.stdout_fd = pipe.write_fd;
let status = run_program(&mut child_state, &mut sub_runtime, &reparsed);
state.last_status = status;
pipe.write_fd.close();
let mut output = pipe.read_fd.read_all();
while output.ends_with('\n') {
output.pop();
}
output
}
pub(super) fn expand_tilde_assignment(state: &ShellState, value: &str) -> String {
value
.split(':')
.map(|segment| {
if segment.starts_with('~') {
expand_tilde(state, segment)
} else {
segment.to_string()
}
})
.collect::<Vec<_>>()
.join(":")
}
pub(super) fn expand_command_name<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
name_word: &Word,
) -> Option<String> {
let name_expanded = expand_word(state, runtime, name_word);
match name_expanded.first() {
Some(n) if !n.is_empty() => {
if tilde_allowed_in_word(name_word) {
Some(expand_tilde(state, n))
} else {
Some(n.clone())
}
}
_ => None,
}
}
fn append_expanded_word_fields<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
argv: &mut Vec<String>,
word: &Word,
) {
let expanded = expand_word(state, runtime, word);
let allow_tilde = tilde_allowed_in_word(word);
let allow_glob = glob_expansion_allowed(word);
for field in expanded {
let field = if allow_tilde {
expand_tilde(state, &field)
} else {
field
};
if allow_glob && !state.has_option(OPT_NOGLOB) && contains_glob_chars(&field) {
let matches = sys::glob_expand(&field);
if matches.is_empty() {
argv.push(field);
} else {
argv.extend(matches);
}
} else {
argv.push(field);
}
}
}
fn tilde_allowed_in_word(word: &Word) -> bool {
match word {
Word::String(string) => {
!string.single_quoted()
&& string.split_fields()
&& string.value().starts_with('~')
&& string
.source()
.map(source_allows_tilde_expansion)
.unwrap_or(true)
}
Word::List(list) if !list.double_quoted() => match list.children().first() {
Some(Word::String(string)) if !string.single_quoted() && string.split_fields() => {
string.value().starts_with('~')
&& string
.source()
.map(source_allows_tilde_expansion)
.unwrap_or(true)
&& !(string.value() == "~" && list.children().len() > 1)
}
_ => false,
},
_ => false,
}
}
fn glob_expansion_allowed(word: &Word) -> bool {
match word {
Word::String(string) => {
!string.single_quoted()
&& string.split_fields()
&& string
.source()
.map(source_contains_unescaped_glob)
.unwrap_or_else(|| contains_glob_chars(string.value()))
}
Word::Parameter(_) | Word::Command(_) | Word::Arithmetic(_) => true,
Word::List(list) => {
!list.double_quoted() && list.children().iter().any(glob_expansion_allowed)
}
}
}
fn source_allows_tilde_expansion(source: &str) -> bool {
let mut chars = source.chars();
if chars.next() != Some('~') {
return false;
}
for ch in chars {
match ch {
'/' => return true,
'\\' | '\'' | '"' | '$' | '`' => return false,
_ if ch.is_whitespace() => return false,
_ => {}
}
}
true
}
fn source_contains_unescaped_glob(source: &str) -> bool {
let mut escaped = false;
for ch in source.chars() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'*' | '?' | '[' => return true,
_ => {}
}
}
false
}
pub(super) fn build_argv<R: Runtime>(
state: &mut ShellState,
runtime: &mut R,
cmd_name: String,
arguments: &[Word],
) -> Vec<String> {
let mut argv = vec![cmd_name];
for arg in arguments {
append_expanded_word_fields(state, runtime, &mut argv, arg);
}
argv
}
pub(super) fn contains_glob_chars(s: &str) -> bool {
s.contains('*') || s.contains('?') || s.contains('[')
}