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