pub(crate) struct HeredocBody {
pub(crate) body: String,
pub(crate) expands: bool,
}
pub(crate) struct SplitCommand {
pub(crate) segments: Vec<String>,
pub(crate) heredocs: Vec<HeredocBody>,
}
pub(crate) struct PendingHeredoc {
pub(crate) delimiter: String,
pub(crate) strip_tabs: bool,
pub(crate) expands: bool,
pub(crate) body: String,
}
pub(crate) fn scan_heredoc_operator(
chars: &[char],
mut i: usize,
current: &mut String,
pending: &mut std::collections::VecDeque<PendingHeredoc>,
) -> usize {
current.push_str("<<");
i += 2;
let mut strip_tabs = false;
if chars.get(i) == Some(&'-') {
strip_tabs = true;
current.push('-');
i += 1;
}
while chars.get(i).is_some_and(|c| *c == ' ' || *c == '\t') {
current.push(chars[i]);
i += 1;
}
let mut delimiter = String::new();
let mut quoted = false;
while let Some(&c) = chars.get(i) {
match c {
'\'' | '"' => {
quoted = true;
current.push(c);
i += 1;
while let Some(&d) = chars.get(i) {
current.push(d);
i += 1;
if d == c {
break;
}
delimiter.push(d);
}
},
'\\' => {
quoted = true;
current.push(c);
i += 1;
if let Some(&d) = chars.get(i) {
current.push(d);
delimiter.push(d);
i += 1;
}
},
c if c.is_whitespace() || matches!(c, ';' | '|' | '&' | '<' | '>') => break,
_ => {
current.push(c);
delimiter.push(c);
i += 1;
},
}
}
if !delimiter.is_empty() && heredoc_terminates(chars, i, &delimiter, strip_tabs) {
pending.push_back(PendingHeredoc {
delimiter,
strip_tabs,
expands: !quoted,
body: String::new(),
});
}
i
}
pub(crate) fn heredoc_terminates(
chars: &[char],
from: usize,
delimiter: &str,
strip_tabs: bool,
) -> bool {
let mut i = from;
while i < chars.len() {
let (line, next) = read_line(chars, i);
let compare = if strip_tabs {
line.trim_start_matches('\t')
} else {
line.as_str()
};
if compare == delimiter {
return true;
}
i = next;
}
false
}
pub(crate) fn read_line(chars: &[char], i: usize) -> (String, usize) {
let mut j = i;
while j < chars.len() && chars[j] != '\n' {
j += 1;
}
let line: String = chars[i..j].iter().collect();
(line, (j + 1).min(chars.len()))
}
pub(crate) struct Substitution {
pub(crate) outer: std::ops::Range<usize>,
pub(crate) inner: std::ops::Range<usize>,
}
pub(crate) fn scan_substitutions(chars: &[char], quote_blind: bool) -> Vec<Substitution> {
fn close_of(chars: &[char], open: usize, opener: char, closer: char) -> usize {
let mut depth = 1u32;
let mut j = open + 1;
while j < chars.len() {
if chars[j] == opener {
depth += 1;
} else if chars[j] == closer {
depth -= 1;
if depth == 0 {
break;
}
}
j += 1;
}
j
}
let mut out = Vec::new();
let mut i = 0;
let mut in_single = false;
while i < chars.len() {
let c = chars[i];
if in_single {
if c == '\'' {
in_single = false;
}
i += 1;
continue;
}
match c {
'\'' if !quote_blind => {
in_single = true;
i += 1;
},
'\\' => i += 2, '`' => {
let mut j = i + 1;
while j < chars.len() && chars[j] != '`' {
if chars[j] == '\\' {
j += 1;
}
j += 1;
}
out.push(Substitution {
outer: i..(j + 1).min(chars.len()),
inner: (i + 1).min(chars.len())..j.min(chars.len()),
});
i = j + 1;
},
'$' | '<' | '>' if chars.get(i + 1) == Some(&'(') => {
let j = close_of(chars, i + 1, '(', ')');
out.push(Substitution {
outer: i..(j + 1).min(chars.len()),
inner: (i + 2).min(chars.len())..j.min(chars.len()),
});
i = j + 1;
},
'$' if chars.get(i + 1) == Some(&'[') => {
let j = close_of(chars, i + 1, '[', ']');
out.push(Substitution {
outer: i..(j + 1).min(chars.len()),
inner: (i + 2).min(chars.len())..j.min(chars.len()),
});
i = j + 1;
},
_ => i += 1,
}
}
out
}
pub(crate) fn substitution_spans(chars: &[char]) -> Vec<std::ops::Range<usize>> {
scan_substitutions(chars, false)
.into_iter()
.map(|s| s.outer)
.collect()
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint; see .github/baselines/expect_budget.txt"
)]
pub(crate) fn split_command(command: &str) -> SplitCommand {
fn flush(segments: &mut Vec<String>, current: &mut String) {
let seg = current.trim();
if !seg.is_empty() {
segments.push(seg.to_string());
}
current.clear();
}
let chars: Vec<char> = command.chars().collect();
let subst_spans = substitution_spans(&chars);
let in_subst = |i: usize| subst_spans.iter().any(|r| r.contains(&i));
let mut segments = Vec::new();
let mut heredocs = Vec::new();
let mut pending: std::collections::VecDeque<PendingHeredoc> = std::collections::VecDeque::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if in_single {
current.push(c);
if c == '\'' {
in_single = false;
}
i += 1;
continue;
}
if in_double {
current.push(c);
if c == '\\' {
if let Some(&n) = chars.get(i + 1) {
current.push(n);
i += 1;
}
} else if c == '"' {
in_double = false;
}
i += 1;
continue;
}
match c {
'\'' => {
in_single = true;
current.push(c);
i += 1;
},
'"' => {
in_double = true;
current.push(c);
i += 1;
},
'\\' => {
current.push(c);
if let Some(&n) = chars.get(i + 1) {
current.push(n);
i += 1;
}
i += 1;
},
'<' if chars.get(i + 1) == Some(&'<') && !in_subst(i) => {
if chars.get(i + 2) == Some(&'<') {
current.push_str("<<<");
i += 3;
} else {
i = scan_heredoc_operator(&chars, i, &mut current, &mut pending);
}
},
'#' if current.is_empty() || current.ends_with(char::is_whitespace) => {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
},
';' => {
flush(&mut segments, &mut current);
i += 1;
},
'\n' => {
flush(&mut segments, &mut current);
i += 1;
while !pending.is_empty() {
if i >= chars.len() {
while let Some(h) = pending.pop_front() {
heredocs.push(HeredocBody {
body: h.body,
expands: h.expands,
});
}
break;
}
let (line, next) = read_line(&chars, i);
i = next;
let h = pending.front_mut().expect("checked non-empty");
let compare = if h.strip_tabs {
line.trim_start_matches('\t')
} else {
line.as_str()
};
if compare == h.delimiter {
let done = pending.pop_front().expect("checked non-empty");
heredocs.push(HeredocBody {
body: done.body,
expands: done.expands,
});
} else {
h.body.push_str(compare);
h.body.push('\n');
}
}
},
'|' => {
flush(&mut segments, &mut current);
i += 1;
if matches!(chars.get(i), Some('|') | Some('&')) {
i += 1;
}
},
'&' => {
if current.trim_end().ends_with('>') || chars.get(i + 1) == Some(&'>') {
current.push(c);
} else {
flush(&mut segments, &mut current);
if chars.get(i + 1) == Some(&'&') {
i += 1;
}
}
i += 1;
},
_ => {
current.push(c);
i += 1;
},
}
}
flush(&mut segments, &mut current);
for h in pending {
heredocs.push(HeredocBody {
body: h.body,
expands: h.expands,
});
}
SplitCommand { segments, heredocs }
}
pub(crate) const MAX_SUBST_DEPTH: u8 = 4;
pub(crate) fn extract_substitutions(command: &str) -> Vec<String> {
extract_substitutions_inner(command, false)
}
pub(crate) fn extract_substitutions_quote_blind(command: &str) -> Vec<String> {
extract_substitutions_inner(command, true)
}
pub(crate) fn extract_substitutions_inner(command: &str, quote_blind: bool) -> Vec<String> {
let chars: Vec<char> = command.chars().collect();
scan_substitutions(&chars, quote_blind)
.into_iter()
.map(|s| chars[s.inner].iter().collect())
.collect()
}
pub(crate) fn collapse_parent_refs(p: &str) -> String {
let absolute = p.starts_with('/');
let mut stack: Vec<&str> = Vec::new();
for comp in p.split('/') {
match comp {
"" | "." => {},
".." => {
if stack.is_empty() || matches!(stack.last(), Some(&"..")) {
if !absolute {
stack.push("..");
}
} else {
stack.pop();
}
},
other => stack.push(other),
}
}
let joined = stack.join("/");
if absolute {
format!("/{joined}")
} else {
joined
}
}
pub(crate) fn tokenize(command: &str) -> Vec<String> {
shell_words::split(command)
.unwrap_or_else(|_| command.split_whitespace().map(str::to_string).collect())
}
pub(crate) fn basename(arg: &str) -> &str {
arg.rsplit(['/', '\\']).next().unwrap_or(arg)
}