#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RawQuoteKind {
Single,
Double,
Backtick,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct QuoteState {
quote: Option<RawQuoteKind>,
escaped: bool,
}
impl QuoteState {
#[must_use]
pub fn in_multiline_literal(&self) -> bool {
self.quote.is_some()
}
#[must_use]
pub fn in_single_quotes(&self) -> bool {
self.quote == Some(RawQuoteKind::Single)
}
#[must_use]
pub fn in_quotes(&self) -> bool {
self.quote.is_some()
}
pub fn consume_raw_char(&mut self, ch: char, include_backticks: bool) -> bool {
if self.escaped {
self.escaped = false;
return true;
}
match self.quote {
Some(RawQuoteKind::Single) => {
if ch == '\'' {
self.quote = None;
}
true
}
Some(RawQuoteKind::Double) => {
if ch == '"' {
self.quote = None;
} else if ch == '\\' {
self.escaped = true;
}
true
}
Some(RawQuoteKind::Backtick) if include_backticks => {
if ch == '`' {
self.quote = None;
} else if ch == '\\' {
self.escaped = true;
}
true
}
_ if ch == '\\' => {
self.escaped = true;
true
}
_ if ch == '\'' => {
self.quote = Some(RawQuoteKind::Single);
true
}
_ if ch == '"' => {
self.quote = Some(RawQuoteKind::Double);
true
}
_ if include_backticks && ch == '`' => {
self.quote = Some(RawQuoteKind::Backtick);
true
}
_ => false,
}
}
pub fn consume_shell_word_char(&mut self, ch: char) -> bool {
if self.escaped {
self.escaped = false;
return true;
}
match self.quote {
Some(RawQuoteKind::Single) => {
if ch == '\'' {
self.quote = None;
}
true
}
Some(RawQuoteKind::Double) => {
if ch == '"' {
self.quote = None;
return true;
}
if ch == '\\' {
self.escaped = true;
return true;
}
false
}
_ if ch == '\\' => {
self.escaped = true;
true
}
_ if ch == '\'' => {
self.quote = Some(RawQuoteKind::Single);
true
}
_ if ch == '"' => {
self.quote = Some(RawQuoteKind::Double);
true
}
_ => false,
}
}
pub fn scan_line(&mut self, line: &str) {
self.escaped = false;
for (index, ch) in line.char_indices() {
match self.quote {
Some(RawQuoteKind::Single) => {
if ch == '\'' {
self.quote = None;
}
}
Some(RawQuoteKind::Double) => {
if ch == '"' && !self.escaped {
self.quote = None;
}
}
_ => {
if ch == '#' && shell_comment_can_start(line, index) {
break;
}
if ch == '\'' || (ch == '"' && !self.escaped) {
self.quote = Some(if ch == '\'' {
RawQuoteKind::Single
} else {
RawQuoteKind::Double
});
}
}
}
self.escaped = ch == '\\' && !self.escaped;
if ch != '\\' {
self.escaped = false;
}
}
self.escaped = false;
}
}
#[derive(Debug, Clone, Copy)]
pub struct RawShellScanner<'source> {
source: &'source str,
upper: usize,
}
impl<'source> RawShellScanner<'source> {
#[must_use]
pub fn new(source: &'source str) -> Self {
Self::bounded(source, source.len())
}
#[must_use]
pub fn bounded(source: &'source str, upper: usize) -> Self {
Self {
source,
upper: upper.min(source.len()),
}
}
#[must_use]
pub fn skip_single_quoted_body(&self, mut offset: usize) -> usize {
while offset < self.upper {
let Some(ch) = self.source[offset..].chars().next() else {
break;
};
offset += ch.len_utf8();
if ch == '\'' {
break;
}
}
offset
}
#[must_use]
pub fn skip_double_quoted_body(&self, mut offset: usize) -> usize {
while offset < self.upper {
let Some(ch) = self.source[offset..].chars().next() else {
break;
};
offset += ch.len_utf8();
if ch == '\\' {
if let Some(escaped) = self.source[offset..].chars().next() {
offset += escaped.len_utf8();
}
} else if ch == '"' {
break;
}
}
offset
}
#[must_use]
pub fn skip_escaped_or_quoted_at(&self, offset: usize) -> Option<usize> {
let ch = self.source[offset..].chars().next()?;
let next = offset + ch.len_utf8();
match ch {
'\\' => Some(
self.source[next..self.upper]
.chars()
.next()
.map_or(next, |escaped| next + escaped.len_utf8()),
),
'\'' => Some(self.skip_single_quoted_body(next)),
'"' => Some(self.skip_double_quoted_body(next)),
_ => None,
}
}
#[must_use]
pub fn skip_quoted_or_comment_at(&self, offset: usize) -> Option<usize> {
self.skip_escaped_or_quoted_at(offset).or_else(|| {
self.comment_starts_at(offset)
.then(|| self.comment_end_from(offset))
})
}
#[must_use]
pub fn find_comment(&self, start: usize, upper: usize) -> Option<usize> {
let mut offset = start.min(self.upper);
let upper = upper.min(self.upper);
while offset < upper {
if let Some(next) = self.skip_escaped_or_quoted_at(offset) {
offset = next;
continue;
}
let ch = self.source[offset..].chars().next()?;
if ch == '#' && shell_comment_can_start(self.source, offset) {
return Some(offset);
}
offset += ch.len_utf8();
}
None
}
#[must_use]
pub fn is_escaped(&self, index: usize) -> bool {
offset_is_backslash_escaped(self.source, index)
}
#[must_use]
pub fn matching_command_substitution_close(&self, body_start: usize) -> Option<usize> {
let mut quote = QuoteState::default();
let mut paren_depth = 0usize;
let mut index = body_start.min(self.upper);
while index < self.upper {
let ch = self.source[index..].chars().next()?;
let next_index = index + ch.len_utf8();
if quote.consume_raw_char(ch, false) {
index = next_index;
continue;
}
match ch {
'(' => paren_depth += 1,
')' => {
if paren_depth == 0 {
return Some(index);
}
paren_depth -= 1;
}
_ => {}
}
index = next_index;
}
None
}
#[must_use]
pub fn next_command_substitution(&self, mut index: usize) -> Option<(usize, usize)> {
let bytes = self.source.as_bytes();
let mut quote = QuoteState::default();
while index + 1 < self.upper {
let ch = self.source[index..].chars().next()?;
let next_index = index + ch.len_utf8();
if quote.consume_shell_word_char(ch) {
index = next_index;
continue;
}
if !quote.in_single_quotes()
&& bytes[index] == b'$'
&& bytes[index + 1] == b'('
&& bytes.get(index + 2).is_none_or(|byte| *byte != b'(')
&& let Some(close_offset) = self.matching_command_substitution_close(index + 2)
{
return Some((index, close_offset));
}
index = next_index;
}
None
}
#[must_use]
pub fn has_unclosed_substitution_before(&self, upper: usize) -> bool {
let mut depth = 0usize;
let mut index = 0usize;
let upper = upper.min(self.upper);
let mut in_single_quotes = false;
let mut in_double_quotes = false;
while index < upper {
let Some(ch) = self.source[index..].chars().next() else {
break;
};
let next_index = index + ch.len_utf8();
if ch == '\\' {
index = self.source[next_index..upper]
.chars()
.next()
.map_or(next_index, |escaped| next_index + escaped.len_utf8());
continue;
}
if ch == '\'' && !in_double_quotes {
in_single_quotes = !in_single_quotes;
index = next_index;
continue;
}
if ch == '"' && !in_single_quotes {
in_double_quotes = !in_double_quotes;
index = next_index;
continue;
}
if in_single_quotes {
index = next_index;
continue;
}
let bytes = self.source.as_bytes();
match ch {
'$' | '<' | '>' if bytes.get(index + 1) == Some(&b'(') => {
depth += 1;
index = index.saturating_add(2);
continue;
}
')' if depth > 0 => depth -= 1,
_ => {}
}
index = next_index;
}
depth > 0
}
#[must_use]
pub fn shell_word_end(&self, start: usize) -> Option<usize> {
let bytes = self.source.as_bytes();
let mut index = start.min(self.upper);
let mut in_single_quote = false;
let mut in_double_quote = false;
while index < self.upper {
let byte = bytes[index];
if in_single_quote {
if byte == b'\'' {
in_single_quote = false;
}
index += 1;
continue;
}
if byte == b'\\' {
index = advance_escaped_shell_char(self.source, index).min(self.upper);
continue;
}
if !in_double_quote && byte.is_ascii_whitespace() {
break;
}
match byte {
b'\'' if !in_double_quote => {
in_single_quote = true;
index += 1;
}
b'"' => {
in_double_quote = !in_double_quote;
index += 1;
}
b'$' if bytes.get(index + 1) == Some(&b'(') => {
index = self.skip_balanced_shell_construct(index + 2, b'(', b')')?;
}
b'$' if bytes.get(index + 1) == Some(&b'{') => {
index = self.skip_balanced_shell_construct(index + 2, b'{', b'}')?;
}
b'<' | b'>' if !in_double_quote && bytes.get(index + 1) == Some(&b'(') => {
index = self.skip_balanced_shell_construct(index + 2, b'(', b')')?;
}
b'`' => {
index = self.skip_legacy_backtick_construct(index + 1)?;
}
_ => index = advance_shell_char(self.source, index).min(self.upper),
}
}
(!in_single_quote && !in_double_quote).then_some(index)
}
#[must_use]
pub fn skip_balanced_shell_construct(
&self,
mut index: usize,
open: u8,
close: u8,
) -> Option<usize> {
let bytes = self.source.as_bytes();
let mut depth = 1usize;
let mut in_single_quote = false;
let mut in_double_quote = false;
while index < self.upper {
let byte = bytes[index];
if in_single_quote {
if byte == b'\'' {
in_single_quote = false;
}
index += 1;
continue;
}
if byte == b'\\' {
index = advance_escaped_shell_char(self.source, index).min(self.upper);
continue;
}
match byte {
b'\'' if !in_double_quote => {
in_single_quote = true;
index += 1;
}
b'"' => {
in_double_quote = !in_double_quote;
index += 1;
}
b'$' if bytes.get(index + 1) == Some(&b'(') => {
index = self.skip_balanced_shell_construct(index + 2, b'(', b')')?;
}
b'$' if bytes.get(index + 1) == Some(&b'{') => {
index = self.skip_balanced_shell_construct(index + 2, b'{', b'}')?;
}
b'<' | b'>' if !in_double_quote && bytes.get(index + 1) == Some(&b'(') => {
index = self.skip_balanced_shell_construct(index + 2, b'(', b')')?;
}
b'`' => {
index = self.skip_legacy_backtick_construct(index + 1)?;
}
_ if byte == open && !in_double_quote => {
depth += 1;
index += 1;
}
_ if byte == close && !in_double_quote => {
depth -= 1;
index += 1;
if depth == 0 {
return Some(index);
}
}
_ => index = advance_shell_char(self.source, index).min(self.upper),
}
}
None
}
#[must_use]
pub fn skip_legacy_backtick_construct(&self, mut index: usize) -> Option<usize> {
let bytes = self.source.as_bytes();
let mut in_single_quote = false;
let mut in_double_quote = false;
while index < self.upper {
let byte = bytes[index];
if in_single_quote {
if byte == b'\'' {
in_single_quote = false;
}
index += 1;
continue;
}
if byte == b'\\' {
index = advance_escaped_shell_char(self.source, index).min(self.upper);
continue;
}
match byte {
b'\'' if !in_double_quote => {
in_single_quote = true;
index += 1;
}
b'"' => {
in_double_quote = !in_double_quote;
index += 1;
}
b'`' if !in_double_quote => return Some(index + 1),
_ => index = advance_shell_char(self.source, index).min(self.upper),
}
}
None
}
#[must_use]
pub fn skip_legacy_backtick_substitution_body(&self, mut index: usize) -> Option<usize> {
let bytes = self.source.as_bytes();
while index < self.upper {
match bytes[index] {
b'\\' => index = advance_escaped_shell_char(self.source, index).min(self.upper),
b'`' => return Some(index + 1),
_ => index = advance_shell_char(self.source, index).min(self.upper),
}
}
None
}
fn comment_starts_at(&self, offset: usize) -> bool {
self.source[offset..self.upper].starts_with('#')
&& shell_comment_can_start(self.source, offset)
}
fn comment_end_from(&self, offset: usize) -> usize {
self.source[offset..self.upper]
.find('\n')
.map_or(self.upper, |newline| offset + newline + 1)
}
}
#[must_use]
pub fn shell_comment_can_start(source: &str, offset: usize) -> bool {
source[..offset]
.chars()
.next_back()
.is_none_or(|ch| ch == '\n' || ch.is_whitespace() || matches!(ch, ';' | '&' | '|'))
}
#[must_use]
pub fn advance_shell_char(text: &str, index: usize) -> usize {
text[index..]
.chars()
.next()
.map_or(index + 1, |ch| index + ch.len_utf8())
}
#[must_use]
pub fn advance_escaped_shell_char(text: &str, index: usize) -> usize {
let next = advance_shell_char(text, index);
if next < text.len() {
advance_shell_char(text, next)
} else {
next
}
}
#[must_use]
pub fn offset_is_backslash_escaped(source: &str, offset: usize) -> bool {
byte_is_backslash_escaped(source.as_bytes(), offset)
}
#[must_use]
pub fn byte_is_backslash_escaped(bytes: &[u8], index: usize) -> bool {
let mut cursor = index.min(bytes.len());
let mut backslashes = 0usize;
while cursor > 0 && bytes[cursor - 1] == b'\\' {
backslashes += 1;
cursor -= 1;
}
backslashes % 2 == 1
}
#[cfg(test)]
mod tests {
use super::RawShellScanner;
#[test]
fn finds_comments_outside_quotes() {
let source = "echo '# no' \"# no\" value#no # yes";
let scanner = RawShellScanner::new(source);
assert_eq!(scanner.find_comment(0, source.len()), Some(28));
}
#[test]
fn matches_nested_command_substitution_close() {
let raw = "$(echo \"$(date)\"; (cd x)) tail";
let scanner = RawShellScanner::new(raw);
assert_eq!(
scanner.matching_command_substitution_close(2),
Some("$(echo \"$(date)\"; (cd x))".len() - 1),
);
}
#[test]
fn tracks_unclosed_process_substitution() {
let raw = "diff <(sort a";
let scanner = RawShellScanner::new(raw);
assert!(scanner.has_unclosed_substitution_before(raw.len()));
}
#[test]
fn finds_next_command_substitution_outside_single_quotes() {
let raw = "echo '$(nope)' $(date) $((1 + 2))";
let scanner = RawShellScanner::new(raw);
assert_eq!(scanner.next_command_substitution(0), Some((15, 21)));
}
#[test]
fn legacy_backtick_substitution_body_stops_at_first_unescaped_backtick() {
let raw = "`echo \"`\" tail`";
let scanner = RawShellScanner::new(raw);
assert_eq!(
scanner.skip_legacy_backtick_substitution_body(1),
Some("`echo \"`".len()),
);
}
#[test]
fn legacy_backtick_construct_keeps_quote_aware_word_scan_behavior() {
let raw = "`echo \"`\" tail`";
let scanner = RawShellScanner::new(raw);
assert_eq!(scanner.skip_legacy_backtick_construct(1), Some(raw.len()));
}
}