pub fn shell_tokenize(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut in_single = false;
let mut in_double = false;
let mut parameter_depth: u32 = 0;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'\\' if !in_single => {
let escapes_next =
!in_double || matches!(chars.peek(), Some('$' | '`' | '"' | '\\' | '\n'));
if escapes_next {
if let Some(next) = chars.next() {
current.push(next);
}
} else {
current.push('\\');
}
}
'$' if !in_single && chars.peek() == Some(&'{') => {
parameter_depth += 1;
current.push(c);
}
'}' if !in_single && parameter_depth > 0 => {
parameter_depth -= 1;
current.push(c);
}
c if c.is_whitespace() && !in_single && !in_double && parameter_depth == 0 => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
pub fn quote_aware_token_end(input: &str) -> usize {
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single = false;
let mut in_double = false;
let mut paren_depth: u32 = 0;
let mut parameter_depth: u32 = 0;
while i < len {
let ch = bytes[i];
match ch {
b'\'' if !in_double => {
in_single = !in_single;
i += 1;
}
b'"' if !in_single => {
in_double = !in_double;
i += 1;
}
b'\\' if !in_single => {
i = (i + 2).min(len);
}
b'(' if !in_single && !in_double => {
paren_depth += 1;
i += 1;
}
b')' if !in_single && !in_double && paren_depth > 0 => {
paren_depth -= 1;
i += 1;
}
b'$' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'{') => {
parameter_depth += 1;
i += 1;
}
b'}' if !in_single && parameter_depth > 0 => {
parameter_depth -= 1;
i += 1;
}
b if b.is_ascii_whitespace()
&& !in_single
&& !in_double
&& paren_depth == 0
&& parameter_depth == 0 =>
{
return i;
}
_ => i += 1,
}
}
len
}
pub fn extract_all_commands(command: &str) -> Vec<String> {
split_on_operators(command)
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[derive(Clone, Copy)]
struct Frame {
in_single_quote: bool,
in_double_quote: bool,
paren_depth: u32,
brace_depth: u32,
bracket_depth: u32,
kind: FrameKind,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FrameKind {
Base,
DollarParen,
Backtick,
}
impl Frame {
const fn new(kind: FrameKind) -> Self {
Self {
in_single_quote: false,
in_double_quote: false,
paren_depth: 0,
brace_depth: 0,
bracket_depth: 0,
kind,
}
}
const fn shields_operators(&self) -> bool {
self.in_single_quote
|| self.in_double_quote
|| self.paren_depth > 0
|| self.brace_depth > 0
|| self.bracket_depth > 0
}
}
fn opens_double_bracket(bytes: &[u8], i: usize) -> bool {
if bytes.get(i) != Some(&b'[') || bytes.get(i + 1) != Some(&b'[') {
return false;
}
let boundary_before = i == 0
|| matches!(
bytes[i - 1],
b' ' | b'\t' | b'\n' | b'\r' | b';' | b'&' | b'|' | b'('
);
let space_after = matches!(bytes.get(i + 2), Some(b' ' | b'\t' | b'\n' | b'\r'));
boundary_before && space_after
}
fn closes_double_bracket(bytes: &[u8], i: usize) -> bool {
if bytes.get(i) != Some(&b']') || bytes.get(i + 1) != Some(&b']') {
return false;
}
let space_before = i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\n' | b'\r');
let separator_after = matches!(
bytes.get(i + 2),
None | Some(b' ' | b'\t' | b'\n' | b'\r' | b';' | b'&' | b'|' | b')')
);
space_before && separator_after
}
pub fn split_on_operators(command: &str) -> Vec<&str> {
split_with_separators(command)
.into_iter()
.map(|(segment, _)| segment)
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Separator {
Sequence,
And,
Or,
Pipe,
Background,
}
pub fn segments_with_separators(command: &str) -> Vec<(String, Option<Separator>)> {
split_with_separators(command)
.into_iter()
.map(|(segment, sep)| (segment.trim().to_string(), sep))
.filter(|(segment, _)| !segment.is_empty())
.collect()
}
pub(super) fn segments_after_separators(command: &str) -> Vec<(String, Option<Separator>)> {
let mut previous = None;
let mut out = Vec::new();
for (segment, sep) in split_with_separators(command) {
let segment = segment.trim();
if !segment.is_empty() {
out.push((segment.to_string(), previous));
}
previous = sep;
}
out
}
pub(super) fn has_call_operator_before_break(command: &str) -> bool {
split_with_separators(command).windows(2).any(|pair| {
let [(before, amp), (target, after)] = pair else {
return false;
};
before.trim().is_empty()
&& *amp == Some(Separator::Background)
&& target.trim().is_empty()
&& *after == Some(Separator::Sequence)
})
}
pub fn sequence_statements(command: &str) -> Vec<&str> {
let base = command.as_ptr() as usize;
let mut out = Vec::new();
let mut start: Option<usize> = None;
for (segment, sep) in split_with_separators(command) {
let at = segment.as_ptr() as usize - base;
let begin = *start.get_or_insert(at);
if matches!(sep, Some(Separator::Sequence) | None) {
out.push(&command[begin..at + segment.len()]);
start = None;
}
}
if let Some(begin) = start {
out.push(&command[begin..]);
}
out.into_iter()
.map(str::trim)
.filter(|statement| !statement.is_empty())
.collect()
}
fn split_with_separators(command: &str) -> Vec<(&str, Option<Separator>)> {
let mut segments = Vec::new();
let mut start = 0;
let bytes = command.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut stack: Vec<Frame> = vec![Frame::new(FrameKind::Base)];
while i < len {
let ch = bytes[i];
let opens_substitution = |i: usize| -> Option<(FrameKind, usize)> {
match bytes[i] {
b'$' if i + 1 < len && bytes[i + 1] == b'(' => Some((FrameKind::DollarParen, 2)),
b'`' => Some((FrameKind::Backtick, 1)),
_ => None,
}
};
let frame = *stack.last().expect("base frame is never popped");
if frame.in_single_quote {
if ch == b'\'' {
stack.last_mut().expect("frame").in_single_quote = false;
}
i += 1;
continue;
}
if frame.in_double_quote {
match ch {
b'\\' => i = (i + 2).min(len),
b'"' => {
stack.last_mut().expect("frame").in_double_quote = false;
i += 1;
}
_ => {
if let Some((kind, width)) = opens_substitution(i) {
stack.push(Frame::new(kind));
i += width;
} else {
i += 1;
}
}
}
continue;
}
if let Some((kind, width)) = opens_substitution(i) {
if kind == FrameKind::Backtick && frame.kind == FrameKind::Backtick {
stack.pop();
} else {
stack.push(Frame::new(kind));
}
i += width;
continue;
}
let at_top = stack.len() == 1;
let unshielded = at_top && !frame.shields_operators();
if ch == b'[' && opens_double_bracket(bytes, i) {
stack.last_mut().expect("frame").bracket_depth += 1;
i += 2;
continue;
}
if ch == b']' && frame.bracket_depth > 0 && closes_double_bracket(bytes, i) {
let top = stack.last_mut().expect("frame");
top.bracket_depth = top.bracket_depth.saturating_sub(1);
i += 2;
continue;
}
match ch {
b'\\' => {
i = (i + 2).min(len);
}
b'\'' => {
stack.last_mut().expect("frame").in_single_quote = true;
i += 1;
}
b'"' => {
stack.last_mut().expect("frame").in_double_quote = true;
i += 1;
}
b'(' => {
stack.last_mut().expect("frame").paren_depth += 1;
i += 1;
}
b')' => {
let top = stack.last_mut().expect("frame");
if top.kind == FrameKind::DollarParen && top.paren_depth == 0 {
stack.pop();
} else {
top.paren_depth = top.paren_depth.saturating_sub(1);
}
i += 1;
}
b'{' => {
stack.last_mut().expect("frame").brace_depth += 1;
i += 1;
}
b'}' => {
let top = stack.last_mut().expect("frame");
top.brace_depth = top.brace_depth.saturating_sub(1);
i += 1;
}
b'\n' | b'\r' if unshielded && is_function_head(&command[start..i]) => {
i += 1;
}
b'\n' | b'\r' | b';' if unshielded => {
segments.push((&command[start..i], Some(Separator::Sequence)));
i += 1;
start = i;
}
b'&' if unshielded => {
if i + 1 < len && bytes[i + 1] == b'&' {
segments.push((&command[start..i], Some(Separator::And)));
i += 2;
start = i;
} else if (i > 0 && matches!(bytes[i - 1], b'>' | b'<'))
|| (i + 1 < len && bytes[i + 1] == b'>')
{
i += 1;
} else {
segments.push((&command[start..i], Some(Separator::Background)));
i += 1;
start = i;
}
}
b'|' if unshielded => {
if i + 1 < len && bytes[i + 1] == b'|' {
segments.push((&command[start..i], Some(Separator::Or)));
i += 2;
start = i;
} else if i > 0 && bytes[i - 1] == b'>' {
i += 1;
} else {
segments.push((&command[start..i], Some(Separator::Pipe)));
i += 1;
start = i;
}
}
_ => {
i += 1;
}
}
}
if start < len {
segments.push((&command[start..], None));
}
segments
}
pub fn extract_command_tokens_from_segment(segment: &str) -> Vec<String> {
let trimmed = segment.trim();
if trimmed.is_empty() {
return Vec::new();
}
let cmd_part = skip_powershell_assignment(skip_env_assignments(trimmed));
if cmd_part.is_empty() {
return Vec::new();
}
if is_powershell_value_expression(cmd_part) {
return Vec::new();
}
let tokens = shell_tokenize(cmd_part);
let mut token_iter = tokens.into_iter();
let first_token = match token_iter.next() {
Some(t) if t == "{" => token_iter.next(),
other => other,
};
let Some(first_token) = first_token else {
return Vec::new();
};
let basename = first_token
.rsplit('/')
.next()
.unwrap_or(&first_token)
.to_string();
let basename = if first_token.contains('/') && ENV_SETTING_BUILTINS.contains(&basename.as_str())
{
first_token.clone()
} else {
basename
};
std::iter::once(basename).chain(token_iter).collect()
}
pub fn exec_name(token: &str) -> String {
exec_name_for(
token,
cfg!(any(target_os = "macos", windows)),
cfg!(windows),
)
}
pub fn exec_name_for(token: &str, fold_case: bool, windows: bool) -> String {
let base = if windows {
token.rsplit(['/', '\\']).next()
} else {
token.rsplit('/').next()
}
.unwrap_or(token);
if !fold_case {
return base.to_owned();
}
let lower = base.to_ascii_lowercase();
match lower.strip_suffix(".exe") {
Some(stem) if windows => stem.to_owned(),
_ => lower,
}
}
pub fn extract_base_from_segment(segment: &str) -> String {
extract_command_tokens_from_segment(segment)
.into_iter()
.next()
.unwrap_or_default()
}
pub fn skip_powershell_assignment(segment: &str) -> &str {
let trimmed = segment.trim_start();
if super::ps_statements::host_mode() != super::ps_statements::Mode::PowerShell {
return trimmed;
}
let Some(variable_end) = powershell_local_variable_end(trimmed) else {
return trimmed;
};
let after_variable = trimmed[variable_end..].trim_start();
let Some(after_equals) = after_variable.strip_prefix('=') else {
return trimmed;
};
after_equals.trim_start()
}
fn powershell_local_variable_end(segment: &str) -> Option<usize> {
let bytes = segment.as_bytes();
if bytes.first() != Some(&b'$') {
return None;
}
let mut end = 1;
let first = *bytes.get(end)?;
if !(first.is_ascii_alphabetic() || first == b'_') {
return None;
}
end += 1;
while bytes
.get(end)
.is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
{
end += 1;
}
(bytes.get(end) != Some(&b':')).then_some(end)
}
fn is_powershell_value_expression(segment: &str) -> bool {
if super::ps_statements::host_mode() != super::ps_statements::Mode::PowerShell {
return false;
}
let trimmed = segment.trim();
let Some(variable_end) = powershell_local_variable_end(trimmed) else {
return false;
};
let suffix = trimmed[variable_end..].trim();
suffix.is_empty()
|| (suffix.starts_with('[')
&& suffix.ends_with(']')
&& !suffix.contains(['(', ')', ';', '|', '&', '=']))
}
pub fn detect_function_def(segment: &str) -> Option<String> {
function_def_parts(segment).map(|(name, _)| name.to_string())
}
pub fn function_body(segment: &str) -> Option<&str> {
function_def_parts(segment).map(|(_, body)| body)
}
fn function_def_parts(segment: &str) -> Option<(&str, &str)> {
let (name, rest) = function_def_head(segment)?;
let body = rest.trim();
if body.starts_with('{') {
return Some((name, super::compound::balanced_brace_inner(body)?));
}
if body.starts_with('(') {
let (inner, end) = super::compound::balanced_paren_at(body, 0)?;
return (end == body.len()).then_some((name, inner.trim()));
}
None
}
fn is_function_head(text: &str) -> bool {
function_def_head(text).is_some_and(|(_, rest)| rest.trim().is_empty())
}
fn function_def_head(segment: &str) -> Option<(&str, &str)> {
let cmd_part = skip_env_assignments(segment.trim());
let keyword_rest = cmd_part
.strip_prefix("function")
.filter(|rest| rest.starts_with(|c: char| c.is_whitespace()));
let (name, after_name) = match keyword_rest {
Some(rest) => split_function_name(rest.trim_start())?,
None => split_function_name(cmd_part)?,
};
match strip_empty_parens(after_name) {
Some(rest) => Some((name, rest)),
None if keyword_rest.is_some()
&& (after_name.is_empty() || after_name.starts_with(|c: char| c.is_whitespace())) =>
{
Some((name, after_name))
}
None => None,
}
}
fn split_function_name(text: &str) -> Option<(&str, &str)> {
let end = text
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(text.len());
(end > 0).then(|| text.split_at(end))
}
fn strip_empty_parens(text: &str) -> Option<&str> {
let rest = text.trim_start_matches([' ', '\t']).strip_prefix('(')?;
rest.trim_start_matches([' ', '\t']).strip_prefix(')')
}
const ENV_SETTING_BUILTINS: &[&str] = &[
"export", "unset", "readonly", "local", "declare", "typeset", "umask",
];
#[derive(Clone, Copy, PartialEq)]
enum OperandKind {
Declaration,
Unset,
Umask,
}
fn operand_kind_of(unquoted: &str) -> Option<OperandKind> {
match unquoted {
"umask" => Some(OperandKind::Umask),
"unset" => Some(OperandKind::Unset),
n if ENV_SETTING_BUILTINS.contains(&n) => Some(OperandKind::Declaration),
_ => None,
}
}
fn is_identifier(s: &str) -> bool {
let mut bytes = s.bytes();
bytes
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
&& bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
fn is_safe_flag(token: &str, allowed: &str) -> bool {
token == "--"
|| token
.strip_prefix('-')
.is_some_and(|f| !f.is_empty() && f.chars().all(|c| allowed.contains(c)))
}
fn is_umask_mode(token: &str) -> bool {
if matches!(token, "-S" | "-p") {
return true;
}
if (1..=4).contains(&token.len()) && token.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
return true;
}
!token.is_empty()
&& token.split(',').all(|clause| {
let rest = clause.trim_start_matches(['u', 'g', 'o', 'a']);
rest.strip_prefix(['-', '+', '='])
.is_some_and(|perm| perm.chars().all(|c| "rwxXst".contains(c)))
})
}
fn accepts_operand(kind: OperandKind, raw: &str, unquoted: &str) -> bool {
match kind {
OperandKind::Umask => is_umask_mode(unquoted),
OperandKind::Unset => is_identifier(unquoted) || is_safe_flag(unquoted, "v"),
OperandKind::Declaration => {
if is_identifier(unquoted) || is_safe_flag(unquoted, "rxgplutv") {
return true;
}
raw.split_once('=').is_some_and(|(name, value)| {
is_identifier(name)
&& !EVALUATED_SPECIAL_VARS.contains(&name)
&& is_safe_declaration_value(value)
})
}
}
}
fn is_safe_declaration_value(value: &str) -> bool {
const BARE: &str = "_./:,@%+=-";
if let Some(inner) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
return !inner.contains(['\'', '(', '$', '`']);
}
if let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
return !inner.contains(['"', '(', '[', '`', '\\'])
&& only_plain_expansions(inner, |_| true);
}
only_plain_expansions(value, |c| c.is_ascii_alphanumeric() || BARE.contains(c))
}
fn only_plain_expansions(s: &str, literal: impl Fn(char) -> bool) -> bool {
let mut rest = s;
while let Some(c) = rest.chars().next() {
if c != '$' {
if !literal(c) {
return false;
}
rest = &rest[c.len_utf8()..];
continue;
}
rest = &rest[1..];
let name = if let Some(braced) = rest.strip_prefix('{') {
let Some((name, after)) = braced.split_once('}') else {
return false;
};
rest = after;
name
} else {
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let (name, after) = rest.split_at(end);
rest = after;
name
};
if !is_identifier(name) {
return false;
}
}
true
}
pub const EVALUATED_SPECIAL_VARS: &[&str] = &[
"SECONDS",
"RANDOM",
"SRANDOM",
"OPTIND",
"HISTCMD",
"BASH_XTRACEFD",
"BASH_COMPAT",
];
pub fn assigns_evaluated_special(token: &str) -> bool {
token.split_once('=').is_some_and(|(name, _)| {
EVALUATED_SPECIAL_VARS.contains(&name.strip_suffix('+').unwrap_or(name))
})
}
const REFUSED_OPERAND_SENTINEL: &str = "\0refused-builtin-operand";
pub fn refused_builtin_operand(segment: &str) -> Option<String> {
skip_env_prefix(segment.trim()).err()
}
pub fn skip_env_assignments(segment: &str) -> &str {
skip_env_prefix(segment).unwrap_or(REFUSED_OPERAND_SENTINEL)
}
fn skip_env_prefix(segment: &str) -> Result<&str, String> {
let mut rest = segment;
let mut operand_kind: Option<OperandKind> = None;
loop {
let rest_trimmed = rest.trim_start();
if rest_trimmed.is_empty() {
return Ok(rest_trimmed);
}
let end = quote_aware_token_end(rest_trimmed);
if end == 0 {
return Ok(rest_trimmed);
}
let raw_token = &rest_trimmed[..end];
let unquoted: String = raw_token
.chars()
.filter(|c| *c != '"' && *c != '\'')
.collect();
if let Some(kind) = operand_kind {
if accepts_operand(kind, raw_token, &unquoted) {
rest = &rest_trimmed[end..];
continue;
}
return Err(raw_token.to_string());
}
let dequoted: String = unquoted.chars().filter(|c| *c != '\\').collect();
if raw_token != dequoted && operand_kind_of(&dequoted).is_some() {
return Err(raw_token.to_string());
}
if let Some(kind) = operand_kind_of(&unquoted) {
operand_kind = Some(kind);
rest = &rest_trimmed[end..];
continue;
}
let is_posix_assignment = unquoted.split_once('=').is_some_and(|(name, _)| {
let mut bytes = name.bytes();
bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
});
if is_posix_assignment {
if assigns_evaluated_special(&unquoted) {
return Err(raw_token.to_string());
}
rest = &rest_trimmed[end..];
} else if raw_token.contains("$'") || raw_token.contains("$\"") {
return Err(raw_token.to_string());
} else {
return Ok(rest_trimmed);
}
}
}
pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
extract_all_commands(command)
}
pub fn extract_base_command(command: &str) -> String {
let first_seg = split_on_operators(command)
.into_iter()
.next()
.unwrap_or(command);
extract_base_from_segment(first_seg)
}