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(super) 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(super) 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(super) 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 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(super) 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();
std::iter::once(basename).chain(token_iter).collect()
}
pub(super) fn extract_base_from_segment(segment: &str) -> String {
extract_command_tokens_from_segment(segment)
.into_iter()
.next()
.unwrap_or_default()
}
pub(super) fn skip_powershell_assignment(segment: &str) -> &str {
let trimmed = segment.trim_start();
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 {
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(super) fn detect_function_def(segment: &str) -> Option<String> {
function_def_parts(segment).map(|(name, _)| name.to_string())
}
pub(super) 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"];
pub(super) fn skip_env_assignments(segment: &str) -> &str {
let mut rest = segment;
loop {
let rest_trimmed = rest.trim_start();
if rest_trimmed.is_empty() {
return rest_trimmed;
}
let end = quote_aware_token_end(rest_trimmed);
if end == 0 {
return rest_trimmed;
}
let raw_token = &rest_trimmed[..end];
let unquoted: String = raw_token
.chars()
.filter(|c| *c != '"' && *c != '\'')
.collect();
let base_token = unquoted.rsplit('/').next().unwrap_or(unquoted.as_str());
if ENV_SETTING_BUILTINS.contains(&base_token) {
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 {
rest = &rest_trimmed[end..];
} else {
return 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)
}