Skip to main content

badness_parser/bib/
lexer.rs

1//! A total, lossless lexer for BibTeX/BibLaTeX surface syntax.
2//!
3//! Every byte of the input ends up in exactly one token, so concatenating all
4//! token texts reproduces the input verbatim — the losslessness invariant.
5//!
6//! The lexer is context-free: brace/quote *structure* is the parser's job (it
7//! tracks brace depth to build `BRACE_GROUP` / `QUOTED` value nodes), exactly as
8//! the LaTeX lexer leaves `{`/`}` grouping to its grammar. BibTeX needs no
9//! verbatim or catcode modes, so this lexer is simpler than the LaTeX one.
10//!
11//! The specials `@ { } ( ) , = # " %` each lex as a single-character token; runs
12//! of whitespace, line breaks, and "word" characters (anything else) coalesce. A
13//! word run made up solely of ASCII digits is classified [`SyntaxKind::NUMBER`],
14//! else [`SyntaxKind::WORD`] — so a later formatter / linter can tell an unquoted
15//! number from a macro name.
16//!
17//! `%` is a *bare* [`SyntaxKind::PERCENT`] token here, not a comment: whether it
18//! opens a comment depends on brace/quote context (`{50% off}` is literal text),
19//! and that context is the grammar's to know. The grammar wraps the run from a
20//! `%` to the end of its line in a [`SyntaxKind::COMMENT`] node only where BibTeX
21//! allows one.
22
23use smol_str::SmolStr;
24
25use crate::bib::syntax::SyntaxKind;
26
27/// A single lexed token: its kind plus the exact source slice it covers.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Token {
30    pub kind: SyntaxKind,
31    pub text: SmolStr,
32}
33
34/// Is `c` one of the single-character special tokens?
35fn special_kind(c: u8) -> Option<SyntaxKind> {
36    Some(match c {
37        b'@' => SyntaxKind::AT,
38        b'{' => SyntaxKind::L_BRACE,
39        b'}' => SyntaxKind::R_BRACE,
40        b'(' => SyntaxKind::L_PAREN,
41        b')' => SyntaxKind::R_PAREN,
42        b',' => SyntaxKind::COMMA,
43        b'=' => SyntaxKind::EQ,
44        b'#' => SyntaxKind::HASH,
45        b'"' => SyntaxKind::QUOTE,
46        b'%' => SyntaxKind::PERCENT,
47        _ => return None,
48    })
49}
50
51/// Does `c` end a word run? (whitespace, a line break, or a special)
52fn is_word_boundary(c: u8) -> bool {
53    matches!(c, b' ' | b'\t' | b'\n' | b'\r') || special_kind(c).is_some()
54}
55
56/// Lex `input` into a flat, lossless token stream.
57pub fn lex(input: &str) -> Vec<Token> {
58    let bytes = input.as_bytes();
59    let mut out = Vec::new();
60    let mut pos = 0;
61    while pos < bytes.len() {
62        let c = bytes[pos];
63        let (kind, len) = if let Some(kind) = special_kind(c) {
64            (kind, 1)
65        } else if c == b'\n' {
66            (SyntaxKind::NEWLINE, 1)
67        } else if c == b'\r' {
68            // `\r\n` is one line break; a lone `\r` is its own.
69            let len = if bytes.get(pos + 1) == Some(&b'\n') {
70                2
71            } else {
72                1
73            };
74            (SyntaxKind::NEWLINE, len)
75        } else if c == b' ' || c == b'\t' {
76            let len = run_len(bytes, pos, |b| b == b' ' || b == b'\t');
77            (SyntaxKind::WHITESPACE, len)
78        } else {
79            let len = run_len(bytes, pos, |b| !is_word_boundary(b));
80            let kind = if bytes[pos..pos + len].iter().all(u8::is_ascii_digit) {
81                SyntaxKind::NUMBER
82            } else {
83                SyntaxKind::WORD
84            };
85            (kind, len)
86        };
87        out.push(Token {
88            kind,
89            text: SmolStr::new(&input[pos..pos + len]),
90        });
91        pos += len;
92    }
93    out
94}
95
96/// Length of the maximal run of bytes from `start` satisfying `pred`. The byte at
97/// `start` is assumed to satisfy `pred`, so the run is always at least one byte.
98fn run_len(bytes: &[u8], start: usize, pred: impl Fn(u8) -> bool) -> usize {
99    let mut i = start + 1;
100    while i < bytes.len() && pred(bytes[i]) {
101        i += 1;
102    }
103    i - start
104}