Skip to main content

alba_syntax/
lexer.rs

1//! Hand-written scanner turning Beamfile source text into a stream of tokens.
2
3use crate::token::{Span, Token, TokenKind};
4use std::iter::Peekable;
5use std::str::CharIndices;
6
7/// An error produced while scanning source text into tokens.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct LexError {
10    pub message: String,
11    pub span: Span,
12}
13
14impl LexError {
15    fn new(message: impl Into<String>, span: Span) -> Self {
16        Self {
17            message: message.into(),
18            span,
19        }
20    }
21}
22
23/// Scans Beamfile source text into a stream of [`Token`]s.
24///
25/// `Lexer` implements `Iterator<Item = Result<Token, LexError>>`: each call
26/// to `next` yields either a token or a scanning error at a precise span.
27///
28/// Newline handling: a `Newline` token is emitted only strictly between two
29/// other tokens. Leading and trailing newline trivia (including blank lines
30/// and comments, which never produce tokens of their own) are dropped
31/// entirely, and any run of consecutive newlines collapses into a single
32/// `Newline` token. Exactly one `Eof` token is yielded at the end of input,
33/// never preceded by a `Newline`; after that, the iterator yields `None`.
34pub struct Lexer<'src> {
35    source: &'src str,
36    chars: Peekable<CharIndices<'src>>,
37    /// True once at least one non-trivia token has been produced.
38    after_first_token: bool,
39    /// True if a newline has been seen since the last produced token.
40    pending_newline: bool,
41    /// Byte offset of the first newline in the current pending run.
42    pending_newline_start: usize,
43    /// True once `Eof` has been produced; further calls return `None`.
44    eof_emitted: bool,
45}
46
47impl<'src> Lexer<'src> {
48    /// Creates a lexer over `source`.
49    pub fn new(source: &'src str) -> Self {
50        Self {
51            source,
52            chars: source.char_indices().peekable(),
53            after_first_token: false,
54            pending_newline: false,
55            pending_newline_start: 0,
56            eof_emitted: false,
57        }
58    }
59
60    fn peek_char(&mut self) -> Option<char> {
61        self.chars.peek().map(|&(_, c)| c)
62    }
63
64    /// Skips whitespace and comments, tracking whether a newline was seen.
65    /// Leaves the iterator positioned at the start of the next real token,
66    /// or at the end of input.
67    fn skip_trivia(&mut self) {
68        loop {
69            match self.peek_char() {
70                Some(' ') | Some('\t') | Some('\r') => {
71                    self.chars.next();
72                }
73                Some('\n') => {
74                    let (idx, _) = self.chars.next().unwrap();
75                    if !self.pending_newline {
76                        self.pending_newline = true;
77                        self.pending_newline_start = idx;
78                    }
79                }
80                Some('#') => {
81                    while let Some(c) = self.peek_char() {
82                        if c == '\n' {
83                            break;
84                        }
85                        self.chars.next();
86                    }
87                }
88                _ => break,
89            }
90        }
91    }
92
93    /// Hyphens are identifier characters after the first one (git hook names
94    /// are spelled `pre-commit`); the DSL has no subtraction, so nothing
95    /// else could claim them.
96    fn scan_ident(&mut self, start: usize) -> Token {
97        let mut end = start;
98        while let Some(&(idx, c)) = self.chars.peek() {
99            if c.is_alphanumeric() || c == '_' || c == '-' {
100                end = idx + c.len_utf8();
101                self.chars.next();
102            } else {
103                break;
104            }
105        }
106        let text = &self.source[start..end];
107        let kind = keyword_kind(text).unwrap_or_else(|| TokenKind::Ident(text.to_string()));
108        Token::new(kind, Span::new(start, end))
109    }
110
111    fn scan_string(&mut self, start: usize, quote: char) -> Result<Token, LexError> {
112        self.chars.next(); // consume the opening quote
113        let mut content = String::new();
114        loop {
115            match self.chars.next() {
116                None => {
117                    return Err(LexError::new(
118                        "unterminated string literal",
119                        Span::new(start, self.source.len()),
120                    ));
121                }
122                Some((idx, c)) if c == quote => {
123                    let end = idx + c.len_utf8();
124                    return Ok(Token::new(TokenKind::Str(content), Span::new(start, end)));
125                }
126                Some((_, '\\')) => match self.chars.next() {
127                    None => {
128                        return Err(LexError::new(
129                            "unterminated string literal",
130                            Span::new(start, self.source.len()),
131                        ));
132                    }
133                    Some((idx, escaped)) => {
134                        let decoded = match escaped {
135                            '"' => '"',
136                            '\\' => '\\',
137                            'n' => '\n',
138                            't' => '\t',
139                            other => {
140                                return Err(LexError::new(
141                                    format!("unknown escape sequence '\\{other}'"),
142                                    Span::new(idx - 1, idx + other.len_utf8()),
143                                ));
144                            }
145                        };
146                        content.push(decoded);
147                    }
148                },
149                Some((_, c)) => content.push(c),
150            }
151        }
152    }
153
154    /// Scans a token that starts with `first`, given `first` has already
155    /// been peeked but not consumed. Consumes one or two characters and
156    /// returns the resulting punctuation/operator token, or an error for an
157    /// unrecognized character.
158    fn scan_punct(&mut self, start: usize, first: char) -> Result<Token, LexError> {
159        self.chars.next(); // consume `first`
160        let single = |kind: TokenKind| Ok(Token::new(kind, Span::new(start, start + 1)));
161        match first {
162            '{' => single(TokenKind::LBrace),
163            '}' => single(TokenKind::RBrace),
164            '[' => single(TokenKind::LBracket),
165            ']' => single(TokenKind::RBracket),
166            '(' => single(TokenKind::LParen),
167            ')' => single(TokenKind::RParen),
168            ',' => single(TokenKind::Comma),
169            ':' => single(TokenKind::Colon),
170            '+' => single(TokenKind::Plus),
171            '.' => single(TokenKind::Dot),
172            '=' => {
173                if self.peek_char() == Some('=') {
174                    self.chars.next();
175                    Ok(Token::new(TokenKind::EqEq, Span::new(start, start + 2)))
176                } else {
177                    single(TokenKind::Eq)
178                }
179            }
180            '!' => {
181                if self.peek_char() == Some('=') {
182                    self.chars.next();
183                    Ok(Token::new(TokenKind::NotEq, Span::new(start, start + 2)))
184                } else {
185                    Err(LexError::new(
186                        "unexpected character '!'",
187                        Span::new(start, start + 1),
188                    ))
189                }
190            }
191            '&' => {
192                if self.peek_char() == Some('&') {
193                    self.chars.next();
194                    Ok(Token::new(TokenKind::AndAnd, Span::new(start, start + 2)))
195                } else {
196                    Err(LexError::new(
197                        "unexpected character '&'",
198                        Span::new(start, start + 1),
199                    ))
200                }
201            }
202            '|' => {
203                if self.peek_char() == Some('|') {
204                    self.chars.next();
205                    Ok(Token::new(TokenKind::OrOr, Span::new(start, start + 2)))
206                } else {
207                    Err(LexError::new(
208                        "unexpected character '|'",
209                        Span::new(start, start + 1),
210                    ))
211                }
212            }
213            other => Err(LexError::new(
214                format!("unexpected character '{other}'"),
215                Span::new(start, start + other.len_utf8()),
216            )),
217        }
218    }
219}
220
221/// Resolves an identifier's text to a keyword token kind, if it is one.
222fn keyword_kind(text: &str) -> Option<TokenKind> {
223    Some(match text {
224        "version" => TokenKind::KwVersion,
225        "import" => TokenKind::KwImport,
226        "as" => TokenKind::KwAs,
227        "let" => TokenKind::KwLet,
228        "default" => TokenKind::KwDefault,
229        "beam" => TokenKind::KwBeam,
230        "hook" => TokenKind::KwHook,
231        "if" => TokenKind::KwIf,
232        "then" => TokenKind::KwThen,
233        "else" => TokenKind::KwElse,
234        "true" => TokenKind::KwTrue,
235        "false" => TokenKind::KwFalse,
236        _ => return None,
237    })
238}
239
240impl<'src> Iterator for Lexer<'src> {
241    type Item = Result<Token, LexError>;
242
243    fn next(&mut self) -> Option<Self::Item> {
244        if self.eof_emitted {
245            return None;
246        }
247
248        self.skip_trivia();
249
250        // A pending newline is only a statement separator if a real token
251        // follows it; a newline run trailing straight into end of input is
252        // dropped like leading trivia (see decision in the doc comment).
253        let Some(&(start, ch)) = self.chars.peek() else {
254            self.pending_newline = false;
255            self.eof_emitted = true;
256            let pos = self.source.len();
257            return Some(Ok(Token::new(TokenKind::Eof, Span::new(pos, pos))));
258        };
259
260        if self.pending_newline && self.after_first_token {
261            self.pending_newline = false;
262            let start = self.pending_newline_start;
263            return Some(Ok(Token::new(
264                TokenKind::Newline,
265                Span::new(start, start + 1),
266            )));
267        }
268        self.pending_newline = false;
269
270        self.after_first_token = true;
271
272        if ch == '"' || ch == '\'' {
273            return Some(self.scan_string(start, ch));
274        }
275        if ch.is_alphabetic() || ch == '_' {
276            return Some(Ok(self.scan_ident(start)));
277        }
278        Some(self.scan_punct(start, ch))
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use crate::Lexer;
285    use crate::TokenKind;
286
287    /// Collects the `TokenKind`s produced by lexing `source`, up to and
288    /// including `Eof`. Panics on the first lexing error.
289    fn lex_kinds(source: &str) -> Vec<TokenKind> {
290        Lexer::new(source)
291            .map(|result| result.expect("unexpected lex error"))
292            .map(|token| token.kind)
293            .collect()
294    }
295
296    #[test]
297    fn lexes_beam_header() {
298        let tokens: Vec<TokenKind> = lex_kinds("beam build {");
299        assert_eq!(
300            tokens,
301            vec![
302                TokenKind::KwBeam,
303                TokenKind::Ident("build".into()),
304                TokenKind::LBrace,
305                TokenKind::Eof,
306            ]
307        );
308    }
309
310    #[test]
311    fn lexes_string_and_operators() {
312        let tokens = lex_kinds(r#"let x = env("PROFILE") == "release""#);
313        assert!(tokens.contains(&TokenKind::Str("PROFILE".into())));
314        assert!(tokens.contains(&TokenKind::EqEq));
315    }
316
317    #[test]
318    fn reports_unterminated_string_with_span() {
319        let err = Lexer::new("run \"oops").find_map(Result::err).unwrap();
320        assert_eq!(err.span.start, 4);
321        assert!(err.message.contains("unterminated string"));
322    }
323
324    #[test]
325    fn skips_comments_and_collapses_newlines() {
326        let tokens = lex_kinds("# comment\n\n\nbeam x {\n}");
327        let newlines = tokens.iter().filter(|t| **t == TokenKind::Newline).count();
328        assert_eq!(newlines, 1);
329    }
330
331    #[test]
332    fn no_newline_token_before_eof() {
333        // Trailing blank lines are trivia, not a statement separator: there
334        // is nothing after them to separate from.
335        let tokens = lex_kinds("beam x {\n}\n\n");
336        assert_eq!(tokens.last(), Some(&TokenKind::Eof));
337        assert_eq!(tokens[tokens.len() - 2], TokenKind::RBrace);
338    }
339
340    #[test]
341    fn lexes_all_keywords() {
342        let tokens = lex_kinds("version import as let default beam if then else true false hook");
343        assert_eq!(
344            tokens,
345            vec![
346                TokenKind::KwVersion,
347                TokenKind::KwImport,
348                TokenKind::KwAs,
349                TokenKind::KwLet,
350                TokenKind::KwDefault,
351                TokenKind::KwBeam,
352                TokenKind::KwIf,
353                TokenKind::KwThen,
354                TokenKind::KwElse,
355                TokenKind::KwTrue,
356                TokenKind::KwFalse,
357                TokenKind::KwHook,
358                TokenKind::Eof,
359            ]
360        );
361    }
362
363    #[test]
364    fn lexes_both_string_delimiters() {
365        let tokens = lex_kinds(r#""double" 'single'"#);
366        assert_eq!(
367            tokens,
368            vec![
369                TokenKind::Str("double".into()),
370                TokenKind::Str("single".into()),
371                TokenKind::Eof,
372            ]
373        );
374    }
375
376    #[test]
377    fn decodes_string_escapes() {
378        let tokens = lex_kinds(r#""a\"b\\c\nd\te""#);
379        assert_eq!(tokens[0], TokenKind::Str("a\"b\\c\nd\te".into()));
380    }
381
382    #[test]
383    fn string_passes_through_interpolation_braces_as_raw_text() {
384        let tokens = lex_kinds(r#""hello {name}""#);
385        assert_eq!(tokens[0], TokenKind::Str("hello {name}".into()));
386    }
387
388    #[test]
389    fn lexes_logical_operators_and_needs_colon_syntax() {
390        let tokens = lex_kinds("a && b || c != d api:build");
391        assert_eq!(
392            tokens,
393            vec![
394                TokenKind::Ident("a".into()),
395                TokenKind::AndAnd,
396                TokenKind::Ident("b".into()),
397                TokenKind::OrOr,
398                TokenKind::Ident("c".into()),
399                TokenKind::NotEq,
400                TokenKind::Ident("d".into()),
401                TokenKind::Ident("api".into()),
402                TokenKind::Colon,
403                TokenKind::Ident("build".into()),
404                TokenKind::Eof,
405            ]
406        );
407    }
408
409    #[test]
410    fn reports_unexpected_character() {
411        let err = Lexer::new("beam @ {").find_map(Result::err).unwrap();
412        assert_eq!(err.span, crate::Span::new(5, 6));
413        assert!(err.message.contains('@'));
414    }
415
416    #[test]
417    fn token_spans_are_accurate_byte_offsets() {
418        let tokens: Vec<_> = Lexer::new("beam build")
419            .map(|r| r.expect("unexpected lex error"))
420            .collect();
421        assert_eq!(tokens[0].span, crate::Span::new(0, 4)); // "beam"
422        assert_eq!(tokens[1].span, crate::Span::new(5, 10)); // "build"
423    }
424
425    #[test]
426    fn eof_span_is_at_end_of_source() {
427        let tokens: Vec<_> = Lexer::new("x")
428            .map(|r| r.expect("unexpected lex error"))
429            .collect();
430        assert_eq!(tokens.last().unwrap().span, crate::Span::new(1, 1));
431    }
432}