use std::borrow::Cow;
use std::sync::Arc;
use utf8_chars::BufReadCharsExt;
use crate::{SourcePosition, SourceSpan};
mod char_reader;
use char_reader::CharReader;
#[derive(Clone, Debug)]
pub(crate) enum TokenEndReason {
EndOfInput,
UnescapedNewLine,
ExpansionClosingChar,
NonNewLineBlank,
HereDocumentBodyStart,
HereDocumentBodyEnd,
HereDocumentEndTag,
OperatorStart,
OperatorEnd,
Other,
}
pub type TokenLocation = SourceSpan;
#[derive(Clone, Debug)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(
any(test, feature = "serde"),
derive(PartialEq, Eq, serde::Serialize, serde::Deserialize)
)]
pub enum Token {
Operator(String, SourceSpan),
Word(String, SourceSpan),
}
impl Token {
pub fn to_str(&self) -> &str {
match self {
Self::Operator(s, _) => s,
Self::Word(s, _) => s,
}
}
pub const fn location(&self) -> &SourceSpan {
match self {
Self::Operator(_, l) => l,
Self::Word(_, l) => l,
}
}
}
#[cfg(feature = "diagnostics")]
impl From<&Token> for miette::SourceSpan {
fn from(token: &Token) -> Self {
let start = token.location().start.as_ref();
Self::new(start.into(), token.location().length())
}
}
#[derive(Clone, Debug)]
pub(crate) struct TokenizeResult {
pub reason: TokenEndReason,
pub token: Option<Token>,
}
#[derive(thiserror::Error, Debug)]
pub enum TokenizerError {
#[error("unterminated escape sequence")]
UnterminatedEscapeSequence,
#[error("unterminated single quote at {0}")]
UnterminatedSingleQuote(SourcePosition),
#[error("unterminated ANSI C quote at {0}")]
UnterminatedAnsiCQuote(SourcePosition),
#[error("unterminated double quote at {0}")]
UnterminatedDoubleQuote(SourcePosition),
#[error("unterminated backquote near {0}")]
UnterminatedBackquote(SourcePosition),
#[error("unterminated extglob near {0}")]
UnterminatedExtendedGlob(SourcePosition),
#[error("unterminated variable expression")]
UnterminatedVariable,
#[error("unterminated command substitution")]
UnterminatedCommandSubstitution,
#[error("unterminated expansion")]
UnterminatedExpansion,
#[error("failed to decode UTF-8 characters")]
FailedDecoding,
#[error("missing here tag for here document body")]
MissingHereTagForDocumentBody,
#[error("missing here-document tag before {0:?}")]
MissingHereTag(String),
#[error("missing here-document tag at end of input")]
MissingHereTagAtEndOfInput,
#[error("unterminated here document; tag(s) {0} declared at {1}")]
UnterminatedHereDocuments(String, String),
#[error("expansions nested too deeply (limit: {})", MAX_EXPANSION_NESTING)]
ExpansionNestingTooDeep,
#[error("failed to read input")]
ReadError(#[from] std::io::Error),
}
impl TokenizerError {
pub const fn is_incomplete(&self) -> bool {
matches!(
self,
Self::UnterminatedEscapeSequence
| Self::UnterminatedAnsiCQuote(..)
| Self::UnterminatedSingleQuote(..)
| Self::UnterminatedDoubleQuote(..)
| Self::UnterminatedBackquote(..)
| Self::UnterminatedCommandSubstitution
| Self::UnterminatedExpansion
| Self::UnterminatedVariable
| Self::UnterminatedExtendedGlob(..)
| Self::UnterminatedHereDocuments(..)
| Self::MissingHereTagAtEndOfInput
)
}
}
#[derive(Debug)]
pub(crate) struct Tokens<'a> {
pub tokens: &'a [Token],
}
#[derive(Clone, Debug)]
enum QuoteMode {
None,
AnsiC(SourcePosition),
Single(SourcePosition),
Double(SourcePosition),
}
#[derive(Clone, Debug, Default)]
enum HereState {
#[default]
None,
NextTokenIsHereTag { remove_tabs: bool },
CurrentTokenIsHereTag {
remove_tabs: bool,
operator_token_result: TokenizeResult,
},
NextLineIsHereDoc,
InHereDocs,
}
impl HereState {
const fn awaiting_tag(&self) -> bool {
matches!(
self,
Self::NextTokenIsHereTag { .. } | Self::CurrentTokenIsHereTag { .. }
)
}
}
#[derive(Clone, Debug)]
struct HereTag {
tag: String,
tag_was_escaped_or_quoted: bool,
remove_tabs: bool,
position: SourcePosition,
tokens: Vec<TokenizeResult>,
pending_tokens_after: Vec<TokenizeResult>,
}
#[derive(Clone, Debug)]
struct CrossTokenParseState {
cursor: SourcePosition,
queued_tokens: Vec<TokenizeResult>,
frame: ExpansionFrame,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NestedExpansion {
CommandSubstitution,
ArithmeticExpansion,
LegacyArithmeticExpansion,
ParameterExpansion,
}
impl NestedExpansion {
const fn closing_char(self) -> char {
match self {
Self::CommandSubstitution | Self::ArithmeticExpansion => ')',
Self::LegacyArithmeticExpansion => ']',
Self::ParameterExpansion => '}',
}
}
const fn is_arithmetic(self) -> bool {
matches!(
self,
Self::ArithmeticExpansion | Self::LegacyArithmeticExpansion
)
}
const fn opening_char(self) -> Option<char> {
match self {
Self::CommandSubstitution | Self::ArithmeticExpansion => Some('('),
Self::LegacyArithmeticExpansion => Some('['),
Self::ParameterExpansion => None,
}
}
const fn closers_after_opening(self) -> usize {
match self {
Self::ArithmeticExpansion => 2,
_ => 1,
}
}
const fn unterminated_error(self) -> TokenizerError {
match self {
Self::ParameterExpansion => TokenizerError::UnterminatedVariable,
_ => TokenizerError::UnterminatedExpansion,
}
}
}
#[derive(Clone, Debug, Default)]
struct ExpansionFrame {
expansion: Option<NestedExpansion>,
depth: u32,
here_state: HereState,
current_here_tags: Vec<HereTag>,
closers_needed: usize,
arithmetic_command_parens: usize,
}
impl ExpansionFrame {
fn note_unquoted_char(&mut self, c: char) {
let Some(expansion) = self.expansion else {
return;
};
if expansion.opening_char() == Some(c) {
self.closers_needed += 1;
} else if expansion.closing_char() == c {
debug_assert!(self.closers_needed > 1, "read the expansion's closing char");
self.closers_needed -= 1;
}
}
}
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub struct TokenizerOptions {
pub enable_extended_globbing: bool,
pub posix_mode: bool,
pub sh_mode: bool,
}
impl Default for TokenizerOptions {
fn default() -> Self {
Self {
enable_extended_globbing: true,
posix_mode: false,
sh_mode: false,
}
}
}
pub(crate) const MAX_EXPANSION_NESTING: u32 = 32;
pub(crate) struct Tokenizer<'a, R: ?Sized + std::io::BufRead> {
char_reader: CharReader<utf8_chars::Chars<'a, R>>,
unread: Option<UnreadChars>,
read_bodies_left_pending: bool,
cross_state: CrossTokenParseState,
options: TokenizerOptions,
}
struct UnreadChars {
chars: Vec<char>,
resume_at: SourcePosition,
}
#[derive(Clone, Debug)]
struct TokenParseState {
pub start_position: SourcePosition,
pub token_so_far: String,
pub token_is_operator: bool,
pub in_escape: bool,
pub quote_mode: QuoteMode,
}
impl TokenParseState {
pub fn new(start_position: &SourcePosition) -> Self {
Self {
start_position: start_position.to_owned(),
token_so_far: String::new(),
token_is_operator: false,
in_escape: false,
quote_mode: QuoteMode::None,
}
}
pub fn pop(&mut self, end_position: &SourcePosition) -> Token {
let end = Arc::new(end_position.to_owned());
let token_location = SourceSpan {
start: Arc::new(std::mem::take(&mut self.start_position)),
end,
};
let token = if std::mem::take(&mut self.token_is_operator) {
Token::Operator(std::mem::take(&mut self.token_so_far), token_location)
} else {
Token::Word(std::mem::take(&mut self.token_so_far), token_location)
};
end_position.clone_into(&mut self.start_position);
self.in_escape = false;
self.quote_mode = QuoteMode::None;
token
}
pub const fn started_token(&self) -> bool {
!self.token_so_far.is_empty()
}
pub fn only_blanks_so_far(&self) -> bool {
!self.token_so_far.is_empty() && self.token_so_far.chars().all(is_blank)
}
pub fn append_char(&mut self, c: char) {
self.token_so_far.push(c);
}
pub fn append_str(&mut self, s: &str) {
self.token_so_far.push_str(s);
}
pub const fn unquoted(&self) -> bool {
!self.in_escape && matches!(self.quote_mode, QuoteMode::None)
}
pub fn current_token(&self) -> &str {
&self.token_so_far
}
pub fn is_specific_operator(&self, operator: &str) -> bool {
self.token_is_operator && self.current_token() == operator
}
pub const fn in_operator(&self) -> bool {
self.token_is_operator
}
fn is_newline(&self) -> bool {
self.token_so_far == "\n"
}
fn replace_with_here_doc(&mut self, s: String) {
self.token_so_far = s;
}
#[allow(clippy::too_many_lines)]
pub fn delimit_current_token(
&mut self,
reason: TokenEndReason,
cross_token_state: &mut CrossTokenParseState,
) -> Result<Option<TokenizeResult>, TokenizerError> {
if !self.started_token() && !matches!(reason, TokenEndReason::HereDocumentBodyEnd) {
return Ok(Some(TokenizeResult {
reason,
token: None,
}));
}
let current_here_state = std::mem::take(&mut cross_token_state.frame.here_state);
match current_here_state {
HereState::NextTokenIsHereTag { remove_tabs } => {
let operator_token_result = TokenizeResult {
reason,
token: Some(self.pop(&cross_token_state.cursor)),
};
cross_token_state.frame.here_state = HereState::CurrentTokenIsHereTag {
remove_tabs,
operator_token_result,
};
return Ok(None);
}
HereState::CurrentTokenIsHereTag {
remove_tabs,
operator_token_result,
} => {
if self.token_is_operator {
return Err(TokenizerError::MissingHereTag(
self.current_token().to_owned(),
));
}
if self.only_blanks_so_far() {
self.pop(&cross_token_state.cursor);
cross_token_state.frame.here_state = HereState::CurrentTokenIsHereTag {
remove_tabs,
operator_token_result,
};
return Ok(None);
}
cross_token_state.frame.here_state = HereState::NextLineIsHereDoc;
let tag = std::format!("{}\n", self.current_token().trim_start_matches(is_blank));
let tag_was_escaped_or_quoted = tag.contains(is_quoting_char);
let tag_token_result = TokenizeResult {
reason,
token: Some(self.pop(&cross_token_state.cursor)),
};
cross_token_state.frame.current_here_tags.push(HereTag {
tag,
tag_was_escaped_or_quoted,
remove_tabs,
position: cross_token_state.cursor.clone(),
tokens: vec![operator_token_result, tag_token_result],
pending_tokens_after: vec![],
});
return Ok(None);
}
HereState::NextLineIsHereDoc => {
if self.is_newline() {
cross_token_state.frame.here_state = HereState::InHereDocs;
} else {
cross_token_state.frame.here_state = HereState::NextLineIsHereDoc;
}
if let Some(last_here_tag) = cross_token_state.frame.current_here_tags.last_mut() {
let token = self.pop(&cross_token_state.cursor);
let result = TokenizeResult {
reason,
token: Some(token),
};
last_here_tag.pending_tokens_after.push(result);
} else {
return Err(TokenizerError::MissingHereTagForDocumentBody);
}
return Ok(None);
}
HereState::InHereDocs => {
let completed_here_tag = cross_token_state.frame.current_here_tags.remove(0);
cross_token_state
.queued_tokens
.extend(completed_here_tag.tokens);
cross_token_state.queued_tokens.push(TokenizeResult {
reason: TokenEndReason::HereDocumentBodyStart,
token: None,
});
cross_token_state.queued_tokens.push(TokenizeResult {
reason,
token: Some(self.pop(&cross_token_state.cursor)),
});
let end_tag = if completed_here_tag.tag_was_escaped_or_quoted {
unquote_str(&completed_here_tag.tag)
} else {
completed_here_tag.tag
};
self.append_str(end_tag.trim_end_matches('\n'));
cross_token_state.queued_tokens.push(TokenizeResult {
reason: TokenEndReason::HereDocumentEndTag,
token: Some(self.pop(&cross_token_state.cursor)),
});
cross_token_state
.queued_tokens
.extend(completed_here_tag.pending_tokens_after);
if cross_token_state.frame.current_here_tags.is_empty() {
cross_token_state.frame.here_state = HereState::None;
} else {
cross_token_state.frame.here_state = HereState::InHereDocs;
}
return Ok(None);
}
HereState::None => (),
}
let token = self.pop(&cross_token_state.cursor);
let result = TokenizeResult {
reason,
token: Some(token),
};
Ok(Some(result))
}
}
pub fn tokenize_str(input: &str) -> Result<Vec<Token>, TokenizerError> {
tokenize_str_with_options(input, &TokenizerOptions::default())
}
pub fn tokenize_str_with_options(
input: &str,
options: &TokenizerOptions,
) -> Result<Vec<Token>, TokenizerError> {
uncached_tokenize_string(input, options)
}
#[cached::macros::cached(
name = "TOKENIZE_CACHE",
max_size = 64,
key = "(String, TokenizerOptions)",
convert = r#"{ (input.to_owned(), options.to_owned()) }"#
)]
fn uncached_tokenize_string(
input: &str,
options: &TokenizerOptions,
) -> Result<Vec<Token>, TokenizerError> {
uncached_tokenize_str(input, options)
}
pub fn uncached_tokenize_str(
input: &str,
options: &TokenizerOptions,
) -> Result<Vec<Token>, TokenizerError> {
let mut reader = std::io::BufReader::new(input.as_bytes());
let mut tokenizer = crate::tokenizer::Tokenizer::new(&mut reader, options);
let mut tokens = vec![];
loop {
match tokenizer.next_token()? {
TokenizeResult {
token: Some(token), ..
} => tokens.push(token),
TokenizeResult {
reason: TokenEndReason::EndOfInput,
..
} => break,
_ => (),
}
}
Ok(tokens)
}
pub(crate) fn command_substitution_body<'a>(
input: &'a str,
options: &TokenizerOptions,
) -> Result<&'a str, TokenizerError> {
expansion_body(input, options, NestedExpansion::CommandSubstitution)
}
pub(crate) fn legacy_arithmetic_expansion_body<'a>(
input: &'a str,
options: &TokenizerOptions,
) -> Result<&'a str, TokenizerError> {
expansion_body(input, options, NestedExpansion::LegacyArithmeticExpansion)
}
fn expansion_body<'a>(
input: &'a str,
options: &TokenizerOptions,
expansion: NestedExpansion,
) -> Result<&'a str, TokenizerError> {
let mut reader = input.as_bytes();
let mut tokenizer = Tokenizer::new(&mut reader, options);
tokenizer.read_bodies_left_pending = false;
let mut state = TokenParseState::new(&tokenizer.cross_state.cursor);
tokenizer.consume_nested_expansion(&mut state, expansion)?;
let body_len = input
.chars()
.take(tokenizer.cross_state.cursor.index - 1)
.map(char::len_utf8)
.sum();
Ok(input.split_at(body_len).0)
}
impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> {
pub fn new(reader: &'a mut R, options: &TokenizerOptions) -> Self {
Tokenizer {
options: options.clone(),
char_reader: CharReader::new(reader.chars()),
unread: None,
read_bodies_left_pending: true,
cross_state: CrossTokenParseState {
cursor: SourcePosition {
index: 0,
line: 1,
column: 1,
},
queued_tokens: vec![],
frame: ExpansionFrame::default(),
},
}
}
#[expect(clippy::unnecessary_wraps)]
pub fn current_location(&self) -> Option<SourcePosition> {
Some(self.cross_state.cursor.clone())
}
fn next_char(&mut self) -> Result<Option<char>, TokenizerError> {
let c = match self.unread.as_mut().and_then(|unread| unread.chars.pop()) {
Some(c) => Some(c),
None => self
.char_reader
.next()
.transpose()
.map_err(TokenizerError::ReadError)?,
};
if let Some(ch) = c {
if ch == '\n' {
self.cross_state.cursor.line += 1;
self.cross_state.cursor.column = 1;
} else {
self.cross_state.cursor.column += 1;
}
self.cross_state.cursor.index += 1;
}
if let Some(unread) = self.unread.take_if(|unread| unread.chars.is_empty()) {
self.cross_state.cursor = unread.resume_at;
}
Ok(c)
}
fn consume_char(&mut self) -> Result<(), TokenizerError> {
let _ = self.next_char()?;
Ok(())
}
fn peek_char(&mut self) -> Result<Option<char>, TokenizerError> {
if let Some(c) = self.unread.as_ref().and_then(|unread| unread.chars.last()) {
return Ok(Some(*c));
}
match self.char_reader.peek() {
Some(result) => match result {
Ok(c) => Ok(Some(*c)),
Err(_) => Err(TokenizerError::FailedDecoding),
},
None => Ok(None),
}
}
fn peek_second_char(&mut self) -> Result<Option<char>, TokenizerError> {
let peeked = match self
.unread
.as_ref()
.map_or(&[][..], |unread| unread.chars.as_slice())
{
[.., second, _] => return Ok(Some(*second)),
[_] => self.char_reader.peek(),
[] => self.char_reader.peek_second(),
};
match peeked {
Some(Ok(c)) => Ok(Some(*c)),
Some(Err(_)) => Err(TokenizerError::FailedDecoding),
None => Ok(None),
}
}
fn skip_line_continuations(&mut self) -> Result<(), TokenizerError> {
while self.peek_char()? == Some('\\') && self.peek_second_char()? == Some('\n') {
self.consume_char()?;
self.consume_char()?;
}
Ok(())
}
fn backslash_is_literal(&self, state: &TokenParseState) -> bool {
if state.in_escape || matches!(state.quote_mode, QuoteMode::Single(_) | QuoteMode::AnsiC(_))
{
return true;
}
let frame = &self.cross_state.frame;
let in_here_doc_body = match frame.here_state {
HereState::InHereDocs => true,
HereState::NextLineIsHereDoc => state.is_newline(),
_ => false,
};
if !in_here_doc_body {
return false;
}
frame
.current_here_tags
.first()
.is_some_and(|tag| tag.tag_was_escaped_or_quoted)
}
fn consume_nested_expansion(
&mut self,
state: &mut TokenParseState,
expansion: NestedExpansion,
) -> Result<(), TokenizerError> {
let depth = self.cross_state.frame.depth + 1;
if depth > MAX_EXPANSION_NESTING {
return Err(TokenizerError::ExpansionNestingTooDeep);
}
let frame = ExpansionFrame {
expansion: Some(expansion),
depth,
closers_needed: expansion.closers_after_opening(),
..ExpansionFrame::default()
};
debug_assert!(self.cross_state.queued_tokens.is_empty());
let enclosing = std::mem::replace(&mut self.cross_state.frame, frame);
let result = self.consume_expansion_contents(state, expansion);
debug_assert!(result.is_err() || self.cross_state.queued_tokens.is_empty());
self.cross_state.frame = enclosing;
result
}
fn consume_expansion_contents(
&mut self,
state: &mut TokenParseState,
expansion: NestedExpansion,
) -> Result<(), TokenizerError> {
let mut bodies = String::new();
loop {
let result = self.next_token()?;
match result.reason {
TokenEndReason::UnescapedNewLine => state.append_str(&std::mem::take(&mut bodies)),
TokenEndReason::ExpansionClosingChar => {
let closing_char = self
.next_char()?
.ok_or_else(|| expansion.unterminated_error())?;
let here_state = &self.cross_state.frame.here_state;
if here_state.awaiting_tag() {
return Err(TokenizerError::MissingHereTag(closing_char.to_string()));
} else if matches!(here_state, HereState::NextLineIsHereDoc)
&& self.read_bodies_left_pending
{
self.read_here_doc_bodies_left_pending(state)?;
}
state.append_char(closing_char);
return Ok(());
}
TokenEndReason::EndOfInput => return Err(expansion.unterminated_error()),
_ => (),
}
append_expansion_token(state, &mut bodies, &result);
}
}
fn read_here_doc_bodies_left_pending(
&mut self,
state: &mut TokenParseState,
) -> Result<(), TokenizerError> {
let rest_of_line_start = self.cross_state.cursor.clone();
let mut rest_of_line = vec![];
while let Some(c) = self.next_char()? {
rest_of_line.push(c);
if c == '\n' {
break;
}
}
self.cross_state.frame.here_state = HereState::InHereDocs;
let mut bodies = String::new();
while !matches!(self.cross_state.frame.here_state, HereState::None)
|| !self.cross_state.queued_tokens.is_empty()
{
let result = self.next_token()?;
append_expansion_token(state, &mut bodies, &result);
}
state.append_str(&bodies);
state.append_char('\n');
if !rest_of_line.is_empty() {
rest_of_line.reverse();
let resume_at = std::mem::replace(&mut self.cross_state.cursor, rest_of_line_start);
self.unread = Some(UnreadChars {
chars: rest_of_line,
resume_at,
});
}
Ok(())
}
#[allow(clippy::unwrap_in_result)]
fn consume_dollar_or_backquote(
&mut self,
state: &mut TokenParseState,
c: char,
in_double_quotes: bool,
) -> Result<(), TokenizerError> {
if c == '$' {
self.consume_char()?;
self.skip_line_continuations()?;
let char_after_dollar_sign = self.peek_char()?;
match char_after_dollar_sign {
Some('(') => {
state.append_char('$');
state.append_char(self.next_char()?.unwrap());
self.skip_line_continuations()?;
let expansion = if matches!(self.peek_char()?, Some('(')) {
state.append_char(self.next_char()?.unwrap());
NestedExpansion::ArithmeticExpansion
} else {
NestedExpansion::CommandSubstitution
};
self.consume_nested_expansion(state, expansion)?;
}
Some('[') => {
state.append_char('$');
state.append_char(self.next_char()?.unwrap());
self.consume_nested_expansion(
state,
NestedExpansion::LegacyArithmeticExpansion,
)?;
}
Some('{') => {
state.append_char('$');
state.append_char(self.next_char()?.unwrap());
self.consume_nested_expansion(state, NestedExpansion::ParameterExpansion)?;
}
Some('$') => {
self.consume_char()?;
state.append_str("$$");
}
Some('\'') if !in_double_quotes => {
state.append_char('$');
state.quote_mode = QuoteMode::AnsiC(self.cross_state.cursor.clone());
state.append_char(self.next_char()?.unwrap());
}
_ => {
state.append_char('$');
}
}
} else {
let backquote_pos = self.cross_state.cursor.clone();
self.consume_char()?;
state.append_char(c);
let mut escaping_enabled = false;
let mut done = false;
while !done {
if !escaping_enabled {
self.skip_line_continuations()?;
}
let next_char_in_backquote = self.next_char()?;
if let Some(cib) = next_char_in_backquote {
state.append_char(cib);
if !escaping_enabled && cib == '\\' {
escaping_enabled = true;
} else {
if !escaping_enabled && cib == '`' {
done = true;
}
escaping_enabled = false;
}
} else {
return Err(TokenizerError::UnterminatedBackquote(backquote_pos));
}
}
}
Ok(())
}
#[expect(clippy::cognitive_complexity)]
#[expect(clippy::if_same_then_else)]
#[expect(clippy::panic_in_result_fn)]
#[expect(clippy::too_many_lines)]
#[allow(clippy::unwrap_in_result)]
pub fn next_token(&mut self) -> Result<TokenizeResult, TokenizerError> {
let mut state = TokenParseState::new(&self.cross_state.cursor);
let mut result: Option<TokenizeResult> = None;
let expansion = self.cross_state.frame.expansion;
let closing_char = expansion.map(NestedExpansion::closing_char);
let include_space = expansion.is_some();
let in_parameter_expansion = expansion == Some(NestedExpansion::ParameterExpansion);
let in_arithmetic_expansion = expansion.is_some_and(NestedExpansion::is_arithmetic);
while result.is_none() {
if !self.cross_state.queued_tokens.is_empty() {
return Ok(self.cross_state.queued_tokens.remove(0));
}
let mut next = self.peek_char()?;
if next == Some('\\')
&& self.peek_second_char()? == Some('\n')
&& !self.backslash_is_literal(&state)
{
self.skip_line_continuations()?;
if !state.started_token() {
state.start_position = self.cross_state.cursor.clone();
}
next = self.peek_char()?;
}
let c = next.unwrap_or('\0');
if next.is_none() {
if state.in_escape {
return Err(TokenizerError::UnterminatedEscapeSequence);
}
match state.quote_mode {
QuoteMode::None => (),
QuoteMode::AnsiC(pos) => {
return Err(TokenizerError::UnterminatedAnsiCQuote(pos));
}
QuoteMode::Single(pos) => {
return Err(TokenizerError::UnterminatedSingleQuote(pos));
}
QuoteMode::Double(pos) => {
return Err(TokenizerError::UnterminatedDoubleQuote(pos));
}
}
if !matches!(self.cross_state.frame.here_state, HereState::None) {
if self.cross_state.frame.here_state.awaiting_tag() && state.started_token() {
state.delimit_current_token(
TokenEndReason::EndOfInput,
&mut self.cross_state,
)?;
}
if self.cross_state.frame.here_state.awaiting_tag() {
return Err(TokenizerError::MissingHereTagAtEndOfInput);
}
if matches!(self.cross_state.frame.here_state, HereState::InHereDocs)
&& self.remove_here_end_tag(&mut state, &mut result, false)?
{
continue;
}
let tag_names = self
.cross_state
.frame
.current_here_tags
.iter()
.map(|tag| std::format!("`{}`", tag.tag.trim_end_matches('\n')))
.collect::<Vec<_>>()
.join(", ");
let tag_positions = self
.cross_state
.frame
.current_here_tags
.iter()
.map(|tag| std::format!("{}", tag.position))
.collect::<Vec<_>>()
.join(", ");
return Err(TokenizerError::UnterminatedHereDocuments(
tag_names,
tag_positions,
));
}
result = state
.delimit_current_token(TokenEndReason::EndOfInput, &mut self.cross_state)?;
} else if matches!(self.cross_state.frame.here_state, HereState::InHereDocs) {
if !self.cross_state.frame.current_here_tags.is_empty()
&& self.cross_state.frame.current_here_tags[0].remove_tabs
&& (!state.started_token() || state.current_token().ends_with('\n'))
&& c == '\t'
{
self.consume_char()?;
} else {
self.consume_char()?;
state.append_char(c);
if c == '\n' {
self.remove_here_end_tag(&mut state, &mut result, true)?;
} else if c == '\\'
&& self
.cross_state
.frame
.current_here_tags
.first()
.is_some_and(|tag| !tag.tag_was_escaped_or_quoted)
{
if let Some(escaped) = self.next_char()? {
state.append_char(escaped);
}
}
}
} else if state.unquoted()
&& !state.in_operator()
&& closing_char == Some(c)
&& self.cross_state.frame.closers_needed == 1
{
let reason = if state.started_token() {
TokenEndReason::Other
} else {
TokenEndReason::ExpansionClosingChar
};
result = state.delimit_current_token(reason, &mut self.cross_state)?;
} else if state.in_operator() {
let mut hypothetical_token = state.current_token().to_owned();
hypothetical_token.push(c);
if state.unquoted() && self.is_operator(hypothetical_token.as_ref()) {
self.consume_char()?;
state.append_char(c);
} else {
assert!(state.started_token());
if in_arithmetic_expansion || in_parameter_expansion {
} else if self.cross_state.frame.arithmetic_command_parens > 0 {
let parens = &mut self.cross_state.frame.arithmetic_command_parens;
if state.is_specific_operator("(") {
*parens += 1;
} else if state.is_specific_operator(")") {
*parens -= 1;
}
} else if state.is_specific_operator("<<") {
self.cross_state.frame.here_state =
HereState::NextTokenIsHereTag { remove_tabs: false };
} else if state.is_specific_operator("<<-") {
self.cross_state.frame.here_state =
HereState::NextTokenIsHereTag { remove_tabs: true };
} else if state.is_specific_operator("(") && c == '(' {
self.cross_state.frame.arithmetic_command_parens = 1;
}
let reason = if state.current_token() == "\n" {
TokenEndReason::UnescapedNewLine
} else {
TokenEndReason::OperatorEnd
};
result = state.delimit_current_token(reason, &mut self.cross_state)?;
}
} else if does_char_newly_affect_quoting(&state, c) {
if c == '\\' {
self.consume_char()?;
state.in_escape = true;
state.append_char(c);
} else if c == '\'' {
state.quote_mode = QuoteMode::Single(self.cross_state.cursor.clone());
self.consume_char()?;
state.append_char(c);
} else if c == '\"' {
state.quote_mode = QuoteMode::Double(self.cross_state.cursor.clone());
self.consume_char()?;
state.append_char(c);
}
}
else if !state.in_escape
&& matches!(
state.quote_mode,
QuoteMode::Single(..) | QuoteMode::AnsiC(..)
)
&& c == '\''
{
state.quote_mode = QuoteMode::None;
self.consume_char()?;
state.append_char(c);
} else if !state.in_escape
&& matches!(state.quote_mode, QuoteMode::Double(..))
&& c == '\"'
{
state.quote_mode = QuoteMode::None;
self.consume_char()?;
state.append_char(c);
}
else if state.in_escape {
state.in_escape = false;
self.consume_char()?;
state.append_char(c);
} else if (state.unquoted()
|| (matches!(state.quote_mode, QuoteMode::Double(_)) && !state.in_escape))
&& (c == '$' || c == '`')
{
let in_double_quotes = matches!(state.quote_mode, QuoteMode::Double(_));
self.consume_dollar_or_backquote(&mut state, c, in_double_quotes)?;
}
else if c == '('
&& self.options.enable_extended_globbing
&& !in_parameter_expansion
&& state.unquoted()
&& !state.in_operator()
&& state
.current_token()
.ends_with(|x| Self::can_start_extglob(x))
{
self.consume_char()?;
state.append_char(c);
let mut paren_depth = 1;
let mut quote: Option<(char, bool)> = None;
let mut after_dollar = false;
while paren_depth > 0 {
if !matches!(quote, Some(('\'', _))) {
self.skip_line_continuations()?;
}
let Some(extglob_char) = self.peek_char()? else {
return Err(TokenizerError::UnterminatedExtendedGlob(
self.cross_state.cursor.clone(),
));
};
let was_after_dollar = std::mem::take(&mut after_dollar);
let starts_nested_construct = match quote {
None => extglob_char == '`',
Some(('"', _)) => matches!(extglob_char, '`' | '$'),
Some(_) => false,
};
if starts_nested_construct {
let in_double_quotes = quote.is_some();
self.consume_dollar_or_backquote(
&mut state,
extglob_char,
in_double_quotes,
)?;
continue;
}
self.consume_char()?;
state.append_char(extglob_char);
match extglob_char {
'\\' if quote.is_none_or(|(_, escapes)| escapes) => {
if let Some(escaped_char) = self.next_char()? {
state.append_char(escaped_char);
}
}
c if quote.is_some_and(|(close, _)| close == c) => quote = None,
_ if quote.is_some() => (),
'\'' => quote = Some(('\'', was_after_dollar)),
'"' => quote = Some(('"', true)),
'$' => after_dollar = !was_after_dollar,
'(' => paren_depth += 1,
')' => paren_depth -= 1,
_ => (),
}
}
} else if state.unquoted() && Self::can_start_operator(c) {
if state.started_token() {
result = state.delimit_current_token(
TokenEndReason::OperatorStart,
&mut self.cross_state,
)?;
} else {
state.token_is_operator = true;
self.consume_char()?;
state.append_char(c);
self.cross_state.frame.note_unquoted_char(c);
}
} else if state.unquoted() && is_blank(c) {
if state.started_token() {
result = state.delimit_current_token(
TokenEndReason::NonNewLineBlank,
&mut self.cross_state,
)?;
if include_space {
continue;
}
} else if include_space {
state.append_char(c);
} else {
state.start_position.column += 1;
state.start_position.index += 1;
}
self.consume_char()?;
}
else if !state.token_is_operator
&& (in_parameter_expansion
|| (state.started_token() && !(c == '#' && state.only_blanks_so_far())))
{
self.consume_char()?;
state.append_char(c);
if state.unquoted() {
self.cross_state.frame.note_unquoted_char(c);
}
} else if c == '#' {
self.consume_char()?;
let mut done = false;
while !done {
done = match self.peek_char()? {
Some('\n') => true,
None => true,
_ => {
self.consume_char()?;
false
}
};
}
} else if state.started_token() {
result =
state.delimit_current_token(TokenEndReason::Other, &mut self.cross_state)?;
} else {
self.consume_char()?;
state.append_char(c);
self.cross_state.frame.note_unquoted_char(c);
}
}
let result = result.unwrap();
Ok(result)
}
fn remove_here_end_tag(
&mut self,
state: &mut TokenParseState,
result: &mut Option<TokenizeResult>,
ends_with_newline: bool,
) -> Result<bool, TokenizerError> {
if self.cross_state.frame.current_here_tags.is_empty() {
return Ok(false);
}
let next_here_tag = &self.cross_state.frame.current_here_tags[0];
let tag_str: Cow<'_, str> = if next_here_tag.tag_was_escaped_or_quoted {
unquote_str(next_here_tag.tag.as_str()).into()
} else {
next_here_tag.tag.as_str().into()
};
let tag_str = if !ends_with_newline {
tag_str
.strip_suffix('\n')
.unwrap_or_else(|| tag_str.as_ref())
} else {
tag_str.as_ref()
};
if tag_str.is_empty() {
return Ok(false);
}
if let Some(current_token_without_here_tag) = state.current_token().strip_suffix(tag_str) {
if current_token_without_here_tag.is_empty()
|| current_token_without_here_tag.ends_with('\n')
{
state.replace_with_here_doc(current_token_without_here_tag.to_owned());
*result = state.delimit_current_token(
TokenEndReason::HereDocumentBodyEnd,
&mut self.cross_state,
)?;
return Ok(true);
}
}
Ok(false)
}
const fn can_start_extglob(c: char) -> bool {
matches!(c, '@' | '!' | '?' | '+' | '*')
}
const fn can_start_operator(c: char) -> bool {
matches!(c, '&' | '(' | ')' | ';' | '\n' | '|' | '<' | '>')
}
fn is_operator(&self, s: &str) -> bool {
if !self.options.sh_mode && matches!(s, "<<<" | "&>" | "&>>" | ";;&" | ";&" | "|&") {
return true;
}
matches!(
s,
"&" | "&&"
| "("
| ")"
| ";"
| ";;"
| "\n"
| "|"
| "||"
| "<"
| ">"
| ">|"
| "<<"
| ">>"
| "<&"
| ">&"
| "<<-"
| "<>"
)
}
}
impl<R: ?Sized + std::io::BufRead> Iterator for Tokenizer<'_, R> {
type Item = Result<TokenizeResult, TokenizerError>;
fn next(&mut self) -> Option<Self::Item> {
match self.next_token() {
#[expect(clippy::manual_map)]
Ok(result) => match result.token {
Some(_) => Some(Ok(result)),
None => None,
},
Err(e) => Some(Err(e)),
}
}
}
fn append_expansion_token(
state: &mut TokenParseState,
bodies: &mut String,
result: &TokenizeResult,
) {
let text = result.token.as_ref().map_or("", Token::to_str);
match result.reason {
TokenEndReason::HereDocumentBodyStart => bodies.push('\n'),
TokenEndReason::HereDocumentBodyEnd | TokenEndReason::HereDocumentEndTag => {
bodies.push_str(text);
}
_ => state.append_str(text),
}
}
const fn is_blank(c: char) -> bool {
c == ' ' || c == '\t'
}
const fn does_char_newly_affect_quoting(state: &TokenParseState, c: char) -> bool {
if state.in_escape {
return false;
}
match state.quote_mode {
QuoteMode::Double(_) | QuoteMode::AnsiC(_) => {
if c == '\\' {
true
} else {
false
}
}
QuoteMode::Single(_) => false,
QuoteMode::None => is_quoting_char(c),
}
}
const fn is_quoting_char(c: char) -> bool {
matches!(c, '\\' | '\'' | '\"')
}
pub fn unquote_str(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut quote = None;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match (c, quote) {
('\\', None) => result.extend(chars.next()),
('\\', Some('"')) => {
if !matches!(chars.peek(), Some('$' | '`' | '"' | '\\' | '\n')) {
result.push(c);
}
result.extend(chars.next());
}
(_, Some(q)) if c == q => quote = None,
('\'' | '"', None) => quote = Some(c),
_ => result.push(c),
}
}
result
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use insta::assert_ron_snapshot;
use pretty_assertions::{assert_eq, assert_matches};
use super::*;
#[derive(serde::Serialize, serde::Deserialize)]
struct TokenizerResult<'a> {
input: &'a str,
result: Vec<Token>,
}
fn test_tokenizer(input: &str) -> Result<TokenizerResult<'_>> {
Ok(TokenizerResult {
input,
result: tokenize_str(input)?,
})
}
#[test]
fn tokenize_empty() -> Result<()> {
let tokens = tokenize_str("")?;
assert_eq!(tokens.len(), 0);
Ok(())
}
#[test]
fn tokenize_line_continuation() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"a\
bc"
)?);
Ok(())
}
#[test]
fn tokenize_operators() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("a>>b")?);
Ok(())
}
#[test]
fn tokenize_line_continuations() -> Result<()> {
for (input, expected) in [
("a&\\\n&b", &["a", "&&", "b"][..]),
("a|\\\n&b", &["a", "|&", "b"]),
("a&&\\\n", &["a", "&&"]),
("a <<\\\n", &["a", "<<"]),
("a&&\\b", &["a", "&&", "\\b"]),
("a<\\\n<b\nx\nb\n", &["a", "<<", "b", "x\n", "b", "\n"]),
("a<<\\b\n$x\nb\n", &["a", "<<", "\\b", "$x\n", "b", "\n"]),
("$\\\n(a) $\\\n{a}", &["$(a)", "${a}"]),
("$(\\\n(1))", &["$((1))"]),
(
"$(a\\\nb) ${a\\\nb} $((1+\\\n1))",
&["$(ab)", "${ab}", "$((1+1))"],
),
("\"a\\\nb\" `'a\\\nb'`", &["\"ab\"", "`'ab'`"]),
("@(a\\\nb|\"a\\\nb\")", &["@(ab|\"ab\")"]),
("a\\\\\n", &["a\\\\", "\n"]),
("'a\\\nb' $'a\\\nb'", &["'a\\\nb'", "$'a\\\nb'"]),
("`\\\\\n`", &["`\\\\\n`"]),
("@('a\\\nb')", &["@('a\\\nb')"]),
("a #b\\\nc", &["a", "\n", "c"]),
(
"<<a\nb\\\nc\\\\\nd\na\n",
&["<<", "a", "bc\\\\\nd\n", "a", "\n"],
),
("<<a\nb\\\na\na\n", &["<<", "a", "ba\n", "a", "\n"]),
("<<'a'\n\\\nb\na\n", &["<<", "'a'", "\\\nb\n", "a", "\n"]),
] {
let tokens = tokenize_str(input)?;
let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
assert_eq!(token_strs, expected, "tokenizing {input:?}");
}
Ok(())
}
#[test]
fn tokenize_token_after_blanks_and_line_continuation() -> Result<()> {
assert_matches!(
&tokenize_str("a; \\\n c")?[..],
[_, _, Token::Word(word, span)]
if word == "c" && (span.start.line, span.start.column, span.start.index) == (2, 3, 7)
);
Ok(())
}
#[test]
fn tokenize_operator_followed_by_backslash() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("a;\\b|\\\n c")?);
Ok(())
}
#[test]
fn tokenize_comment() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"a #comment
"
)?);
Ok(())
}
#[test]
fn tokenize_comment_in_command_substitution() {
for prefix in ["", " ", " ", " ", "\t", "\t\t", " \t", "\t "] {
let input = format!("$({prefix}# it's a comment\n)\n");
let tokens = tokenize_str(input.as_str()).unwrap();
let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
assert_eq!(
token_strs,
[format!("$({prefix}\n)").as_str(), "\n"],
"tokenizing {input:?}"
);
}
}
#[test]
fn tokenize_comment_at_eof() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r"a #comment")?);
Ok(())
}
#[test]
fn tokenize_empty_here_doc() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"cat <<HERE
HERE
"
)?);
Ok(())
}
#[test]
fn tokenize_here_doc() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"cat <<HERE
SOMETHING
HERE
echo after
"
)?);
assert_ron_snapshot!(test_tokenizer(
r"cat <<HERE
SOMETHING
HERE
"
)?);
assert_ron_snapshot!(test_tokenizer(
r"cat <<HERE
SOMETHING
HERE
"
)?);
assert_ron_snapshot!(test_tokenizer(
r"cat <<HERE
SOMETHING
HERE"
)?);
Ok(())
}
#[test]
fn tokenize_here_doc_with_tab_removal() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"cat <<-HERE
SOMETHING
HERE
"
)?);
Ok(())
}
#[test]
fn tokenize_here_doc_with_other_tokens() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"cat <<EOF | wc -l
A B C
1 2 3
D E F
EOF
"
)?);
Ok(())
}
#[test]
fn tokenize_multiple_here_docs() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"cat <<HERE1 <<HERE2
SOMETHING
HERE1
OTHER
HERE2
echo after
"
)?);
Ok(())
}
#[test]
fn tokenize_unterminated_here_doc() {
let result = tokenize_str(
r"cat <<HERE
SOMETHING
",
);
assert!(result.is_err());
}
#[test]
fn tokenize_here_tag_with_unterminated_expansion() {
for input in [
"<<E$[\t\t",
"<<-E$[\t\t",
"<<E$(\t\t",
"<<E$((\t\t",
"<<E\"$[\t\t",
"x <<$(( )",
"cat <<'' $(",
] {
assert_matches!(
tokenize_str(input),
Err(TokenizerError::UnterminatedExpansion),
"for {input:?}"
);
}
}
#[test]
fn tokenize_here_doc_with_empty_quoted_tag_at_end_of_input() {
for input in ["cat <<'' ", "cat <<\"\" "] {
assert_matches!(
tokenize_str(input),
Err(TokenizerError::UnterminatedHereDocuments(..)),
"for {input:?}"
);
}
}
#[test]
fn tokenize_here_tag_containing_expansions() {
for tag in ["$(x)", "E${x}", "$((1 + 2))", "$[1]", "$(echo a)", "\r"] {
let input = std::format!("cat <<{tag}\nbody\n{tag}\n");
assert_matches!(
tokenize_str(input.as_str()),
Ok(tokens) if tokens.iter().any(|t| matches!(t, Token::Word(w, _) if w == "body\n")),
"for {input:?}"
);
}
}
#[test]
fn tokenize_missing_here_tag_reports_what_was_found() {
for (input, found) in [
("cat <<\n", "\n"),
("cat <<;", ";"),
("cat <<)|$(\n)", ")"),
("echo $(cat << )", ")"),
("echo $(cat <<)", ")"),
("echo \"$(cat <<-)\"", ")"),
] {
assert_matches!(
tokenize_str(input),
Err(TokenizerError::MissingHereTag(actual)) if actual == found,
"for {input:?}"
);
}
}
#[test]
fn tokenize_input_ending_where_here_tag_expected_is_incomplete() {
for input in ["cat << ", "cat << \\\n"] {
assert_matches!(
tokenize_str(input),
Err(e) if e.is_incomplete(),
"for {input:?}"
);
}
}
#[test]
fn tokenize_here_doc_operator_in_parameter_expansion_is_literal() {
for word in ["${x:-<<}", "${x:-<< foo}", "${x:-a<<b}", "${x:-<<-EOF}"] {
let input = std::format!("echo {word}\n");
assert_matches!(
tokenize_str(input.as_str()),
Ok(tokens) if tokens.iter().any(|t| matches!(t, Token::Word(w, _) if w == word)),
"for {input:?}"
);
}
}
#[test]
fn tokenize_words_verbatim() {
for word in [
"${u:-a@(}",
"$${u",
"$$'a\\'x",
"\\$'a\\'x",
"$[ a[1] + 1 ]",
"$[ a[ a[0] ] ]",
"$[ ${a[1]} + 1 ]",
"$[ $[1] + a[$[0]] ]",
"$[ \"[\" ]",
"@(\"$'x'\")",
"\"${x:-a\tb}\"",
] {
assert_matches!(
tokenize_str(word),
Ok(tokens) if matches!(tokens.as_slice(), [Token::Word(w, _)] if w == word),
"for {word:?}"
);
}
}
#[test]
fn tokenize_here_doc_left_open_by_command_substitution() -> Result<()> {
for (input, expected) in [
(
"x=\"$(cat <<EOF)\"\nbody\nEOF\n",
&["x=\"$(cat <<EOF\nbody\nEOF\n)\"", "\n"][..],
),
(
"echo \"[$(cat <<EOF | tr a-z A-Z)]\"\nbody\nEOF\n",
&["echo", "\"[$(cat <<EOF | tr a-z A-Z\nbody\nEOF\n)]\"", "\n"],
),
(
"echo \"[$(cat <<A; cat <<-B)]\"\na\nA\n\tb\n\tB\n",
&["echo", "\"[$(cat <<A; cat <<-B\na\nA\nb\nB\n)]\"", "\n"],
),
(
"echo \"[$(echo $(cat <<EOF))]\"\nbody\nEOF\n",
&["echo", "\"[$(echo $(cat <<EOF\nbody\nEOF\n))]\"", "\n"],
),
(
"echo \"[${x:-$(cat <<EOF)}]\"\nbody\nEOF\n",
&["echo", "\"[${x:-$(cat <<EOF\nbody\nEOF\n)}]\"", "\n"],
),
(
"cat <<A; echo \"[$(cat <<B)]\"\nb\nB\na\nA\n",
&[
"cat",
"<<",
"A",
"a\n",
"A",
";",
"echo",
"\"[$(cat <<B\nb\nB\n)]\"",
"\n",
],
),
(
"echo \"[$(cat <<A)]\" \"[$(cat <<B)]\"\na\nA\nb\nB\n",
&[
"echo",
"\"[$(cat <<A\na\nA\n)]\"",
"\"[$(cat <<B\nb\nB\n)]\"",
"\n",
],
),
] {
let tokens = tokenize_str(input)?;
let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
assert_eq!(token_strs, expected, "for {input:?}");
}
let tokens = tokenize_str("echo \"$(cat <<EOF)\" x\nbody\nEOF\ny\n")?;
let positions: Vec<_> = tokens
.iter()
.map(|t| {
(
t.to_str(),
t.location().start.line,
t.location().start.column,
)
})
.collect();
assert_eq!(
positions,
[
("echo", 1, 1),
("\"$(cat <<EOF\nbody\nEOF\n)\"", 1, 6),
("x", 1, 21),
("\n", 1, 22),
("y", 4, 1),
("\n", 4, 2),
]
);
Ok(())
}
#[test]
fn tokenize_unterminated_here_doc_names_its_tag() {
assert_matches!(
tokenize_str("cat <<]|$[\n]"),
Err(TokenizerError::UnterminatedHereDocuments(tags, _)) if tags == "`]`"
);
}
#[test]
fn tokenize_deeply_nested_expansions_is_bounded() {
for (open, close) in [("$(", ")"), ("\"$(", ")\""), ("${x:-", "}"), ("$[", "]")] {
let input = std::format!("echo {}x{}", open.repeat(10_000), close.repeat(10_000));
assert_matches!(
tokenize_str(input.as_str()),
Err(TokenizerError::ExpansionNestingTooDeep),
"for {open:?}"
);
}
}
#[test]
fn tokenize_nesting_up_to_limit() {
let nested = |depth: u32| {
let depth = depth as usize;
tokenize_str(&std::format!(
"echo {}x{}",
"$(".repeat(depth),
")".repeat(depth)
))
};
assert!(nested(MAX_EXPANSION_NESTING).is_ok());
assert_matches!(
nested(MAX_EXPANSION_NESTING + 1),
Err(TokenizerError::ExpansionNestingTooDeep)
);
}
#[test]
fn tokenize_missing_here_tag() {
let result = tokenize_str(
r"cat <<
",
);
assert!(result.is_err());
}
#[test]
fn tokenize_here_doc_in_command_substitution() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"echo $(cat <<HERE
TEXT
HERE
)"
)?);
Ok(())
}
#[test]
fn tokenize_here_doc_in_double_quoted_command_substitution() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r#"echo "$(cat <<HERE
TEXT
HERE
)""#
)?);
Ok(())
}
#[test]
fn tokenize_here_doc_in_double_quoted_command_substitution_with_space() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r#"echo "$(cat << HERE
TEXT
HERE
)""#
)?);
Ok(())
}
#[test]
fn tokenize_complex_here_docs_in_command_substitution() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(
r"echo $(cat <<HERE1 <<HERE2 | wc -l
TEXT
HERE1
OTHER
HERE2
)"
)?);
Ok(())
}
#[test]
fn tokenize_simple_backquote() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r"echo `echo hi`")?);
Ok(())
}
#[test]
fn tokenize_backquote_with_escape() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r"echo `echo\`hi`")?);
Ok(())
}
#[test]
fn tokenize_unterminated_backquote() {
assert_matches!(
tokenize_str("`"),
Err(TokenizerError::UnterminatedBackquote(_))
);
}
#[test]
fn tokenize_unterminated_command_substitution() {
assert_matches!(
tokenize_str("$("),
Err(TokenizerError::UnterminatedExpansion)
);
}
#[test]
fn command_substitution_body_stops_at_closing_paren() -> Result<()> {
let options = TokenizerOptions::default();
assert_eq!(
command_substitution_body("echo hi) rest", &options)?,
"echo hi"
);
assert_eq!(
command_substitution_body(r#"echo ")" (a)) rest"#, &options)?,
r#"echo ")" (a)"#
);
assert_eq!(
command_substitution_body("cat <<'EOF'\n\"it's ) `\nEOF\n) rest", &options)?,
"cat <<'EOF'\n\"it's ) `\nEOF\n"
);
assert_eq!(
command_substitution_body("cat <<E\n)\nE\n) rest", &options)?,
"cat <<E\n)\nE\n"
);
assert_eq!(
command_substitution_body("cat <<E\n)\nE\necho after) rest", &options)?,
"cat <<E\n)\nE\necho after"
);
assert_eq!(
command_substitution_body("cat <<EOF) rest\n", &options)?,
"cat <<EOF"
);
assert_eq!(
command_substitution_body("echo $(cat <<EOF)) rest\n", &options)?,
"echo $(cat <<EOF)"
);
assert_eq!(
command_substitution_body(r#"printf "%s" @(")")) rest"#, &options)?,
r#"printf "%s" @(")")"#
);
assert_eq!(
command_substitution_body("echo “é”) ü", &options)?,
"echo “é”"
);
Ok(())
}
#[test]
fn command_substitution_body_unterminated() {
let options = TokenizerOptions::default();
assert_matches!(
command_substitution_body("echo hi", &options),
Err(TokenizerError::UnterminatedExpansion)
);
assert_matches!(
command_substitution_body("echo 'hi)", &options),
Err(TokenizerError::UnterminatedSingleQuote(_))
);
}
#[test]
fn tokenize_unterminated_arithmetic_expansion() {
assert_matches!(
tokenize_str("$(("),
Err(TokenizerError::UnterminatedExpansion)
);
}
#[test]
fn tokenize_unterminated_legacy_arithmetic_expansion() {
assert_matches!(
tokenize_str("$["),
Err(TokenizerError::UnterminatedExpansion)
);
}
#[test]
fn tokenize_command_substitution() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("a$(echo hi)b c")?);
Ok(())
}
#[test]
fn tokenize_command_substitution_with_subshell() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("$( (:) )")?);
Ok(())
}
#[test]
fn tokenize_command_substitution_containing_extglob() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("echo $(echo !(x))")?);
Ok(())
}
#[test]
fn tokenize_extglob_with_quotes_and_escapes() -> Result<()> {
for (input, expected) in [
(r#"@(")") y"#, [r#"@(")")"#, "y"]),
(r"@(a|')'|b)x y", [r"@(a|')'|b)x", "y"]),
(r#"@("(") y"#, [r#"@("(")"#, "y"]),
(r#"@("\")") y"#, [r#"@("\")")"#, "y"]),
(r"@('\')x y", [r"@('\')x", "y"]),
(r#"@(\") y"#, [r#"@(\")"#, "y"]),
(r"@(\)) y", [r"@(\))", "y"]),
(r"@($'\'')x y", [r"@($'\'')x", "y"]),
(r"@($'a\')'|b) y", [r"@($'a\')'|b)", "y"]),
(r"@(\$'a\')x y", [r"@(\$'a\')x", "y"]),
(r"@($$'a\')x y", [r"@($$'a\')x", "y"]),
(r"@($$$'\'')x y", [r"@($$$'\'')x", "y"]),
(r"@(}`'`)x y", [r"@(}`'`)x", "y"]),
(r#"@(`echo \\"`)x y"#, [r#"@(`echo \\"`)x"#, "y"]),
(r"@(`$'\'`)x y", [r"@(`$'\'`)x", "y"]),
(r#"@(`echo ")"`)x y"#, [r#"@(`echo ")"`)x"#, "y"]),
(r#"@("$(echo ")")")x y"#, [r#"@("$(echo ")")")x"#, "y"]),
(r#"@("`echo "\)"`")x y"#, [r#"@("`echo "\)"`")x"#, "y"]),
(r#"@("${u:-"}"}")x y"#, [r#"@("${u:-"}"}")x"#, "y"]),
(r#"@($")")x y"#, [r#"@($")")x"#, "y"]),
(r#"+(a|@(b|")")|'"') y"#, [r#"+(a|@(b|")")|'"')"#, "y"]),
] {
let tokens = tokenize_str(input)?;
let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
assert_eq!(token_strs, expected, "input: {input}");
}
Ok(())
}
#[test]
fn tokenize_unterminated_construct_in_extglob() {
for input in [r#"@("$(\$"\))x"#, r#"@($"$(\)")x"#, r"@(`)x", r#"@("`)")x"#] {
assert!(tokenize_str(input).is_err(), "input: {input}");
}
}
#[test]
fn tokenize_unterminated_extglob() {
for input in [r"@(a", r#"@(")""#, r"@(')'", r"@(\)"] {
assert_matches!(
tokenize_str(input),
Err(TokenizerError::UnterminatedExtendedGlob(_)),
"input: {input}"
);
}
}
#[test]
fn tokenize_arithmetic_expression() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("a$((1+2))b c")?);
Ok(())
}
#[test]
fn tokenize_arithmetic_expression_with_space() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("$(( 1 ))")?);
Ok(())
}
#[test]
fn tokenize_arithmetic_expression_with_parens() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("$(( (0) ))")?);
Ok(())
}
#[test]
fn tokenize_special_parameters() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("$$")?);
assert_ron_snapshot!(test_tokenizer("$@")?);
assert_ron_snapshot!(test_tokenizer("$!")?);
assert_ron_snapshot!(test_tokenizer("$?")?);
assert_ron_snapshot!(test_tokenizer("$*")?);
Ok(())
}
#[test]
fn tokenize_unbraced_parameter_expansion() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("$x")?);
assert_ron_snapshot!(test_tokenizer("a$x")?);
Ok(())
}
#[test]
fn tokenize_unterminated_parameter_expansion() {
assert_matches!(
tokenize_str("${x"),
Err(TokenizerError::UnterminatedVariable)
);
}
#[test]
fn tokenize_braced_parameter_expansion() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("${x}")?);
assert_ron_snapshot!(test_tokenizer("a${x}b")?);
Ok(())
}
#[test]
fn tokenize_braced_parameter_expansion_with_escaping() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r"a${x\}}b")?);
Ok(())
}
#[test]
fn tokenize_whitespace() -> Result<()> {
assert_ron_snapshot!(test_tokenizer("1 2 3")?);
Ok(())
}
#[test]
fn tokenize_escaped_whitespace() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r"1\ 2 3")?);
Ok(())
}
#[test]
fn tokenize_single_quote() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r"x'a b'y")?);
Ok(())
}
#[test]
fn tokenize_double_quote() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r#"x"a b"y"#)?);
Ok(())
}
#[test]
fn tokenize_double_quoted_command_substitution() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r#"x"$(echo hi)"y"#)?);
Ok(())
}
#[test]
fn tokenize_double_quoted_arithmetic_expression() -> Result<()> {
assert_ron_snapshot!(test_tokenizer(r#"x"$((1+2))"y"#)?);
Ok(())
}
#[test]
fn test_quote_removal() {
assert_eq!(unquote_str(r#""hello""#), "hello");
assert_eq!(unquote_str(r"'hello'"), "hello");
assert_eq!(unquote_str(r#""hel\"lo""#), r#"hel"lo"#);
assert_eq!(unquote_str(r"hel\'lo"), r"hel'lo");
assert_eq!(unquote_str(r"a\ b\\c"), r"a b\c");
assert_eq!(unquote_str(r#"a'b c'd"e f"g"#), "ab cde fg");
assert_eq!(unquote_str(r#"'a"b'"c'd""#), r#"a"bc'd"#);
assert_eq!(unquote_str(r"'a\b'"), r"a\b");
assert_eq!(unquote_str(r"'a\'b"), r"a\b");
assert_eq!(unquote_str(r#""a\$b\`c\"d\\e""#), r#"a$b`c"d\e"#);
assert_eq!(unquote_str(r#""a\b\'c""#), r"a\b\'c");
assert_eq!(unquote_str("\"a\\\nb\""), "a\nb");
assert_eq!(unquote_str("a\\\nb"), "a\nb");
assert_eq!(unquote_str(r"a\"), "a");
assert_eq!(unquote_str(r#""a\"#), r"a\");
assert_eq!(unquote_str(r"'a\"), r"a\");
assert_eq!(unquote_str(r#""a b"#), "a b");
assert_eq!(unquote_str(r#"$HOME'~'"$x""#), "$HOME~$x");
assert_eq!(unquote_str(""), "");
}
}