Skip to main content

command_stream/fy/
lexer.rs

1//! Shell lexer for the `$fy` translator.
2//!
3//! Produces a flat, lossless token stream: every character of the input ends
4//! up in exactly one token's text, so the formalizer can rebuild the original
5//! source from the links network.
6
7/// Token kinds emitted by [`tokenize`].
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TokenType {
10    Word,
11    Operator,
12    Redirect,
13    Newline,
14    Comment,
15    Eof,
16}
17
18/// One lexed token.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Token {
21    pub token_type: TokenType,
22    pub text: String,
23}
24
25impl Token {
26    fn new(token_type: TokenType, text: impl Into<String>) -> Self {
27        Token {
28            token_type,
29            text: text.into(),
30        }
31    }
32}
33
34const OPERATORS: [&str; 7] = ["&&", "||", ";;", ";", "|", "(", ")"];
35const WORD_TERMINATORS: [char; 10] = [' ', '\t', '\n', ';', '|', '&', '(', ')', '<', '>'];
36
37/// Consumes a balanced span that starts at `start` (for example `$(`, `${`).
38/// Returns the index just past the closing delimiter.
39fn read_balanced(source: &[char], start: usize, open: char, close: char) -> usize {
40    let mut depth = 0usize;
41    let mut index = start;
42    while index < source.len() {
43        let character = source[index];
44        if character == '\\' {
45            index += 2;
46            continue;
47        }
48        if character == open {
49            depth += 1;
50        } else if character == close {
51            depth -= 1;
52            if depth == 0 {
53                return index + 1;
54            }
55        }
56        index += 1;
57    }
58    source.len()
59}
60
61/// Consumes a quoted span including both quote characters.
62fn read_quoted(source: &[char], start: usize, quote: char) -> usize {
63    let mut index = start + 1;
64    while index < source.len() {
65        let character = source[index];
66        if quote == '"' && character == '\\' {
67            index += 2;
68            continue;
69        }
70        if character == quote {
71            return index + 1;
72        }
73        index += 1;
74    }
75    source.len()
76}
77
78/// Reads one shell word starting at `start`, treating quotes, `$(...)`,
79/// `${...}` and backticks as atomic spans.
80fn read_word(source: &[char], start: usize) -> (String, usize) {
81    let mut index = start;
82    while index < source.len() {
83        let character = source[index];
84        if character == '\\' {
85            index += 2;
86            continue;
87        }
88        if character == '\'' || character == '"' {
89            index = read_quoted(source, index, character);
90            continue;
91        }
92        if character == '`' {
93            index = read_quoted(source, index, '`');
94            continue;
95        }
96        if character == '$' && source.get(index + 1) == Some(&'(') {
97            index = read_balanced(source, index + 1, '(', ')');
98            continue;
99        }
100        if character == '$' && source.get(index + 1) == Some(&'{') {
101            index = read_balanced(source, index + 1, '{', '}');
102            continue;
103        }
104        if WORD_TERMINATORS.contains(&character) {
105            break;
106        }
107        index += 1;
108    }
109    let end = index.min(source.len());
110    (source[start..end].iter().collect(), end)
111}
112
113/// A redirection operator may be prefixed by a file descriptor (`2>`). Only a
114/// leading run of digits counts as part of the operator, never as a word.
115fn match_redirect(source: &[char], index: usize) -> Option<String> {
116    let mut cursor = index;
117    while source.get(cursor).is_some_and(char::is_ascii_digit) {
118        cursor += 1;
119    }
120    for operator in [">>", ">&", "<<<", "<<", ">", "<"] {
121        if starts_with(source, cursor, operator) {
122            return Some(
123                source[index..cursor + operator.chars().count()]
124                    .iter()
125                    .collect(),
126            );
127        }
128    }
129    None
130}
131
132fn starts_with(source: &[char], index: usize, text: &str) -> bool {
133    text.chars()
134        .enumerate()
135        .all(|(offset, expected)| source.get(index + offset) == Some(&expected))
136}
137
138fn match_operator(source: &[char], index: usize) -> Option<&'static str> {
139    OPERATORS
140        .into_iter()
141        .find(|operator| starts_with(source, index, operator))
142}
143
144/// Whitespace, line splices, newlines and comments.
145///
146/// Returns the token to emit (`None` emits nothing) and the index to continue
147/// from, or `None` when the character starts a word or operator instead.
148fn match_trivia(source: &[char], index: usize) -> Option<(Option<Token>, usize)> {
149    let character = source[index];
150
151    if character == '\n' {
152        return Some((Some(Token::new(TokenType::Newline, "\n")), index + 1));
153    }
154    if character == ' ' || character == '\t' || character == '\r' {
155        return Some((None, index + 1));
156    }
157    // A backslash-newline pair splices two physical lines into one.
158    if character == '\\' && source.get(index + 1) == Some(&'\n') {
159        return Some((None, index + 2));
160    }
161    // `#` only opens a comment at the start of a word.
162    let after_separator = index == 0
163        || source[index - 1].is_whitespace()
164        || [';', '|', '&', '(', ')'].contains(&source[index - 1]);
165    if character == '#' && after_separator {
166        let end = source[index..]
167            .iter()
168            .position(|&c| c == '\n')
169            .map_or(source.len(), |offset| index + offset);
170        let text: String = source[index..end].iter().collect();
171        return Some((Some(Token::new(TokenType::Comment, text)), end));
172    }
173    None
174}
175
176/// Tokenizes a complete shell script. The stream always ends with an EOF token.
177pub fn tokenize(source: &str) -> Vec<Token> {
178    let source: Vec<char> = source.chars().collect();
179    let mut tokens = Vec::new();
180    let mut index = 0usize;
181
182    while index < source.len() {
183        if let Some((token, next)) = match_trivia(&source, index) {
184            if let Some(token) = token {
185                tokens.push(token);
186            }
187            index = next;
188            continue;
189        }
190
191        if let Some(redirect) = match_redirect(&source, index) {
192            index += redirect.chars().count();
193            tokens.push(Token::new(TokenType::Redirect, redirect));
194            continue;
195        }
196        // `&` on its own backgrounds a command; `&&` was matched by the operator list.
197        let operator = match_operator(&source, index).or({
198            if source[index] == '&' {
199                Some("&")
200            } else {
201                None
202            }
203        });
204        if let Some(operator) = operator {
205            tokens.push(Token::new(TokenType::Operator, operator));
206            index += operator.chars().count();
207            continue;
208        }
209
210        let (text, next) = read_word(&source, index);
211        if text.is_empty() {
212            index += 1;
213            continue;
214        }
215        tokens.push(Token::new(TokenType::Word, text));
216        index = next;
217    }
218
219    tokens.push(Token::new(TokenType::Eof, ""));
220    tokens
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn kinds(source: &str) -> Vec<(TokenType, String)> {
228        tokenize(source)
229            .into_iter()
230            .map(|token| (token.token_type, token.text))
231            .collect()
232    }
233
234    #[test]
235    fn tokenizes_words_and_operators() {
236        assert_eq!(
237            kinds("ls -la | grep x"),
238            vec![
239                (TokenType::Word, "ls".to_string()),
240                (TokenType::Word, "-la".to_string()),
241                (TokenType::Operator, "|".to_string()),
242                (TokenType::Word, "grep".to_string()),
243                (TokenType::Word, "x".to_string()),
244                (TokenType::Eof, String::new()),
245            ]
246        );
247    }
248
249    #[test]
250    fn keeps_substitutions_and_quotes_in_one_word() {
251        assert_eq!(
252            kinds("echo \"$(ls | wc -l) items\""),
253            vec![
254                (TokenType::Word, "echo".to_string()),
255                (TokenType::Word, "\"$(ls | wc -l) items\"".to_string()),
256                (TokenType::Eof, String::new()),
257            ]
258        );
259    }
260
261    #[test]
262    fn reads_a_file_descriptor_prefixed_redirect() {
263        assert_eq!(
264            kinds("ls >out.txt 2>&1"),
265            vec![
266                (TokenType::Word, "ls".to_string()),
267                (TokenType::Redirect, ">".to_string()),
268                (TokenType::Word, "out.txt".to_string()),
269                (TokenType::Redirect, "2>&".to_string()),
270                (TokenType::Word, "1".to_string()),
271                (TokenType::Eof, String::new()),
272            ]
273        );
274    }
275
276    #[test]
277    fn emits_comments_and_newlines() {
278        assert_eq!(
279            kinds("# note\nls"),
280            vec![
281                (TokenType::Comment, "# note".to_string()),
282                (TokenType::Newline, "\n".to_string()),
283                (TokenType::Word, "ls".to_string()),
284                (TokenType::Eof, String::new()),
285            ]
286        );
287    }
288}