Skip to main content

fslite_command/
lexer.rs

1//! A deliberately tiny, hand-written tokenizer for `fslite-command`'s line
2//! grammar. It is not a shell: there is no expansion of any kind (globs,
3//! `$VAR`, `~`, command substitution) and no shell metacharacter (`|`, `;`,
4//! `&`, `<`, `>`, backtick, `$(`) is ever treated as literal text when it
5//! appears unquoted — it is rejected outright, so a user who pastes a real
6//! shell command gets a clear error instead of a confusing partial parse.
7
8/// The maximum accepted input line length, checked before any allocation
9/// proportional to the input beyond the raw string itself.
10pub const MAX_LINE_LEN: usize = 65536;
11
12const REJECTED_UNQUOTED_METACHARACTERS: &[char] = &['|', ';', '&', '<', '>', '`'];
13
14/// One lexical token: a bare word/path, or a `--flag[=value]`.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub enum Token {
17    /// A positional argument (verb or path).
18    Word(String),
19    /// A `--name` or `--name=value` flag.
20    Flag { name: String, value: Option<String> },
21}
22
23/// Why a line could not be tokenized.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub enum LexError {
26    /// A `'` or `"` was opened but never closed.
27    UnterminatedQuote,
28    /// A `\` inside a double-quoted string preceded an unsupported character.
29    InvalidEscape(char),
30    /// The input contained a NUL byte.
31    NulByte,
32    /// The input exceeded [`MAX_LINE_LEN`], checked before tokenizing.
33    TooLong { max: usize, actual: usize },
34    /// An unquoted shell metacharacter appeared outside a quoted token.
35    UnsupportedMetacharacter(char),
36}
37
38/// Tokenizes one line of `fslite-command` grammar.
39pub fn tokenize(line: &str) -> Result<Vec<Token>, LexError> {
40    if line.len() > MAX_LINE_LEN {
41        return Err(LexError::TooLong {
42            max: MAX_LINE_LEN,
43            actual: line.len(),
44        });
45    }
46    if line.contains('\0') {
47        return Err(LexError::NulByte);
48    }
49    // `$(` is checked as a two-character sequence; single '$' and '~' are
50    // never rejected or expanded — see the `dollar_and_tilde_are_never_expanded` test.
51    if line.contains("$(") {
52        return Err(LexError::UnsupportedMetacharacter('$'));
53    }
54
55    let mut tokens = Vec::new();
56    let mut chars = line.chars().peekable();
57
58    while let Some(&ch) = chars.peek() {
59        if ch.is_whitespace() {
60            chars.next();
61            continue;
62        }
63        if REJECTED_UNQUOTED_METACHARACTERS.contains(&ch) {
64            return Err(LexError::UnsupportedMetacharacter(ch));
65        }
66
67        let word = read_word(&mut chars)?;
68        tokens.push(classify(word));
69    }
70
71    Ok(tokens)
72}
73
74fn classify(word: String) -> Token {
75    match word.strip_prefix("--") {
76        Some(rest) => match rest.split_once('=') {
77            Some((name, value)) => Token::Flag {
78                name: name.to_string(),
79                value: Some(value.to_string()),
80            },
81            None => Token::Flag {
82                name: rest.to_string(),
83                value: None,
84            },
85        },
86        None => Token::Word(word),
87    }
88}
89
90fn read_word(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String, LexError> {
91    let mut word = String::new();
92    // Whether this word has already consumed *any* input, including a
93    // quoted segment that happened to yield zero characters (e.g. `''` or
94    // `""`). `!word.is_empty()` is NOT an equivalent proxy for this: an
95    // empty quoted segment leaves `word` empty even though a token has
96    // definitely started, which would let a metacharacter immediately
97    // following it (e.g. `'';rm -rf /`) fall through as literal text
98    // instead of being rejected.
99    let mut started = false;
100
101    while let Some(&ch) = chars.peek() {
102        if ch.is_whitespace() {
103            break;
104        }
105        if REJECTED_UNQUOTED_METACHARACTERS.contains(&ch) && started {
106            // A metacharacter ending a word (e.g. `foo;`) is still rejected —
107            // stop and let the outer loop's boundary check on the *next*
108            // iteration catch it. To fail immediately rather than silently
109            // absorbing it as a separate empty word, check right here too.
110            return Err(LexError::UnsupportedMetacharacter(ch));
111        }
112
113        match ch {
114            '\'' => {
115                chars.next();
116                word.push_str(&read_single_quoted(chars)?);
117                started = true;
118            }
119            '"' => {
120                chars.next();
121                word.push_str(&read_double_quoted(chars)?);
122                started = true;
123            }
124            _ => {
125                word.push(ch);
126                chars.next();
127                started = true;
128            }
129        }
130    }
131
132    Ok(word)
133}
134
135fn read_single_quoted(
136    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
137) -> Result<String, LexError> {
138    let mut content = String::new();
139    loop {
140        match chars.next() {
141            None => return Err(LexError::UnterminatedQuote),
142            Some('\'') => return Ok(content),
143            Some(ch) => content.push(ch),
144        }
145    }
146}
147
148fn read_double_quoted(
149    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
150) -> Result<String, LexError> {
151    let mut content = String::new();
152    loop {
153        match chars.next() {
154            None => return Err(LexError::UnterminatedQuote),
155            Some('"') => return Ok(content),
156            Some('\\') => match chars.next() {
157                None => return Err(LexError::UnterminatedQuote),
158                Some('n') => content.push('\n'),
159                Some('t') => content.push('\t'),
160                Some('"') => content.push('"'),
161                Some('\\') => content.push('\\'),
162                Some(other) => return Err(LexError::InvalidEscape(other)),
163            },
164            Some(ch) => content.push(ch),
165        }
166    }
167}