Skip to main content

molframe_cif/lexer/
tokenize.rs

1//! Turning bytes into tokens.
2//!
3//! Hand-written over `&[u8]` rather than assembled from combinators, for one
4//! reason that matters more than speed: the cursor already knows its offset,
5//! line and column, so every token carries an exact location for free. A parser
6//! built on top of it can point at the character that went wrong instead of
7//! naming the file.
8//!
9//! Nothing here allocates. A token borrows its text from the input, and the
10//! quoting style travels alongside so that a later stage can tell a value that
11//! was written `.` from one that was written `'.'`.
12
13use memchr::{memchr, memchr_iter, memrchr};
14use molframe_core::span::{ByteSpan, Position};
15
16/// How a value was written, which decides what it means.
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum Quoting {
19    /// Written without quotes.
20    Bare,
21    /// Written between single quotes.
22    Single,
23    /// Written between double quotes.
24    Double,
25    /// Written as a semicolon-delimited block.
26    Text,
27}
28
29/// What the lexer found.
30#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum Token<'a> {
32    /// The start of a data block, carrying its name.
33    Block(&'a str),
34    /// The start of a save frame, carrying its name.
35    FrameStart(&'a str),
36    /// The end of a save frame.
37    FrameEnd,
38    /// A loop header.
39    Loop,
40    /// An item name, including its leading underscore.
41    Tag(&'a str),
42    /// A value, with how it was written.
43    Value(&'a str, Quoting),
44}
45
46/// A token and where it came from.
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub struct Spanned<'a> {
49    /// What was found.
50    pub token: Token<'a>,
51    /// Where it was found.
52    pub span: ByteSpan,
53}
54
55/// Something the lexer could not get past.
56#[derive(Clone, Copy, PartialEq, Eq, Debug)]
57pub enum LexError {
58    /// A quoted value ran to the end of its line without closing.
59    UnterminatedQuote(Position),
60    /// A semicolon-delimited block ran to the end of the file without closing.
61    UnterminatedText(Position),
62    /// The input is not valid text.
63    NotText,
64    /// A source location exceeded the core's 32-bit position representation.
65    PositionOverflow(Position),
66}
67
68/// Walks the tokens of a file.
69///
70/// # Examples
71///
72/// ```
73/// use molframe_cif::lexer::{Lexer, Token};
74///
75/// let mut lexer = Lexer::new(b"data_test\n_entry.id 1ABC\n")?;
76/// let first = lexer.next_token()?;
77/// assert_eq!(first.map(|spanned| spanned.token), Some(Token::Block("test")));
78/// # Ok::<(), molframe_cif::lexer::LexError>(())
79/// ```
80#[derive(Clone, Debug)]
81pub struct Lexer<'a> {
82    input: &'a str,
83    /// Byte offset of the next unread byte.
84    offset: usize,
85    /// One-based line of the next unread byte.
86    line: u64,
87    /// Byte offset at which that line starts, so the column is a subtraction.
88    line_start: usize,
89}
90
91impl<'a> Lexer<'a> {
92    /// Creates a lexer over `bytes`.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`LexError::NotText`] when the bytes are not valid text, or
97    /// [`LexError::PositionOverflow`] when the input is longer than a source
98    /// position can address.
99    pub fn new(bytes: &'a [u8]) -> Result<Self, LexError> {
100        let input = str::from_utf8(bytes).map_err(|_| LexError::NotText)?;
101        // Offsets are usize inside the lexer and widen to u64 only when a
102        // `Position` is built, so this one check covers every later offset.
103        if u64::try_from(input.len()).is_err() {
104            return Err(LexError::PositionOverflow(Position::START));
105        }
106        Ok(Self {
107            input,
108            offset: 0,
109            line: 1,
110            line_start: 0,
111        })
112    }
113
114    /// Where the lexer has reached.
115    #[must_use]
116    pub fn position(&self) -> Position {
117        // Both conversions are exact: `new` checked that the input fits a u64.
118        let column = (self.offset - self.line_start) as u64 + 1;
119        Position::new(self.offset as u64, self.line, column)
120    }
121
122    /// Reads the next token, or `None` at the end of the input.
123    ///
124    /// # Errors
125    ///
126    /// Returns the reason the input could not be got past.
127    pub fn next_token(&mut self) -> Result<Option<Spanned<'a>>, LexError> {
128        self.skip_trivia();
129        let start = self.position();
130        let Some(byte) = self.peek() else {
131            return Ok(None);
132        };
133
134        let token = match byte {
135            b'_' => Token::Tag(self.take_bare()),
136            b'\'' => Token::Value(self.take_quoted(b'\'')?, Quoting::Single),
137            b'"' => Token::Value(self.take_quoted(b'"')?, Quoting::Double),
138            b';' if self.offset == self.line_start => {
139                Token::Value(self.take_text()?, Quoting::Text)
140            }
141            _ => self.keyword_or_value(),
142        };
143        Ok(Some(Spanned {
144            token,
145            span: ByteSpan::new(start, self.offset as u64),
146        }))
147    }
148
149    /// A bare word, which may be a keyword or an ordinary value.
150    fn keyword_or_value(&mut self) -> Token<'a> {
151        let word = self.take_bare();
152
153        // Nearly every bare word in a coordinate loop is a value, and the three
154        // keywords begin with three distinct letters. Deciding on the first
155        // byte turns three prefix comparisons per value into one.
156        match word.as_bytes().first() {
157            Some(b'd' | b'D') => {
158                if let Some(name) = strip_prefix_ignore_case(word, "data_") {
159                    return Token::Block(name);
160                }
161            }
162            Some(b's' | b'S') => {
163                if let Some(name) = strip_prefix_ignore_case(word, "save_") {
164                    return if name.is_empty() {
165                        Token::FrameEnd
166                    } else {
167                        Token::FrameStart(name)
168                    };
169                }
170            }
171            Some(b'l' | b'L') if word.eq_ignore_ascii_case("loop_") => {
172                return Token::Loop;
173            }
174            _ => {}
175        }
176        Token::Value(word, Quoting::Bare)
177    }
178
179    fn peek(&self) -> Option<u8> {
180        self.input.as_bytes().get(self.offset).copied()
181    }
182
183    fn remaining(&self) -> &'a str {
184        slice_from(self.input, self.offset)
185    }
186
187    /// Moves the cursor over `count` bytes, which may include newlines.
188    ///
189    /// A long run has its newlines found with a vectorised search; a short one
190    /// walks its bytes. Most advances in an `atom_site` loop are a single
191    /// separating space, where setting up a vectorised search costs more than
192    /// inspecting the byte.
193    fn advance(&mut self, count: usize) {
194        /// Shortest run for which a vectorised newline search pays for itself.
195        const BULK_THRESHOLD: usize = 32;
196
197        let bytes = self.input.as_bytes();
198        let end = self.offset.saturating_add(count).min(bytes.len());
199        let Some(skipped) = bytes.get(self.offset..end) else {
200            return;
201        };
202        if skipped.len() < BULK_THRESHOLD {
203            for (index, byte) in skipped.iter().enumerate() {
204                if *byte == b'\n' {
205                    self.line += 1;
206                    self.line_start = self.offset + index + 1;
207                }
208            }
209        } else {
210            let newlines = memchr_iter(b'\n', skipped).count();
211            if let Some(last) = memrchr(b'\n', skipped) {
212                self.line += newlines as u64;
213                self.line_start = self.offset + last + 1;
214            }
215        }
216        self.offset = end;
217    }
218
219    /// Moves the cursor over a run the caller has already established holds no
220    /// newline. Bare values are scanned once to find their end, and that scan
221    /// proves the absence of a newline, so this is a single addition.
222    fn advance_non_newline(&mut self, count: usize) {
223        self.offset += count;
224    }
225
226    /// Skips whitespace and comments.
227    ///
228    /// A comment runs to the end of its line, and a `#` inside a quoted value is
229    /// an ordinary character — which is why this only runs between tokens.
230    fn skip_trivia(&mut self) {
231        loop {
232            let bytes = self.remaining().as_bytes();
233            let spaces = found_or(
234                bytes.iter().position(|byte| !byte.is_ascii_whitespace()),
235                bytes.len(),
236            );
237            self.advance(spaces);
238
239            if self.peek() != Some(b'#') {
240                return;
241            }
242            let rest = self.remaining();
243            let line = found_or(memchr(b'\n', rest.as_bytes()), rest.len());
244            self.advance(line);
245        }
246    }
247
248    /// A run of non-whitespace.
249    fn take_bare(&mut self) -> &'a str {
250        let text = self.remaining();
251        let end = found_or(
252            text.bytes().position(|byte| byte.is_ascii_whitespace()),
253            text.len(),
254        );
255        self.advance_non_newline(end);
256        slice_to(text, end)
257    }
258
259    /// A value between quotes.
260    ///
261    /// The closing quote is the one followed by whitespace or the end of the
262    /// line, so an apostrophe inside a quoted word does not end it early.
263    fn take_quoted(&mut self, quote: u8) -> Result<&'a str, LexError> {
264        let opened_at = self.position();
265        self.advance(1);
266        let text = self.remaining();
267        let bytes = text.as_bytes();
268
269        let mut cursor = 0usize;
270        while cursor < bytes.len() {
271            let Some(offset) = memchr(quote, bytes_from(bytes, cursor)) else {
272                break;
273            };
274            let candidate = cursor + offset;
275            let closes = match bytes.get(candidate + 1) {
276                None => true,
277                Some(next) => next.is_ascii_whitespace(),
278            };
279            if closes {
280                self.advance(candidate + 1);
281                return Ok(slice_to(text, candidate));
282            }
283            if bytes.get(candidate).is_some_and(|byte| *byte == b'\n') {
284                break;
285            }
286            cursor = candidate + 1;
287        }
288        Err(LexError::UnterminatedQuote(opened_at))
289    }
290
291    /// A semicolon-delimited block.
292    ///
293    /// It begins with a semicolon in the first column and ends with the next
294    /// one, so a semicolon anywhere else is ordinary text.
295    fn take_text(&mut self) -> Result<&'a str, LexError> {
296        let opened_at = self.position();
297        self.advance(1);
298        // The rest of the opening line belongs to the value.
299        let text = self.remaining();
300        let bytes = text.as_bytes();
301
302        let mut cursor = 0usize;
303        loop {
304            let Some(offset) = memchr(b'\n', bytes_from(bytes, cursor)) else {
305                return Err(LexError::UnterminatedText(opened_at));
306            };
307            let line_start = cursor + offset + 1;
308            if bytes.get(line_start) == Some(&b';') {
309                let value = slice_to(text, cursor + offset);
310                self.advance(line_start + 1);
311                return Ok(value.trim_start_matches(['\r', '\n']));
312            }
313            cursor = line_start;
314        }
315    }
316}
317
318/// The text from `start` onwards, or nothing when `start` is past the end.
319fn slice_from(text: &str, start: usize) -> &str {
320    let Some(slice) = text.get(start..) else {
321        return "";
322    };
323    slice
324}
325
326/// The text up to `end`, or nothing when `end` does not fall on a boundary.
327fn slice_to(text: &str, end: usize) -> &str {
328    let Some(slice) = text.get(..end) else {
329        return "";
330    };
331    slice
332}
333
334/// The position a search found, or `len` when it found nothing.
335const fn found_or(found: Option<usize>, len: usize) -> usize {
336    match found {
337        Some(position) => position,
338        None => len,
339    }
340}
341
342/// The bytes from `start` onwards, or nothing when `start` is past the end.
343fn bytes_from(bytes: &[u8], start: usize) -> &[u8] {
344    match bytes.get(start..) {
345        Some(slice) => slice,
346        None => &[],
347    }
348}
349
350fn strip_prefix_ignore_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
351    let head = text.get(..prefix.len())?;
352    head.eq_ignore_ascii_case(prefix)
353        .then(|| text.get(prefix.len()..))?
354}
355
356#[cfg(test)]
357#[path = "tokenize_tests.rs"]
358mod tests;