Skip to main content

dekopon_shell/
lexer.rs

1//! Tokenizer for the sandboxed shell grammar.
2//!
3//! The scanner is a quote state machine over `char`s. It produces operator tokens and structured
4//! words; nested `$( ... )` and `$(( ... ))` bodies are captured as raw source and handed back to
5//! [`crate::parser`], which re-enters itself on them.
6//!
7//! Constructs the sandbox drops are tokenized rather than skipped so the parser can reject them
8//! with an exact message. Silently discarding a trailing `&`, for example, would let a model
9//! believe backgrounding happened when nothing was backgrounded.
10
11use std::{fmt, iter::Peekable, str::CharIndices};
12
13use thiserror::Error;
14
15/// One lexed token with the source line it started on.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Token {
18    /// What was matched.
19    pub kind: TokenKind,
20    /// One-based source line.
21    pub line: usize,
22}
23
24/// Token classes.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub enum TokenKind {
27    /// A structured word.
28    Word(RawWord),
29    /// `|`.
30    Pipe,
31    /// `;`.
32    Semicolon,
33    /// `;;`, which ends one `case` clause.
34    DoubleSemicolon,
35    /// A line break.
36    Newline,
37    /// `&&`.
38    AndAnd,
39    /// `||`.
40    OrOr,
41    /// `&`. Kept as a token so the parser can hard-fail on backgrounding.
42    Ampersand,
43    /// `(`.
44    LeftParen,
45    /// `)`.
46    RightParen,
47    /// `{` used as a reserved word.
48    LeftBrace,
49    /// `}` used as a reserved word.
50    RightBrace,
51    /// `>`.
52    Great,
53    /// `>>`.
54    GreatGreat,
55    /// `<`. Kept so the parser can explain that there are no files to read.
56    Less,
57    /// A `<<DELIM` here-document, with its body already collected off the following lines.
58    HereDoc(RawWord),
59    /// `<(`. Kept so the parser can reject process substitution by name.
60    LessParen,
61}
62
63impl fmt::Display for TokenKind {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        let rendered = match self {
66            Self::Word(word) => return write!(formatter, "word {}", word.describe()),
67            Self::HereDoc(_) => "here-document",
68            Self::Pipe => "|",
69            Self::Semicolon => ";",
70            Self::DoubleSemicolon => ";;",
71            Self::Newline => "newline",
72            Self::AndAnd => "&&",
73            Self::OrOr => "||",
74            Self::Ampersand => "&",
75            Self::LeftParen => "(",
76            Self::RightParen => ")",
77            Self::LeftBrace => "{",
78            Self::RightBrace => "}",
79            Self::Great => ">",
80            Self::GreatGreat => ">>",
81            Self::Less => "<",
82            Self::LessParen => "<(",
83        };
84        formatter.write_str(rendered)
85    }
86}
87
88/// A word before command substitutions and arithmetic have been parsed.
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct RawWord {
91    /// Parts in source order.
92    pub parts: Vec<RawPart>,
93}
94
95impl RawWord {
96    /// Returns the text when the word is a single unquoted literal.
97    #[must_use]
98    pub fn as_literal(&self) -> Option<&str> {
99        match self.parts.as_slice() {
100            [RawPart::Literal(text)] => Some(text),
101            _ => None,
102        }
103    }
104
105    /// Renders a short description for parser diagnostics.
106    #[must_use]
107    pub fn describe(&self) -> String {
108        self.as_literal().map_or_else(
109            || "with expansions".to_owned(),
110            |literal| format!("{literal:?}"),
111        )
112    }
113}
114
115/// One component of a raw word.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub enum RawPart {
118    /// Unquoted literal text.
119    Literal(String),
120    /// Single-quoted text.
121    SingleQuoted(String),
122    /// Double-quoted parts.
123    DoubleQuoted(Vec<RawPart>),
124    /// A parameter reference.
125    Parameter(RawParameter),
126    /// Raw `$( ... )` body.
127    CommandSubstitution(String),
128    /// Raw `$(( ... ))` body.
129    Arithmetic(String),
130}
131
132/// A parameter reference before index words are parsed.
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub enum RawParameter {
135    /// `$NAME`, `${NAME}`, `${NAME[index]...}`.
136    Named {
137        /// Variable name.
138        name: String,
139        /// Zero or more index words, applied left to right.
140        indices: Vec<RawWord>,
141    },
142    /// `$1` .. `${N}`.
143    Positional(usize),
144    /// `$@`.
145    AllPositional,
146    /// `$*`.
147    AllPositionalJoined,
148    /// `$#`.
149    PositionalCount,
150    /// `$?`.
151    LastStatus,
152}
153
154/// A tokenizer failure.
155#[derive(Clone, Debug, Eq, Error, PartialEq)]
156#[error("line {line}: {message}")]
157pub struct LexError {
158    /// One-based source line.
159    pub line: usize,
160    /// Human-readable detail.
161    pub message: String,
162}
163
164/// Why backtick command substitution is refused, in both quoting contexts.
165const BACKTICK_REJECTION: &str = "backtick command substitution is not supported; use `$( ... )`, which nests and quotes cleanly";
166
167/// Why file-descriptor redirection is refused.
168const FD_REDIRECTION_REJECTION: &str = "file-descriptor redirection (`2>`, `>&2`, `2>&1`) is not supported: this shell has one combined output stream, not numbered descriptors; `>` and `>>` write named in-memory buffers";
169
170/// Why the here-string `<<<` is refused.
171///
172/// It is one character away from a here-document and means something else entirely, so it is named
173/// rather than left to fail as a malformed delimiter.
174const HERE_STRING_REJECTION: &str = "the here-string `<<<` is not supported; pipe the value instead, as in `echo \"$x\" | cmd`, or use a here-document `<<EOF ... EOF`";
175
176/// Why bash's fall-through `case` terminators are refused.
177const CASE_FALLTHROUGH_REJECTION: &str = "`;&` and `;;&` are not supported: a `case` clause here runs alone and never falls through to the next; end every clause with `;;`";
178
179/// A `<<DELIM` whose body has not been read yet.
180///
181/// The body of a here-document begins on the line *after* the operator, so the token is pushed
182/// where it appears and filled in when the scanner reaches that newline. `cat <<EOF | jq .` depends
183/// on that: the rest of the line is ordinary shell, and only then does the body start.
184struct PendingHereDoc {
185    /// Terminator line, already unquoted.
186    delimiter: String,
187    /// `<<-`: strip leading tabs from body lines and from the terminator.
188    strip_tabs: bool,
189    /// Whether the body interpolates `$NAME` and `$( )`; false when the delimiter was quoted.
190    expand: bool,
191    /// Line the operator appeared on, for diagnostics.
192    line: usize,
193    /// Index in `tokens` of the placeholder to fill in.
194    token: usize,
195}
196
197impl LexError {
198    fn new(line: usize, message: impl Into<String>) -> Self {
199        Self {
200            line,
201            message: message.into(),
202        }
203    }
204}
205
206/// Tokenizes one script.
207pub fn tokenize(source: &str) -> Result<Vec<Token>, LexError> {
208    Lexer::new(source).run()
209}
210
211struct Lexer<'a> {
212    source: &'a str,
213    chars: Peekable<CharIndices<'a>>,
214    line: usize,
215    tokens: Vec<Token>,
216    parts: Vec<RawPart>,
217    literal: String,
218    word_started: bool,
219    word_line: usize,
220    /// Here-documents whose operator has been seen but whose body has not started yet.
221    pending_here_docs: Vec<PendingHereDoc>,
222}
223
224impl<'a> Lexer<'a> {
225    fn new(source: &'a str) -> Self {
226        Self {
227            source,
228            chars: source.char_indices().peekable(),
229            line: 1,
230            tokens: Vec::new(),
231            parts: Vec::new(),
232            literal: String::new(),
233            word_started: false,
234            word_line: 1,
235            pending_here_docs: Vec::new(),
236        }
237    }
238
239    fn run(mut self) -> Result<Vec<Token>, LexError> {
240        while let Some((index, character)) = self.chars.next() {
241            match character {
242                '\n' => {
243                    self.finish_word();
244                    self.push(TokenKind::Newline);
245                    self.line += 1;
246                    // The body of every here-document opened on the line just ended starts here.
247                    self.read_pending_here_doc_bodies()?;
248                }
249                ' ' | '\t' | '\r' => self.finish_word(),
250                '#' if !self.word_started => self.skip_comment(),
251                '\\' => self.read_escape()?,
252                '\'' => self.read_single_quoted()?,
253                '"' => self.read_double_quoted()?,
254                '$' => self.read_dollar(index)?,
255                // Backticks are the one dropped construct a model reaches for by reflex, so they
256                // are rejected by name rather than falling through to the literal arm below. A
257                // silent literal would hand back the source text as if the command had run.
258                '`' => return Err(LexError::new(self.line, BACKTICK_REJECTION)),
259                '|' | '&' | ';' | '<' | '>' | '(' | ')' => self.read_operator(character)?,
260                '{' | '}' if self.brace_is_reserved_word() => {
261                    self.finish_word();
262                    self.push(if character == '{' {
263                        TokenKind::LeftBrace
264                    } else {
265                        TokenKind::RightBrace
266                    });
267                }
268                other => self.push_literal(other),
269            }
270        }
271        self.finish_word();
272        if let Some(pending) = self.pending_here_docs.first() {
273            return Err(LexError::new(
274                pending.line,
275                format!(
276                    "unterminated here-document: the script ended before a line containing exactly {:?}",
277                    pending.delimiter
278                ),
279            ));
280        }
281        Ok(self.tokens)
282    }
283
284    fn push(&mut self, kind: TokenKind) {
285        let line = self.line;
286        self.tokens.push(Token { kind, line });
287    }
288
289    fn push_literal(&mut self, character: char) {
290        self.begin_word();
291        self.literal.push(character);
292    }
293
294    fn begin_word(&mut self) {
295        if !self.word_started {
296            self.word_started = true;
297            self.word_line = self.line;
298        }
299    }
300
301    fn flush_literal(&mut self) {
302        if !self.literal.is_empty() {
303            let literal = std::mem::take(&mut self.literal);
304            self.parts.push(RawPart::Literal(literal));
305        }
306    }
307
308    fn push_part(&mut self, part: RawPart) {
309        self.begin_word();
310        self.flush_literal();
311        self.parts.push(part);
312    }
313
314    fn finish_word(&mut self) {
315        if !self.word_started {
316            return;
317        }
318        self.flush_literal();
319        let parts = std::mem::take(&mut self.parts);
320        let line = self.word_line;
321        self.word_started = false;
322        self.tokens.push(Token {
323            kind: TokenKind::Word(RawWord { parts }),
324            line,
325        });
326    }
327
328    /// `{` and `}` are reserved words only as complete words, so `a{b}` stays one literal word.
329    ///
330    /// Brace expansion (`{a,b,c}`) is dropped: braces inside a word are ordinary characters.
331    fn brace_is_reserved_word(&mut self) -> bool {
332        if self.word_started {
333            return false;
334        }
335        matches!(
336            self.chars.peek().map(|(_, character)| *character),
337            None | Some(' ' | '\t' | '\r' | '\n' | ';' | '&' | '|' | '(' | ')' | '<' | '>')
338        )
339    }
340
341    fn skip_comment(&mut self) {
342        while let Some((_, character)) = self.chars.peek() {
343            if *character == '\n' {
344                return;
345            }
346            self.chars.next();
347        }
348    }
349
350    fn read_escape(&mut self) -> Result<(), LexError> {
351        match self.chars.next() {
352            Some((_, '\n')) => {
353                self.line += 1;
354                Ok(())
355            }
356            Some((_, character)) => {
357                // An escaped character is bash's one-character quote: `\*` and `'*'` are the same
358                // word. Recording it as a single-quoted part instead of erasing the backslash into
359                // plain literal text is what lets a `case` pattern tell `\*)` — match one literal
360                // asterisk — apart from the bare `*)` default branch, which would otherwise
361                // silently capture every subject.
362                self.push_part(RawPart::SingleQuoted(character.to_string()));
363                Ok(())
364            }
365            None => Err(LexError::new(
366                self.line,
367                "script ends with a trailing backslash",
368            )),
369        }
370    }
371
372    fn read_single_quoted(&mut self) -> Result<(), LexError> {
373        let opened = self.line;
374        let mut text = String::new();
375        loop {
376            match self.chars.next() {
377                Some((_, '\'')) => break,
378                Some((_, character)) => {
379                    if character == '\n' {
380                        self.line += 1;
381                    }
382                    text.push(character);
383                }
384                None => {
385                    return Err(LexError::new(opened, "unterminated single-quoted string"));
386                }
387            }
388        }
389        self.push_part(RawPart::SingleQuoted(text));
390        Ok(())
391    }
392
393    fn read_double_quoted(&mut self) -> Result<(), LexError> {
394        let parts = self.read_interpolated(Some('"'), "unterminated double-quoted string")?;
395        self.push_part(RawPart::DoubleQuoted(parts));
396        Ok(())
397    }
398
399    /// Scans interpolated text: literals, `$NAME`, `$( )`, and `$(( ))`.
400    ///
401    /// Shared by double-quoted strings and by the body of an unquoted here-document, because bash
402    /// interpolates both by the same rules. The one difference is which characters a backslash may
403    /// escape: `\"` is an escaped quote inside quotes and ordinary text inside a here-document,
404    /// where collapsing it would silently corrupt embedded JSON such as `{"a": "\"x\""}`.
405    fn read_interpolated(
406        &mut self,
407        terminator: Option<char>,
408        unterminated: &str,
409    ) -> Result<Vec<RawPart>, LexError> {
410        let opened = self.line;
411        let escapable: &[char] = if terminator == Some('"') {
412            &['"', '\\', '$', '`']
413        } else {
414            &['\\', '$', '`']
415        };
416        let mut parts = Vec::new();
417        let mut literal = String::new();
418        loop {
419            let Some((index, character)) = self.chars.next() else {
420                if terminator.is_none() {
421                    break;
422                }
423                return Err(LexError::new(opened, unterminated));
424            };
425            if Some(character) == terminator {
426                break;
427            }
428            match character {
429                '\\' => match self.chars.next() {
430                    Some((_, escaped)) if escapable.contains(&escaped) => literal.push(escaped),
431                    Some((_, '\n')) => self.line += 1,
432                    Some((_, other)) => {
433                        literal.push('\\');
434                        literal.push(other);
435                    }
436                    None => {
437                        if terminator.is_none() {
438                            literal.push('\\');
439                            break;
440                        }
441                        return Err(LexError::new(opened, unterminated));
442                    }
443                },
444                '$' => {
445                    let part = self.read_dollar_part(index)?;
446                    match part {
447                        Some(part) => {
448                            if !literal.is_empty() {
449                                parts.push(RawPart::Literal(std::mem::take(&mut literal)));
450                            }
451                            parts.push(part);
452                        }
453                        None => literal.push('$'),
454                    }
455                }
456                '`' => return Err(LexError::new(self.line, BACKTICK_REJECTION)),
457                other => {
458                    if other == '\n' {
459                        self.line += 1;
460                    }
461                    literal.push(other);
462                }
463            }
464        }
465        if !literal.is_empty() {
466            parts.push(RawPart::Literal(literal));
467        }
468        Ok(parts)
469    }
470
471    /// Records a `<<DELIM` operator, leaving a placeholder token for its body.
472    fn read_here_doc_header(&mut self) -> Result<(), LexError> {
473        if self.chars.peek().map(|(_, character)| *character) == Some('<') {
474            return Err(LexError::new(self.line, HERE_STRING_REJECTION));
475        }
476        let strip_tabs = self.chars.peek().map(|(_, character)| *character) == Some('-');
477        if strip_tabs {
478            self.chars.next();
479        }
480        while matches!(
481            self.chars.peek().map(|(_, character)| *character),
482            Some(' ' | '\t')
483        ) {
484            self.chars.next();
485        }
486
487        let (delimiter, expand) = self.read_here_doc_delimiter()?;
488        let line = self.line;
489        let token = self.tokens.len();
490        self.tokens.push(Token {
491            kind: TokenKind::HereDoc(RawWord { parts: Vec::new() }),
492            line,
493        });
494        self.pending_here_docs.push(PendingHereDoc {
495            delimiter,
496            strip_tabs,
497            expand,
498            line,
499            token,
500        });
501        Ok(())
502    }
503
504    /// Reads the terminator word after `<<`, reporting whether the body interpolates.
505    ///
506    /// Any quoting anywhere in the delimiter turns interpolation off, exactly as in bash: `<<'EOF'`,
507    /// `<<"EOF"`, and `<<\EOF` all mean "this body is literal text".
508    fn read_here_doc_delimiter(&mut self) -> Result<(String, bool), LexError> {
509        let line = self.line;
510        let mut delimiter = String::new();
511        let mut quoted = false;
512        while let Some((_, character)) = self.chars.peek().copied() {
513            match character {
514                '\'' | '"' => {
515                    self.chars.next();
516                    quoted = true;
517                    loop {
518                        match self.chars.next() {
519                            Some((_, closing)) if closing == character => break,
520                            Some((_, '\n')) | None => {
521                                return Err(LexError::new(
522                                    line,
523                                    "unterminated quoted here-document delimiter",
524                                ));
525                            }
526                            Some((_, other)) => delimiter.push(other),
527                        }
528                    }
529                }
530                '\\' => {
531                    self.chars.next();
532                    quoted = true;
533                    match self.chars.next() {
534                        // A line continuation here is bash's `<<\` + newline. Folding the newline
535                        // into the delimiter would make a terminator no body line can ever equal,
536                        // swallowing the rest of the script and skewing every later line number.
537                        Some((_, '\n')) => {
538                            return Err(LexError::new(
539                                line,
540                                "a here-document delimiter cannot be split across lines; write it on the same line as `<<`",
541                            ));
542                        }
543                        Some((_, escaped)) => delimiter.push(escaped),
544                        None => {
545                            return Err(LexError::new(
546                                line,
547                                "script ends with a trailing backslash",
548                            ));
549                        }
550                    }
551                }
552                other if other.is_ascii_alphanumeric() || matches!(other, '_' | '.' | '-') => {
553                    delimiter.push(other);
554                    self.chars.next();
555                }
556                _ => break,
557            }
558        }
559        if delimiter.is_empty() {
560            return Err(LexError::new(
561                line,
562                "expected a here-document delimiter after `<<`, as in `cat <<EOF`",
563            ));
564        }
565        Ok((delimiter, !quoted))
566    }
567
568    /// Consumes the body of every here-document opened on the line that just ended.
569    fn read_pending_here_doc_bodies(&mut self) -> Result<(), LexError> {
570        // Several here-documents may open on one line (`cmd <<A <<B`); bash reads their bodies in
571        // the order the operators appeared, and so does this.
572        let pending = std::mem::take(&mut self.pending_here_docs);
573        for specification in pending {
574            let mut body = self.read_here_doc_body(&specification)?;
575            // Drop the newline that ended the last body line. Values in this shell are not
576            // newline-terminated — `echo hi` produces `"hi"`, and emitting a value adds the line
577            // ending — so keeping it would make `cat <<EOF` print a trailing blank line that the
578            // same here-document does not produce in bash.
579            body.pop();
580            let parts = if specification.expand {
581                Self::interpolate_here_doc_body(&body, specification.line)?
582            } else if body.is_empty() {
583                Vec::new()
584            } else {
585                vec![RawPart::Literal(body)]
586            };
587            self.tokens[specification.token] = Token {
588                kind: TokenKind::HereDoc(RawWord { parts }),
589                line: specification.line,
590            };
591        }
592        Ok(())
593    }
594
595    /// Reads raw body lines up to the terminator line.
596    fn read_here_doc_body(&mut self, specification: &PendingHereDoc) -> Result<String, LexError> {
597        let mut body = String::new();
598        loop {
599            let mut line = String::new();
600            let mut terminated = false;
601            for (_, character) in self.chars.by_ref() {
602                if character == '\n' {
603                    terminated = true;
604                    break;
605                }
606                line.push(character);
607            }
608            if terminated {
609                self.line += 1;
610            }
611
612            // `<<-` strips leading tabs — and only tabs, never spaces — from both the body lines
613            // and the terminator, which is what lets a here-document sit at the indentation of the
614            // block around it.
615            let content = if specification.strip_tabs {
616                line.trim_start_matches('\t')
617            } else {
618                line.as_str()
619            };
620            if content == specification.delimiter {
621                return Ok(body);
622            }
623            if !terminated {
624                return Err(LexError::new(
625                    specification.line,
626                    format!(
627                        "unterminated here-document: the script ended before a line containing exactly {:?}",
628                        specification.delimiter
629                    ),
630                ));
631            }
632            body.push_str(content);
633            body.push('\n');
634        }
635    }
636
637    /// Interpolates an unquoted here-document body by re-scanning it as quoted-style text.
638    fn interpolate_here_doc_body(body: &str, line: usize) -> Result<Vec<RawPart>, LexError> {
639        let mut lexer = Lexer::new(body);
640        // The body starts on the line *after* the operator, so a diagnostic from inside it counts
641        // from there. Seeding this with the operator's own line put every such error one line early.
642        lexer.line = line + 1;
643        lexer
644            .read_interpolated(None, "unterminated here-document")
645            .map_err(|error| LexError::new(error.line, error.message))
646    }
647
648    fn read_dollar(&mut self, index: usize) -> Result<(), LexError> {
649        match self.read_dollar_part(index)? {
650            Some(part) => self.push_part(part),
651            // A `$` that introduces nothing recognizable is an ordinary character, as in bash.
652            None => self.push_literal('$'),
653        }
654        Ok(())
655    }
656
657    fn read_dollar_part(&mut self, dollar_index: usize) -> Result<Option<RawPart>, LexError> {
658        let Some((_, next)) = self.chars.peek().copied() else {
659            return Ok(None);
660        };
661        match next {
662            '(' => {
663                self.chars.next();
664                if self.chars.peek().map(|(_, character)| *character) == Some('(') {
665                    self.chars.next();
666                    let body = self.read_balanced(dollar_index, '(', ')', 2, "$(( ... ))")?;
667                    return Ok(Some(RawPart::Arithmetic(body)));
668                }
669                let body = self.read_balanced(dollar_index, '(', ')', 1, "$( ... )")?;
670                Ok(Some(RawPart::CommandSubstitution(body)))
671            }
672            '{' => {
673                self.chars.next();
674                self.read_braced_parameter().map(Some)
675            }
676            '?' => {
677                self.chars.next();
678                Ok(Some(RawPart::Parameter(RawParameter::LastStatus)))
679            }
680            '@' => {
681                self.chars.next();
682                Ok(Some(RawPart::Parameter(RawParameter::AllPositional)))
683            }
684            '*' => {
685                self.chars.next();
686                Ok(Some(RawPart::Parameter(RawParameter::AllPositionalJoined)))
687            }
688            '#' => {
689                self.chars.next();
690                Ok(Some(RawPart::Parameter(RawParameter::PositionalCount)))
691            }
692            digit if digit.is_ascii_digit() => {
693                self.chars.next();
694                let position = usize::from(
695                    digit
696                        .to_digit(10)
697                        .and_then(|value| u8::try_from(value).ok())
698                        .unwrap_or_default(),
699                );
700                Ok(Some(RawPart::Parameter(RawParameter::Positional(position))))
701            }
702            first if first.is_ascii_alphabetic() || first == '_' => {
703                let name = self.read_name();
704                Ok(Some(RawPart::Parameter(RawParameter::Named {
705                    name,
706                    indices: Vec::new(),
707                })))
708            }
709            _ => Ok(None),
710        }
711    }
712
713    fn read_name(&mut self) -> String {
714        let mut name = String::new();
715        while let Some((_, character)) = self.chars.peek().copied() {
716            if character.is_ascii_alphanumeric() || character == '_' {
717                name.push(character);
718                self.chars.next();
719            } else {
720                break;
721            }
722        }
723        name
724    }
725
726    fn read_braced_parameter(&mut self) -> Result<RawPart, LexError> {
727        let line = self.line;
728        match self.chars.peek().map(|(_, character)| *character) {
729            Some('?') => {
730                self.chars.next();
731                self.expect_brace_close(line)?;
732                return Ok(RawPart::Parameter(RawParameter::LastStatus));
733            }
734            Some('@') => {
735                self.chars.next();
736                self.expect_brace_close(line)?;
737                return Ok(RawPart::Parameter(RawParameter::AllPositional));
738            }
739            Some('*') => {
740                self.chars.next();
741                self.expect_brace_close(line)?;
742                return Ok(RawPart::Parameter(RawParameter::AllPositionalJoined));
743            }
744            Some('#') => {
745                self.chars.next();
746                if self.chars.peek().map(|(_, character)| *character) == Some('}') {
747                    self.chars.next();
748                    return Ok(RawPart::Parameter(RawParameter::PositionalCount));
749                }
750                return Err(LexError::new(
751                    line,
752                    "${#name} length expansion is not supported; use `jq length` or `wc` instead",
753                ));
754            }
755            Some(digit) if digit.is_ascii_digit() => {
756                let mut digits = String::new();
757                while let Some((_, character)) = self.chars.peek().copied() {
758                    if character.is_ascii_digit() {
759                        digits.push(character);
760                        self.chars.next();
761                    } else {
762                        break;
763                    }
764                }
765                self.expect_brace_close(line)?;
766                let position = digits.parse::<usize>().map_err(|_| {
767                    LexError::new(
768                        line,
769                        format!("positional parameter ${digits} is out of range"),
770                    )
771                })?;
772                return Ok(RawPart::Parameter(RawParameter::Positional(position)));
773            }
774            _ => {}
775        }
776
777        let name = self.read_name();
778        if name.is_empty() {
779            return Err(LexError::new(line, "empty ${} parameter reference"));
780        }
781
782        let mut indices = Vec::new();
783        loop {
784            match self.chars.peek().map(|(_, character)| *character) {
785                Some('}') => {
786                    self.chars.next();
787                    break;
788                }
789                Some('[') => {
790                    self.chars.next();
791                    indices.push(self.read_index_word(line)?);
792                }
793                Some(other) => {
794                    return Err(LexError::new(
795                        line,
796                        format!(
797                            "unsupported ${{{name}{other}...}} parameter expansion; this shell keeps only ${{NAME}} and ${{NAME[index]}}"
798                        ),
799                    ));
800                }
801                None => return Err(LexError::new(line, "unterminated ${} parameter reference")),
802            }
803        }
804
805        Ok(RawPart::Parameter(RawParameter::Named { name, indices }))
806    }
807
808    fn expect_brace_close(&mut self, line: usize) -> Result<(), LexError> {
809        match self.chars.next() {
810            Some((_, '}')) => Ok(()),
811            _ => Err(LexError::new(line, "unterminated ${} parameter reference")),
812        }
813    }
814
815    /// Reads the index text inside `${NAME[...]}`.
816    ///
817    /// Bash's own sparse and associative array emulation is dropped: `${NAME[@]}` and `${NAME[*]}`
818    /// are rejected by name because indexing here is backed by real JSON arrays and objects.
819    fn read_index_word(&mut self, line: usize) -> Result<RawWord, LexError> {
820        let mut text = String::new();
821        loop {
822            match self.chars.next() {
823                Some((_, ']')) => break,
824                Some((_, '\n')) => {
825                    return Err(LexError::new(line, "unterminated ${NAME[index]} reference"));
826                }
827                Some((_, character)) => text.push(character),
828                None => return Err(LexError::new(line, "unterminated ${NAME[index]} reference")),
829            }
830        }
831        if text == "@" || text == "*" {
832            return Err(LexError::new(
833                line,
834                "${NAME[@]} array expansion is not supported; an unquoted $NAME holding a JSON array already expands element by element",
835            ));
836        }
837        let tokens = tokenize(&text)?;
838        let mut words = tokens.into_iter().filter_map(|token| match token.kind {
839            TokenKind::Word(word) => Some(word),
840            _ => None,
841        });
842        let word = words
843            .next()
844            .ok_or_else(|| LexError::new(line, "empty ${NAME[index]} reference"))?;
845        if words.next().is_some() {
846            return Err(LexError::new(
847                line,
848                "${NAME[index]} accepts exactly one index expression",
849            ));
850        }
851        Ok(word)
852    }
853
854    /// Captures a balanced `$( ... )` or `$(( ... ))` body as raw source.
855    fn read_balanced(
856        &mut self,
857        dollar_index: usize,
858        open: char,
859        close: char,
860        mut depth: usize,
861        label: &str,
862    ) -> Result<String, LexError> {
863        let opened = self.line;
864        let initial = depth;
865        let start = dollar_index + '$'.len_utf8() + open.len_utf8() * depth;
866        // `$(( ... ))` closes two levels, so the body ends at the *first* closing parenthesis while
867        // scanning continues to the second. Recording that position keeps the captured body exact.
868        let mut body_end = None;
869        loop {
870            let Some((index, character)) = self.chars.next() else {
871                return Err(LexError::new(opened, format!("unterminated {label}")));
872            };
873            match character {
874                '\n' => self.line += 1,
875                '\\' => {
876                    if let Some((_, next)) = self.chars.next() {
877                        if next == '\n' {
878                            self.line += 1;
879                        }
880                        continue;
881                    }
882                }
883                '\'' => {
884                    for (_, quoted) in self.chars.by_ref() {
885                        if quoted == '\n' {
886                            self.line += 1;
887                        }
888                        if quoted == '\'' {
889                            break;
890                        }
891                    }
892                    continue;
893                }
894                '"' => {
895                    let mut escaped = false;
896                    for (_, quoted) in self.chars.by_ref() {
897                        if quoted == '\n' {
898                            self.line += 1;
899                        }
900                        if escaped {
901                            escaped = false;
902                            continue;
903                        }
904                        if quoted == '\\' {
905                            escaped = true;
906                            continue;
907                        }
908                        if quoted == '"' {
909                            break;
910                        }
911                    }
912                    continue;
913                }
914                matched if matched == open => depth += 1,
915                matched if matched == close => {
916                    depth -= 1;
917                    if depth + 1 == initial && body_end.is_none() {
918                        body_end = Some(index);
919                    }
920                    if depth == 0 {
921                        let end = body_end.unwrap_or(index);
922                        return Ok(self.source[start..end].to_owned());
923                    }
924                }
925                _ => {}
926            }
927        }
928    }
929
930    fn read_operator(&mut self, character: char) -> Result<(), LexError> {
931        // `2>`, `2>&1`, and `>&2` all begin as a bare digit word glued to a redirection operator.
932        // Letting the digit finish as an ordinary word would append it to argv and divert the
933        // output into a buffer named `/dev/null`, so the whole shape is rejected by name instead.
934        if matches!(character, '<' | '>')
935            && self.word_started
936            && self.parts.is_empty()
937            && !self.literal.is_empty()
938            && self.literal.chars().all(|digit| digit.is_ascii_digit())
939        {
940            return Err(LexError::new(self.line, FD_REDIRECTION_REJECTION));
941        }
942        self.finish_word();
943        let next = self.chars.peek().map(|(_, character)| *character);
944        if matches!((character, next), ('<' | '>', Some('&'))) {
945            return Err(LexError::new(self.line, FD_REDIRECTION_REJECTION));
946        }
947        let kind = match (character, next) {
948            ('|', Some('|')) => {
949                self.chars.next();
950                TokenKind::OrOr
951            }
952            ('|', _) => TokenKind::Pipe,
953            ('&', Some('&')) => {
954                self.chars.next();
955                TokenKind::AndAnd
956            }
957            ('&', _) => TokenKind::Ampersand,
958            (';', Some(';')) => {
959                self.chars.next();
960                // `;;&` and `;&` are bash's two fall-through terminators. Reading either as a
961                // plain `;;` would run one clause where the script asked for several.
962                if self.chars.peek().map(|(_, character)| *character) == Some('&') {
963                    return Err(LexError::new(self.line, CASE_FALLTHROUGH_REJECTION));
964                }
965                TokenKind::DoubleSemicolon
966            }
967            (';', Some('&')) => return Err(LexError::new(self.line, CASE_FALLTHROUGH_REJECTION)),
968            (';', _) => TokenKind::Semicolon,
969            ('>', Some('>')) => {
970                self.chars.next();
971                TokenKind::GreatGreat
972            }
973            ('>', _) => TokenKind::Great,
974            ('<', Some('<')) => {
975                self.chars.next();
976                return self.read_here_doc_header();
977            }
978            ('<', Some('(')) => {
979                self.chars.next();
980                TokenKind::LessParen
981            }
982            ('<', _) => TokenKind::Less,
983            ('(', _) => TokenKind::LeftParen,
984            (')', _) => TokenKind::RightParen,
985            _ => unreachable!("read_operator is only called for operator characters"),
986        };
987        self.push(kind);
988        Ok(())
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::{RawParameter, RawPart, TokenKind, tokenize};
995
996    fn kinds(source: &str) -> Vec<TokenKind> {
997        tokenize(source)
998            .expect("tokenizes")
999            .into_iter()
1000            .map(|token| token.kind)
1001            .collect()
1002    }
1003
1004    fn single_word(source: &str) -> Vec<RawPart> {
1005        match kinds(source).into_iter().next().expect("one token") {
1006            TokenKind::Word(word) => word.parts,
1007            other => panic!("expected a word, found {other}"),
1008        }
1009    }
1010
1011    #[test]
1012    fn splits_words_and_operators() {
1013        assert_eq!(
1014            kinds("echo hi | grep h && true"),
1015            vec![
1016                TokenKind::Word(super::RawWord {
1017                    parts: vec![RawPart::Literal("echo".to_owned())]
1018                }),
1019                TokenKind::Word(super::RawWord {
1020                    parts: vec![RawPart::Literal("hi".to_owned())]
1021                }),
1022                TokenKind::Pipe,
1023                TokenKind::Word(super::RawWord {
1024                    parts: vec![RawPart::Literal("grep".to_owned())]
1025                }),
1026                TokenKind::Word(super::RawWord {
1027                    parts: vec![RawPart::Literal("h".to_owned())]
1028                }),
1029                TokenKind::AndAnd,
1030                TokenKind::Word(super::RawWord {
1031                    parts: vec![RawPart::Literal("true".to_owned())]
1032                }),
1033            ]
1034        );
1035    }
1036
1037    #[test]
1038    fn comments_run_to_end_of_line() {
1039        assert_eq!(
1040            kinds("echo a # trailing\necho b"),
1041            vec![
1042                TokenKind::Word(super::RawWord {
1043                    parts: vec![RawPart::Literal("echo".to_owned())]
1044                }),
1045                TokenKind::Word(super::RawWord {
1046                    parts: vec![RawPart::Literal("a".to_owned())]
1047                }),
1048                TokenKind::Newline,
1049                TokenKind::Word(super::RawWord {
1050                    parts: vec![RawPart::Literal("echo".to_owned())]
1051                }),
1052                TokenKind::Word(super::RawWord {
1053                    parts: vec![RawPart::Literal("b".to_owned())]
1054                }),
1055            ]
1056        );
1057    }
1058
1059    #[test]
1060    fn a_hash_inside_a_word_is_literal() {
1061        assert_eq!(single_word("a#b"), vec![RawPart::Literal("a#b".to_owned())]);
1062    }
1063
1064    #[test]
1065    fn single_quotes_are_fully_literal() {
1066        assert_eq!(
1067            single_word(r#"'$VAR $(cmd) \n'"#),
1068            vec![RawPart::SingleQuoted(r"$VAR $(cmd) \n".to_owned())]
1069        );
1070    }
1071
1072    #[test]
1073    fn double_quotes_interpolate_parameters_and_substitutions() {
1074        assert_eq!(
1075            single_word(r#""a ${NAME} $(echo b)""#),
1076            vec![RawPart::DoubleQuoted(vec![
1077                RawPart::Literal("a ".to_owned()),
1078                RawPart::Parameter(RawParameter::Named {
1079                    name: "NAME".to_owned(),
1080                    indices: Vec::new(),
1081                }),
1082                RawPart::Literal(" ".to_owned()),
1083                RawPart::CommandSubstitution("echo b".to_owned()),
1084            ])]
1085        );
1086    }
1087
1088    #[test]
1089    fn nested_command_substitution_keeps_its_full_body() {
1090        assert_eq!(
1091            single_word("$(echo $(echo inner) ')' )"),
1092            vec![RawPart::CommandSubstitution(
1093                "echo $(echo inner) ')' ".to_owned()
1094            )]
1095        );
1096    }
1097
1098    #[test]
1099    fn arithmetic_expansion_is_distinguished_from_command_substitution() {
1100        assert_eq!(
1101            single_word("$(( 1 + (2 * 3) ))"),
1102            vec![RawPart::Arithmetic(" 1 + (2 * 3) ".to_owned())]
1103        );
1104    }
1105
1106    #[test]
1107    fn indexed_parameters_capture_their_index_word() {
1108        let parts = single_word("${obj[key]}");
1109        let RawPart::Parameter(RawParameter::Named { name, indices }) = &parts[0] else {
1110            panic!("expected an indexed parameter, found {parts:?}");
1111        };
1112        assert_eq!(name, "obj");
1113        assert_eq!(indices.len(), 1);
1114        assert_eq!(indices[0].as_literal(), Some("key"));
1115    }
1116
1117    #[test]
1118    fn every_dropped_parameter_expansion_is_rejected_by_name() {
1119        // One case per rejection branch, so a branch that regresses to falling through to
1120        // `read_name` (where `${#x}` would quietly become the positional count `$#`) fails here.
1121        for (source, expected) in [
1122            ("echo ${arr[@]}", "${NAME[@]}"),
1123            ("echo ${arr[*]}", "${NAME[@]}"),
1124            ("echo ${#items}", "${#name} length expansion"),
1125            ("echo ${name:-default}", "keeps only"),
1126            ("echo ${name/a/b}", "keeps only"),
1127            ("echo ${name^^}", "keeps only"),
1128            ("echo ${}", "empty ${} parameter reference"),
1129            ("echo ${name", "unterminated"),
1130        ] {
1131            let error = tokenize(source)
1132                .map(|tokens| format!("{tokens:?}"))
1133                .expect_err(source);
1134            assert!(error.message.contains(expected), "{source}: {error}");
1135        }
1136    }
1137
1138    #[test]
1139    fn positional_parameters_cover_at_hash_and_star() {
1140        assert_eq!(
1141            single_word("$@"),
1142            vec![RawPart::Parameter(RawParameter::AllPositional)]
1143        );
1144        assert_eq!(
1145            single_word("$*"),
1146            vec![RawPart::Parameter(RawParameter::AllPositionalJoined)]
1147        );
1148        assert_eq!(
1149            single_word("${*}"),
1150            vec![RawPart::Parameter(RawParameter::AllPositionalJoined)]
1151        );
1152        assert_eq!(
1153            single_word("$#"),
1154            vec![RawPart::Parameter(RawParameter::PositionalCount)]
1155        );
1156    }
1157
1158    #[test]
1159    fn backtick_substitution_is_rejected_by_name() {
1160        for source in ["echo `echo hi`", r#"echo "`echo hi`""#, "x=`date`"] {
1161            let error = tokenize(source).expect_err("backticks are dropped");
1162            assert!(error.message.contains("backtick"), "{source}: {error}");
1163            assert!(error.message.contains("$( ... )"), "{source}: {error}");
1164        }
1165        // An escaped backtick is ordinary text in both bash and here; escapes lex as
1166        // single-quoted parts so words remember which characters were quoted.
1167        assert_eq!(
1168            single_word(r"\`"),
1169            vec![RawPart::SingleQuoted("`".to_owned())]
1170        );
1171        assert_eq!(
1172            single_word("'`echo hi`'"),
1173            vec![RawPart::SingleQuoted("`echo hi`".to_owned())]
1174        );
1175    }
1176
1177    #[test]
1178    fn file_descriptor_redirection_is_rejected_by_name() {
1179        for source in ["echo hi 2>buf", "echo hi 2>&1", "echo hi >&2", "cmd 2>>buf"] {
1180            let error = tokenize(source).expect_err("fd redirection is dropped");
1181            assert!(
1182                error.message.contains("file-descriptor redirection"),
1183                "{source}: {error}"
1184            );
1185        }
1186        // A digit that is a plain argument, separated from the operator, still redirects normally.
1187        assert!(kinds("echo 2 > buf").contains(&TokenKind::Great));
1188    }
1189
1190    #[test]
1191    fn case_clause_terminators_are_tokenized_and_fall_through_is_not() {
1192        assert!(kinds("a;;").contains(&TokenKind::DoubleSemicolon));
1193        for source in ["a;;&", "a;&"] {
1194            let error = tokenize(source).expect_err("fall-through is dropped");
1195            assert!(error.message.contains("falls through"), "{source}: {error}");
1196        }
1197    }
1198
1199    #[test]
1200    fn a_here_document_collects_the_lines_after_its_operator() {
1201        let TokenKind::HereDoc(body) = &kinds("cat <<EOF\nhello\nthere\nEOF\n")[1] else {
1202            panic!("expected a here-document token");
1203        };
1204        assert_eq!(
1205            body.parts,
1206            vec![RawPart::Literal("hello\nthere".to_owned())]
1207        );
1208    }
1209
1210    #[test]
1211    fn an_unquoted_here_document_interpolates_and_a_quoted_one_does_not() {
1212        let TokenKind::HereDoc(expanded) = &kinds("cat <<EOF\nid=$id\nEOF\n")[1] else {
1213            panic!("expected a here-document token");
1214        };
1215        assert_eq!(
1216            expanded.parts,
1217            vec![
1218                RawPart::Literal("id=".to_owned()),
1219                RawPart::Parameter(RawParameter::Named {
1220                    name: "id".to_owned(),
1221                    indices: Vec::new(),
1222                }),
1223            ]
1224        );
1225
1226        // Any quoting of the delimiter turns the whole body literal, as in bash.
1227        for source in [
1228            "cat <<'EOF'\nid=$id\nEOF\n",
1229            "cat <<\"EOF\"\nid=$id\nEOF\n",
1230            "cat <<\\EOF\nid=$id\nEOF\n",
1231        ] {
1232            let TokenKind::HereDoc(literal) = &kinds(source)[1] else {
1233                panic!("expected a here-document token for {source:?}");
1234            };
1235            assert_eq!(
1236                literal.parts,
1237                vec![RawPart::Literal("id=$id".to_owned())],
1238                "{source}"
1239            );
1240        }
1241    }
1242
1243    #[test]
1244    fn a_here_document_body_keeps_backslashes_that_json_depends_on() {
1245        // `\"` is an escaped quote inside double quotes and ordinary text in a here-document.
1246        // Collapsing it here would silently rewrite embedded JSON.
1247        let TokenKind::HereDoc(body) = &kinds("cat <<EOF\n{\"a\": \"\\\"x\\\"\"}\nEOF\n")[1] else {
1248            panic!("expected a here-document token");
1249        };
1250        assert_eq!(
1251            body.parts,
1252            vec![RawPart::Literal("{\"a\": \"\\\"x\\\"\"}".to_owned())]
1253        );
1254    }
1255
1256    #[test]
1257    fn a_dash_here_document_strips_leading_tabs_from_body_and_terminator() {
1258        let TokenKind::HereDoc(body) = &kinds("cat <<-EOF\n\t\tindented\n\tEOF\n")[1] else {
1259            panic!("expected a here-document token");
1260        };
1261        assert_eq!(body.parts, vec![RawPart::Literal("indented".to_owned())]);
1262
1263        // Only tabs, never spaces: a space-indented terminator does not close the document.
1264        assert!(tokenize("cat <<-EOF\nbody\n    EOF\n").is_err());
1265    }
1266
1267    #[test]
1268    fn the_rest_of_the_operator_line_is_ordinary_shell() {
1269        // `cat <<EOF | jq .` must keep working: the body starts on the next line, not immediately.
1270        let tokens = kinds("cat <<EOF | jq .\n{\"a\":1}\nEOF\n");
1271        assert!(matches!(tokens[1], TokenKind::HereDoc(_)));
1272        assert!(tokens.contains(&TokenKind::Pipe));
1273    }
1274
1275    #[test]
1276    fn several_here_documents_on_one_line_read_their_bodies_in_order() {
1277        let tokens = kinds("f <<A <<B\nfirst\nA\nsecond\nB\n");
1278        let bodies = tokens
1279            .iter()
1280            .filter_map(|token| match token {
1281                TokenKind::HereDoc(body) => Some(body.parts.clone()),
1282                _ => None,
1283            })
1284            .collect::<Vec<_>>();
1285        assert_eq!(
1286            bodies,
1287            vec![
1288                vec![RawPart::Literal("first".to_owned())],
1289                vec![RawPart::Literal("second".to_owned())],
1290            ]
1291        );
1292    }
1293
1294    #[test]
1295    fn a_diagnostic_inside_a_here_document_body_counts_from_the_body() {
1296        // The body starts on the line after the operator. Seeding the sub-scanner with the
1297        // operator's own line reported every error inside a body one line early.
1298        let error = tokenize("echo one\ncat <<EOF\nbad `sub`\nEOF\n").expect_err("backticks");
1299        assert_eq!(error.line, 3, "{error}");
1300    }
1301
1302    #[test]
1303    fn a_here_document_delimiter_cannot_be_split_across_lines() {
1304        // `<<\` + newline is a line continuation in bash. Folding the newline into the delimiter
1305        // produced a terminator no line could match, swallowing the rest of the script.
1306        let error = tokenize("cat <<\\\nEOF\nbody\nEOF\n").expect_err("a split delimiter");
1307        assert!(error.message.contains("cannot be split"), "{error}");
1308    }
1309
1310    #[test]
1311    fn malformed_here_documents_are_rejected_by_name() {
1312        for (source, expected) in [
1313            ("cat <<EOF\nbody\n", "unterminated here-document"),
1314            ("cat <<EOF", "unterminated here-document"),
1315            ("cat <<\n", "expected a here-document delimiter"),
1316            ("cat <<<\"$x\"", "here-string"),
1317        ] {
1318            let error = tokenize(source).expect_err(source);
1319            assert!(error.message.contains(expected), "{source}: {error}");
1320        }
1321    }
1322
1323    #[test]
1324    fn braces_are_reserved_words_only_as_complete_words() {
1325        assert_eq!(
1326            kinds("f() { echo hi; }"),
1327            vec![
1328                TokenKind::Word(super::RawWord {
1329                    parts: vec![RawPart::Literal("f".to_owned())]
1330                }),
1331                TokenKind::LeftParen,
1332                TokenKind::RightParen,
1333                TokenKind::LeftBrace,
1334                TokenKind::Word(super::RawWord {
1335                    parts: vec![RawPart::Literal("echo".to_owned())]
1336                }),
1337                TokenKind::Word(super::RawWord {
1338                    parts: vec![RawPart::Literal("hi".to_owned())]
1339                }),
1340                TokenKind::Semicolon,
1341                TokenKind::RightBrace,
1342            ]
1343        );
1344        // Brace expansion is dropped, so `{a,b}` stays one literal word.
1345        assert_eq!(
1346            single_word("{a,b}"),
1347            vec![RawPart::Literal("{a,b}".to_owned())]
1348        );
1349    }
1350
1351    #[test]
1352    fn globbing_characters_are_ordinary_literals() {
1353        assert_eq!(single_word("*"), vec![RawPart::Literal("*".to_owned())]);
1354        assert_eq!(
1355            single_word("a?[b]~"),
1356            vec![RawPart::Literal("a?[b]~".to_owned())]
1357        );
1358    }
1359
1360    #[test]
1361    fn redirection_and_backgrounding_operators_are_tokenized_not_dropped() {
1362        assert!(kinds("echo hi > buf").contains(&TokenKind::Great));
1363        assert!(kinds("echo hi >> buf").contains(&TokenKind::GreatGreat));
1364        assert!(kinds("sleep 1 &").contains(&TokenKind::Ampersand));
1365        assert!(kinds("cat < f").contains(&TokenKind::Less));
1366        assert!(kinds("diff <(a) b").contains(&TokenKind::LessParen));
1367    }
1368
1369    #[test]
1370    fn unterminated_quotes_are_reported_with_a_line() {
1371        let error = tokenize("echo 'open").expect_err("unterminated quote");
1372        assert_eq!(error.line, 1);
1373        assert!(error.message.contains("unterminated"), "{error}");
1374    }
1375}