Skip to main content

brush_parser/
tokenizer.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3use utf8_chars::BufReadCharsExt;
4
5use crate::{SourcePosition, SourceSpan};
6
7#[derive(Clone, Debug)]
8pub(crate) enum TokenEndReason {
9    /// End of input was reached.
10    EndOfInput,
11    /// An unescaped newline char was reached.
12    UnescapedNewLine,
13    /// Specified terminating char.
14    SpecifiedTerminatingChar,
15    /// A non-newline blank char was reached.
16    NonNewLineBlank,
17    /// A here-document's body is starting.
18    HereDocumentBodyStart,
19    /// A here-document's body was terminated.
20    HereDocumentBodyEnd,
21    /// A here-document's end tag was reached.
22    HereDocumentEndTag,
23    /// An operator was started.
24    OperatorStart,
25    /// An operator was terminated.
26    OperatorEnd,
27    /// Some other condition was reached.
28    Other,
29}
30
31/// Compatibility alias for `SourceSpan`.
32pub type TokenLocation = SourceSpan;
33
34/// Represents a token extracted from a shell script.
35#[derive(Clone, Debug)]
36#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
37#[cfg_attr(
38    any(test, feature = "serde"),
39    derive(PartialEq, Eq, serde::Serialize, serde::Deserialize)
40)]
41pub enum Token {
42    /// An operator token.
43    Operator(String, SourceSpan),
44    /// A word token.
45    Word(String, SourceSpan),
46}
47
48impl Token {
49    /// Returns the string value of the token.
50    pub fn to_str(&self) -> &str {
51        match self {
52            Self::Operator(s, _) => s,
53            Self::Word(s, _) => s,
54        }
55    }
56
57    /// Returns the location of the token in the source script.
58    pub const fn location(&self) -> &SourceSpan {
59        match self {
60            Self::Operator(_, l) => l,
61            Self::Word(_, l) => l,
62        }
63    }
64}
65
66#[cfg(feature = "diagnostics")]
67impl From<&Token> for miette::SourceSpan {
68    fn from(token: &Token) -> Self {
69        let start = token.location().start.as_ref();
70        Self::new(start.into(), token.location().length())
71    }
72}
73
74/// Encapsulates the result of tokenizing a shell script.
75#[derive(Clone, Debug)]
76pub(crate) struct TokenizeResult {
77    /// Reason for tokenization ending.
78    pub reason: TokenEndReason,
79    /// The token that was extracted, if any.
80    pub token: Option<Token>,
81}
82
83/// Represents an error that occurred during tokenization.
84#[derive(thiserror::Error, Debug)]
85pub enum TokenizerError {
86    /// An unterminated escape sequence was encountered at the end of the input stream.
87    #[error("unterminated escape sequence")]
88    UnterminatedEscapeSequence,
89
90    /// An unterminated single-quoted substring was encountered at the end of the input stream.
91    #[error("unterminated single quote at {0}")]
92    UnterminatedSingleQuote(SourcePosition),
93
94    /// An unterminated ANSI C-quoted substring was encountered at the end of the input stream.
95    #[error("unterminated ANSI C quote at {0}")]
96    UnterminatedAnsiCQuote(SourcePosition),
97
98    /// An unterminated double-quoted substring was encountered at the end of the input stream.
99    #[error("unterminated double quote at {0}")]
100    UnterminatedDoubleQuote(SourcePosition),
101
102    /// An unterminated back-quoted substring was encountered at the end of the input stream.
103    #[error("unterminated backquote near {0}")]
104    UnterminatedBackquote(SourcePosition),
105
106    /// An unterminated extended glob (extglob) pattern was encountered at the end of the input
107    /// stream.
108    #[error("unterminated extglob near {0}")]
109    UnterminatedExtendedGlob(SourcePosition),
110
111    /// An unterminated variable expression was encountered at the end of the input stream.
112    #[error("unterminated variable expression")]
113    UnterminatedVariable,
114
115    /// An unterminated command substitiion was encountered at the end of the input stream.
116    #[error("unterminated command substitution")]
117    UnterminatedCommandSubstitution,
118
119    /// An unterminated arithmetic or other expansion was encountered at the end of the input
120    /// stream.
121    #[error("unterminated expansion")]
122    UnterminatedExpansion,
123
124    /// An error occurred decoding UTF-8 characters in the input stream.
125    #[error("failed to decode UTF-8 characters")]
126    FailedDecoding,
127
128    /// An I/O here tag was missing.
129    #[error("missing here tag for here document body")]
130    MissingHereTagForDocumentBody,
131
132    /// The indicated I/O here tag was missing.
133    #[error("missing here tag '{0}'")]
134    MissingHereTag(String),
135
136    /// An unterminated here document sequence was encountered at the end of the input stream.
137    #[error("unterminated here document sequence; tag(s) [{0}] found at: [{1}]")]
138    UnterminatedHereDocuments(String, String),
139
140    /// An I/O error occurred while reading from the input stream.
141    #[error("failed to read input")]
142    ReadError(#[from] std::io::Error),
143}
144
145impl TokenizerError {
146    /// Returns true if the error represents an error that could possibly be due
147    /// to an incomplete input stream.
148    pub const fn is_incomplete(&self) -> bool {
149        matches!(
150            self,
151            Self::UnterminatedEscapeSequence
152                | Self::UnterminatedAnsiCQuote(..)
153                | Self::UnterminatedSingleQuote(..)
154                | Self::UnterminatedDoubleQuote(..)
155                | Self::UnterminatedBackquote(..)
156                | Self::UnterminatedCommandSubstitution
157                | Self::UnterminatedExpansion
158                | Self::UnterminatedVariable
159                | Self::UnterminatedExtendedGlob(..)
160                | Self::UnterminatedHereDocuments(..)
161        )
162    }
163}
164
165/// Encapsulates a sequence of tokens.
166#[derive(Debug)]
167pub(crate) struct Tokens<'a> {
168    /// Sequence of tokens.
169    pub tokens: &'a [Token],
170}
171
172#[derive(Clone, Debug)]
173enum QuoteMode {
174    None,
175    AnsiC(SourcePosition),
176    Single(SourcePosition),
177    Double(SourcePosition),
178}
179
180#[derive(Clone, Debug, Default)]
181enum HereState {
182    /// In this state, we are not currently tracking any here-documents.
183    #[default]
184    None,
185    /// In this state, we expect that the next token will be a here tag.
186    NextTokenIsHereTag { remove_tabs: bool },
187    /// In this state, the *current* token is a here tag.
188    CurrentTokenIsHereTag {
189        remove_tabs: bool,
190        operator_token_result: TokenizeResult,
191    },
192    /// In this state, we expect that the *next line* will be the body of
193    /// a here-document.
194    NextLineIsHereDoc,
195    /// In this state, we are in the set of lines that comprise 1 or more
196    /// consecutive here-document bodies.
197    InHereDocs,
198}
199
200#[derive(Clone, Debug)]
201struct HereTag {
202    tag: String,
203    tag_was_escaped_or_quoted: bool,
204    remove_tabs: bool,
205    position: SourcePosition,
206    tokens: Vec<TokenizeResult>,
207    pending_tokens_after: Vec<TokenizeResult>,
208}
209
210#[derive(Clone, Debug)]
211struct CrossTokenParseState {
212    /// Cursor within the overall token stream; used for error reporting.
213    cursor: SourcePosition,
214    /// Current state of parsing here-documents.
215    here_state: HereState,
216    /// Ordered queue of here tags for which we're still looking for matching here-document bodies.
217    current_here_tags: Vec<HereTag>,
218    /// Tokens already tokenized that should be used first to serve requests for tokens.
219    queued_tokens: Vec<TokenizeResult>,
220    /// Are we in an arithmetic expansion?
221    arithmetic_expansion: bool,
222}
223
224/// Options controlling how the tokenizer operates.
225#[derive(Clone, Debug, Hash, Eq, PartialEq)]
226pub struct TokenizerOptions {
227    /// Whether or not to enable extended globbing patterns (extglob).
228    pub enable_extended_globbing: bool,
229    /// Whether or not to operate in POSIX compliance mode.
230    pub posix_mode: bool,
231    /// Whether or not we're running in SH emulation mode.
232    pub sh_mode: bool,
233}
234
235impl Default for TokenizerOptions {
236    fn default() -> Self {
237        Self {
238            enable_extended_globbing: true,
239            posix_mode: false,
240            sh_mode: false,
241        }
242    }
243}
244
245/// A tokenizer for shell scripts.
246pub(crate) struct Tokenizer<'a, R: ?Sized + std::io::BufRead> {
247    char_reader: std::iter::Peekable<utf8_chars::Chars<'a, R>>,
248    cross_state: CrossTokenParseState,
249    options: TokenizerOptions,
250}
251
252/// Encapsulates the current token parsing state.
253#[derive(Clone, Debug)]
254struct TokenParseState {
255    pub start_position: SourcePosition,
256    pub token_so_far: String,
257    pub token_is_operator: bool,
258    pub in_escape: bool,
259    pub quote_mode: QuoteMode,
260}
261
262impl TokenParseState {
263    pub fn new(start_position: &SourcePosition) -> Self {
264        Self {
265            start_position: start_position.to_owned(),
266            token_so_far: String::new(),
267            token_is_operator: false,
268            in_escape: false,
269            quote_mode: QuoteMode::None,
270        }
271    }
272
273    pub fn pop(&mut self, end_position: &SourcePosition) -> Token {
274        let end = Arc::new(end_position.to_owned());
275        let token_location = SourceSpan {
276            start: Arc::new(std::mem::take(&mut self.start_position)),
277            end,
278        };
279
280        let token = if std::mem::take(&mut self.token_is_operator) {
281            Token::Operator(std::mem::take(&mut self.token_so_far), token_location)
282        } else {
283            Token::Word(std::mem::take(&mut self.token_so_far), token_location)
284        };
285
286        end_position.clone_into(&mut self.start_position);
287        self.in_escape = false;
288        self.quote_mode = QuoteMode::None;
289
290        token
291    }
292
293    pub const fn started_token(&self) -> bool {
294        !self.token_so_far.is_empty()
295    }
296
297    /// Returns true if the token so far consists only of blanks.
298    ///
299    /// This can only happen when tokenizing with `include_space`, where blanks are accumulated
300    /// into the token so that the original text of a nested construct can be reproduced. Such
301    /// blanks are not a word, so a `#` following them still begins a comment.
302    pub fn only_blanks_so_far(&self) -> bool {
303        !self.token_so_far.is_empty() && self.token_so_far.chars().all(is_blank)
304    }
305
306    pub fn append_char(&mut self, c: char) {
307        self.token_so_far.push(c);
308    }
309
310    pub fn append_str(&mut self, s: &str) {
311        self.token_so_far.push_str(s);
312    }
313
314    pub const fn unquoted(&self) -> bool {
315        !self.in_escape && matches!(self.quote_mode, QuoteMode::None)
316    }
317
318    pub fn current_token(&self) -> &str {
319        &self.token_so_far
320    }
321
322    pub fn is_specific_operator(&self, operator: &str) -> bool {
323        self.token_is_operator && self.current_token() == operator
324    }
325
326    pub const fn in_operator(&self) -> bool {
327        self.token_is_operator
328    }
329
330    fn is_newline(&self) -> bool {
331        self.token_so_far == "\n"
332    }
333
334    fn replace_with_here_doc(&mut self, s: String) {
335        self.token_so_far = s;
336    }
337
338    #[allow(clippy::too_many_lines)]
339    pub fn delimit_current_token(
340        &mut self,
341        reason: TokenEndReason,
342        cross_token_state: &mut CrossTokenParseState,
343    ) -> Result<Option<TokenizeResult>, TokenizerError> {
344        // If we don't have anything in the token, then don't yield an empty string token
345        // *unless* it's the body of a here document.
346        if !self.started_token() && !matches!(reason, TokenEndReason::HereDocumentBodyEnd) {
347            return Ok(Some(TokenizeResult {
348                reason,
349                token: None,
350            }));
351        }
352
353        // TODO(tokenizer): Make sure the here-tag meets criteria (and isn't a newline).
354        let current_here_state = std::mem::take(&mut cross_token_state.here_state);
355        match current_here_state {
356            HereState::NextTokenIsHereTag { remove_tabs } => {
357                // Don't yield the operator as a token yet. We need to make sure we collect
358                // up everything we need for all the here-documents with tags on this line.
359                let operator_token_result = TokenizeResult {
360                    reason,
361                    token: Some(self.pop(&cross_token_state.cursor)),
362                };
363
364                cross_token_state.here_state = HereState::CurrentTokenIsHereTag {
365                    remove_tabs,
366                    operator_token_result,
367                };
368
369                return Ok(None);
370            }
371            HereState::CurrentTokenIsHereTag {
372                remove_tabs,
373                operator_token_result,
374            } => {
375                if self.is_newline() {
376                    return Err(TokenizerError::MissingHereTag(
377                        self.current_token().to_owned(),
378                    ));
379                }
380
381                cross_token_state.here_state = HereState::NextLineIsHereDoc;
382
383                // Include the trailing \n in the here tag so it's easier to check against.
384                let tag = std::format!("{}\n", self.current_token().trim_ascii_start());
385                let tag_was_escaped_or_quoted = tag.contains(is_quoting_char);
386
387                let tag_token_result = TokenizeResult {
388                    reason,
389                    token: Some(self.pop(&cross_token_state.cursor)),
390                };
391
392                cross_token_state.current_here_tags.push(HereTag {
393                    tag,
394                    tag_was_escaped_or_quoted,
395                    remove_tabs,
396                    position: cross_token_state.cursor.clone(),
397                    tokens: vec![operator_token_result, tag_token_result],
398                    pending_tokens_after: vec![],
399                });
400
401                return Ok(None);
402            }
403            HereState::NextLineIsHereDoc => {
404                if self.is_newline() {
405                    cross_token_state.here_state = HereState::InHereDocs;
406                } else {
407                    cross_token_state.here_state = HereState::NextLineIsHereDoc;
408                }
409
410                if let Some(last_here_tag) = cross_token_state.current_here_tags.last_mut() {
411                    let token = self.pop(&cross_token_state.cursor);
412                    let result = TokenizeResult {
413                        reason,
414                        token: Some(token),
415                    };
416
417                    last_here_tag.pending_tokens_after.push(result);
418                } else {
419                    return Err(TokenizerError::MissingHereTagForDocumentBody);
420                }
421
422                return Ok(None);
423            }
424            HereState::InHereDocs => {
425                // We hit the end of the current here-document.
426                let completed_here_tag = cross_token_state.current_here_tags.remove(0);
427
428                // First queue the redirection operator and (start) here-tag.
429                cross_token_state
430                    .queued_tokens
431                    .extend(completed_here_tag.tokens);
432
433                // Leave a hint that we are about to start a here-document.
434                cross_token_state.queued_tokens.push(TokenizeResult {
435                    reason: TokenEndReason::HereDocumentBodyStart,
436                    token: None,
437                });
438
439                // Then queue the body document we just finished.
440                cross_token_state.queued_tokens.push(TokenizeResult {
441                    reason,
442                    token: Some(self.pop(&cross_token_state.cursor)),
443                });
444
445                // Then queue up the (end) here-tag.
446                let end_tag = if completed_here_tag.tag_was_escaped_or_quoted {
447                    unquote_str(&completed_here_tag.tag)
448                } else {
449                    completed_here_tag.tag
450                };
451                self.append_str(end_tag.trim_end_matches('\n'));
452                cross_token_state.queued_tokens.push(TokenizeResult {
453                    reason: TokenEndReason::HereDocumentEndTag,
454                    token: Some(self.pop(&cross_token_state.cursor)),
455                });
456
457                // Now we're ready to queue up any tokens that came between the completed
458                // here tag and the next here tag (or newline after it if it was the last).
459                cross_token_state
460                    .queued_tokens
461                    .extend(completed_here_tag.pending_tokens_after);
462
463                if cross_token_state.current_here_tags.is_empty() {
464                    cross_token_state.here_state = HereState::None;
465                } else {
466                    cross_token_state.here_state = HereState::InHereDocs;
467                }
468
469                return Ok(None);
470            }
471            HereState::None => (),
472        }
473
474        let token = self.pop(&cross_token_state.cursor);
475        let result = TokenizeResult {
476            reason,
477            token: Some(token),
478        };
479
480        Ok(Some(result))
481    }
482}
483
484/// Break the given input shell script string into tokens, returning the tokens.
485///
486/// # Arguments
487///
488/// * `input` - The shell script to tokenize.
489pub fn tokenize_str(input: &str) -> Result<Vec<Token>, TokenizerError> {
490    tokenize_str_with_options(input, &TokenizerOptions::default())
491}
492
493/// Break the given input shell script string into tokens, returning the tokens.
494///
495/// # Arguments
496///
497/// * `input` - The shell script to tokenize.
498/// * `options` - Options controlling how the tokenizer operates.
499pub fn tokenize_str_with_options(
500    input: &str,
501    options: &TokenizerOptions,
502) -> Result<Vec<Token>, TokenizerError> {
503    uncached_tokenize_string(input, options)
504}
505
506#[cached::macros::cached(
507    name = "TOKENIZE_CACHE",
508    max_size = 64,
509    key = "(String, TokenizerOptions)",
510    convert = r#"{ (input.to_owned(), options.to_owned()) }"#
511)]
512fn uncached_tokenize_string(
513    input: &str,
514    options: &TokenizerOptions,
515) -> Result<Vec<Token>, TokenizerError> {
516    uncached_tokenize_str(input, options)
517}
518
519/// Break the given input shell script string into tokens, returning the tokens.
520/// No caching is performed.
521///
522/// # Arguments
523///
524/// * `input` - The shell script to tokenize.
525pub fn uncached_tokenize_str(
526    input: &str,
527    options: &TokenizerOptions,
528) -> Result<Vec<Token>, TokenizerError> {
529    let mut reader = std::io::BufReader::new(input.as_bytes());
530    let mut tokenizer = crate::tokenizer::Tokenizer::new(&mut reader, options);
531
532    let mut tokens = vec![];
533    loop {
534        match tokenizer.next_token()? {
535            TokenizeResult {
536                token: Some(token), ..
537            } => tokens.push(token),
538            TokenizeResult {
539                reason: TokenEndReason::EndOfInput,
540                ..
541            } => break,
542            _ => (),
543        }
544    }
545
546    Ok(tokens)
547}
548
549/// Given the text following the `$(` that opens a command substitution, returns the
550/// command's text up to (but not including) the `)` that closes it. The command is
551/// tokenized to find that `)`, so quoting, nested constructs, and here-document bodies
552/// are skipped over exactly as they are when tokenizing a full script.
553///
554/// # Errors
555///
556/// Returns an error if the closing `)` can't be found: either the input ends first
557/// ([`TokenizerError::UnterminatedExpansion`]), or tokenizing the command fails before
558/// reaching it (e.g., an unterminated quote or here-document). This is not a syntax
559/// check of the command itself; it's parsed separately, when it is executed.
560pub(crate) fn command_substitution_body<'a>(
561    input: &'a str,
562    options: &TokenizerOptions,
563) -> Result<&'a str, TokenizerError> {
564    let mut reader = input.as_bytes();
565    let mut tokenizer = Tokenizer::new(&mut reader, options);
566
567    // Consume tokens through the `)` that balances the implied opening `(`. This returns
568    // early with an error in exactly the cases documented above. We don't need the token
569    // text collected in `state`: it's a normalized rendering, not a slice of `input`.
570    let mut state = TokenParseState::new(&tokenizer.cross_state.cursor);
571    tokenizer.consume_nested_construct(&mut state, ')', "(", 1)?;
572
573    // The cursor counts characters (not bytes) consumed, the last being the closing `)`;
574    // convert the characters before it to a byte length.
575    let body_len = input
576        .chars()
577        .take(tokenizer.cross_state.cursor.index - 1)
578        .map(char::len_utf8)
579        .sum();
580    Ok(input.split_at(body_len).0)
581}
582
583impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> {
584    pub fn new(reader: &'a mut R, options: &TokenizerOptions) -> Self {
585        Tokenizer {
586            options: options.clone(),
587            char_reader: reader.chars().peekable(),
588            cross_state: CrossTokenParseState {
589                cursor: SourcePosition {
590                    index: 0,
591                    line: 1,
592                    column: 1,
593                },
594                here_state: HereState::None,
595                current_here_tags: vec![],
596                queued_tokens: vec![],
597                arithmetic_expansion: false,
598            },
599        }
600    }
601
602    #[expect(clippy::unnecessary_wraps)]
603    pub fn current_location(&self) -> Option<SourcePosition> {
604        Some(self.cross_state.cursor.clone())
605    }
606
607    fn next_char(&mut self) -> Result<Option<char>, TokenizerError> {
608        let c = self
609            .char_reader
610            .next()
611            .transpose()
612            .map_err(TokenizerError::ReadError)?;
613
614        if let Some(ch) = c {
615            if ch == '\n' {
616                self.cross_state.cursor.line += 1;
617                self.cross_state.cursor.column = 1;
618            } else {
619                self.cross_state.cursor.column += 1;
620            }
621            self.cross_state.cursor.index += 1;
622        }
623
624        Ok(c)
625    }
626
627    fn consume_char(&mut self) -> Result<(), TokenizerError> {
628        let _ = self.next_char()?;
629        Ok(())
630    }
631
632    fn peek_char(&mut self) -> Result<Option<char>, TokenizerError> {
633        match self.char_reader.peek() {
634            Some(result) => match result {
635                Ok(c) => Ok(Some(*c)),
636                Err(_) => Err(TokenizerError::FailedDecoding),
637            },
638            None => Ok(None),
639        }
640    }
641
642    pub fn next_token(&mut self) -> Result<TokenizeResult, TokenizerError> {
643        self.next_token_until(None, false /* include space? */)
644    }
645
646    /// Consumes a nested construct (e.g., `$((...))` or `$[...]`), handling nested delimiters
647    /// and here-documents.
648    ///
649    /// # Arguments
650    ///
651    /// * `state` - The current token parse state to append characters to.
652    /// * `terminating_char` - The character that terminates the construct (e.g., `)` or `]`).
653    /// * `nesting_open` - The character that increases nesting depth when encountered (e.g., `(` or
654    ///   `[`).
655    /// * `initial_nesting` - The initial nesting count (e.g., 2 for `$((`, 1 for `$[`).
656    fn consume_nested_construct(
657        &mut self,
658        state: &mut TokenParseState,
659        terminating_char: char,
660        nesting_open: &str,
661        mut nesting_count: u32,
662    ) -> Result<(), TokenizerError> {
663        let mut pending_here_doc_tokens = vec![];
664        let mut drain_here_doc_tokens = false;
665
666        loop {
667            let cur_token = if drain_here_doc_tokens && !pending_here_doc_tokens.is_empty() {
668                if pending_here_doc_tokens.len() == 1 {
669                    drain_here_doc_tokens = false;
670                }
671                pending_here_doc_tokens.remove(0)
672            } else {
673                let cur_token = self.next_token_until(Some(terminating_char), true)?;
674
675                if matches!(
676                    cur_token.reason,
677                    TokenEndReason::HereDocumentBodyStart
678                        | TokenEndReason::HereDocumentBodyEnd
679                        | TokenEndReason::HereDocumentEndTag
680                ) {
681                    pending_here_doc_tokens.push(cur_token);
682                    continue;
683                }
684                cur_token
685            };
686
687            if matches!(cur_token.reason, TokenEndReason::UnescapedNewLine)
688                && !pending_here_doc_tokens.is_empty()
689            {
690                pending_here_doc_tokens.push(cur_token);
691                drain_here_doc_tokens = true;
692                continue;
693            }
694
695            if let Some(cur_token_value) = cur_token.token {
696                state.append_str(cur_token_value.to_str());
697
698                if matches!(cur_token_value, Token::Operator(o, _) if o == nesting_open) {
699                    nesting_count += 1;
700                }
701            }
702
703            match cur_token.reason {
704                TokenEndReason::HereDocumentBodyStart => {
705                    state.append_char('\n');
706                }
707                TokenEndReason::NonNewLineBlank => state.append_char(' '),
708                TokenEndReason::SpecifiedTerminatingChar => {
709                    nesting_count -= 1;
710                    if nesting_count == 0 {
711                        break;
712                    }
713                    state.append_char(self.next_char()?.unwrap());
714                }
715                TokenEndReason::EndOfInput => {
716                    return Err(TokenizerError::UnterminatedExpansion);
717                }
718                _ => (),
719            }
720        }
721
722        state.append_char(self.next_char()?.unwrap());
723        Ok(())
724    }
725
726    /// Consumes a `$` or backquote (`c`, the next char) and whatever construct it begins
727    /// (e.g., `$(...)`, `${...}`, `` `...` ``), appending all of it to `state`'s token.
728    #[allow(clippy::unwrap_in_result)]
729    #[expect(clippy::too_many_lines)]
730    fn consume_dollar_or_backquote(
731        &mut self,
732        state: &mut TokenParseState,
733        c: char,
734    ) -> Result<(), TokenizerError> {
735        if c == '$' {
736            // Consume the '$' so we can peek beyond.
737            self.consume_char()?;
738
739            // Now peek beyond to see what we have.
740            let char_after_dollar_sign = self.peek_char()?;
741            match char_after_dollar_sign {
742                Some('(') => {
743                    // Add the '$' we already consumed to the token.
744                    state.append_char('$');
745
746                    // Consume the '(' and add it to the token.
747                    state.append_char(self.next_char()?.unwrap());
748
749                    // Check to see if this is possibly an arithmetic expression
750                    // (i.e., one that starts with `$((`).
751                    let (initial_nesting, is_arithmetic) = if matches!(self.peek_char()?, Some('('))
752                    {
753                        // Consume the second '(' and add it to the token.
754                        state.append_char(self.next_char()?.unwrap());
755                        (2, true)
756                    } else {
757                        (1, false)
758                    };
759
760                    if is_arithmetic {
761                        self.cross_state.arithmetic_expansion = true;
762                    }
763
764                    self.consume_nested_construct(state, ')', "(", initial_nesting)?;
765
766                    if is_arithmetic {
767                        self.cross_state.arithmetic_expansion = false;
768                    }
769                }
770
771                Some('[') => {
772                    // Add the '$' we already consumed to the token.
773                    state.append_char('$');
774
775                    // Consume the '[' and add it to the token.
776                    state.append_char(self.next_char()?.unwrap());
777
778                    // Keep track that we're in an arithmetic expression, since
779                    // some text will be interpreted differently as a result.
780                    self.cross_state.arithmetic_expansion = true;
781
782                    self.consume_nested_construct(state, ']', "[", 1)?;
783
784                    self.cross_state.arithmetic_expansion = false;
785                }
786
787                Some('{') => {
788                    // Add the '$' we already consumed to the token.
789                    state.append_char('$');
790
791                    // Consume the '{' and add it to the token.
792                    state.append_char(self.next_char()?.unwrap());
793
794                    let mut pending_here_doc_tokens = vec![];
795                    let mut drain_here_doc_tokens = false;
796
797                    loop {
798                        let cur_token =
799                            if drain_here_doc_tokens && !pending_here_doc_tokens.is_empty() {
800                                if pending_here_doc_tokens.len() == 1 {
801                                    drain_here_doc_tokens = false;
802                                }
803
804                                pending_here_doc_tokens.remove(0)
805                            } else {
806                                let cur_token = self
807                                    .next_token_until(Some('}'), false /* include space? */)?;
808
809                                // See if this is a here-document-related token we need to hold
810                                // onto until after we've seen all the tokens that need to show
811                                // up before we get to the body.
812                                if matches!(
813                                    cur_token.reason,
814                                    TokenEndReason::HereDocumentBodyStart
815                                        | TokenEndReason::HereDocumentBodyEnd
816                                        | TokenEndReason::HereDocumentEndTag
817                                ) {
818                                    pending_here_doc_tokens.push(cur_token);
819                                    continue;
820                                }
821
822                                cur_token
823                            };
824
825                        if matches!(cur_token.reason, TokenEndReason::UnescapedNewLine)
826                            && !pending_here_doc_tokens.is_empty()
827                        {
828                            pending_here_doc_tokens.push(cur_token);
829                            drain_here_doc_tokens = true;
830                            continue;
831                        }
832
833                        if let Some(cur_token_value) = cur_token.token {
834                            state.append_str(cur_token_value.to_str());
835                        }
836
837                        match cur_token.reason {
838                            TokenEndReason::HereDocumentBodyStart => {
839                                state.append_char('\n');
840                            }
841                            TokenEndReason::NonNewLineBlank => state.append_char(' '),
842                            TokenEndReason::SpecifiedTerminatingChar => {
843                                // We hit the end brace we were looking for but did not
844                                // yet consume it. Do so now.
845                                state.append_char(self.next_char()?.unwrap());
846                                break;
847                            }
848                            TokenEndReason::EndOfInput => {
849                                return Err(TokenizerError::UnterminatedVariable);
850                            }
851                            _ => (),
852                        }
853                    }
854                }
855                _ => {
856                    // This is either a different character, or else the end of the string.
857                    // Either way, add the '$' we already consumed to the token.
858                    state.append_char('$');
859                }
860            }
861        } else {
862            // We look for the terminating backquote. First disable normal consumption and
863            // consume the starting backquote.
864            let backquote_pos = self.cross_state.cursor.clone();
865            self.consume_char()?;
866
867            // Add the opening backquote to the token.
868            state.append_char(c);
869
870            // Now continue until we see an unescaped backquote.
871            let mut escaping_enabled = false;
872            let mut done = false;
873            while !done {
874                // Read (and consume) the next char.
875                let next_char_in_backquote = self.next_char()?;
876                if let Some(cib) = next_char_in_backquote {
877                    // Include it in the token no matter what.
878                    state.append_char(cib);
879
880                    // Watch out for escaping.
881                    if !escaping_enabled && cib == '\\' {
882                        escaping_enabled = true;
883                    } else {
884                        // Look for an unescaped backquote to terminate.
885                        if !escaping_enabled && cib == '`' {
886                            done = true;
887                        }
888                        escaping_enabled = false;
889                    }
890                } else {
891                    return Err(TokenizerError::UnterminatedBackquote(backquote_pos));
892                }
893            }
894        }
895
896        Ok(())
897    }
898
899    /// Returns the next token from the input stream, optionally stopping early when a specified
900    /// terminating character is encountered.
901    ///
902    /// # Arguments
903    ///
904    /// * `terminating_char` - An optional character that, if encountered, will stop the
905    ///   tokenization process and return the token up to that character.
906    /// * `include_space` - If true, include spaces in the tokenization process. This is not
907    ///   typically the case, but can be helpful when needing to preserve the original source text
908    ///   embedded within a command substitution or similar construct.
909    #[expect(clippy::cognitive_complexity)]
910    #[expect(clippy::if_same_then_else)]
911    #[expect(clippy::panic_in_result_fn)]
912    #[expect(clippy::too_many_lines)]
913    #[allow(clippy::unwrap_in_result)]
914    fn next_token_until(
915        &mut self,
916        terminating_char: Option<char>,
917        include_space: bool,
918    ) -> Result<TokenizeResult, TokenizerError> {
919        let mut state = TokenParseState::new(&self.cross_state.cursor);
920        let mut result: Option<TokenizeResult> = None;
921
922        while result.is_none() {
923            // First satisfy token results from our queue. Once we exhaust the queue then
924            // we'll look at the input stream.
925            if !self.cross_state.queued_tokens.is_empty() {
926                return Ok(self.cross_state.queued_tokens.remove(0));
927            }
928
929            let next = self.peek_char()?;
930            let c = next.unwrap_or('\0');
931
932            // When we hit the end of the input, then we're done with the current token (if there is
933            // one).
934            if next.is_none() {
935                // TODO(tokenizer): Verify we're not waiting on some terminating character?
936                // Verify we're out of all quotes.
937                if state.in_escape {
938                    return Err(TokenizerError::UnterminatedEscapeSequence);
939                }
940                match state.quote_mode {
941                    QuoteMode::None => (),
942                    QuoteMode::AnsiC(pos) => {
943                        return Err(TokenizerError::UnterminatedAnsiCQuote(pos));
944                    }
945                    QuoteMode::Single(pos) => {
946                        return Err(TokenizerError::UnterminatedSingleQuote(pos));
947                    }
948                    QuoteMode::Double(pos) => {
949                        return Err(TokenizerError::UnterminatedDoubleQuote(pos));
950                    }
951                }
952
953                // Verify we're not in a here document.
954                if !matches!(self.cross_state.here_state, HereState::None) {
955                    if self.remove_here_end_tag(&mut state, &mut result, false)? {
956                        // If we hit end tag without a trailing newline, try to get next token.
957                        continue;
958                    }
959
960                    let tag_names = self
961                        .cross_state
962                        .current_here_tags
963                        .iter()
964                        .map(|tag| tag.tag.trim())
965                        .collect::<Vec<_>>()
966                        .join(", ");
967                    let tag_positions = self
968                        .cross_state
969                        .current_here_tags
970                        .iter()
971                        .map(|tag| std::format!("{}", tag.position))
972                        .collect::<Vec<_>>()
973                        .join(", ");
974                    return Err(TokenizerError::UnterminatedHereDocuments(
975                        tag_names,
976                        tag_positions,
977                    ));
978                }
979
980                result = state
981                    .delimit_current_token(TokenEndReason::EndOfInput, &mut self.cross_state)?;
982            //
983            // Handle being in a here document.
984            //
985            } else if matches!(self.cross_state.here_state, HereState::InHereDocs) {
986                //
987                // For now, just include the character in the current token. We also check
988                // if there are leading tabs to be removed.
989                //
990                if !self.cross_state.current_here_tags.is_empty()
991                    && self.cross_state.current_here_tags[0].remove_tabs
992                    && (!state.started_token() || state.current_token().ends_with('\n'))
993                    && c == '\t'
994                {
995                    // Consume it but don't include it.
996                    self.consume_char()?;
997                } else {
998                    self.consume_char()?;
999                    state.append_char(c);
1000
1001                    // See if this was a newline character following the terminating here tag.
1002                    if c == '\n' {
1003                        self.remove_here_end_tag(&mut state, &mut result, true)?;
1004                    }
1005                }
1006            //
1007            // Look for the specially specified terminating char. A newline operator in progress
1008            // (which may start a here-doc body) is delimited first, below, so that its token
1009            // doesn't carry this terminating char as its end reason. Other operators must not
1010            // take that path: it would start a here-doc for the `<<` in `${x:-<<}`.
1011            //
1012            } else if state.unquoted()
1013                && !(state.in_operator() && state.is_newline())
1014                && terminating_char == Some(c)
1015            {
1016                result = state.delimit_current_token(
1017                    TokenEndReason::SpecifiedTerminatingChar,
1018                    &mut self.cross_state,
1019                )?;
1020            } else if state.in_operator() {
1021                //
1022                // We're in an operator. See if this character continues an operator, or if it
1023                // must be a separate token (because it wouldn't make a prefix of an operator).
1024                //
1025
1026                let mut hypothetical_token = state.current_token().to_owned();
1027                hypothetical_token.push(c);
1028
1029                if state.unquoted() && self.is_operator(hypothetical_token.as_ref()) {
1030                    self.consume_char()?;
1031                    state.append_char(c);
1032                } else {
1033                    assert!(state.started_token());
1034
1035                    //
1036                    // N.B. If the completed operator indicates a here-document, then keep
1037                    // track that the *next* token should be the here-tag.
1038                    //
1039                    if self.cross_state.arithmetic_expansion {
1040                        //
1041                        // We're in an arithmetic context; don't consider << and <<-
1042                        // special. They're not here-docs, they're either a left-shift
1043                        // operator or a left-shift operator followed by a unary
1044                        // minus operator.
1045                        //
1046
1047                        if state.is_specific_operator(")") && c == ')' {
1048                            self.cross_state.arithmetic_expansion = false;
1049                        }
1050                    } else if state.is_specific_operator("<<") {
1051                        self.cross_state.here_state =
1052                            HereState::NextTokenIsHereTag { remove_tabs: false };
1053                    } else if state.is_specific_operator("<<-") {
1054                        self.cross_state.here_state =
1055                            HereState::NextTokenIsHereTag { remove_tabs: true };
1056                    } else if state.is_specific_operator("(") && c == '(' {
1057                        self.cross_state.arithmetic_expansion = true;
1058                    }
1059
1060                    let reason = if state.current_token() == "\n" {
1061                        TokenEndReason::UnescapedNewLine
1062                    } else {
1063                        TokenEndReason::OperatorEnd
1064                    };
1065
1066                    result = state.delimit_current_token(reason, &mut self.cross_state)?;
1067                }
1068            //
1069            // See if this is a character that changes the current escaping/quoting state.
1070            //
1071            } else if does_char_newly_affect_quoting(&state, c) {
1072                if c == '\\' {
1073                    // Consume the backslash ourselves so we can peek past it.
1074                    self.consume_char()?;
1075
1076                    if matches!(self.peek_char()?, Some('\n')) {
1077                        // Make sure the newline char gets consumed too.
1078                        self.consume_char()?;
1079
1080                        // Make sure to include neither the backslash nor the newline character.
1081                    } else {
1082                        state.in_escape = true;
1083                        state.append_char(c);
1084                    }
1085                } else if c == '\'' {
1086                    if state.token_so_far.ends_with('$') {
1087                        state.quote_mode = QuoteMode::AnsiC(self.cross_state.cursor.clone());
1088                    } else {
1089                        state.quote_mode = QuoteMode::Single(self.cross_state.cursor.clone());
1090                    }
1091
1092                    self.consume_char()?;
1093                    state.append_char(c);
1094                } else if c == '\"' {
1095                    state.quote_mode = QuoteMode::Double(self.cross_state.cursor.clone());
1096                    self.consume_char()?;
1097                    state.append_char(c);
1098                }
1099            }
1100            //
1101            // Handle end of single-quote, double-quote, or ANSI-C quote.
1102            else if !state.in_escape
1103                && matches!(
1104                    state.quote_mode,
1105                    QuoteMode::Single(..) | QuoteMode::AnsiC(..)
1106                )
1107                && c == '\''
1108            {
1109                state.quote_mode = QuoteMode::None;
1110                self.consume_char()?;
1111                state.append_char(c);
1112            } else if !state.in_escape
1113                && matches!(state.quote_mode, QuoteMode::Double(..))
1114                && c == '\"'
1115            {
1116                state.quote_mode = QuoteMode::None;
1117                self.consume_char()?;
1118                state.append_char(c);
1119            }
1120            //
1121            // Handle end of escape sequence.
1122            // TODO(tokenizer): Handle double-quote specific escape sequences.
1123            else if state.in_escape {
1124                state.in_escape = false;
1125                self.consume_char()?;
1126                state.append_char(c);
1127            } else if (state.unquoted()
1128                || (matches!(state.quote_mode, QuoteMode::Double(_)) && !state.in_escape))
1129                && (c == '$' || c == '`')
1130            {
1131                // TODO(tokenizer): handle quoted $ or ` in a double quote
1132                self.consume_dollar_or_backquote(&mut state, c)?;
1133            }
1134            //
1135            // [Extension]
1136            // If extended globbing is enabled, the last consumed character is an
1137            // unquoted start of an extglob pattern, *and* if the current character
1138            // is an open parenthesis, then this begins an extglob pattern.
1139            else if c == '('
1140                && self.options.enable_extended_globbing
1141                && state.unquoted()
1142                && !state.in_operator()
1143                && state
1144                    .current_token()
1145                    .ends_with(|x| Self::can_start_extglob(x))
1146            {
1147                // Consume the '(' and append it.
1148                self.consume_char()?;
1149                state.append_char(c);
1150
1151                let mut paren_depth = 1;
1152                // The char that closes the current quote, and whether backslash
1153                // escapes within it (it doesn't in '...', but does in "..." and $'...').
1154                let mut quote: Option<(char, bool)> = None;
1155                let mut after_dollar = false;
1156
1157                // Keep consuming until we see the matching end ')'. Parens inside
1158                // quotes are literal pattern characters, not delimiters. As in bash,
1159                // `$(...)` and `${...}` are only nested constructs inside double
1160                // quotes; elsewhere their parens simply count toward the nesting.
1161                while paren_depth > 0 {
1162                    let Some(extglob_char) = self.peek_char()? else {
1163                        return Err(TokenizerError::UnterminatedExtendedGlob(
1164                            self.cross_state.cursor.clone(),
1165                        ));
1166                    };
1167                    let was_after_dollar = std::mem::take(&mut after_dollar);
1168
1169                    let starts_nested_construct = match quote {
1170                        None => extglob_char == '`',
1171                        Some(('"', _)) => matches!(extglob_char, '`' | '$'),
1172                        Some(_) => false,
1173                    };
1174                    if starts_nested_construct {
1175                        self.consume_dollar_or_backquote(&mut state, extglob_char)?;
1176                        continue;
1177                    }
1178
1179                    // Include it in the token.
1180                    self.consume_char()?;
1181                    state.append_char(extglob_char);
1182
1183                    match extglob_char {
1184                        // Take the escaped char as-is. If the input ends instead, the next
1185                        // iteration reports the unterminated extglob.
1186                        '\\' if quote.is_none_or(|(_, escapes)| escapes) => {
1187                            if let Some(escaped_char) = self.next_char()? {
1188                                state.append_char(escaped_char);
1189                            }
1190                        }
1191                        c if quote.is_some_and(|(close, _)| close == c) => quote = None,
1192                        _ if quote.is_some() => (),
1193                        '\'' => quote = Some(('\'', was_after_dollar)),
1194                        '"' => quote = Some(('"', true)),
1195                        // `$$` is a parameter, not a `$` that could start `$'`.
1196                        '$' => after_dollar = !was_after_dollar,
1197                        '(' => paren_depth += 1,
1198                        ')' => paren_depth -= 1,
1199                        _ => (),
1200                    }
1201                }
1202            //
1203            // If the character *can* start an operator, then it will.
1204            //
1205            } else if state.unquoted() && Self::can_start_operator(c) {
1206                if state.started_token() {
1207                    result = state.delimit_current_token(
1208                        TokenEndReason::OperatorStart,
1209                        &mut self.cross_state,
1210                    )?;
1211                } else {
1212                    state.token_is_operator = true;
1213                    self.consume_char()?;
1214                    state.append_char(c);
1215                }
1216            //
1217            // Whitespace gets discarded (and delimits tokens).
1218            //
1219            } else if state.unquoted() && is_blank(c) {
1220                if state.started_token() {
1221                    result = state.delimit_current_token(
1222                        TokenEndReason::NonNewLineBlank,
1223                        &mut self.cross_state,
1224                    )?;
1225                } else if include_space {
1226                    state.append_char(c);
1227                } else {
1228                    // Make sure we don't include this char in the token range.
1229                    state.start_position.column += 1;
1230                    state.start_position.index += 1;
1231                }
1232
1233                self.consume_char()?;
1234            }
1235            //
1236            // N.B. We need to remember if we were recursively called in a variable
1237            // expansion expression; in that case we won't think a token was started but...
1238            // we'd be wrong.
1239            //
1240            // The `!only_blanks_so_far` clause keeps a comment recognizable inside a nested
1241            // construct. With `include_space`, a blank is appended to the token when none is
1242            // started and delimits the token when one is, so the blanks before a `#` alternate
1243            // between the two; after an odd number of them a token is "in progress" and the `#`
1244            // was appended to it rather than starting a comment. `$( #'<newline>)` then failed to
1245            // tokenize with an unterminated single quote, while `$(  #'<newline>)` — two blanks —
1246            // was fine.
1247            //
1248            else if !state.token_is_operator
1249                && (state.started_token() || matches!(terminating_char, Some('}')))
1250                && !(c == '#' && state.only_blanks_so_far())
1251            {
1252                self.consume_char()?;
1253                state.append_char(c);
1254            } else if c == '#' {
1255                // Consume the '#'.
1256                self.consume_char()?;
1257
1258                let mut done = false;
1259                while !done {
1260                    done = match self.peek_char()? {
1261                        Some('\n') => true,
1262                        None => true,
1263                        _ => {
1264                            // Consume the peeked char; it's part of the comment.
1265                            self.consume_char()?;
1266                            false
1267                        }
1268                    };
1269                }
1270                // Re-start loop as if the comment never happened.
1271            } else if state.started_token() {
1272                // In all other cases where we have an in-progress token, we delimit here.
1273                result =
1274                    state.delimit_current_token(TokenEndReason::Other, &mut self.cross_state)?;
1275            } else {
1276                // If we got here, then we don't have a token in progress and we're not starting an
1277                // operator. Add the character to a new token.
1278                self.consume_char()?;
1279                state.append_char(c);
1280            }
1281        }
1282
1283        let result = result.unwrap();
1284
1285        Ok(result)
1286    }
1287
1288    fn remove_here_end_tag(
1289        &mut self,
1290        state: &mut TokenParseState,
1291        result: &mut Option<TokenizeResult>,
1292        ends_with_newline: bool,
1293    ) -> Result<bool, TokenizerError> {
1294        // Bail immediately if we don't even have a *starting* here tag.
1295        if self.cross_state.current_here_tags.is_empty() {
1296            return Ok(false);
1297        }
1298
1299        let next_here_tag = &self.cross_state.current_here_tags[0];
1300
1301        let tag_str: Cow<'_, str> = if next_here_tag.tag_was_escaped_or_quoted {
1302            unquote_str(next_here_tag.tag.as_str()).into()
1303        } else {
1304            next_here_tag.tag.as_str().into()
1305        };
1306
1307        let tag_str = if !ends_with_newline {
1308            tag_str
1309                .strip_suffix('\n')
1310                .unwrap_or_else(|| tag_str.as_ref())
1311        } else {
1312            tag_str.as_ref()
1313        };
1314
1315        if let Some(current_token_without_here_tag) = state.current_token().strip_suffix(tag_str) {
1316            // Make sure that was either the start of the here document, or there
1317            // was a newline between the preceding part
1318            // and the tag.
1319            if current_token_without_here_tag.is_empty()
1320                || current_token_without_here_tag.ends_with('\n')
1321            {
1322                state.replace_with_here_doc(current_token_without_here_tag.to_owned());
1323
1324                // Delimit the end of the here-document body.
1325                *result = state.delimit_current_token(
1326                    TokenEndReason::HereDocumentBodyEnd,
1327                    &mut self.cross_state,
1328                )?;
1329
1330                return Ok(true);
1331            }
1332        }
1333        Ok(false)
1334    }
1335
1336    const fn can_start_extglob(c: char) -> bool {
1337        matches!(c, '@' | '!' | '?' | '+' | '*')
1338    }
1339
1340    const fn can_start_operator(c: char) -> bool {
1341        matches!(c, '&' | '(' | ')' | ';' | '\n' | '|' | '<' | '>')
1342    }
1343
1344    fn is_operator(&self, s: &str) -> bool {
1345        // Handle non-POSIX operators.
1346        if !self.options.sh_mode && matches!(s, "<<<" | "&>" | "&>>" | ";;&" | ";&" | "|&") {
1347            return true;
1348        }
1349
1350        matches!(
1351            s,
1352            "&" | "&&"
1353                | "("
1354                | ")"
1355                | ";"
1356                | ";;"
1357                | "\n"
1358                | "|"
1359                | "||"
1360                | "<"
1361                | ">"
1362                | ">|"
1363                | "<<"
1364                | ">>"
1365                | "<&"
1366                | ">&"
1367                | "<<-"
1368                | "<>"
1369        )
1370    }
1371}
1372
1373impl<R: ?Sized + std::io::BufRead> Iterator for Tokenizer<'_, R> {
1374    type Item = Result<TokenizeResult, TokenizerError>;
1375
1376    fn next(&mut self) -> Option<Self::Item> {
1377        match self.next_token() {
1378            #[expect(clippy::manual_map)]
1379            Ok(result) => match result.token {
1380                Some(_) => Some(Ok(result)),
1381                None => None,
1382            },
1383            Err(e) => Some(Err(e)),
1384        }
1385    }
1386}
1387
1388const fn is_blank(c: char) -> bool {
1389    c == ' ' || c == '\t'
1390}
1391
1392const fn does_char_newly_affect_quoting(state: &TokenParseState, c: char) -> bool {
1393    // If we're currently escaped, then nothing affects quoting.
1394    if state.in_escape {
1395        return false;
1396    }
1397
1398    match state.quote_mode {
1399        // When we're in a double quote or ANSI-C quote, only a subset of escape
1400        // sequences are recognized.
1401        QuoteMode::Double(_) | QuoteMode::AnsiC(_) => {
1402            if c == '\\' {
1403                // TODO(tokenizer): handle backslash in double quote
1404                true
1405            } else {
1406                false
1407            }
1408        }
1409        // When we're in a single quote, nothing affects quoting.
1410        QuoteMode::Single(_) => false,
1411        // When we're not already in a quote, then we can straightforwardly look for a
1412        // quote mark or backslash.
1413        QuoteMode::None => is_quoting_char(c),
1414    }
1415}
1416
1417const fn is_quoting_char(c: char) -> bool {
1418    matches!(c, '\\' | '\'' | '\"')
1419}
1420
1421/// Return a string with all the quoting removed.
1422///
1423/// # Arguments
1424///
1425/// * `s` - The string to unquote.
1426pub fn unquote_str(s: &str) -> String {
1427    let mut result = String::new();
1428
1429    let mut in_escape = false;
1430    for c in s.chars() {
1431        match c {
1432            c if in_escape => {
1433                result.push(c);
1434                in_escape = false;
1435            }
1436            '\\' => in_escape = true,
1437            c if is_quoting_char(c) => (),
1438            c => result.push(c),
1439        }
1440    }
1441
1442    result
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447
1448    use super::*;
1449    use anyhow::Result;
1450    use insta::assert_ron_snapshot;
1451    use pretty_assertions::{assert_eq, assert_matches};
1452
1453    #[derive(serde::Serialize, serde::Deserialize)]
1454    struct TokenizerResult<'a> {
1455        input: &'a str,
1456        result: Vec<Token>,
1457    }
1458
1459    fn test_tokenizer(input: &str) -> Result<TokenizerResult<'_>> {
1460        Ok(TokenizerResult {
1461            input,
1462            result: tokenize_str(input)?,
1463        })
1464    }
1465
1466    #[test]
1467    fn tokenize_empty() -> Result<()> {
1468        let tokens = tokenize_str("")?;
1469        assert_eq!(tokens.len(), 0);
1470        Ok(())
1471    }
1472
1473    #[test]
1474    fn tokenize_line_continuation() -> Result<()> {
1475        assert_ron_snapshot!(test_tokenizer(
1476            r"a\
1477bc"
1478        )?);
1479        Ok(())
1480    }
1481
1482    #[test]
1483    fn tokenize_operators() -> Result<()> {
1484        assert_ron_snapshot!(test_tokenizer("a>>b")?);
1485        Ok(())
1486    }
1487
1488    #[test]
1489    fn tokenize_comment() -> Result<()> {
1490        assert_ron_snapshot!(test_tokenizer(
1491            r"a #comment
1492"
1493        )?);
1494        Ok(())
1495    }
1496
1497    #[test]
1498    fn tokenize_comment_in_command_substitution() {
1499        // A comment inside `$( )` is a comment however many blanks precede its `#`. An odd number
1500        // of them used to leave a token in progress, so the `#` was appended to that token instead
1501        // of starting a comment, and the apostrophe in the comment's text then opened a quote that
1502        // was never closed.
1503        //
1504        // The comment is dropped from the text reconstructed for the substitution, leaving just
1505        // the blanks that preceded it. A blank that delimits an in-progress token comes back as a
1506        // single space, so the blanks aren't always reproduced verbatim; that's insignificant
1507        // inside `$( )`, where the text gets re-parsed as a program.
1508        for (prefix, reconstructed_blanks) in [
1509            ("", ""),
1510            (" ", " "),
1511            ("  ", "  "),
1512            ("   ", "   "),
1513            ("\t", "\t"),
1514            ("\t\t", "\t "),
1515            (" \t", "  "),
1516            ("\t ", "\t "),
1517        ] {
1518            let input = format!("$({prefix}# it's a comment\n)\n");
1519            let tokens = tokenize_str(input.as_str()).unwrap();
1520            let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
1521            assert_eq!(
1522                token_strs,
1523                [format!("$({reconstructed_blanks}\n)").as_str(), "\n"],
1524                "tokenizing {input:?}"
1525            );
1526        }
1527    }
1528
1529    #[test]
1530    fn tokenize_comment_at_eof() -> Result<()> {
1531        assert_ron_snapshot!(test_tokenizer(r"a #comment")?);
1532        Ok(())
1533    }
1534
1535    #[test]
1536    fn tokenize_empty_here_doc() -> Result<()> {
1537        assert_ron_snapshot!(test_tokenizer(
1538            r"cat <<HERE
1539HERE
1540"
1541        )?);
1542        Ok(())
1543    }
1544
1545    #[test]
1546    fn tokenize_here_doc() -> Result<()> {
1547        assert_ron_snapshot!(test_tokenizer(
1548            r"cat <<HERE
1549SOMETHING
1550HERE
1551echo after
1552"
1553        )?);
1554        assert_ron_snapshot!(test_tokenizer(
1555            r"cat <<HERE
1556SOMETHING
1557HERE
1558"
1559        )?);
1560        assert_ron_snapshot!(test_tokenizer(
1561            r"cat <<HERE
1562SOMETHING
1563HERE
1564
1565"
1566        )?);
1567        assert_ron_snapshot!(test_tokenizer(
1568            r"cat <<HERE
1569SOMETHING
1570HERE"
1571        )?);
1572        Ok(())
1573    }
1574
1575    #[test]
1576    fn tokenize_here_doc_with_tab_removal() -> Result<()> {
1577        assert_ron_snapshot!(test_tokenizer(
1578            r"cat <<-HERE
1579	SOMETHING
1580	HERE
1581"
1582        )?);
1583        Ok(())
1584    }
1585
1586    #[test]
1587    fn tokenize_here_doc_with_other_tokens() -> Result<()> {
1588        assert_ron_snapshot!(test_tokenizer(
1589            r"cat <<EOF | wc -l
1590A B C
15911 2 3
1592D E F
1593EOF
1594"
1595        )?);
1596        Ok(())
1597    }
1598
1599    #[test]
1600    fn tokenize_multiple_here_docs() -> Result<()> {
1601        assert_ron_snapshot!(test_tokenizer(
1602            r"cat <<HERE1 <<HERE2
1603SOMETHING
1604HERE1
1605OTHER
1606HERE2
1607echo after
1608"
1609        )?);
1610        Ok(())
1611    }
1612
1613    #[test]
1614    fn tokenize_unterminated_here_doc() {
1615        let result = tokenize_str(
1616            r"cat <<HERE
1617SOMETHING
1618",
1619        );
1620        assert!(result.is_err());
1621    }
1622
1623    #[test]
1624    fn tokenize_missing_here_tag() {
1625        let result = tokenize_str(
1626            r"cat <<
1627",
1628        );
1629        assert!(result.is_err());
1630    }
1631
1632    #[test]
1633    fn tokenize_here_doc_in_command_substitution() -> Result<()> {
1634        assert_ron_snapshot!(test_tokenizer(
1635            r"echo $(cat <<HERE
1636TEXT
1637HERE
1638)"
1639        )?);
1640        Ok(())
1641    }
1642
1643    #[test]
1644    fn tokenize_here_doc_in_double_quoted_command_substitution() -> Result<()> {
1645        assert_ron_snapshot!(test_tokenizer(
1646            r#"echo "$(cat <<HERE
1647TEXT
1648HERE
1649)""#
1650        )?);
1651        Ok(())
1652    }
1653
1654    #[test]
1655    fn tokenize_here_doc_in_double_quoted_command_substitution_with_space() -> Result<()> {
1656        assert_ron_snapshot!(test_tokenizer(
1657            r#"echo "$(cat << HERE
1658TEXT
1659HERE
1660)""#
1661        )?);
1662        Ok(())
1663    }
1664
1665    #[test]
1666    fn tokenize_complex_here_docs_in_command_substitution() -> Result<()> {
1667        assert_ron_snapshot!(test_tokenizer(
1668            r"echo $(cat <<HERE1 <<HERE2 | wc -l
1669TEXT
1670HERE1
1671OTHER
1672HERE2
1673)"
1674        )?);
1675        Ok(())
1676    }
1677
1678    #[test]
1679    fn tokenize_simple_backquote() -> Result<()> {
1680        assert_ron_snapshot!(test_tokenizer(r"echo `echo hi`")?);
1681        Ok(())
1682    }
1683
1684    #[test]
1685    fn tokenize_backquote_with_escape() -> Result<()> {
1686        assert_ron_snapshot!(test_tokenizer(r"echo `echo\`hi`")?);
1687        Ok(())
1688    }
1689
1690    #[test]
1691    fn tokenize_unterminated_backquote() {
1692        assert_matches!(
1693            tokenize_str("`"),
1694            Err(TokenizerError::UnterminatedBackquote(_))
1695        );
1696    }
1697
1698    #[test]
1699    fn tokenize_unterminated_command_substitution() {
1700        // $( is consumed before the tokenizer knows whether it's $( or $((,
1701        // so it goes through consume_nested_construct and yields UnterminatedExpansion.
1702        assert_matches!(
1703            tokenize_str("$("),
1704            Err(TokenizerError::UnterminatedExpansion)
1705        );
1706    }
1707
1708    #[test]
1709    fn command_substitution_body_stops_at_closing_paren() -> Result<()> {
1710        let options = TokenizerOptions::default();
1711        assert_eq!(
1712            command_substitution_body("echo hi) rest", &options)?,
1713            "echo hi"
1714        );
1715        assert_eq!(
1716            command_substitution_body(r#"echo ")" (a)) rest"#, &options)?,
1717            r#"echo ")" (a)"#
1718        );
1719        assert_eq!(
1720            command_substitution_body("cat <<'EOF'\n\"it's ) `\nEOF\n) rest", &options)?,
1721            "cat <<'EOF'\n\"it's ) `\nEOF\n"
1722        );
1723        // A `)` right after the newline that starts a here-doc body belongs to the body.
1724        assert_eq!(
1725            command_substitution_body("cat <<E\n)\nE\n) rest", &options)?,
1726            "cat <<E\n)\nE\n"
1727        );
1728        assert_eq!(
1729            command_substitution_body("cat <<E\n)\nE\necho after) rest", &options)?,
1730            "cat <<E\n)\nE\necho after"
1731        );
1732        // A quoted `)` in an extglob closes neither the pattern nor the command.
1733        assert_eq!(
1734            command_substitution_body(r#"printf "%s" @(")")) rest"#, &options)?,
1735            r#"printf "%s" @(")")"#
1736        );
1737        // Multi-byte characters inside and after the command.
1738        assert_eq!(
1739            command_substitution_body("echo “é”) ü", &options)?,
1740            "echo “é”"
1741        );
1742        Ok(())
1743    }
1744
1745    #[test]
1746    fn command_substitution_body_unterminated() {
1747        let options = TokenizerOptions::default();
1748        assert_matches!(
1749            command_substitution_body("echo hi", &options),
1750            Err(TokenizerError::UnterminatedExpansion)
1751        );
1752        assert_matches!(
1753            command_substitution_body("echo 'hi)", &options),
1754            Err(TokenizerError::UnterminatedSingleQuote(_))
1755        );
1756    }
1757
1758    #[test]
1759    fn tokenize_unterminated_arithmetic_expansion() {
1760        assert_matches!(
1761            tokenize_str("$(("),
1762            Err(TokenizerError::UnterminatedExpansion)
1763        );
1764    }
1765
1766    #[test]
1767    fn tokenize_unterminated_legacy_arithmetic_expansion() {
1768        assert_matches!(
1769            tokenize_str("$["),
1770            Err(TokenizerError::UnterminatedExpansion)
1771        );
1772    }
1773
1774    #[test]
1775    fn tokenize_command_substitution() -> Result<()> {
1776        assert_ron_snapshot!(test_tokenizer("a$(echo hi)b c")?);
1777        Ok(())
1778    }
1779
1780    #[test]
1781    fn tokenize_command_substitution_with_subshell() -> Result<()> {
1782        assert_ron_snapshot!(test_tokenizer("$( (:) )")?);
1783        Ok(())
1784    }
1785
1786    #[test]
1787    fn tokenize_command_substitution_containing_extglob() -> Result<()> {
1788        assert_ron_snapshot!(test_tokenizer("echo $(echo !(x))")?);
1789        Ok(())
1790    }
1791
1792    #[test]
1793    fn tokenize_extglob_with_quotes_and_escapes() -> Result<()> {
1794        for (input, expected) in [
1795            // Quoted parens don't close the pattern.
1796            (r#"@(")") y"#, [r#"@(")")"#, "y"]),
1797            (r"@(a|')'|b)x y", [r"@(a|')'|b)x", "y"]),
1798            (r#"@("(") y"#, [r#"@("(")"#, "y"]),
1799            // Escaped quote inside double quotes doesn't end the quoting.
1800            (r#"@("\")") y"#, [r#"@("\")")"#, "y"]),
1801            // Backslash is literal inside single quotes.
1802            (r"@('\')x y", [r"@('\')x", "y"]),
1803            // Escaped quote outside quotes doesn't start quoting.
1804            (r#"@(\") y"#, [r#"@(\")"#, "y"]),
1805            (r"@(\)) y", [r"@(\))", "y"]),
1806            // Backslash escapes a quote inside ANSI-C quotes, but not inside `\$'...'`.
1807            (r"@($'\'')x y", [r"@($'\'')x", "y"]),
1808            (r"@($'a\')'|b) y", [r"@($'a\')'|b)", "y"]),
1809            (r"@(\$'a\')x y", [r"@(\$'a\')x", "y"]),
1810            // `$$` is a parameter, so a quote after it is ANSI-C only if a third `$` follows.
1811            (r"@($$'a\')x y", [r"@($$'a\')x", "y"]),
1812            (r"@($$$'\'')x y", [r"@($$$'\'')x", "y"]),
1813            // Backquotes are a region of their own: quotes inside them don't nest.
1814            (r"@(}`'`)x y", [r"@(}`'`)x", "y"]),
1815            (r#"@(`echo \\"`)x y"#, [r#"@(`echo \\"`)x"#, "y"]),
1816            (r"@(`$'\'`)x y", [r"@(`$'\'`)x", "y"]),
1817            (r#"@(`echo ")"`)x y"#, [r#"@(`echo ")"`)x"#, "y"]),
1818            // Inside double quotes, `$(...)` and backquotes are nested constructs.
1819            (r#"@("$(echo ")")")x y"#, [r#"@("$(echo ")")")x"#, "y"]),
1820            (r#"@("`echo "\)"`")x y"#, [r#"@("`echo "\)"`")x"#, "y"]),
1821            (r#"@("${u:-"}"}")x y"#, [r#"@("${u:-"}"}")x"#, "y"]),
1822            (r#"@($")")x y"#, [r#"@($")")x"#, "y"]),
1823            // Nesting still counts unquoted parens; each quote kind hides the other.
1824            (r#"+(a|@(b|")")|'"') y"#, [r#"+(a|@(b|")")|'"')"#, "y"]),
1825        ] {
1826            let tokens = tokenize_str(input)?;
1827            let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect();
1828            assert_eq!(token_strs, expected, "input: {input}");
1829        }
1830        Ok(())
1831    }
1832
1833    #[test]
1834    fn tokenize_unterminated_construct_in_extglob() {
1835        // Each of these leaves a quote, backquote, or `$(` inside the extglob open.
1836        for input in [r#"@("$(\$"\))x"#, r#"@($"$(\)")x"#, r"@(`)x", r#"@("`)")x"#] {
1837            assert!(tokenize_str(input).is_err(), "input: {input}");
1838        }
1839    }
1840
1841    #[test]
1842    fn tokenize_unterminated_extglob() {
1843        for input in [r"@(a", r#"@(")""#, r"@(')'", r"@(\)"] {
1844            assert_matches!(
1845                tokenize_str(input),
1846                Err(TokenizerError::UnterminatedExtendedGlob(_)),
1847                "input: {input}"
1848            );
1849        }
1850    }
1851
1852    #[test]
1853    fn tokenize_arithmetic_expression() -> Result<()> {
1854        assert_ron_snapshot!(test_tokenizer("a$((1+2))b c")?);
1855        Ok(())
1856    }
1857
1858    #[test]
1859    fn tokenize_arithmetic_expression_with_space() -> Result<()> {
1860        // N.B. The spacing comes out a bit odd, but it gets processed okay
1861        // by later stages.
1862        assert_ron_snapshot!(test_tokenizer("$(( 1 ))")?);
1863        Ok(())
1864    }
1865    #[test]
1866    fn tokenize_arithmetic_expression_with_parens() -> Result<()> {
1867        assert_ron_snapshot!(test_tokenizer("$(( (0) ))")?);
1868        Ok(())
1869    }
1870
1871    #[test]
1872    fn tokenize_special_parameters() -> Result<()> {
1873        assert_ron_snapshot!(test_tokenizer("$$")?);
1874        assert_ron_snapshot!(test_tokenizer("$@")?);
1875        assert_ron_snapshot!(test_tokenizer("$!")?);
1876        assert_ron_snapshot!(test_tokenizer("$?")?);
1877        assert_ron_snapshot!(test_tokenizer("$*")?);
1878        Ok(())
1879    }
1880
1881    #[test]
1882    fn tokenize_unbraced_parameter_expansion() -> Result<()> {
1883        assert_ron_snapshot!(test_tokenizer("$x")?);
1884        assert_ron_snapshot!(test_tokenizer("a$x")?);
1885        Ok(())
1886    }
1887
1888    #[test]
1889    fn tokenize_unterminated_parameter_expansion() {
1890        assert_matches!(
1891            tokenize_str("${x"),
1892            Err(TokenizerError::UnterminatedVariable)
1893        );
1894    }
1895
1896    #[test]
1897    fn tokenize_braced_parameter_expansion() -> Result<()> {
1898        assert_ron_snapshot!(test_tokenizer("${x}")?);
1899        assert_ron_snapshot!(test_tokenizer("a${x}b")?);
1900        Ok(())
1901    }
1902
1903    #[test]
1904    fn tokenize_braced_parameter_expansion_with_escaping() -> Result<()> {
1905        assert_ron_snapshot!(test_tokenizer(r"a${x\}}b")?);
1906        Ok(())
1907    }
1908
1909    #[test]
1910    fn tokenize_whitespace() -> Result<()> {
1911        assert_ron_snapshot!(test_tokenizer("1 2 3")?);
1912        Ok(())
1913    }
1914
1915    #[test]
1916    fn tokenize_escaped_whitespace() -> Result<()> {
1917        assert_ron_snapshot!(test_tokenizer(r"1\ 2 3")?);
1918        Ok(())
1919    }
1920
1921    #[test]
1922    fn tokenize_single_quote() -> Result<()> {
1923        assert_ron_snapshot!(test_tokenizer(r"x'a b'y")?);
1924        Ok(())
1925    }
1926
1927    #[test]
1928    fn tokenize_double_quote() -> Result<()> {
1929        assert_ron_snapshot!(test_tokenizer(r#"x"a b"y"#)?);
1930        Ok(())
1931    }
1932
1933    #[test]
1934    fn tokenize_double_quoted_command_substitution() -> Result<()> {
1935        assert_ron_snapshot!(test_tokenizer(r#"x"$(echo hi)"y"#)?);
1936        Ok(())
1937    }
1938
1939    #[test]
1940    fn tokenize_double_quoted_arithmetic_expression() -> Result<()> {
1941        assert_ron_snapshot!(test_tokenizer(r#"x"$((1+2))"y"#)?);
1942        Ok(())
1943    }
1944
1945    #[test]
1946    fn test_quote_removal() {
1947        assert_eq!(unquote_str(r#""hello""#), "hello");
1948        assert_eq!(unquote_str(r"'hello'"), "hello");
1949        assert_eq!(unquote_str(r#""hel\"lo""#), r#"hel"lo"#);
1950        assert_eq!(unquote_str(r"'hel\'lo'"), r"hel'lo");
1951    }
1952}