Skip to main content

shrub_parser/
tokenizer.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3
4use utf8_chars::BufReadCharsExt;
5
6use crate::{SourcePosition, SourceSpan};
7
8mod char_reader;
9use char_reader::CharReader;
10
11#[derive(Clone, Debug)]
12pub(crate) enum TokenEndReason {
13    /// End of input was reached.
14    EndOfInput,
15    /// An unescaped newline char was reached.
16    UnescapedNewLine,
17    /// The next char (not yet consumed) closes the nested expansion being
18    /// tokenized. Never carries a token, so here-document bookkeeping can't
19    /// hold it back.
20    ExpansionClosingChar,
21    /// A non-newline blank char was reached.
22    NonNewLineBlank,
23    /// A here-document's body is starting.
24    HereDocumentBodyStart,
25    /// A here-document's body was terminated.
26    HereDocumentBodyEnd,
27    /// A here-document's end tag was reached.
28    HereDocumentEndTag,
29    /// An operator was started.
30    OperatorStart,
31    /// An operator was terminated.
32    OperatorEnd,
33    /// Some other condition was reached.
34    Other,
35}
36
37/// Compatibility alias for `SourceSpan`.
38pub type TokenLocation = SourceSpan;
39
40/// Represents a token extracted from a shell script.
41#[derive(Clone, Debug)]
42#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
43#[cfg_attr(
44    any(test, feature = "serde"),
45    derive(PartialEq, Eq, serde::Serialize, serde::Deserialize)
46)]
47pub enum Token {
48    /// An operator token.
49    Operator(String, SourceSpan),
50    /// A word token.
51    Word(String, SourceSpan),
52}
53
54impl Token {
55    /// Returns the string value of the token.
56    pub fn to_str(&self) -> &str {
57        match self {
58            Self::Operator(s, _) => s,
59            Self::Word(s, _) => s,
60        }
61    }
62
63    /// Returns the location of the token in the source script.
64    pub const fn location(&self) -> &SourceSpan {
65        match self {
66            Self::Operator(_, l) => l,
67            Self::Word(_, l) => l,
68        }
69    }
70}
71
72#[cfg(feature = "diagnostics")]
73impl From<&Token> for miette::SourceSpan {
74    fn from(token: &Token) -> Self {
75        let start = token.location().start.as_ref();
76        Self::new(start.into(), token.location().length())
77    }
78}
79
80/// Encapsulates the result of tokenizing a shell script.
81#[derive(Clone, Debug)]
82pub(crate) struct TokenizeResult {
83    /// Reason for tokenization ending.
84    pub reason: TokenEndReason,
85    /// The token that was extracted, if any.
86    pub token: Option<Token>,
87}
88
89/// Represents an error that occurred during tokenization.
90#[derive(thiserror::Error, Debug)]
91pub enum TokenizerError {
92    /// An unterminated escape sequence was encountered at the end of the input
93    /// stream.
94    #[error("unterminated escape sequence")]
95    UnterminatedEscapeSequence,
96
97    /// An unterminated single-quoted substring was encountered at the end of
98    /// the input stream.
99    #[error("unterminated single quote at {0}")]
100    UnterminatedSingleQuote(SourcePosition),
101
102    /// An unterminated ANSI C-quoted substring was encountered at the end of
103    /// the input stream.
104    #[error("unterminated ANSI C quote at {0}")]
105    UnterminatedAnsiCQuote(SourcePosition),
106
107    /// An unterminated double-quoted substring was encountered at the end of
108    /// the input stream.
109    #[error("unterminated double quote at {0}")]
110    UnterminatedDoubleQuote(SourcePosition),
111
112    /// An unterminated back-quoted substring was encountered at the end of the
113    /// input stream.
114    #[error("unterminated backquote near {0}")]
115    UnterminatedBackquote(SourcePosition),
116
117    /// An unterminated extended glob (extglob) pattern was encountered at the
118    /// end of the input stream.
119    #[error("unterminated extglob near {0}")]
120    UnterminatedExtendedGlob(SourcePosition),
121
122    /// An unterminated variable expression was encountered at the end of the
123    /// input stream.
124    #[error("unterminated variable expression")]
125    UnterminatedVariable,
126
127    /// An unterminated command substitiion was encountered at the end of the
128    /// input stream.
129    #[error("unterminated command substitution")]
130    UnterminatedCommandSubstitution,
131
132    /// An unterminated arithmetic or other expansion was encountered at the end
133    /// of the input stream.
134    #[error("unterminated expansion")]
135    UnterminatedExpansion,
136
137    /// An error occurred decoding UTF-8 characters in the input stream.
138    #[error("failed to decode UTF-8 characters")]
139    FailedDecoding,
140
141    /// A here-document body was due with no here tag recorded for it. This
142    /// reflects an inconsistency in the tokenizer's own state, not a
143    /// problem with the input.
144    #[error("missing here tag for here document body")]
145    MissingHereTagForDocumentBody,
146
147    /// A here-document operator (`<<` or `<<-`) was followed by an operator
148    /// (e.g., a newline, `;` or `)`) where its tag belongs; the string is
149    /// that operator.
150    #[error("missing here-document tag before {0:?}")]
151    MissingHereTag(String),
152
153    /// The input ended after a here-document operator (`<<` or `<<-`) and
154    /// before its tag, but not right at the operator (e.g., after blanks
155    /// following it). Input ending right at the operator (even after a line
156    /// continuation, which is removed) tokenizes, leaving the parser
157    /// to report its end. Unlike [`TokenizerError::MissingHereTag`], more input
158    /// could still supply the tag.
159    #[error("missing here-document tag at end of input")]
160    MissingHereTagAtEndOfInput,
161
162    /// An unterminated here document sequence was encountered at the end of the
163    /// input stream.
164    #[error("unterminated here document; tag(s) {0} declared at {1}")]
165    UnterminatedHereDocuments(String, String),
166
167    /// Expansions delimited by brackets (`$(...)`, `$((...))`, `$[...]` and
168    /// `${...}`) were nested in one another more deeply than the tokenizer
169    /// supports.
170    #[error("expansions nested too deeply (limit: {})", MAX_EXPANSION_NESTING)]
171    ExpansionNestingTooDeep,
172
173    /// An I/O error occurred while reading from the input stream.
174    #[error("failed to read input")]
175    ReadError(#[from] std::io::Error),
176}
177
178impl TokenizerError {
179    /// Returns true if the error represents an error that could possibly be due
180    /// to an incomplete input stream.
181    pub const fn is_incomplete(&self) -> bool {
182        matches!(
183            self,
184            Self::UnterminatedEscapeSequence
185                | Self::UnterminatedAnsiCQuote(..)
186                | Self::UnterminatedSingleQuote(..)
187                | Self::UnterminatedDoubleQuote(..)
188                | Self::UnterminatedBackquote(..)
189                | Self::UnterminatedCommandSubstitution
190                | Self::UnterminatedExpansion
191                | Self::UnterminatedVariable
192                | Self::UnterminatedExtendedGlob(..)
193                | Self::UnterminatedHereDocuments(..)
194                | Self::MissingHereTagAtEndOfInput
195        )
196    }
197}
198
199/// Encapsulates a sequence of tokens.
200#[derive(Debug)]
201pub(crate) struct Tokens<'a> {
202    /// Sequence of tokens.
203    pub tokens: &'a [Token],
204}
205
206#[derive(Clone, Debug)]
207enum QuoteMode {
208    None,
209    AnsiC(SourcePosition),
210    Single(SourcePosition),
211    Double(SourcePosition),
212}
213
214#[derive(Clone, Debug, Default)]
215enum HereState {
216    /// In this state, we are not currently tracking any here-documents.
217    #[default]
218    None,
219    /// In this state, a here-document operator is being delimited, and the next
220    /// token will be its tag.
221    NextTokenIsHereTag { remove_tabs: bool },
222    /// In this state, the operator has been read, and the token now being read
223    /// (once it ends) will be its tag.
224    CurrentTokenIsHereTag {
225        remove_tabs: bool,
226        operator_token_result: TokenizeResult,
227    },
228    /// In this state, we expect that the *next line* will be the body of
229    /// a here-document.
230    NextLineIsHereDoc,
231    /// In this state, we are in the set of lines that comprise 1 or more
232    /// consecutive here-document bodies.
233    InHereDocs,
234}
235
236impl HereState {
237    /// Returns true if a here-document operator has been read but its tag
238    /// hasn't (yet).
239    const fn awaiting_tag(&self) -> bool {
240        matches!(
241            self,
242            Self::NextTokenIsHereTag { .. } | Self::CurrentTokenIsHereTag { .. }
243        )
244    }
245}
246
247#[derive(Clone, Debug)]
248struct HereTag {
249    tag: String,
250    tag_was_escaped_or_quoted: bool,
251    remove_tabs: bool,
252    position: SourcePosition,
253    tokens: Vec<TokenizeResult>,
254    pending_tokens_after: Vec<TokenizeResult>,
255}
256
257#[derive(Clone, Debug)]
258struct CrossTokenParseState {
259    /// Cursor within the overall token stream; used for error reporting.
260    cursor: SourcePosition,
261    /// Tokens already tokenized that should be used first to serve requests for
262    /// tokens.
263    queued_tokens: Vec<TokenizeResult>,
264    /// State of the expansion frame being tokenized; see [`ExpansionFrame`].
265    frame: ExpansionFrame,
266}
267
268/// An expansion delimited by brackets, whose contents are tokenized recursively
269/// (as a [`ExpansionFrame`] of their own) while it's consumed as part of a
270/// word.
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272enum NestedExpansion {
273    /// `$(...)`
274    CommandSubstitution,
275    /// `$((...))`
276    ArithmeticExpansion,
277    /// `$[...]`
278    LegacyArithmeticExpansion,
279    /// `${...}`
280    ParameterExpansion,
281}
282
283impl NestedExpansion {
284    /// Returns the character that closes the expansion.
285    const fn closing_char(self) -> char {
286        match self {
287            Self::CommandSubstitution | Self::ArithmeticExpansion => ')',
288            Self::LegacyArithmeticExpansion => ']',
289            Self::ParameterExpansion => '}',
290        }
291    }
292
293    /// Returns whether the expansion's contents are arithmetic, where `<<` is a
294    /// shift.
295    const fn is_arithmetic(self) -> bool {
296        matches!(
297            self,
298            Self::ArithmeticExpansion | Self::LegacyArithmeticExpansion
299        )
300    }
301
302    /// Returns the character that, in the expansion's contents, pairs with its
303    /// closing char: the `(` of `$( (a) )`, or the `[` of `$[ a[1] ]`. Bash
304    /// doesn't pair braces in `${...}`.
305    const fn opening_char(self) -> Option<char> {
306        match self {
307            Self::CommandSubstitution | Self::ArithmeticExpansion => Some('('),
308            Self::LegacyArithmeticExpansion => Some('['),
309            Self::ParameterExpansion => None,
310        }
311    }
312
313    /// Returns how many closing chars end the expansion, counting from just
314    /// after its opening: two for `$((`, whose opening leaves two parens
315    /// open, and one otherwise.
316    const fn closers_after_opening(self) -> usize {
317        match self {
318            Self::ArithmeticExpansion => 2,
319            _ => 1,
320        }
321    }
322
323    /// Returns the error for input that ends before the expansion is closed.
324    const fn unterminated_error(self) -> TokenizerError {
325        match self {
326            Self::ParameterExpansion => TokenizerError::UnterminatedVariable,
327            _ => TokenizerError::UnterminatedExpansion,
328        }
329    }
330}
331
332/// Tokenizer state belonging to one frame of the stack of nested expansions
333/// being tokenized; the bottom frame is the top level of the input, outside any
334/// expansion. Consuming a nested expansion pushes a fresh frame for its
335/// contents and pops it at the expansion's end, as bash saves and restores its
336/// parser state around a command substitution. The stack is the
337/// recursion itself: each enclosing frame is held by the call consuming the
338/// expansion nested in it.
339#[derive(Clone, Debug, Default)]
340struct ExpansionFrame {
341    /// The expansion whose contents this frame is, if any (none at the bottom).
342    expansion: Option<NestedExpansion>,
343    /// How many frames enclose this one; see [`MAX_EXPANSION_NESTING`].
344    depth: u32,
345    /// Current state of parsing here-documents.
346    here_state: HereState,
347    /// Ordered queue of here tags for which we're still looking for matching
348    /// here-document bodies.
349    current_here_tags: Vec<HereTag>,
350    /// How many closing chars (see [`NestedExpansion::closing_char`]) the
351    /// expansion needs before it ends. Its contents may use the closing
352    /// char too, paired with an opener of their own (see
353    /// [`NestedExpansion::opening_char`]); each opener read needs one more.
354    closers_needed: usize,
355    /// How many parens of the `((...))` command being read are still open, if
356    /// any (zero outside one). Within one, `<<` is a shift rather than a
357    /// here-document operator. (In an arithmetic expansion's contents, `<<`
358    /// is always a shift; see [`NestedExpansion::is_arithmetic`].)
359    arithmetic_command_parens: usize,
360}
361
362impl ExpansionFrame {
363    /// Notes that `c` was read unquoted from the frame's own contents (not from
364    /// anything nested in them), keeping count of the closing chars the
365    /// expansion needs: an opener needs one more, and a closing char that
366    /// doesn't end the expansion closes an opener.
367    fn note_unquoted_char(&mut self, c: char) {
368        let Some(expansion) = self.expansion else {
369            return;
370        };
371        if expansion.opening_char() == Some(c) {
372            self.closers_needed += 1;
373        } else if expansion.closing_char() == c {
374            // A closing char that ends the expansion is taken as such before
375            // it's read as contents, so it never gets here.
376            debug_assert!(self.closers_needed > 1, "read the expansion's closing char");
377            self.closers_needed -= 1;
378        }
379    }
380}
381
382/// Options controlling how the tokenizer operates.
383#[derive(Clone, Debug, Hash, Eq, PartialEq)]
384pub struct TokenizerOptions {
385    /// Whether or not to enable extended globbing patterns (extglob).
386    pub enable_extended_globbing: bool,
387    /// Whether or not to operate in POSIX compliance mode.
388    pub posix_mode: bool,
389    /// Whether or not we're running in SH emulation mode.
390    pub sh_mode: bool,
391}
392
393impl Default for TokenizerOptions {
394    fn default() -> Self {
395        Self {
396            enable_extended_globbing: true,
397            posix_mode: false,
398            sh_mode: false,
399        }
400    }
401}
402
403/// Maximum depth of nested expansions (see [`NestedExpansion`]) the tokenizer
404/// will descend into, so deeply nested input can't overflow the stack while
405/// it's tokenized. Real scripts rarely nest more than a few levels: of ~2,900
406/// surveyed (including bash-completion and ble.sh), none nested more than 4.
407///
408/// This bounds only the tokenizer's own recursion. Words keep their nesting, so
409/// later passes over them (e.g., expanding them) recurse as deeply, with stack
410/// frames of their own that may be far larger (in an unoptimized build, over
411/// 100 KiB per level of `${...}` expansion).
412pub(crate) const MAX_EXPANSION_NESTING: u32 = 32;
413
414/// A tokenizer for shell scripts.
415pub(crate) struct Tokenizer<'a, R: ?Sized + std::io::BufRead> {
416    char_reader: CharReader<utf8_chars::Chars<'a, R>>,
417    /// Chars read ahead and put back, to be read again before any further
418    /// input; see [`Tokenizer::read_here_doc_bodies_left_pending`].
419    unread: Option<UnreadChars>,
420    /// Whether to read the bodies of here-documents left pending when a nested
421    /// expansion ends;
422    /// see [`Tokenizer::read_here_doc_bodies_left_pending`].
423    /// [`command_substitution_body`] doesn't: it wants only the text before
424    /// the `)`, and the bodies follow it.
425    read_bodies_left_pending: bool,
426    cross_state: CrossTokenParseState,
427    options: TokenizerOptions,
428}
429
430/// Chars read ahead of the tokenizer and put back.
431struct UnreadChars {
432    /// The chars, in reverse order (the next to be read last).
433    chars: Vec<char>,
434    /// Where the cursor resumes once they've all been read again: where it was
435    /// after reading ahead, not where it would be after the chars
436    /// themselves.
437    resume_at: SourcePosition,
438}
439
440/// Encapsulates the current token parsing state.
441#[derive(Clone, Debug)]
442struct TokenParseState {
443    pub start_position: SourcePosition,
444    pub token_so_far: String,
445    pub token_is_operator: bool,
446    pub in_escape: bool,
447    pub quote_mode: QuoteMode,
448}
449
450impl TokenParseState {
451    pub fn new(start_position: &SourcePosition) -> Self {
452        Self {
453            start_position: start_position.to_owned(),
454            token_so_far: String::new(),
455            token_is_operator: false,
456            in_escape: false,
457            quote_mode: QuoteMode::None,
458        }
459    }
460
461    pub fn pop(&mut self, end_position: &SourcePosition) -> Token {
462        let end = Arc::new(end_position.to_owned());
463        let token_location = SourceSpan {
464            start: Arc::new(std::mem::take(&mut self.start_position)),
465            end,
466        };
467
468        let token = if std::mem::take(&mut self.token_is_operator) {
469            Token::Operator(std::mem::take(&mut self.token_so_far), token_location)
470        } else {
471            Token::Word(std::mem::take(&mut self.token_so_far), token_location)
472        };
473
474        end_position.clone_into(&mut self.start_position);
475        self.in_escape = false;
476        self.quote_mode = QuoteMode::None;
477
478        token
479    }
480
481    pub const fn started_token(&self) -> bool {
482        !self.token_so_far.is_empty()
483    }
484
485    /// Returns true if the token so far consists only of blanks.
486    ///
487    /// This can only happen when tokenizing with `include_space`, where blanks
488    /// are accumulated into the token so that the original text of a nested
489    /// expansion can be reproduced. Such blanks are not a word, so a `#`
490    /// following them still begins a comment.
491    pub fn only_blanks_so_far(&self) -> bool {
492        !self.token_so_far.is_empty() && self.token_so_far.chars().all(is_blank)
493    }
494
495    pub fn append_char(&mut self, c: char) {
496        self.token_so_far.push(c);
497    }
498
499    pub fn append_str(&mut self, s: &str) {
500        self.token_so_far.push_str(s);
501    }
502
503    pub const fn unquoted(&self) -> bool {
504        !self.in_escape && matches!(self.quote_mode, QuoteMode::None)
505    }
506
507    pub fn current_token(&self) -> &str {
508        &self.token_so_far
509    }
510
511    pub fn is_specific_operator(&self, operator: &str) -> bool {
512        self.token_is_operator && self.current_token() == operator
513    }
514
515    pub const fn in_operator(&self) -> bool {
516        self.token_is_operator
517    }
518
519    fn is_newline(&self) -> bool {
520        self.token_so_far == "\n"
521    }
522
523    fn replace_with_here_doc(&mut self, s: String) {
524        self.token_so_far = s;
525    }
526
527    /// Ends the current token, queueing here-document state as needed.
528    ///
529    /// # Errors
530    ///
531    /// - [`TokenizerError::MissingHereTag`] if an operator appears where a
532    ///   here-document tag is required.
533    /// - [`TokenizerError::MissingHereTagForDocumentBody`] if a here-document
534    ///   body ends with no recorded tag, which indicates inconsistent tokenizer
535    ///   state.
536    #[allow(clippy::too_many_lines)]
537    pub fn delimit_current_token(
538        &mut self,
539        reason: TokenEndReason,
540        cross_token_state: &mut CrossTokenParseState,
541    ) -> Result<Option<TokenizeResult>, TokenizerError> {
542        // If we don't have anything in the token, then don't yield an empty
543        // string token *unless* it's the body of a here document.
544        if !self.started_token() && !matches!(reason, TokenEndReason::HereDocumentBodyEnd) {
545            return Ok(Some(TokenizeResult {
546                reason,
547                token: None,
548            }));
549        }
550
551        let current_here_state = std::mem::take(&mut cross_token_state.frame.here_state);
552        match current_here_state {
553            HereState::NextTokenIsHereTag { remove_tabs } => {
554                // Don't yield the operator as a token yet. We need to make sure
555                // we collect up everything we need for all the
556                // here-documents with tags on this line.
557                let operator_token_result = TokenizeResult {
558                    reason,
559                    token: Some(self.pop(&cross_token_state.cursor)),
560                };
561
562                cross_token_state.frame.here_state = HereState::CurrentTokenIsHereTag {
563                    remove_tabs,
564                    operator_token_result,
565                };
566
567                return Ok(None);
568            }
569            HereState::CurrentTokenIsHereTag {
570                remove_tabs,
571                operator_token_result,
572            } => {
573                // A here tag must be a word; an operator in its place (e.g., a
574                // newline, `;` or `)`) means it's missing.
575                if self.token_is_operator {
576                    return Err(TokenizerError::MissingHereTag(
577                        self.current_token().to_owned(),
578                    ));
579                }
580
581                // Inside a nested expansion, where blanks are kept, blanks
582                // between the operator and the tag may be
583                // delimited as a token of their own (e.g., the
584                // first of the two blanks in `$(cat <<  EOF)`). They're not the
585                // tag; skip them and keep waiting for it.
586                if self.only_blanks_so_far() {
587                    self.pop(&cross_token_state.cursor);
588                    cross_token_state.frame.here_state = HereState::CurrentTokenIsHereTag {
589                        remove_tabs,
590                        operator_token_result,
591                    };
592                    return Ok(None);
593                }
594
595                cross_token_state.frame.here_state = HereState::NextLineIsHereDoc;
596
597                // Include the trailing \n in the here tag so it's easier to
598                // check against. Kept blanks may lead the token
599                // (e.g., `$(cat << EOF)`); strip only those, since
600                // anything else (even a carriage return) is part of the tag.
601                let tag = std::format!("{}\n", self.current_token().trim_start_matches(is_blank));
602                let tag_was_escaped_or_quoted = tag.contains(is_quoting_char);
603
604                let tag_token_result = TokenizeResult {
605                    reason,
606                    token: Some(self.pop(&cross_token_state.cursor)),
607                };
608
609                cross_token_state.frame.current_here_tags.push(HereTag {
610                    tag,
611                    tag_was_escaped_or_quoted,
612                    remove_tabs,
613                    position: cross_token_state.cursor.clone(),
614                    tokens: vec![operator_token_result, tag_token_result],
615                    pending_tokens_after: vec![],
616                });
617
618                return Ok(None);
619            }
620            HereState::NextLineIsHereDoc => {
621                if self.is_newline() {
622                    cross_token_state.frame.here_state = HereState::InHereDocs;
623                } else {
624                    cross_token_state.frame.here_state = HereState::NextLineIsHereDoc;
625                }
626
627                if let Some(last_here_tag) = cross_token_state.frame.current_here_tags.last_mut() {
628                    let token = self.pop(&cross_token_state.cursor);
629                    let result = TokenizeResult {
630                        reason,
631                        token: Some(token),
632                    };
633
634                    last_here_tag.pending_tokens_after.push(result);
635                } else {
636                    return Err(TokenizerError::MissingHereTagForDocumentBody);
637                }
638
639                return Ok(None);
640            }
641            HereState::InHereDocs => {
642                // We hit the end of the current here-document.
643                let completed_here_tag = cross_token_state.frame.current_here_tags.remove(0);
644
645                // First queue the redirection operator and (start) here-tag.
646                cross_token_state
647                    .queued_tokens
648                    .extend(completed_here_tag.tokens);
649
650                // Leave a hint that we are about to start a here-document.
651                cross_token_state.queued_tokens.push(TokenizeResult {
652                    reason: TokenEndReason::HereDocumentBodyStart,
653                    token: None,
654                });
655
656                // Then queue the body document we just finished.
657                cross_token_state.queued_tokens.push(TokenizeResult {
658                    reason,
659                    token: Some(self.pop(&cross_token_state.cursor)),
660                });
661
662                // Then queue up the (end) here-tag.
663                let end_tag = if completed_here_tag.tag_was_escaped_or_quoted {
664                    unquote_str(&completed_here_tag.tag)
665                } else {
666                    completed_here_tag.tag
667                };
668                self.append_str(end_tag.trim_end_matches('\n'));
669                cross_token_state.queued_tokens.push(TokenizeResult {
670                    reason: TokenEndReason::HereDocumentEndTag,
671                    token: Some(self.pop(&cross_token_state.cursor)),
672                });
673
674                // Now we're ready to queue up any tokens that came between the
675                // completed here tag and the next here tag (or
676                // newline after it if it was the last).
677                cross_token_state
678                    .queued_tokens
679                    .extend(completed_here_tag.pending_tokens_after);
680
681                if cross_token_state.frame.current_here_tags.is_empty() {
682                    cross_token_state.frame.here_state = HereState::None;
683                } else {
684                    cross_token_state.frame.here_state = HereState::InHereDocs;
685                }
686
687                return Ok(None);
688            }
689            HereState::None => (),
690        }
691
692        let token = self.pop(&cross_token_state.cursor);
693        let result = TokenizeResult {
694            reason,
695            token: Some(token),
696        };
697
698        Ok(Some(result))
699    }
700}
701
702/// Break the given input shell script string into tokens, returning the tokens.
703///
704/// # Arguments
705///
706/// * `input` - The shell script to tokenize.
707///
708/// # Errors
709///
710/// - [`crate::TokenizerError::UnterminatedEscapeSequence`],
711///   [`crate::TokenizerError::UnterminatedSingleQuote`],
712///   [`crate::TokenizerError::UnterminatedAnsiCQuote`],
713///   [`crate::TokenizerError::UnterminatedDoubleQuote`],
714///   [`crate::TokenizerError::UnterminatedBackquote`] or
715///   [`crate::TokenizerError::UnterminatedExtendedGlob`] if the input ends
716///   inside the matching quoting construct.
717/// - [`crate::TokenizerError::UnterminatedVariable`],
718///   [`crate::TokenizerError::UnterminatedCommandSubstitution`] or
719///   [`crate::TokenizerError::UnterminatedExpansion`] if the input ends inside
720///   a `${...}`, `$(...)`, `$((...))` or `$[...]` expansion.
721/// - [`crate::TokenizerError::UnterminatedHereDocuments`] if the input ends
722///   before the body of a declared here-document.
723/// - [`crate::TokenizerError::MissingHereTag`] or
724///   [`crate::TokenizerError::MissingHereTagAtEndOfInput`] if a `<<` or `<<-`
725///   operator has no tag.
726/// - [`crate::TokenizerError::ExpansionNestingTooDeep`] if expansions nest more
727///   than 32 levels deep.
728pub fn tokenize_str(input: &str) -> Result<Vec<Token>, TokenizerError> {
729    tokenize_str_with_options(input, &TokenizerOptions::default())
730}
731
732/// Break the given input shell script string into tokens, returning the tokens.
733///
734/// # Arguments
735///
736/// * `input` - The shell script to tokenize.
737/// * `options` - Options controlling how the tokenizer operates.
738///
739/// # Errors
740///
741/// - [`crate::TokenizerError::UnterminatedEscapeSequence`],
742///   [`crate::TokenizerError::UnterminatedSingleQuote`],
743///   [`crate::TokenizerError::UnterminatedAnsiCQuote`],
744///   [`crate::TokenizerError::UnterminatedDoubleQuote`],
745///   [`crate::TokenizerError::UnterminatedBackquote`] or
746///   [`crate::TokenizerError::UnterminatedExtendedGlob`] if the input ends
747///   inside the matching quoting construct.
748/// - [`crate::TokenizerError::UnterminatedVariable`],
749///   [`crate::TokenizerError::UnterminatedCommandSubstitution`] or
750///   [`crate::TokenizerError::UnterminatedExpansion`] if the input ends inside
751///   a `${...}`, `$(...)`, `$((...))` or `$[...]` expansion.
752/// - [`crate::TokenizerError::UnterminatedHereDocuments`] if the input ends
753///   before the body of a declared here-document.
754/// - [`crate::TokenizerError::MissingHereTag`] or
755///   [`crate::TokenizerError::MissingHereTagAtEndOfInput`] if a `<<` or `<<-`
756///   operator has no tag.
757/// - [`crate::TokenizerError::ExpansionNestingTooDeep`] if expansions nest more
758///   than 32 levels deep.
759pub fn tokenize_str_with_options(
760    input: &str,
761    options: &TokenizerOptions,
762) -> Result<Vec<Token>, TokenizerError> {
763    uncached_tokenize_string(input, options)
764}
765
766#[cached::macros::cached(
767    name = "TOKENIZE_CACHE",
768    max_size = 64,
769    key = "(String, TokenizerOptions)",
770    convert = r#"{ (input.to_owned(), options.to_owned()) }"#
771)]
772fn uncached_tokenize_string(
773    input: &str,
774    options: &TokenizerOptions,
775) -> Result<Vec<Token>, TokenizerError> {
776    uncached_tokenize_str(input, options)
777}
778
779/// Break the given input shell script string into tokens, returning the tokens.
780/// No caching is performed.
781///
782/// # Arguments
783///
784/// * `input` - The shell script to tokenize.
785///
786/// # Errors
787///
788/// - [`crate::TokenizerError::UnterminatedEscapeSequence`],
789///   [`crate::TokenizerError::UnterminatedSingleQuote`],
790///   [`crate::TokenizerError::UnterminatedAnsiCQuote`],
791///   [`crate::TokenizerError::UnterminatedDoubleQuote`],
792///   [`crate::TokenizerError::UnterminatedBackquote`] or
793///   [`crate::TokenizerError::UnterminatedExtendedGlob`] if the input ends
794///   inside the matching quoting construct.
795/// - [`crate::TokenizerError::UnterminatedVariable`],
796///   [`crate::TokenizerError::UnterminatedCommandSubstitution`] or
797///   [`crate::TokenizerError::UnterminatedExpansion`] if the input ends inside
798///   a `${...}`, `$(...)`, `$((...))` or `$[...]` expansion.
799/// - [`crate::TokenizerError::UnterminatedHereDocuments`] if the input ends
800///   before the body of a declared here-document.
801/// - [`crate::TokenizerError::MissingHereTag`] or
802///   [`crate::TokenizerError::MissingHereTagAtEndOfInput`] if a `<<` or `<<-`
803///   operator has no tag.
804/// - [`crate::TokenizerError::ExpansionNestingTooDeep`] if expansions nest more
805///   than 32 levels deep.
806pub fn uncached_tokenize_str(
807    input: &str,
808    options: &TokenizerOptions,
809) -> Result<Vec<Token>, TokenizerError> {
810    let mut reader = std::io::BufReader::new(input.as_bytes());
811    let mut tokenizer = crate::tokenizer::Tokenizer::new(&mut reader, options);
812
813    let mut tokens = vec![];
814    loop {
815        match tokenizer.next_token()? {
816            TokenizeResult {
817                token: Some(token), ..
818            } => tokens.push(token),
819            TokenizeResult {
820                reason: TokenEndReason::EndOfInput,
821                ..
822            } => break,
823            _ => (),
824        }
825    }
826
827    Ok(tokens)
828}
829
830/// Given the text following the `$(` that opens a command substitution, returns
831/// the command's text up to (but not including) the `)` that closes it. The
832/// command is tokenized to find that `)`, so quoting, nested expansions, and
833/// here-document bodies are skipped over exactly as they are when tokenizing a
834/// full script.
835///
836/// # Errors
837///
838/// Returns an error if the closing `)` can't be found: either the input ends
839/// first ([`TokenizerError::UnterminatedExpansion`]), or tokenizing the command
840/// fails before reaching it (e.g., an unterminated quote or here-document).
841/// This is not a syntax check of the command itself; it's parsed separately,
842/// when it is executed.
843pub(crate) fn command_substitution_body<'a>(
844    input: &'a str,
845    options: &TokenizerOptions,
846) -> Result<&'a str, TokenizerError> {
847    expansion_body(input, options, NestedExpansion::CommandSubstitution)
848}
849
850/// Given the text following `$[`, returns the arithmetic expression before its
851/// closing `]`. The tokenizer counts nested brackets without recursing and
852/// skips quoted brackets and nested expansions. Arithmetic syntax is checked
853/// separately when the expression is evaluated.
854pub(crate) fn legacy_arithmetic_expansion_body<'a>(
855    input: &'a str,
856    options: &TokenizerOptions,
857) -> Result<&'a str, TokenizerError> {
858    expansion_body(input, options, NestedExpansion::LegacyArithmeticExpansion)
859}
860
861fn expansion_body<'a>(
862    input: &'a str,
863    options: &TokenizerOptions,
864    expansion: NestedExpansion,
865) -> Result<&'a str, TokenizerError> {
866    let mut reader = input.as_bytes();
867    let mut tokenizer = Tokenizer::new(&mut reader, options);
868    tokenizer.read_bodies_left_pending = false;
869
870    // Consume tokens through the expansion's closing character. We don't need
871    // the token text collected in `state`: it's a normalized rendering, not
872    // a slice of `input`.
873    let mut state = TokenParseState::new(&tokenizer.cross_state.cursor);
874    tokenizer.consume_nested_expansion(&mut state, expansion)?;
875
876    // The cursor counts characters (not bytes) consumed, the last being the
877    // closing character; convert the characters before it to a byte length.
878    let body_len = input
879        .chars()
880        .take(tokenizer.cross_state.cursor.index - 1)
881        .map(char::len_utf8)
882        .sum();
883    Ok(input.split_at(body_len).0)
884}
885
886impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> {
887    pub fn new(reader: &'a mut R, options: &TokenizerOptions) -> Self {
888        Tokenizer {
889            options: options.clone(),
890            char_reader: CharReader::new(reader.chars()),
891            unread: None,
892            read_bodies_left_pending: true,
893            cross_state: CrossTokenParseState {
894                cursor: SourcePosition {
895                    index: 0,
896                    line: 1,
897                    column: 1,
898                },
899                queued_tokens: vec![],
900                frame: ExpansionFrame::default(),
901            },
902        }
903    }
904
905    #[expect(clippy::unnecessary_wraps)]
906    pub fn current_location(&self) -> Option<SourcePosition> {
907        Some(self.cross_state.cursor.clone())
908    }
909
910    fn next_char(&mut self) -> Result<Option<char>, TokenizerError> {
911        let c = match self.unread.as_mut().and_then(|unread| unread.chars.pop()) {
912            Some(c) => Some(c),
913            None => self
914                .char_reader
915                .next()
916                .transpose()
917                .map_err(TokenizerError::ReadError)?,
918        };
919
920        if let Some(ch) = c {
921            if ch == '\n' {
922                self.cross_state.cursor.line += 1;
923                self.cross_state.cursor.column = 1;
924            } else {
925                self.cross_state.cursor.column += 1;
926            }
927            self.cross_state.cursor.index += 1;
928        }
929
930        if let Some(unread) = self.unread.take_if(|unread| unread.chars.is_empty()) {
931            self.cross_state.cursor = unread.resume_at;
932        }
933
934        Ok(c)
935    }
936
937    fn consume_char(&mut self) -> Result<(), TokenizerError> {
938        let _ = self.next_char()?;
939        Ok(())
940    }
941
942    fn peek_char(&mut self) -> Result<Option<char>, TokenizerError> {
943        if let Some(c) = self.unread.as_ref().and_then(|unread| unread.chars.last()) {
944            return Ok(Some(*c));
945        }
946
947        match self.char_reader.peek() {
948            Some(result) => match result {
949                Ok(c) => Ok(Some(*c)),
950                Err(_) => Err(TokenizerError::FailedDecoding),
951            },
952            None => Ok(None),
953        }
954    }
955
956    /// Returns the char after the next one, consuming neither.
957    fn peek_second_char(&mut self) -> Result<Option<char>, TokenizerError> {
958        // Any unread chars come first. They're kept in reverse order (the next
959        // one last).
960        let peeked = match self
961            .unread
962            .as_ref()
963            .map_or(&[][..], |unread| unread.chars.as_slice())
964        {
965            [.., second, _] => return Ok(Some(*second)),
966            [_] => self.char_reader.peek(),
967            [] => self.char_reader.peek_second(),
968        };
969
970        match peeked {
971            Some(Ok(c)) => Ok(Some(*c)),
972            Some(Err(_)) => Err(TokenizerError::FailedDecoding),
973            None => Ok(None),
974        }
975    }
976
977    /// Consumes any line continuations (backslash-newline pairs) next in the
978    /// input. Bash removes them as it reads input, before anything else
979    /// sees it, except where a backslash is taken literally: right after an
980    /// escaping backslash, within single quotes (including `$'...'`),
981    /// in a comment, or in the body of a here-document whose tag is quoted. The
982    /// tokenizer calls this wherever it reads input in which they're
983    /// removed.
984    fn skip_line_continuations(&mut self) -> Result<(), TokenizerError> {
985        while self.peek_char()? == Some('\\') && self.peek_second_char()? == Some('\n') {
986            self.consume_char()?;
987            self.consume_char()?;
988        }
989        Ok(())
990    }
991
992    /// Returns whether a backslash next in the input would be taken literally,
993    /// given the state of the token in progress, rather than possibly
994    /// starting a line continuation; see
995    /// [`Tokenizer::skip_line_continuations`].
996    fn backslash_is_literal(&self, state: &TokenParseState) -> bool {
997        if state.in_escape || matches!(state.quote_mode, QuoteMode::Single(_) | QuoteMode::AnsiC(_))
998        {
999            return true;
1000        }
1001
1002        // A here-document body follows the newline ending the line holding its
1003        // tag.
1004        let frame = &self.cross_state.frame;
1005        let in_here_doc_body = match frame.here_state {
1006            HereState::InHereDocs => true,
1007            HereState::NextLineIsHereDoc => state.is_newline(),
1008            _ => false,
1009        };
1010        if !in_here_doc_body {
1011            return false;
1012        }
1013
1014        // The whole body is literal if its tag was quoted. (Otherwise, a
1015        // backslash escaping another is read along with it, so the next
1016        // one is never escaped.)
1017        frame
1018            .current_here_tags
1019            .first()
1020            .is_some_and(|tag| tag.tag_was_escaped_or_quoted)
1021    }
1022
1023    /// Consumes the rest of a nested expansion, whose opening (e.g., `$(`) has
1024    /// already been appended to `state`'s token, appending the rest to it
1025    /// as well.
1026    fn consume_nested_expansion(
1027        &mut self,
1028        state: &mut TokenParseState,
1029        expansion: NestedExpansion,
1030    ) -> Result<(), TokenizerError> {
1031        // This function and `next_token` are mutually recursive, as deep as the
1032        // *input* nests expansions; bound it so pathological input
1033        // can't overflow the stack.
1034        let depth = self.cross_state.frame.depth + 1;
1035        if depth > MAX_EXPANSION_NESTING {
1036            return Err(TokenizerError::ExpansionNestingTooDeep);
1037        }
1038
1039        // Push a frame for the expansion's contents, where none of the
1040        // enclosing frame's state applies: they're all part of one
1041        // word, so none of their tokens can be (or end) a
1042        // here tag pending before it, and they can't be in a `((...))` command.
1043        // Pop it (restoring the enclosing frame) once they end.
1044        let frame = ExpansionFrame {
1045            expansion: Some(expansion),
1046            depth,
1047            closers_needed: expansion.closers_after_opening(),
1048            ..ExpansionFrame::default()
1049        };
1050
1051        // Frames share the queue of tokens, but none are queued across a push
1052        // or pop: tokens are only read from the input (and so
1053        // expansions only consumed) once the queue is empty, and
1054        // consuming one leaves it empty.
1055        debug_assert!(self.cross_state.queued_tokens.is_empty());
1056        let enclosing = std::mem::replace(&mut self.cross_state.frame, frame);
1057        let result = self.consume_expansion_contents(state, expansion);
1058        debug_assert!(result.is_err() || self.cross_state.queued_tokens.is_empty());
1059        self.cross_state.frame = enclosing;
1060        result
1061    }
1062
1063    /// Consumes the tokens of a nested expansion's contents, through its
1064    /// closing char, appending their text to `state`'s token.
1065    fn consume_expansion_contents(
1066        &mut self,
1067        state: &mut TokenParseState,
1068        expansion: NestedExpansion,
1069    ) -> Result<(), TokenizerError> {
1070        let mut bodies = String::new();
1071
1072        loop {
1073            let result = self.next_token()?;
1074
1075            match result.reason {
1076                // The bodies set aside go after the rest of the line holding their tags.
1077                TokenEndReason::UnescapedNewLine => state.append_str(&std::mem::take(&mut bodies)),
1078                TokenEndReason::ExpansionClosingChar => {
1079                    // The closing char comes next; consume it too.
1080                    let closing_char = self
1081                        .next_char()?
1082                        .ok_or_else(|| expansion.unterminated_error())?;
1083
1084                    let here_state = &self.cross_state.frame.here_state;
1085                    if here_state.awaiting_tag() {
1086                        // The expansion ended right after a here-document
1087                        // operator, as in `$(cat <<)`.
1088                        return Err(TokenizerError::MissingHereTag(closing_char.to_string()));
1089                    } else if matches!(here_state, HereState::NextLineIsHereDoc)
1090                        && self.read_bodies_left_pending
1091                    {
1092                        self.read_here_doc_bodies_left_pending(state)?;
1093                    }
1094
1095                    state.append_char(closing_char);
1096                    return Ok(());
1097                }
1098                TokenEndReason::EndOfInput => return Err(expansion.unterminated_error()),
1099                _ => (),
1100            }
1101
1102            append_expansion_token(state, &mut bodies, &result);
1103        }
1104    }
1105
1106    /// Reads the bodies of here-documents still pending when a nested
1107    /// expansion's contents end, as in `$(cat <<EOF)`, completing the
1108    /// expansion's text: appends to `state`'s token the tokens held back
1109    /// for them, then the bodies, as though the line ended just before
1110    /// the expansion's closing char (consumed, but not yet appended).
1111    ///
1112    /// Bash accepts this (with a warning), reading the bodies from the lines
1113    /// after the current one, ahead of any other here-documents pending by
1114    /// then. So set the rest of the line aside, read the bodies, and put it
1115    /// back to be tokenized as though they weren't there.
1116    fn read_here_doc_bodies_left_pending(
1117        &mut self,
1118        state: &mut TokenParseState,
1119    ) -> Result<(), TokenizerError> {
1120        let rest_of_line_start = self.cross_state.cursor.clone();
1121        let mut rest_of_line = vec![];
1122        while let Some(c) = self.next_char()? {
1123            rest_of_line.push(c);
1124            if c == '\n' {
1125                break;
1126            }
1127        }
1128
1129        // As when a line ends, the held tokens come out in order, with the
1130        // bodies (and end tags) interleaved; the bodies go after all of
1131        // them.
1132        self.cross_state.frame.here_state = HereState::InHereDocs;
1133        let mut bodies = String::new();
1134        while !matches!(self.cross_state.frame.here_state, HereState::None)
1135            || !self.cross_state.queued_tokens.is_empty()
1136        {
1137            let result = self.next_token()?;
1138            append_expansion_token(state, &mut bodies, &result);
1139        }
1140        state.append_str(&bodies);
1141        state.append_char('\n');
1142
1143        if !rest_of_line.is_empty() {
1144            rest_of_line.reverse();
1145            let resume_at = std::mem::replace(&mut self.cross_state.cursor, rest_of_line_start);
1146            self.unread = Some(UnreadChars {
1147                chars: rest_of_line,
1148                resume_at,
1149            });
1150        }
1151
1152        Ok(())
1153    }
1154
1155    /// Consumes a `$` or backquote (`c`, the next char) and whatever expansion
1156    /// it begins (e.g., `$(...)`, `${...}`, `` `...` ``), appending all of
1157    /// it to `state`'s token. `in_double_quotes` says whether `c` is inside
1158    /// double quotes, which `state` doesn't always track (e.g., within an
1159    /// extglob pattern).
1160    #[allow(clippy::unwrap_in_result)]
1161    fn consume_dollar_or_backquote(
1162        &mut self,
1163        state: &mut TokenParseState,
1164        c: char,
1165        in_double_quotes: bool,
1166    ) -> Result<(), TokenizerError> {
1167        if c == '$' {
1168            // Consume the '$' so we can peek beyond.
1169            self.consume_char()?;
1170            self.skip_line_continuations()?;
1171
1172            // Now peek beyond to see what we have.
1173            let char_after_dollar_sign = self.peek_char()?;
1174            match char_after_dollar_sign {
1175                Some('(') => {
1176                    // Add the '$' we already consumed to the token.
1177                    state.append_char('$');
1178
1179                    // Consume the '(' and add it to the token.
1180                    state.append_char(self.next_char()?.unwrap());
1181
1182                    // Check to see if this is possibly an arithmetic expression
1183                    // (i.e., one that starts with `$((`).
1184                    self.skip_line_continuations()?;
1185                    let expansion = if matches!(self.peek_char()?, Some('(')) {
1186                        // Consume the second '(' and add it to the token.
1187                        state.append_char(self.next_char()?.unwrap());
1188                        NestedExpansion::ArithmeticExpansion
1189                    } else {
1190                        NestedExpansion::CommandSubstitution
1191                    };
1192
1193                    self.consume_nested_expansion(state, expansion)?;
1194                }
1195
1196                Some('[') => {
1197                    // Add the '$' we already consumed to the token.
1198                    state.append_char('$');
1199
1200                    // Consume the '[' and add it to the token.
1201                    state.append_char(self.next_char()?.unwrap());
1202
1203                    self.consume_nested_expansion(
1204                        state,
1205                        NestedExpansion::LegacyArithmeticExpansion,
1206                    )?;
1207                }
1208
1209                Some('{') => {
1210                    // Add the '$' we already consumed to the token.
1211                    state.append_char('$');
1212
1213                    // Consume the '{' and add it to the token.
1214                    state.append_char(self.next_char()?.unwrap());
1215
1216                    self.consume_nested_expansion(state, NestedExpansion::ParameterExpansion)?;
1217                }
1218                // `$$` is a parameter of its own; whatever follows it starts nothing.
1219                Some('$') => {
1220                    self.consume_char()?;
1221                    state.append_str("$$");
1222                }
1223
1224                // `$'...'` is ANSI-C quoting, but only outside double quotes.
1225                Some('\'') if !in_double_quotes => {
1226                    state.append_char('$');
1227                    state.quote_mode = QuoteMode::AnsiC(self.cross_state.cursor.clone());
1228                    state.append_char(self.next_char()?.unwrap());
1229                }
1230
1231                _ => {
1232                    // This is either a different character, or else the end of
1233                    // the string. Either way, add the '$'
1234                    // we already consumed to the token.
1235                    state.append_char('$');
1236                }
1237            }
1238        } else {
1239            // We look for the terminating backquote. First disable normal
1240            // consumption and consume the starting backquote.
1241            let backquote_pos = self.cross_state.cursor.clone();
1242            self.consume_char()?;
1243
1244            // Add the opening backquote to the token.
1245            state.append_char(c);
1246
1247            // Now continue until we see an unescaped backquote.
1248            let mut escaping_enabled = false;
1249            let mut done = false;
1250            while !done {
1251                // As in bash, line continuations are removed throughout (even
1252                // within quotes in the command), except right
1253                // after an escaping backslash.
1254                if !escaping_enabled {
1255                    self.skip_line_continuations()?;
1256                }
1257
1258                // Read (and consume) the next char.
1259                let next_char_in_backquote = self.next_char()?;
1260                if let Some(cib) = next_char_in_backquote {
1261                    // Include it in the token no matter what.
1262                    state.append_char(cib);
1263
1264                    // Watch out for escaping.
1265                    if !escaping_enabled && cib == '\\' {
1266                        escaping_enabled = true;
1267                    } else {
1268                        // Look for an unescaped backquote to terminate.
1269                        if !escaping_enabled && cib == '`' {
1270                            done = true;
1271                        }
1272                        escaping_enabled = false;
1273                    }
1274                } else {
1275                    return Err(TokenizerError::UnterminatedBackquote(backquote_pos));
1276                }
1277            }
1278        }
1279
1280        Ok(())
1281    }
1282
1283    /// Returns the next token from the input stream.
1284    ///
1285    /// Within a nested expansion's contents, the closing char that ends the
1286    /// expansion ends the token in progress and is then reported on its own
1287    /// (see [`TokenEndReason::ExpansionClosingChar`]), and blanks are kept
1288    /// in tokens so that the expansion's original text can be reproduced.
1289    ///
1290    /// # Errors
1291    ///
1292    /// Returns a [`TokenizerError`] if the input is malformed or cannot be
1293    /// read. The variants are the same as for [`tokenize_str`].
1294    ///
1295    /// # Panics
1296    ///
1297    /// Panics if the tokenizer's internal invariants are violated, for example
1298    /// if an operator is being completed with no token in progress. That
1299    /// indicates a bug, not bad input.
1300    #[expect(clippy::cognitive_complexity)]
1301    #[expect(clippy::if_same_then_else)]
1302    #[expect(clippy::panic_in_result_fn)]
1303    #[expect(clippy::too_many_lines)]
1304    #[allow(clippy::unwrap_in_result)]
1305    pub fn next_token(&mut self) -> Result<TokenizeResult, TokenizerError> {
1306        let mut state = TokenParseState::new(&self.cross_state.cursor);
1307        let mut result: Option<TokenizeResult> = None;
1308
1309        let expansion = self.cross_state.frame.expansion;
1310        let closing_char = expansion.map(NestedExpansion::closing_char);
1311        let include_space = expansion.is_some();
1312        // Directly inside `${...}`, text is a word, not a command: bash
1313        // recognizes no here-documents, comments or extglobs there.
1314        let in_parameter_expansion = expansion == Some(NestedExpansion::ParameterExpansion);
1315        let in_arithmetic_expansion = expansion.is_some_and(NestedExpansion::is_arithmetic);
1316
1317        while result.is_none() {
1318            // First satisfy token results from our queue. Once we exhaust the
1319            // queue then we'll look at the input stream.
1320            if !self.cross_state.queued_tokens.is_empty() {
1321                return Ok(self.cross_state.queued_tokens.remove(0));
1322            }
1323
1324            // As bash does as it reads input, drop any line continuations
1325            // before looking at the next char, unless the backslash
1326            // would be taken literally. A token not yet started
1327            // starts after them.
1328            let mut next = self.peek_char()?;
1329            if next == Some('\\')
1330                && self.peek_second_char()? == Some('\n')
1331                && !self.backslash_is_literal(&state)
1332            {
1333                self.skip_line_continuations()?;
1334                if !state.started_token() {
1335                    state.start_position = self.cross_state.cursor.clone();
1336                }
1337                next = self.peek_char()?;
1338            }
1339            let c = next.unwrap_or('\0');
1340
1341            // When we hit the end of the input, then we're done with the
1342            // current token (if there is one).
1343            if next.is_none() {
1344                // TODO(tokenizer): Verify we're not waiting on some terminating
1345                // character? Verify we're out of all quotes.
1346                if state.in_escape {
1347                    return Err(TokenizerError::UnterminatedEscapeSequence);
1348                }
1349                match state.quote_mode {
1350                    QuoteMode::None => (),
1351                    QuoteMode::AnsiC(pos) => {
1352                        return Err(TokenizerError::UnterminatedAnsiCQuote(pos));
1353                    }
1354                    QuoteMode::Single(pos) => {
1355                        return Err(TokenizerError::UnterminatedSingleQuote(pos));
1356                    }
1357                    QuoteMode::Double(pos) => {
1358                        return Err(TokenizerError::UnterminatedDoubleQuote(pos));
1359                    }
1360                }
1361
1362                // Verify we're not in a here document.
1363                if !matches!(self.cross_state.frame.here_state, HereState::None) {
1364                    // If the input ends while a here tag is expected, finish
1365                    // the tag in progress so it's recorded
1366                    // (and reported below); without one, the tag
1367                    // is missing.
1368                    if self.cross_state.frame.here_state.awaiting_tag() && state.started_token() {
1369                        state.delimit_current_token(
1370                            TokenEndReason::EndOfInput,
1371                            &mut self.cross_state,
1372                        )?;
1373                    }
1374                    if self.cross_state.frame.here_state.awaiting_tag() {
1375                        return Err(TokenizerError::MissingHereTagAtEndOfInput);
1376                    }
1377
1378                    // Only a body can end at an end tag. Outside one, an empty
1379                    // tag would "match" on every pass
1380                    // without making progress, looping forever.
1381                    if matches!(self.cross_state.frame.here_state, HereState::InHereDocs)
1382                        && self.remove_here_end_tag(&mut state, &mut result, false)?
1383                    {
1384                        // If we hit end tag without a trailing newline, try to
1385                        // get next token.
1386                        continue;
1387                    }
1388
1389                    let tag_names = self
1390                        .cross_state
1391                        .frame
1392                        .current_here_tags
1393                        .iter()
1394                        .map(|tag| std::format!("`{}`", tag.tag.trim_end_matches('\n')))
1395                        .collect::<Vec<_>>()
1396                        .join(", ");
1397                    let tag_positions = self
1398                        .cross_state
1399                        .frame
1400                        .current_here_tags
1401                        .iter()
1402                        .map(|tag| std::format!("{}", tag.position))
1403                        .collect::<Vec<_>>()
1404                        .join(", ");
1405                    return Err(TokenizerError::UnterminatedHereDocuments(
1406                        tag_names,
1407                        tag_positions,
1408                    ));
1409                }
1410
1411                result = state
1412                    .delimit_current_token(TokenEndReason::EndOfInput, &mut self.cross_state)?;
1413            //
1414            // Handle being in a here document.
1415            //
1416            } else if matches!(self.cross_state.frame.here_state, HereState::InHereDocs) {
1417                //
1418                // For now, just include the character in the current token. We
1419                // also check if there are leading tabs to be
1420                // removed.
1421                //
1422                if !self.cross_state.frame.current_here_tags.is_empty()
1423                    && self.cross_state.frame.current_here_tags[0].remove_tabs
1424                    && (!state.started_token() || state.current_token().ends_with('\n'))
1425                    && c == '\t'
1426                {
1427                    // Consume it but don't include it.
1428                    self.consume_char()?;
1429                } else {
1430                    self.consume_char()?;
1431                    state.append_char(c);
1432
1433                    // See if this was a newline character following the
1434                    // terminating here tag.
1435                    if c == '\n' {
1436                        self.remove_here_end_tag(&mut state, &mut result, true)?;
1437                    } else if c == '\\'
1438                        && self
1439                            .cross_state
1440                            .frame
1441                            .current_here_tags
1442                            .first()
1443                            .is_some_and(|tag| !tag.tag_was_escaped_or_quoted)
1444                    {
1445                        // In the body of a here-document whose tag isn't
1446                        // quoted, a backslash
1447                        // escapes the next char; take that as-is too. (It isn't
1448                        // a newline: that would have
1449                        // been a line continuation, already removed.)
1450                        if let Some(escaped) = self.next_char()? {
1451                            state.append_char(escaped);
1452                        }
1453                    }
1454                }
1455            //
1456            // Look for the closing char that ends the nested expansion being
1457            // consumed (one that closes an opener in its contents
1458            // is read like any other char, below). An operator
1459            // in progress is delimited first, below, so that it takes effect: a
1460            // newline may start a here-doc body, and a `<<` expects
1461            // its tag (and reports it missing). Any other
1462            // token in progress is delimited here, leaving the closing char to
1463            // be reported on its own next time around.
1464            //
1465            } else if state.unquoted()
1466                && !state.in_operator()
1467                && closing_char == Some(c)
1468                && self.cross_state.frame.closers_needed == 1
1469            {
1470                let reason = if state.started_token() {
1471                    TokenEndReason::Other
1472                } else {
1473                    TokenEndReason::ExpansionClosingChar
1474                };
1475                result = state.delimit_current_token(reason, &mut self.cross_state)?;
1476            } else if state.in_operator() {
1477                //
1478                // We're in an operator. See if this character continues an
1479                // operator, or if it must be a separate token
1480                // (because it wouldn't make a prefix of an operator).
1481                //
1482
1483                let mut hypothetical_token = state.current_token().to_owned();
1484                hypothetical_token.push(c);
1485
1486                if state.unquoted() && self.is_operator(hypothetical_token.as_ref()) {
1487                    self.consume_char()?;
1488                    state.append_char(c);
1489                } else {
1490                    assert!(state.started_token());
1491
1492                    //
1493                    // N.B. If the completed operator indicates a here-document,
1494                    // then keep track that the *next* token
1495                    // should be the here-tag.
1496                    //
1497                    // In an arithmetic context, don't consider << and <<-
1498                    // special. They're not here-docs,
1499                    // they're either a left-shift operator or a left-shift
1500                    // operator followed by a unary minus operator.
1501                    //
1502                    if in_arithmetic_expansion || in_parameter_expansion {
1503                        // Operators have no effect here: arithmetic contents
1504                        // are arithmetic throughout,
1505                        // and `${...}` contents are a word, not a command.
1506                    } else if self.cross_state.frame.arithmetic_command_parens > 0 {
1507                        // A `((...))` command ends at the `)` that balances its
1508                        // first `(`.
1509                        let parens = &mut self.cross_state.frame.arithmetic_command_parens;
1510                        if state.is_specific_operator("(") {
1511                            *parens += 1;
1512                        } else if state.is_specific_operator(")") {
1513                            *parens -= 1;
1514                        }
1515                    } else if state.is_specific_operator("<<") {
1516                        self.cross_state.frame.here_state =
1517                            HereState::NextTokenIsHereTag { remove_tabs: false };
1518                    } else if state.is_specific_operator("<<-") {
1519                        self.cross_state.frame.here_state =
1520                            HereState::NextTokenIsHereTag { remove_tabs: true };
1521                    } else if state.is_specific_operator("(") && c == '(' {
1522                        // A `((...))` command starts with this `(`.
1523                        self.cross_state.frame.arithmetic_command_parens = 1;
1524                    }
1525
1526                    let reason = if state.current_token() == "\n" {
1527                        TokenEndReason::UnescapedNewLine
1528                    } else {
1529                        TokenEndReason::OperatorEnd
1530                    };
1531
1532                    result = state.delimit_current_token(reason, &mut self.cross_state)?;
1533                }
1534            //
1535            // See if this is a character that changes the current
1536            // escaping/quoting state.
1537            //
1538            } else if does_char_newly_affect_quoting(&state, c) {
1539                if c == '\\' {
1540                    self.consume_char()?;
1541                    state.in_escape = true;
1542                    state.append_char(c);
1543                } else if c == '\'' {
1544                    // (A `$'` was already taken as ANSI-C quoting where its `$`
1545                    // was read.)
1546                    state.quote_mode = QuoteMode::Single(self.cross_state.cursor.clone());
1547                    self.consume_char()?;
1548                    state.append_char(c);
1549                } else if c == '\"' {
1550                    state.quote_mode = QuoteMode::Double(self.cross_state.cursor.clone());
1551                    self.consume_char()?;
1552                    state.append_char(c);
1553                }
1554            }
1555            //
1556            // Handle end of single-quote, double-quote, or ANSI-C quote.
1557            else if !state.in_escape
1558                && matches!(
1559                    state.quote_mode,
1560                    QuoteMode::Single(..) | QuoteMode::AnsiC(..)
1561                )
1562                && c == '\''
1563            {
1564                state.quote_mode = QuoteMode::None;
1565                self.consume_char()?;
1566                state.append_char(c);
1567            } else if !state.in_escape
1568                && matches!(state.quote_mode, QuoteMode::Double(..))
1569                && c == '\"'
1570            {
1571                state.quote_mode = QuoteMode::None;
1572                self.consume_char()?;
1573                state.append_char(c);
1574            }
1575            //
1576            // Handle end of escape sequence.
1577            // TODO(tokenizer): Handle double-quote specific escape sequences.
1578            else if state.in_escape {
1579                state.in_escape = false;
1580                self.consume_char()?;
1581                state.append_char(c);
1582            } else if (state.unquoted()
1583                || (matches!(state.quote_mode, QuoteMode::Double(_)) && !state.in_escape))
1584                && (c == '$' || c == '`')
1585            {
1586                // TODO(tokenizer): handle quoted $ or ` in a double quote
1587                let in_double_quotes = matches!(state.quote_mode, QuoteMode::Double(_));
1588                self.consume_dollar_or_backquote(&mut state, c, in_double_quotes)?;
1589            }
1590            //
1591            // [Extension]
1592            // If extended globbing is enabled, the last consumed character is an
1593            // unquoted start of an extglob pattern, *and* if the current character
1594            // is an open parenthesis, then this begins an extglob pattern.
1595            else if c == '('
1596                && self.options.enable_extended_globbing
1597                && !in_parameter_expansion
1598                && state.unquoted()
1599                && !state.in_operator()
1600                && state
1601                    .current_token()
1602                    .ends_with(|x| Self::can_start_extglob(x))
1603            {
1604                // Consume the '(' and append it.
1605                self.consume_char()?;
1606                state.append_char(c);
1607
1608                let mut paren_depth = 1;
1609                // The char that closes the current quote, and whether backslash
1610                // escapes within it (it doesn't in '...', but does in "..." and
1611                // $'...').
1612                let mut quote: Option<(char, bool)> = None;
1613                let mut after_dollar = false;
1614
1615                // Keep consuming until we see the matching end ')'. Parens
1616                // inside quotes are literal pattern characters,
1617                // not delimiters. As in bash, `$(...)` and
1618                // `${...}` are only nested constructs inside double
1619                // quotes; elsewhere their parens simply count toward the
1620                // nesting.
1621                while paren_depth > 0 {
1622                    // Only single quotes (including `$'...'`) keep line
1623                    // continuations; double quotes don't.
1624                    if !matches!(quote, Some(('\'', _))) {
1625                        self.skip_line_continuations()?;
1626                    }
1627                    let Some(extglob_char) = self.peek_char()? else {
1628                        return Err(TokenizerError::UnterminatedExtendedGlob(
1629                            self.cross_state.cursor.clone(),
1630                        ));
1631                    };
1632                    let was_after_dollar = std::mem::take(&mut after_dollar);
1633
1634                    let starts_nested_construct = match quote {
1635                        None => extglob_char == '`',
1636                        Some(('"', _)) => matches!(extglob_char, '`' | '$'),
1637                        Some(_) => false,
1638                    };
1639                    if starts_nested_construct {
1640                        // A `$` only gets here inside double quotes, so it's
1641                        // never taken as starting `$'..
1642                        // .'`, which would set `state`'s quote mode rather than
1643                        // this loop's own `quote`.
1644                        let in_double_quotes = quote.is_some();
1645                        self.consume_dollar_or_backquote(
1646                            &mut state,
1647                            extglob_char,
1648                            in_double_quotes,
1649                        )?;
1650                        continue;
1651                    }
1652
1653                    // Include it in the token.
1654                    self.consume_char()?;
1655                    state.append_char(extglob_char);
1656
1657                    match extglob_char {
1658                        // Take the escaped char as-is. If the input ends instead, the next
1659                        // iteration reports the unterminated extglob.
1660                        '\\' if quote.is_none_or(|(_, escapes)| escapes) => {
1661                            if let Some(escaped_char) = self.next_char()? {
1662                                state.append_char(escaped_char);
1663                            }
1664                        }
1665                        c if quote.is_some_and(|(close, _)| close == c) => quote = None,
1666                        _ if quote.is_some() => (),
1667                        '\'' => quote = Some(('\'', was_after_dollar)),
1668                        '"' => quote = Some(('"', true)),
1669                        // `$$` is a parameter, not a `$` that could start `$'`.
1670                        '$' => after_dollar = !was_after_dollar,
1671                        '(' => paren_depth += 1,
1672                        ')' => paren_depth -= 1,
1673                        _ => (),
1674                    }
1675                }
1676            //
1677            // If the character *can* start an operator, then it will.
1678            //
1679            } else if state.unquoted() && Self::can_start_operator(c) {
1680                if state.started_token() {
1681                    result = state.delimit_current_token(
1682                        TokenEndReason::OperatorStart,
1683                        &mut self.cross_state,
1684                    )?;
1685                } else {
1686                    state.token_is_operator = true;
1687                    self.consume_char()?;
1688                    state.append_char(c);
1689                    self.cross_state.frame.note_unquoted_char(c);
1690                }
1691            //
1692            // Whitespace gets discarded (and delimits tokens).
1693            //
1694            } else if state.unquoted() && is_blank(c) {
1695                if state.started_token() {
1696                    result = state.delimit_current_token(
1697                        TokenEndReason::NonNewLineBlank,
1698                        &mut self.cross_state,
1699                    )?;
1700
1701                    // Where blanks are kept, this one begins the next token, as
1702                    // written.
1703                    if include_space {
1704                        continue;
1705                    }
1706                } else if include_space {
1707                    state.append_char(c);
1708                } else {
1709                    // Make sure we don't include this char in the token range.
1710                    state.start_position.column += 1;
1711                    state.start_position.index += 1;
1712                }
1713
1714                self.consume_char()?;
1715            }
1716            //
1717            // N.B. Inside a parameter expansion, everything left belongs to the word, even
1718            // where no token was started: a `#` there never starts a comment (e.g., `${#x}`).
1719            //
1720            // Elsewhere, the `!only_blanks_so_far` clause keeps a comment recognizable inside a
1721            // nested expansion. Blanks are kept in tokens there, so a `#` after them follows a
1722            // token in progress; being only blanks, that token isn't a word, and the `#` still
1723            // starts a comment (e.g., in `$( #'<newline>)`).
1724            else if !state.token_is_operator
1725                && (in_parameter_expansion
1726                    || (state.started_token() && !(c == '#' && state.only_blanks_so_far())))
1727            {
1728                self.consume_char()?;
1729                state.append_char(c);
1730                if state.unquoted() {
1731                    self.cross_state.frame.note_unquoted_char(c);
1732                }
1733            } else if c == '#' {
1734                // Consume the '#'.
1735                self.consume_char()?;
1736
1737                let mut done = false;
1738                while !done {
1739                    done = match self.peek_char()? {
1740                        Some('\n') => true,
1741                        None => true,
1742                        _ => {
1743                            // Consume the peeked char; it's part of the
1744                            // comment.
1745                            self.consume_char()?;
1746                            false
1747                        }
1748                    };
1749                }
1750                // Re-start loop as if the comment never happened.
1751            } else if state.started_token() {
1752                // In all other cases where we have an in-progress token, we
1753                // delimit here.
1754                result =
1755                    state.delimit_current_token(TokenEndReason::Other, &mut self.cross_state)?;
1756            } else {
1757                // If we got here, then we don't have a token in progress and
1758                // we're not starting an operator. Add the
1759                // character to a new token.
1760                self.consume_char()?;
1761                state.append_char(c);
1762                self.cross_state.frame.note_unquoted_char(c);
1763            }
1764        }
1765
1766        let result = result.unwrap();
1767
1768        Ok(result)
1769    }
1770
1771    fn remove_here_end_tag(
1772        &mut self,
1773        state: &mut TokenParseState,
1774        result: &mut Option<TokenizeResult>,
1775        ends_with_newline: bool,
1776    ) -> Result<bool, TokenizerError> {
1777        // Bail immediately if we don't even have a *starting* here tag.
1778        if self.cross_state.frame.current_here_tags.is_empty() {
1779            return Ok(false);
1780        }
1781
1782        let next_here_tag = &self.cross_state.frame.current_here_tags[0];
1783
1784        let tag_str: Cow<'_, str> = if next_here_tag.tag_was_escaped_or_quoted {
1785            unquote_str(next_here_tag.tag.as_str()).into()
1786        } else {
1787            next_here_tag.tag.as_str().into()
1788        };
1789
1790        let tag_str = if !ends_with_newline {
1791            tag_str
1792                .strip_suffix('\n')
1793                .unwrap_or_else(|| tag_str.as_ref())
1794        } else {
1795            tag_str.as_ref()
1796        };
1797
1798        // At the end of input, the tag's line may lack its newline (`cat
1799        // <<EOF⏎body⏎EOF`). An empty tag would then match the nothing
1800        // after the last newline, which isn't a line: as in bash, its
1801        // body only ends at an empty line.
1802        if tag_str.is_empty() {
1803            return Ok(false);
1804        }
1805
1806        if let Some(current_token_without_here_tag) = state.current_token().strip_suffix(tag_str) {
1807            // Make sure that was either the start of the here document, or
1808            // there was a newline between the preceding part
1809            // and the tag.
1810            if current_token_without_here_tag.is_empty()
1811                || current_token_without_here_tag.ends_with('\n')
1812            {
1813                state.replace_with_here_doc(current_token_without_here_tag.to_owned());
1814
1815                // Delimit the end of the here-document body.
1816                *result = state.delimit_current_token(
1817                    TokenEndReason::HereDocumentBodyEnd,
1818                    &mut self.cross_state,
1819                )?;
1820
1821                return Ok(true);
1822            }
1823        }
1824        Ok(false)
1825    }
1826
1827    const fn can_start_extglob(c: char) -> bool {
1828        matches!(c, '@' | '!' | '?' | '+' | '*')
1829    }
1830
1831    const fn can_start_operator(c: char) -> bool {
1832        matches!(c, '&' | '(' | ')' | ';' | '\n' | '|' | '<' | '>')
1833    }
1834
1835    fn is_operator(&self, s: &str) -> bool {
1836        // Handle non-POSIX operators.
1837        if !self.options.sh_mode && matches!(s, "<<<" | "&>" | "&>>" | ";;&" | ";&" | "|&") {
1838            return true;
1839        }
1840
1841        matches!(
1842            s,
1843            "&" | "&&"
1844                | "("
1845                | ")"
1846                | ";"
1847                | ";;"
1848                | "\n"
1849                | "|"
1850                | "||"
1851                | "<"
1852                | ">"
1853                | ">|"
1854                | "<<"
1855                | ">>"
1856                | "<&"
1857                | ">&"
1858                | "<<-"
1859                | "<>"
1860        )
1861    }
1862}
1863
1864impl<R: ?Sized + std::io::BufRead> Iterator for Tokenizer<'_, R> {
1865    type Item = Result<TokenizeResult, TokenizerError>;
1866
1867    fn next(&mut self) -> Option<Self::Item> {
1868        match self.next_token() {
1869            #[expect(clippy::manual_map)]
1870            Ok(result) => match result.token {
1871                Some(_) => Some(Ok(result)),
1872                None => None,
1873            },
1874            Err(e) => Some(Err(e)),
1875        }
1876    }
1877}
1878
1879/// Appends the text of a token from a nested expansion's contents to the
1880/// expansion's text in `state`'s token, except for here-document bodies (and
1881/// end tags): they come out ahead of the rest of the line holding their tags,
1882/// so they're set aside in `bodies`, to follow it.
1883fn append_expansion_token(
1884    state: &mut TokenParseState,
1885    bodies: &mut String,
1886    result: &TokenizeResult,
1887) {
1888    let text = result.token.as_ref().map_or("", Token::to_str);
1889    match result.reason {
1890        TokenEndReason::HereDocumentBodyStart => bodies.push('\n'),
1891        TokenEndReason::HereDocumentBodyEnd | TokenEndReason::HereDocumentEndTag => {
1892            bodies.push_str(text);
1893        }
1894        _ => state.append_str(text),
1895    }
1896}
1897
1898const fn is_blank(c: char) -> bool {
1899    c == ' ' || c == '\t'
1900}
1901
1902const fn does_char_newly_affect_quoting(state: &TokenParseState, c: char) -> bool {
1903    // If we're currently escaped, then nothing affects quoting.
1904    if state.in_escape {
1905        return false;
1906    }
1907
1908    match state.quote_mode {
1909        // When we're in a double quote or ANSI-C quote, only a subset of escape
1910        // sequences are recognized.
1911        QuoteMode::Double(_) | QuoteMode::AnsiC(_) => {
1912            if c == '\\' {
1913                // TODO(tokenizer): handle backslash in double quote
1914                true
1915            } else {
1916                false
1917            }
1918        }
1919        // When we're in a single quote, nothing affects quoting.
1920        QuoteMode::Single(_) => false,
1921        // When we're not already in a quote, then we can straightforwardly look for a
1922        // quote mark or backslash.
1923        QuoteMode::None => is_quoting_char(c),
1924    }
1925}
1926
1927const fn is_quoting_char(c: char) -> bool {
1928    matches!(c, '\\' | '\'' | '\"')
1929}
1930
1931/// Returns `s` with its quoting removed, as the shell's quote removal does.
1932///
1933/// Removes quote chars that open or close a quote, and backslashes that escape
1934/// the char after them. As in the shell, a backslash is literal in single
1935/// quotes, and in double quotes it escapes only `$`, `` ` ``, `"`, `\`, and
1936/// newline. Unlike the shell's line continuation, an escaped newline is kept.
1937/// Nothing is expanded, and `$'...'` and `$"..."` aren't treated specially.
1938///
1939/// # Arguments
1940///
1941/// * `s` - The string to unquote.
1942pub fn unquote_str(s: &str) -> String {
1943    let mut result = String::with_capacity(s.len());
1944    // The quote (`'` or `"`) the next char is in, if any.
1945    let mut quote = None;
1946    let mut chars = s.chars().peekable();
1947
1948    while let Some(c) = chars.next() {
1949        match (c, quote) {
1950            ('\\', None) => result.extend(chars.next()),
1951            ('\\', Some('"')) => {
1952                if !matches!(chars.peek(), Some('$' | '`' | '"' | '\\' | '\n')) {
1953                    result.push(c);
1954                }
1955                result.extend(chars.next());
1956            }
1957            (_, Some(q)) if c == q => quote = None,
1958            ('\'' | '"', None) => quote = Some(c),
1959            _ => result.push(c),
1960        }
1961    }
1962
1963    result
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968
1969    use anyhow::Result;
1970    use insta::assert_ron_snapshot;
1971    use pretty_assertions::{assert_eq, assert_matches};
1972
1973    use super::*;
1974
1975    #[derive(serde::Serialize, serde::Deserialize)]
1976    struct TokenizerResult<'a> {
1977        input: &'a str,
1978        result: Vec<Token>,
1979    }
1980
1981    fn test_tokenizer(input: &str) -> Result<TokenizerResult<'_>> {
1982        Ok(TokenizerResult {
1983            input,
1984            result: tokenize_str(input)?,
1985        })
1986    }
1987
1988    #[test]
1989    fn tokenize_empty() -> Result<()> {
1990        let tokens = tokenize_str("")?;
1991        assert_eq!(tokens.len(), 0);
1992        Ok(())
1993    }
1994
1995    #[test]
1996    fn tokenize_line_continuation() -> Result<()> {
1997        assert_ron_snapshot!(test_tokenizer(
1998            r"a\
1999bc"
2000        )?);
2001        Ok(())
2002    }
2003
2004    #[test]
2005    fn tokenize_operators() -> Result<()> {
2006        assert_ron_snapshot!(test_tokenizer("a>>b")?);
2007        Ok(())
2008    }
2009
2010    #[test]
2011    fn tokenize_line_continuations() -> Result<()> {
2012        // As in bash, a line continuation is removed wherever the backslash
2013        // isn't quoted, even splitting an operator or following a `$`.
2014        for (input, expected) in [
2015            ("a&\\\n&b", &["a", "&&", "b"][..]),
2016            ("a|\\\n&b", &["a", "|&", "b"]),
2017            ("a&&\\\n", &["a", "&&"]),
2018            // At the end of input, it's as though the input ended right at the operator.
2019            ("a <<\\\n", &["a", "<<"]),
2020            ("a&&\\b", &["a", "&&", "\\b"]),
2021            ("a<\\\n<b\nx\nb\n", &["a", "<<", "b", "x\n", "b", "\n"]),
2022            ("a<<\\b\n$x\nb\n", &["a", "<<", "\\b", "$x\n", "b", "\n"]),
2023            ("$\\\n(a) $\\\n{a}", &["$(a)", "${a}"]),
2024            ("$(\\\n(1))", &["$((1))"]),
2025            (
2026                "$(a\\\nb) ${a\\\nb} $((1+\\\n1))",
2027                &["$(ab)", "${ab}", "$((1+1))"],
2028            ),
2029            ("\"a\\\nb\" `'a\\\nb'`", &["\"ab\"", "`'ab'`"]),
2030            ("@(a\\\nb|\"a\\\nb\")", &["@(ab|\"ab\")"]),
2031            // Kept after an escaping backslash, in single quotes, and in a comment.
2032            ("a\\\\\n", &["a\\\\", "\n"]),
2033            ("'a\\\nb' $'a\\\nb'", &["'a\\\nb'", "$'a\\\nb'"]),
2034            ("`\\\\\n`", &["`\\\\\n`"]),
2035            ("@('a\\\nb')", &["@('a\\\nb')"]),
2036            ("a #b\\\nc", &["a", "\n", "c"]),
2037            // In a here-document's body, they're kept only if its tag is quoted.
2038            (
2039                "<<a\nb\\\nc\\\\\nd\na\n",
2040                &["<<", "a", "bc\\\\\nd\n", "a", "\n"],
2041            ),
2042            ("<<a\nb\\\na\na\n", &["<<", "a", "ba\n", "a", "\n"]),
2043            ("<<'a'\n\\\nb\na\n", &["<<", "'a'", "\\\nb\n", "a", "\n"]),
2044        ] {
2045            let tokens = tokenize_str(input)?;
2046            let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
2047            assert_eq!(token_strs, expected, "tokenizing {input:?}");
2048        }
2049        Ok(())
2050    }
2051
2052    #[test]
2053    fn tokenize_token_after_blanks_and_line_continuation() -> Result<()> {
2054        // The blanks before the line continuation don't count toward where `c`
2055        // starts.
2056        assert_matches!(
2057            &tokenize_str("a; \\\n  c")?[..],
2058            [_, _, Token::Word(word, span)]
2059                if word == "c" && (span.start.line, span.start.column, span.start.index) == (2, 3, 7)
2060        );
2061        Ok(())
2062    }
2063
2064    #[test]
2065    fn tokenize_operator_followed_by_backslash() -> Result<()> {
2066        // The `;` ends before the escaped `b`. The `|` ends after the line
2067        // continuation following it (as a word would), and the `c`
2068        // starts on the next line.
2069        assert_ron_snapshot!(test_tokenizer("a;\\b|\\\n  c")?);
2070        Ok(())
2071    }
2072
2073    #[test]
2074    fn tokenize_comment() -> Result<()> {
2075        assert_ron_snapshot!(test_tokenizer(
2076            r"a #comment
2077"
2078        )?);
2079        Ok(())
2080    }
2081
2082    #[test]
2083    fn tokenize_comment_in_command_substitution() {
2084        // A comment inside `$( )` is a comment however many blanks precede its
2085        // `#`, even though blanks are kept in tokens there, so the
2086        // apostrophe in its text doesn't open a quote. The comment is
2087        // dropped from the text reconstructed for the substitution, leaving
2088        // just the blanks that preceded it, as written.
2089        for prefix in ["", " ", "  ", "   ", "\t", "\t\t", " \t", "\t "] {
2090            let input = format!("$({prefix}# it's a comment\n)\n");
2091            let tokens = tokenize_str(input.as_str()).unwrap();
2092            let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
2093            assert_eq!(
2094                token_strs,
2095                [format!("$({prefix}\n)").as_str(), "\n"],
2096                "tokenizing {input:?}"
2097            );
2098        }
2099    }
2100
2101    #[test]
2102    fn tokenize_comment_at_eof() -> Result<()> {
2103        assert_ron_snapshot!(test_tokenizer(r"a #comment")?);
2104        Ok(())
2105    }
2106
2107    #[test]
2108    fn tokenize_empty_here_doc() -> Result<()> {
2109        assert_ron_snapshot!(test_tokenizer(
2110            r"cat <<HERE
2111HERE
2112"
2113        )?);
2114        Ok(())
2115    }
2116
2117    #[test]
2118    fn tokenize_here_doc() -> Result<()> {
2119        assert_ron_snapshot!(test_tokenizer(
2120            r"cat <<HERE
2121SOMETHING
2122HERE
2123echo after
2124"
2125        )?);
2126        assert_ron_snapshot!(test_tokenizer(
2127            r"cat <<HERE
2128SOMETHING
2129HERE
2130"
2131        )?);
2132        assert_ron_snapshot!(test_tokenizer(
2133            r"cat <<HERE
2134SOMETHING
2135HERE
2136
2137"
2138        )?);
2139        assert_ron_snapshot!(test_tokenizer(
2140            r"cat <<HERE
2141SOMETHING
2142HERE"
2143        )?);
2144        Ok(())
2145    }
2146
2147    #[test]
2148    fn tokenize_here_doc_with_tab_removal() -> Result<()> {
2149        assert_ron_snapshot!(test_tokenizer(
2150            r"cat <<-HERE
2151	SOMETHING
2152	HERE
2153"
2154        )?);
2155        Ok(())
2156    }
2157
2158    #[test]
2159    fn tokenize_here_doc_with_other_tokens() -> Result<()> {
2160        assert_ron_snapshot!(test_tokenizer(
2161            r"cat <<EOF | wc -l
2162A B C
21631 2 3
2164D E F
2165EOF
2166"
2167        )?);
2168        Ok(())
2169    }
2170
2171    #[test]
2172    fn tokenize_multiple_here_docs() -> Result<()> {
2173        assert_ron_snapshot!(test_tokenizer(
2174            r"cat <<HERE1 <<HERE2
2175SOMETHING
2176HERE1
2177OTHER
2178HERE2
2179echo after
2180"
2181        )?);
2182        Ok(())
2183    }
2184
2185    #[test]
2186    fn tokenize_unterminated_here_doc() {
2187        let result = tokenize_str(
2188            r"cat <<HERE
2189SOMETHING
2190",
2191        );
2192        assert!(result.is_err());
2193    }
2194
2195    #[test]
2196    fn tokenize_here_tag_with_unterminated_expansion() {
2197        // An expansion in a here tag, left open at the end of the input. What's
2198        // inside it (often just blanks here) is part of the tag's word,
2199        // not a tag of its own, so the input simply ends mid-expansion.
2200        for input in [
2201            "<<E$[\t\t",
2202            "<<-E$[\t\t",
2203            "<<E$(\t\t",
2204            "<<E$((\t\t",
2205            "<<E\"$[\t\t",
2206            "x <<$(( )",
2207            "cat <<'' $(",
2208        ] {
2209            assert_matches!(
2210                tokenize_str(input),
2211                Err(TokenizerError::UnterminatedExpansion),
2212                "for {input:?}"
2213            );
2214        }
2215    }
2216
2217    #[test]
2218    fn tokenize_here_doc_with_empty_quoted_tag_at_end_of_input() {
2219        // Tags that are empty once unquoted, with the input ending before any
2220        // body. An empty tag matches anywhere, but only a body can end
2221        // at it, and none has started.
2222        for input in ["cat <<'' ", "cat <<\"\" "] {
2223            assert_matches!(
2224                tokenize_str(input),
2225                Err(TokenizerError::UnterminatedHereDocuments(..)),
2226                "for {input:?}"
2227            );
2228        }
2229    }
2230
2231    #[test]
2232    fn tokenize_here_tag_containing_expansions() {
2233        for tag in ["$(x)", "E${x}", "$((1 + 2))", "$[1]", "$(echo  a)", "\r"] {
2234            let input = std::format!("cat <<{tag}\nbody\n{tag}\n");
2235            assert_matches!(
2236                tokenize_str(input.as_str()),
2237                Ok(tokens) if tokens.iter().any(|t| matches!(t, Token::Word(w, _) if w == "body\n")),
2238                "for {input:?}"
2239            );
2240        }
2241    }
2242
2243    #[test]
2244    fn tokenize_missing_here_tag_reports_what_was_found() {
2245        for (input, found) in [
2246            ("cat <<\n", "\n"),
2247            ("cat <<;", ";"),
2248            ("cat <<)|$(\n)", ")"),
2249            ("echo $(cat <<  )", ")"),
2250            ("echo $(cat <<)", ")"),
2251            ("echo \"$(cat <<-)\"", ")"),
2252        ] {
2253            assert_matches!(
2254                tokenize_str(input),
2255                Err(TokenizerError::MissingHereTag(actual)) if actual == found,
2256                "for {input:?}"
2257            );
2258        }
2259    }
2260
2261    #[test]
2262    fn tokenize_input_ending_where_here_tag_expected_is_incomplete() {
2263        // More input could still supply the tag, as after a line continuation.
2264        // (Input ending right at the operator, even after a line
2265        // continuation, is left to the parser.)
2266        for input in ["cat << ", "cat << \\\n"] {
2267            assert_matches!(
2268                tokenize_str(input),
2269                Err(e) if e.is_incomplete(),
2270                "for {input:?}"
2271            );
2272        }
2273    }
2274
2275    #[test]
2276    fn tokenize_here_doc_operator_in_parameter_expansion_is_literal() {
2277        // Bash recognizes no operators directly inside `${...}`.
2278        for word in ["${x:-<<}", "${x:-<< foo}", "${x:-a<<b}", "${x:-<<-EOF}"] {
2279            let input = std::format!("echo {word}\n");
2280            assert_matches!(
2281                tokenize_str(input.as_str()),
2282                Ok(tokens) if tokens.iter().any(|t| matches!(t, Token::Word(w, _) if w == word)),
2283                "for {input:?}"
2284            );
2285        }
2286    }
2287
2288    #[test]
2289    fn tokenize_words_verbatim() {
2290        for word in [
2291            // Extglob-like text inside `${...}` is just text.
2292            "${u:-a@(}",
2293            // `$$` is a parameter; neither a `{` nor a `'` after it starts anything.
2294            "$${u",
2295            "$$'a\\'x",
2296            // Nor does a `'` after an escaped `$`.
2297            "\\$'a\\'x",
2298            // Brackets nest in `$[...]`, as in array subscripts, but not those in quotes or
2299            // nested expansions.
2300            "$[ a[1] + 1 ]",
2301            "$[ a[ a[0] ] ]",
2302            "$[ ${a[1]} + 1 ]",
2303            "$[ $[1] + a[$[0]] ]",
2304            "$[ \"[\" ]",
2305            // A `$'` in double quotes starts nothing, even in an extglob.
2306            "@(\"$'x'\")",
2307            // Blanks inside `${...}` are kept as they are.
2308            "\"${x:-a\tb}\"",
2309        ] {
2310            assert_matches!(
2311                tokenize_str(word),
2312                Ok(tokens) if matches!(tokens.as_slice(), [Token::Word(w, _)] if w == word),
2313                "for {word:?}"
2314            );
2315        }
2316    }
2317
2318    #[test]
2319    fn tokenize_here_doc_left_open_by_command_substitution() -> Result<()> {
2320        // The body is read from the lines after the substitution's, ahead of
2321        // any other here-documents pending by then, and becomes part of
2322        // the substitution's text.
2323        for (input, expected) in [
2324            (
2325                "x=\"$(cat <<EOF)\"\nbody\nEOF\n",
2326                &["x=\"$(cat <<EOF\nbody\nEOF\n)\"", "\n"][..],
2327            ),
2328            (
2329                "echo \"[$(cat <<EOF | tr a-z A-Z)]\"\nbody\nEOF\n",
2330                &["echo", "\"[$(cat <<EOF | tr a-z A-Z\nbody\nEOF\n)]\"", "\n"],
2331            ),
2332            (
2333                "echo \"[$(cat <<A; cat <<-B)]\"\na\nA\n\tb\n\tB\n",
2334                &["echo", "\"[$(cat <<A; cat <<-B\na\nA\nb\nB\n)]\"", "\n"],
2335            ),
2336            (
2337                "echo \"[$(echo $(cat <<EOF))]\"\nbody\nEOF\n",
2338                &["echo", "\"[$(echo $(cat <<EOF\nbody\nEOF\n))]\"", "\n"],
2339            ),
2340            (
2341                "echo \"[${x:-$(cat <<EOF)}]\"\nbody\nEOF\n",
2342                &["echo", "\"[${x:-$(cat <<EOF\nbody\nEOF\n)}]\"", "\n"],
2343            ),
2344            (
2345                "cat <<A; echo \"[$(cat <<B)]\"\nb\nB\na\nA\n",
2346                &[
2347                    "cat",
2348                    "<<",
2349                    "A",
2350                    "a\n",
2351                    "A",
2352                    ";",
2353                    "echo",
2354                    "\"[$(cat <<B\nb\nB\n)]\"",
2355                    "\n",
2356                ],
2357            ),
2358            (
2359                "echo \"[$(cat <<A)]\" \"[$(cat <<B)]\"\na\nA\nb\nB\n",
2360                &[
2361                    "echo",
2362                    "\"[$(cat <<A\na\nA\n)]\"",
2363                    "\"[$(cat <<B\nb\nB\n)]\"",
2364                    "\n",
2365                ],
2366            ),
2367        ] {
2368            let tokens = tokenize_str(input)?;
2369            let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
2370            assert_eq!(token_strs, expected, "for {input:?}");
2371        }
2372
2373        // The rest of the line keeps its place in the input, and the next line
2374        // follows the body.
2375        let tokens = tokenize_str("echo \"$(cat <<EOF)\" x\nbody\nEOF\ny\n")?;
2376        let positions: Vec<_> = tokens
2377            .iter()
2378            .map(|t| {
2379                (
2380                    t.to_str(),
2381                    t.location().start.line,
2382                    t.location().start.column,
2383                )
2384            })
2385            .collect();
2386        assert_eq!(
2387            positions,
2388            [
2389                ("echo", 1, 1),
2390                ("\"$(cat <<EOF\nbody\nEOF\n)\"", 1, 6),
2391                ("x", 1, 21),
2392                ("\n", 1, 22),
2393                ("y", 4, 1),
2394                ("\n", 4, 2),
2395            ]
2396        );
2397
2398        Ok(())
2399    }
2400
2401    #[test]
2402    fn tokenize_unterminated_here_doc_names_its_tag() {
2403        assert_matches!(
2404            tokenize_str("cat <<]|$[\n]"),
2405            Err(TokenizerError::UnterminatedHereDocuments(tags, _)) if tags == "`]`"
2406        );
2407    }
2408
2409    #[test]
2410    fn tokenize_deeply_nested_expansions_is_bounded() {
2411        // The input chooses how deeply expansions nest, and consuming them
2412        // recurses, so the nesting must be bounded rather than overflow
2413        // the stack.
2414        for (open, close) in [("$(", ")"), ("\"$(", ")\""), ("${x:-", "}"), ("$[", "]")] {
2415            let input = std::format!("echo {}x{}", open.repeat(10_000), close.repeat(10_000));
2416            assert_matches!(
2417                tokenize_str(input.as_str()),
2418                Err(TokenizerError::ExpansionNestingTooDeep),
2419                "for {open:?}"
2420            );
2421        }
2422    }
2423
2424    #[test]
2425    fn tokenize_nesting_up_to_limit() {
2426        let nested = |depth: u32| {
2427            let depth = depth as usize;
2428            tokenize_str(&std::format!(
2429                "echo {}x{}",
2430                "$(".repeat(depth),
2431                ")".repeat(depth)
2432            ))
2433        };
2434        assert!(nested(MAX_EXPANSION_NESTING).is_ok());
2435        assert_matches!(
2436            nested(MAX_EXPANSION_NESTING + 1),
2437            Err(TokenizerError::ExpansionNestingTooDeep)
2438        );
2439    }
2440
2441    #[test]
2442    fn tokenize_missing_here_tag() {
2443        let result = tokenize_str(
2444            r"cat <<
2445",
2446        );
2447        assert!(result.is_err());
2448    }
2449
2450    #[test]
2451    fn tokenize_here_doc_in_command_substitution() -> Result<()> {
2452        assert_ron_snapshot!(test_tokenizer(
2453            r"echo $(cat <<HERE
2454TEXT
2455HERE
2456)"
2457        )?);
2458        Ok(())
2459    }
2460
2461    #[test]
2462    fn tokenize_here_doc_in_double_quoted_command_substitution() -> Result<()> {
2463        assert_ron_snapshot!(test_tokenizer(
2464            r#"echo "$(cat <<HERE
2465TEXT
2466HERE
2467)""#
2468        )?);
2469        Ok(())
2470    }
2471
2472    #[test]
2473    fn tokenize_here_doc_in_double_quoted_command_substitution_with_space() -> Result<()> {
2474        assert_ron_snapshot!(test_tokenizer(
2475            r#"echo "$(cat << HERE
2476TEXT
2477HERE
2478)""#
2479        )?);
2480        Ok(())
2481    }
2482
2483    #[test]
2484    fn tokenize_complex_here_docs_in_command_substitution() -> Result<()> {
2485        assert_ron_snapshot!(test_tokenizer(
2486            r"echo $(cat <<HERE1 <<HERE2 | wc -l
2487TEXT
2488HERE1
2489OTHER
2490HERE2
2491)"
2492        )?);
2493        Ok(())
2494    }
2495
2496    #[test]
2497    fn tokenize_simple_backquote() -> Result<()> {
2498        assert_ron_snapshot!(test_tokenizer(r"echo `echo hi`")?);
2499        Ok(())
2500    }
2501
2502    #[test]
2503    fn tokenize_backquote_with_escape() -> Result<()> {
2504        assert_ron_snapshot!(test_tokenizer(r"echo `echo\`hi`")?);
2505        Ok(())
2506    }
2507
2508    #[test]
2509    fn tokenize_unterminated_backquote() {
2510        assert_matches!(
2511            tokenize_str("`"),
2512            Err(TokenizerError::UnterminatedBackquote(_))
2513        );
2514    }
2515
2516    #[test]
2517    fn tokenize_unterminated_command_substitution() {
2518        // $( is consumed before the tokenizer knows whether it's $( or $((,
2519        // so it goes through consume_nested_expansion and yields
2520        // UnterminatedExpansion.
2521        assert_matches!(
2522            tokenize_str("$("),
2523            Err(TokenizerError::UnterminatedExpansion)
2524        );
2525    }
2526
2527    #[test]
2528    fn command_substitution_body_stops_at_closing_paren() -> Result<()> {
2529        let options = TokenizerOptions::default();
2530        assert_eq!(
2531            command_substitution_body("echo hi) rest", &options)?,
2532            "echo hi"
2533        );
2534        assert_eq!(
2535            command_substitution_body(r#"echo ")" (a)) rest"#, &options)?,
2536            r#"echo ")" (a)"#
2537        );
2538        assert_eq!(
2539            command_substitution_body("cat <<'EOF'\n\"it's ) `\nEOF\n) rest", &options)?,
2540            "cat <<'EOF'\n\"it's ) `\nEOF\n"
2541        );
2542        // A `)` right after the newline that starts a here-doc body belongs to
2543        // the body.
2544        assert_eq!(
2545            command_substitution_body("cat <<E\n)\nE\n) rest", &options)?,
2546            "cat <<E\n)\nE\n"
2547        );
2548        assert_eq!(
2549            command_substitution_body("cat <<E\n)\nE\necho after) rest", &options)?,
2550            "cat <<E\n)\nE\necho after"
2551        );
2552        // A here-doc left open by the command (or by a substitution nested in
2553        // it) doesn't matter: only the text before the `)` is wanted,
2554        // and any body would follow it.
2555        assert_eq!(
2556            command_substitution_body("cat <<EOF) rest\n", &options)?,
2557            "cat <<EOF"
2558        );
2559        assert_eq!(
2560            command_substitution_body("echo $(cat <<EOF)) rest\n", &options)?,
2561            "echo $(cat <<EOF)"
2562        );
2563        // A quoted `)` in an extglob closes neither the pattern nor the
2564        // command.
2565        assert_eq!(
2566            command_substitution_body(r#"printf "%s" @(")")) rest"#, &options)?,
2567            r#"printf "%s" @(")")"#
2568        );
2569        // Multi-byte characters inside and after the command.
2570        assert_eq!(
2571            command_substitution_body("echo “é”) ü", &options)?,
2572            "echo “é”"
2573        );
2574        Ok(())
2575    }
2576
2577    #[test]
2578    fn command_substitution_body_unterminated() {
2579        let options = TokenizerOptions::default();
2580        assert_matches!(
2581            command_substitution_body("echo hi", &options),
2582            Err(TokenizerError::UnterminatedExpansion)
2583        );
2584        assert_matches!(
2585            command_substitution_body("echo 'hi)", &options),
2586            Err(TokenizerError::UnterminatedSingleQuote(_))
2587        );
2588    }
2589
2590    #[test]
2591    fn tokenize_unterminated_arithmetic_expansion() {
2592        assert_matches!(
2593            tokenize_str("$(("),
2594            Err(TokenizerError::UnterminatedExpansion)
2595        );
2596    }
2597
2598    #[test]
2599    fn tokenize_unterminated_legacy_arithmetic_expansion() {
2600        assert_matches!(
2601            tokenize_str("$["),
2602            Err(TokenizerError::UnterminatedExpansion)
2603        );
2604    }
2605
2606    #[test]
2607    fn tokenize_command_substitution() -> Result<()> {
2608        assert_ron_snapshot!(test_tokenizer("a$(echo hi)b c")?);
2609        Ok(())
2610    }
2611
2612    #[test]
2613    fn tokenize_command_substitution_with_subshell() -> Result<()> {
2614        assert_ron_snapshot!(test_tokenizer("$( (:) )")?);
2615        Ok(())
2616    }
2617
2618    #[test]
2619    fn tokenize_command_substitution_containing_extglob() -> Result<()> {
2620        assert_ron_snapshot!(test_tokenizer("echo $(echo !(x))")?);
2621        Ok(())
2622    }
2623
2624    #[test]
2625    fn tokenize_extglob_with_quotes_and_escapes() -> Result<()> {
2626        for (input, expected) in [
2627            // Quoted parens don't close the pattern.
2628            (r#"@(")") y"#, [r#"@(")")"#, "y"]),
2629            (r"@(a|')'|b)x y", [r"@(a|')'|b)x", "y"]),
2630            (r#"@("(") y"#, [r#"@("(")"#, "y"]),
2631            // Escaped quote inside double quotes doesn't end the quoting.
2632            (r#"@("\")") y"#, [r#"@("\")")"#, "y"]),
2633            // Backslash is literal inside single quotes.
2634            (r"@('\')x y", [r"@('\')x", "y"]),
2635            // Escaped quote outside quotes doesn't start quoting.
2636            (r#"@(\") y"#, [r#"@(\")"#, "y"]),
2637            (r"@(\)) y", [r"@(\))", "y"]),
2638            // Backslash escapes a quote inside ANSI-C quotes, but not inside `\$'...'`.
2639            (r"@($'\'')x y", [r"@($'\'')x", "y"]),
2640            (r"@($'a\')'|b) y", [r"@($'a\')'|b)", "y"]),
2641            (r"@(\$'a\')x y", [r"@(\$'a\')x", "y"]),
2642            // `$$` is a parameter, so a quote after it is ANSI-C only if a third `$` follows.
2643            (r"@($$'a\')x y", [r"@($$'a\')x", "y"]),
2644            (r"@($$$'\'')x y", [r"@($$$'\'')x", "y"]),
2645            // Backquotes are a region of their own: quotes inside them don't nest.
2646            (r"@(}`'`)x y", [r"@(}`'`)x", "y"]),
2647            (r#"@(`echo \\"`)x y"#, [r#"@(`echo \\"`)x"#, "y"]),
2648            (r"@(`$'\'`)x y", [r"@(`$'\'`)x", "y"]),
2649            (r#"@(`echo ")"`)x y"#, [r#"@(`echo ")"`)x"#, "y"]),
2650            // Inside double quotes, `$(...)` and backquotes are nested constructs.
2651            (r#"@("$(echo ")")")x y"#, [r#"@("$(echo ")")")x"#, "y"]),
2652            (r#"@("`echo "\)"`")x y"#, [r#"@("`echo "\)"`")x"#, "y"]),
2653            (r#"@("${u:-"}"}")x y"#, [r#"@("${u:-"}"}")x"#, "y"]),
2654            (r#"@($")")x y"#, [r#"@($")")x"#, "y"]),
2655            // Nesting still counts unquoted parens; each quote kind hides the other.
2656            (r#"+(a|@(b|")")|'"') y"#, [r#"+(a|@(b|")")|'"')"#, "y"]),
2657        ] {
2658            let tokens = tokenize_str(input)?;
2659            let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
2660            assert_eq!(token_strs, expected, "input: {input}");
2661        }
2662        Ok(())
2663    }
2664
2665    #[test]
2666    fn tokenize_unterminated_construct_in_extglob() {
2667        // Each of these leaves a quote, backquote, or `$(` inside the extglob
2668        // open.
2669        for input in [r#"@("$(\$"\))x"#, r#"@($"$(\)")x"#, r"@(`)x", r#"@("`)")x"#] {
2670            assert!(tokenize_str(input).is_err(), "input: {input}");
2671        }
2672    }
2673
2674    #[test]
2675    fn tokenize_unterminated_extglob() {
2676        for input in [r"@(a", r#"@(")""#, r"@(')'", r"@(\)"] {
2677            assert_matches!(
2678                tokenize_str(input),
2679                Err(TokenizerError::UnterminatedExtendedGlob(_)),
2680                "input: {input}"
2681            );
2682        }
2683    }
2684
2685    #[test]
2686    fn tokenize_arithmetic_expression() -> Result<()> {
2687        assert_ron_snapshot!(test_tokenizer("a$((1+2))b c")?);
2688        Ok(())
2689    }
2690
2691    #[test]
2692    fn tokenize_arithmetic_expression_with_space() -> Result<()> {
2693        // N.B. The spacing comes out a bit odd, but it gets processed okay
2694        // by later stages.
2695        assert_ron_snapshot!(test_tokenizer("$(( 1 ))")?);
2696        Ok(())
2697    }
2698    #[test]
2699    fn tokenize_arithmetic_expression_with_parens() -> Result<()> {
2700        assert_ron_snapshot!(test_tokenizer("$(( (0) ))")?);
2701        Ok(())
2702    }
2703
2704    #[test]
2705    fn tokenize_special_parameters() -> Result<()> {
2706        assert_ron_snapshot!(test_tokenizer("$$")?);
2707        assert_ron_snapshot!(test_tokenizer("$@")?);
2708        assert_ron_snapshot!(test_tokenizer("$!")?);
2709        assert_ron_snapshot!(test_tokenizer("$?")?);
2710        assert_ron_snapshot!(test_tokenizer("$*")?);
2711        Ok(())
2712    }
2713
2714    #[test]
2715    fn tokenize_unbraced_parameter_expansion() -> Result<()> {
2716        assert_ron_snapshot!(test_tokenizer("$x")?);
2717        assert_ron_snapshot!(test_tokenizer("a$x")?);
2718        Ok(())
2719    }
2720
2721    #[test]
2722    fn tokenize_unterminated_parameter_expansion() {
2723        assert_matches!(
2724            tokenize_str("${x"),
2725            Err(TokenizerError::UnterminatedVariable)
2726        );
2727    }
2728
2729    #[test]
2730    fn tokenize_braced_parameter_expansion() -> Result<()> {
2731        assert_ron_snapshot!(test_tokenizer("${x}")?);
2732        assert_ron_snapshot!(test_tokenizer("a${x}b")?);
2733        Ok(())
2734    }
2735
2736    #[test]
2737    fn tokenize_braced_parameter_expansion_with_escaping() -> Result<()> {
2738        assert_ron_snapshot!(test_tokenizer(r"a${x\}}b")?);
2739        Ok(())
2740    }
2741
2742    #[test]
2743    fn tokenize_whitespace() -> Result<()> {
2744        assert_ron_snapshot!(test_tokenizer("1 2 3")?);
2745        Ok(())
2746    }
2747
2748    #[test]
2749    fn tokenize_escaped_whitespace() -> Result<()> {
2750        assert_ron_snapshot!(test_tokenizer(r"1\ 2 3")?);
2751        Ok(())
2752    }
2753
2754    #[test]
2755    fn tokenize_single_quote() -> Result<()> {
2756        assert_ron_snapshot!(test_tokenizer(r"x'a b'y")?);
2757        Ok(())
2758    }
2759
2760    #[test]
2761    fn tokenize_double_quote() -> Result<()> {
2762        assert_ron_snapshot!(test_tokenizer(r#"x"a b"y"#)?);
2763        Ok(())
2764    }
2765
2766    #[test]
2767    fn tokenize_double_quoted_command_substitution() -> Result<()> {
2768        assert_ron_snapshot!(test_tokenizer(r#"x"$(echo hi)"y"#)?);
2769        Ok(())
2770    }
2771
2772    #[test]
2773    fn tokenize_double_quoted_arithmetic_expression() -> Result<()> {
2774        assert_ron_snapshot!(test_tokenizer(r#"x"$((1+2))"y"#)?);
2775        Ok(())
2776    }
2777
2778    #[test]
2779    fn test_quote_removal() {
2780        assert_eq!(unquote_str(r#""hello""#), "hello");
2781        assert_eq!(unquote_str(r"'hello'"), "hello");
2782        assert_eq!(unquote_str(r#""hel\"lo""#), r#"hel"lo"#);
2783        assert_eq!(unquote_str(r"hel\'lo"), r"hel'lo");
2784        assert_eq!(unquote_str(r"a\ b\\c"), r"a b\c");
2785
2786        // Quotes open and close anywhere, and each kind is literal in the
2787        // other.
2788        assert_eq!(unquote_str(r#"a'b c'd"e f"g"#), "ab cde fg");
2789        assert_eq!(unquote_str(r#"'a"b'"c'd""#), r#"a"bc'd"#);
2790
2791        // A backslash is literal in single quotes, so it can't escape the
2792        // closing quote.
2793        assert_eq!(unquote_str(r"'a\b'"), r"a\b");
2794        assert_eq!(unquote_str(r"'a\'b"), r"a\b");
2795
2796        // In double quotes, a backslash escapes only chars that are special
2797        // there.
2798        assert_eq!(unquote_str(r#""a\$b\`c\"d\\e""#), r#"a$b`c"d\e"#);
2799        assert_eq!(unquote_str(r#""a\b\'c""#), r"a\b\'c");
2800        assert_eq!(unquote_str("\"a\\\nb\""), "a\nb");
2801        assert_eq!(unquote_str("a\\\nb"), "a\nb");
2802
2803        // A trailing backslash is removed, unless it's literal (in quotes).
2804        assert_eq!(unquote_str(r"a\"), "a");
2805        assert_eq!(unquote_str(r#""a\"#), r"a\");
2806        assert_eq!(unquote_str(r"'a\"), r"a\");
2807
2808        // An unclosed quote is removed; nothing is expanded.
2809        assert_eq!(unquote_str(r#""a b"#), "a b");
2810        assert_eq!(unquote_str(r#"$HOME'~'"$x""#), "$HOME~$x");
2811        assert_eq!(unquote_str(""), "");
2812    }
2813}