use super::track_char_context;
pub(super) fn strip_outer_quotes(word: &str) -> Option<(&str, bool)> {
let bytes = word.as_bytes();
if word.len() >= 2
&& matches!(bytes[0], b'\'' | b'"')
&& matches!(bytes[word.len() - 1], b'\'' | b'"')
{
let open = bytes[0];
let close = bytes[word.len() - 1];
if open != close {
return None; }
let inner = &word[1..word.len() - 1];
if inner.contains(open as char) {
return None; }
return Some((inner, open == b'\''));
}
if word.contains(['\'', '"']) {
return None;
}
Some((word, false))
}
pub(super) fn strip_quoted_word(word: &str) -> &str {
strip_outer_quotes(word).map_or(word, |(c, _)| c)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CdScan<'a> {
Target(&'a str, usize),
Bare,
BadOption,
}
pub(super) fn cd_target_after_options<'a>(words: &[&'a str], start: usize) -> CdScan<'a> {
let mut i = start;
let mut options_ended = false;
loop {
let Some(w) = words.get(i) else {
return CdScan::Bare;
};
if !options_ended && w.starts_with('-') && *w != "-" {
if *w == "--" {
options_ended = true;
} else if !w[1..].bytes().all(|b| matches!(b, b'P' | b'L')) {
return CdScan::BadOption;
}
i += 1;
continue;
}
return CdScan::Target(strip_quoted_word(w), i + 1);
}
}
fn skip_to_next_line(i: &mut usize, chars: &[(usize, char)]) {
while *i < chars.len() && chars[*i].1 != '\n' {
*i += 1;
}
if *i < chars.len() {
*i += 1;
}
}
fn is_heredoc_start(chars: &[(usize, char)], i: usize) -> bool {
let bare = chars[i].1 == '<' && chars.get(i + 1).is_some_and(|(_, c)| *c == '<');
let fd_prefixed = chars[i].1.is_ascii_digit()
&& chars.get(i + 1).is_some_and(|(_, c)| *c == '<')
&& chars.get(i + 2).is_some_and(|(_, c)| *c == '<');
if !bare && !fd_prefixed {
return false;
}
let herestring_start = if fd_prefixed { i + 3 } else { i + 2 };
chars.get(herestring_start).is_none_or(|(_, c)| *c != '<')
}
fn is_herestring_start(chars: &[(usize, char)], i: usize) -> bool {
let bare = chars[i].1 == '<'
&& chars.get(i + 1).is_some_and(|(_, c)| *c == '<')
&& chars.get(i + 2).is_some_and(|(_, c)| *c == '<');
let fd_prefixed = chars[i].1.is_ascii_digit()
&& chars.get(i + 1).is_some_and(|(_, c)| *c == '<')
&& chars.get(i + 2).is_some_and(|(_, c)| *c == '<')
&& chars.get(i + 3).is_some_and(|(_, c)| *c == '<');
bare || fd_prefixed
}
fn heredoc_terminator_matches(
command: &str,
line_start: usize,
delimiter: &str,
strip_tabs: bool,
) -> bool {
let rest = &command[line_start..];
let candidate = if strip_tabs {
rest.trim_start_matches('\t')
} else {
rest
};
match candidate.strip_prefix(delimiter) {
Some(after) => after.is_empty() || after.starts_with('\r') || after.starts_with('\n'),
None => false,
}
}
pub(super) fn strip_heredoc_bodies(command: &str) -> String {
let chars: Vec<(usize, char)> = command.char_indices().collect();
let mut out = String::with_capacity(command.len());
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut i = 0;
let mut queue: Vec<(String, bool, bool)> = Vec::new();
let mut skipping_body = false;
while i < chars.len() {
if !queue.is_empty() && skipping_body {
let line_start = chars[i].0;
if heredoc_terminator_matches(command, line_start, &queue[0].0, queue[0].1) {
let line_end = command[line_start..]
.find('\n')
.map_or(command.len(), |off| line_start + off);
i = chars
.iter()
.position(|(byte, _)| *byte >= line_end)
.unwrap_or(chars.len());
queue.remove(0);
if queue.is_empty() {
skipping_body = false;
}
continue;
}
if queue[0].2 {
let line_end = command[line_start..]
.find('\n')
.map_or(command.len(), |off| line_start + off);
if emit_body_substitutions(&command[line_start..line_end], &mut out)
&& line_end < command.len()
{
out.push('\n');
}
}
skip_to_next_line(&mut i, &chars);
continue;
}
if !queue.is_empty() && chars[i].1 == '\n' {
out.push('\n');
i += 1;
skipping_body = true;
continue;
}
if !track_char_context(chars[i].1, &mut in_single, &mut in_double, &mut escaped) {
out.push(chars[i].1);
i += 1;
continue;
}
if is_herestring_start(&chars, i) {
i = emit_herestring(&chars, i, &mut out);
continue;
}
if is_heredoc_start(&chars, i) {
match consume_heredoc_marker(command, &chars, i, &mut out, &mut queue) {
Some(next) => i = next,
None => break, }
continue;
}
out.push(chars[i].1);
i += 1;
}
out
}
fn consume_heredoc_marker(
command: &str,
chars: &[(usize, char)],
mut i: usize,
out: &mut String,
queue: &mut Vec<(String, bool, bool)>,
) -> Option<usize> {
out.push(' ');
if chars[i].1.is_ascii_digit() {
i += 1; }
i += 2;
while i < chars.len() && chars[i].1.is_whitespace() {
i += 1;
}
let mut strip_tabs = false;
if i < chars.len() && chars[i].1 == '-' {
strip_tabs = true;
i += 1;
}
while i < chars.len() && chars[i].1.is_whitespace() {
i += 1;
}
if i >= chars.len() {
return None; }
let (delimiter, delim_end, quoted) = parse_heredoc_delimiter(command, chars[i].0);
queue.push((delimiter, strip_tabs, !quoted));
Some(
chars
.iter()
.position(|(byte, _)| *byte >= delim_end)
.unwrap_or(chars.len()),
)
}
fn emit_herestring(chars: &[(usize, char)], i: usize, out: &mut String) -> usize {
let skip = if chars[i].1.is_ascii_digit() { 4 } else { 3 };
for c in &chars[i..i + skip] {
out.push(c.1);
}
i + skip
}
fn emit_body_substitutions(line: &str, out: &mut String) -> bool {
let mut emitted = false;
let mut j = 0;
while j < line.len() {
let c = line[j..].chars().next().expect("j < line.len()");
if c == '\\' {
j += c.len_utf8();
if j < line.len() {
j += line[j..].chars().next().expect("j < line.len()").len_utf8();
}
continue;
}
if let Some((_, next)) = substitution_span(line, j) {
out.push_str(&line[j..next]);
j = next;
emitted = true;
continue;
}
j += c.len_utf8();
}
emitted
}
fn parse_heredoc_delimiter(command: &str, start: usize) -> (String, usize, bool) {
let rest = &command[start..];
if let Some(rest) = rest.strip_prefix('\'') {
if let Some(end) = rest.find('\'') {
let delim = &rest[..end];
return (delim.to_string(), start + 1 + end + 1, true);
}
} else if let Some(rest) = rest.strip_prefix('"')
&& let Some(end) = rest.find('"')
{
let delim = &rest[..end];
return (delim.to_string(), start + 1 + end + 1, true);
}
let end = rest
.find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '&' | ';' | '|' | '(' | ')'))
.unwrap_or(rest.len());
(rest[..end].to_string(), start + end, false)
}
fn find_paren_close(s: &str, from: usize, initial_depth: usize) -> Option<usize> {
let mut depth = initial_depth;
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut i = from;
while i < s.len() {
let c = s[i..].chars().next().expect("i < s.len()");
if !track_char_context(c, &mut in_single, &mut in_double, &mut escaped) {
i += c.len_utf8();
continue;
}
if c == '(' {
depth += 1;
} else if c == ')' {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
i += c.len_utf8();
}
None
}
fn find_substitution_end(s: &str, start: usize) -> (&str, usize) {
match find_paren_close(s, start, 1) {
Some(end) => (&s[start..end - 1], end),
None => (&s[start..], s.len()),
}
}
fn find_backtick_end(s: &str, start: usize) -> (&str, usize) {
let mut i = start;
while i < s.len() {
let c = s[i..].chars().next().expect("i < s.len()");
if c == '\\' {
i += c.len_utf8();
if i < s.len() {
i += s[i..].chars().next().expect("i < s.len()").len_utf8();
}
continue;
}
if c == '`' {
return (&s[start..i], i + 1);
}
i += c.len_utf8();
}
(&s[start..], s.len())
}
fn find_parameter_end(s: &str, i: usize) -> (&str, usize) {
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut k = i + 2;
while k < s.len() {
let c = s[k..].chars().next().expect("k < s.len()");
if !escaped
&& !in_single
&& let Some((_, next)) = any_substitution_span(s, k, in_double)
{
k = next;
continue;
}
if !track_char_context(c, &mut in_single, &mut in_double, &mut escaped) {
k += c.len_utf8();
continue;
}
if c == '}' {
return (&s[i + 2..k], k + 1);
}
k += c.len_utf8();
}
(&s[i + 2..], s.len())
}
pub(super) fn substitution_span(s: &str, i: usize) -> Option<(&str, usize)> {
let b = s.as_bytes();
if b.get(i) == Some(&b'$') && b.get(i + 1) == Some(&b'(') {
Some(find_substitution_end(s, i + 2))
} else if b.get(i) == Some(&b'`') {
Some(find_backtick_end(s, i + 1))
} else if b.get(i) == Some(&b'$') && b.get(i + 1) == Some(&b'{') {
Some(find_parameter_end(s, i))
} else {
None
}
}
fn any_substitution_span(s: &str, i: usize, in_double: bool) -> Option<(&str, usize)> {
if let Some(span) = substitution_span(s, i) {
return Some(span);
}
let b = s.as_bytes();
if !in_double
&& (b.get(i) == Some(&b'<') || b.get(i) == Some(&b'>'))
&& b.get(i + 1) == Some(&b'(')
{
let end = find_paren_close(s, i + 2, 1).unwrap_or(s.len());
let content = if end == s.len() {
&s[i + 2..]
} else {
&s[i + 2..end - 1]
};
return Some((content, end));
}
None
}
pub(super) fn for_each_substitution(
s: &str,
mut visit: impl FnMut(&str, &str, usize, bool) -> bool,
) {
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut i = 0;
while i < s.len() {
let c = s[i..].chars().next().expect("i < s.len()");
if !escaped
&& !in_single
&& let Some((content, next)) = any_substitution_span(s, i, in_double)
{
if !visit(&s[i..next], content, next, in_double) {
return;
}
i = next;
continue;
}
if !track_char_context(c, &mut in_single, &mut in_double, &mut escaped) {
i += c.len_utf8();
continue;
}
i += c.len_utf8();
}
}
fn is_digit_suffix_redirect(w: &str, op: u8) -> bool {
let bytes = w.as_bytes();
if bytes.len() < 2 || !bytes[0].is_ascii_digit() || bytes[bytes.len() - 1] != op {
return false;
}
bytes[..bytes.len() - 1].iter().all(u8::is_ascii_digit)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum TokenKind {
Regular,
Redirect { needs_target: bool },
}
pub(super) fn classify_shell_token(w: &str) -> TokenKind {
match w {
">" | ">&" | ">>" | ">|" | "<" | "<&" | "<>" | "&>" | "&>>" => {
return TokenKind::Redirect { needs_target: true };
}
"2>&1" | "1>&2" => {
return TokenKind::Redirect {
needs_target: false,
};
}
_ => {}
}
if w.starts_with("<<<")
|| (w.len() > 3 && w.as_bytes()[0].is_ascii_digit() && w.ends_with("<<<"))
{
return TokenKind::Redirect { needs_target: true };
}
if is_digit_suffix_redirect(w, b'>') || is_digit_suffix_redirect(w, b'<') {
return TokenKind::Redirect { needs_target: true };
}
if w.starts_with('>') || w.starts_with('<') {
return TokenKind::Redirect {
needs_target: false,
};
}
if w.len() > 1 && w.as_bytes()[0].is_ascii_digit() && (w.contains('>') || w.contains('<')) {
return TokenKind::Redirect {
needs_target: false,
};
}
if w.contains("&>") {
return TokenKind::Redirect {
needs_target: false,
};
}
TokenKind::Regular
}
pub(super) fn split_words_keeping_substitutions(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
while i < s.len() {
let c = s[i..].chars().next().expect("i < len");
if !escaped
&& !in_single
&& let Some((_, next)) = any_substitution_span(s, i, in_double)
{
i = next;
continue;
}
if !track_char_context(c, &mut in_single, &mut in_double, &mut escaped) {
i += c.len_utf8();
continue;
}
if c.is_whitespace() {
if start < i {
out.push(&s[start..i]);
}
i += c.len_utf8();
start = i;
continue;
}
i += c.len_utf8();
}
if start < s.len() {
out.push(&s[start..]);
}
out
}