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 /// Creates a lexer over text already known to be valid.
115 ///
116 /// Positions are relative to the start of `input`.
117 pub(crate) const fn over(input: &'a str) -> Self {
118 Self {
119 input,
120 offset: 0,
121 line: 1,
122 line_start: 0,
123 }
124 }
125
126 /// The text that has not been read yet.
127 pub(crate) fn rest(&self) -> &'a str {
128 self.remaining()
129 }
130
131 /// Moves past `count` unread bytes, keeping line tracking exact.
132 pub(crate) fn skip(&mut self, count: usize) {
133 self.advance(count);
134 }
135
136 /// Where the lexer has reached.
137 #[must_use]
138 pub fn position(&self) -> Position {
139 // Both conversions are exact: `new` checked that the input fits a u64.
140 let column = (self.offset - self.line_start) as u64 + 1;
141 Position::new(self.offset as u64, self.line, column)
142 }
143
144 /// Reads the next token, or `None` at the end of the input.
145 ///
146 /// # Errors
147 ///
148 /// Returns the reason the input could not be got past.
149 pub fn next_token(&mut self) -> Result<Option<Spanned<'a>>, LexError> {
150 self.skip_trivia();
151 let start = self.position();
152 let Some(byte) = self.peek() else {
153 return Ok(None);
154 };
155
156 let token = match byte {
157 b'_' => Token::Tag(self.take_bare()),
158 b'\'' => Token::Value(self.take_quoted(b'\'')?, Quoting::Single),
159 b'"' => Token::Value(self.take_quoted(b'"')?, Quoting::Double),
160 b';' if self.offset == self.line_start => {
161 Token::Value(self.take_text()?, Quoting::Text)
162 }
163 _ => self.keyword_or_value(),
164 };
165 Ok(Some(Spanned {
166 token,
167 span: ByteSpan::new(start, self.offset as u64),
168 }))
169 }
170
171 /// A bare word, which may be a keyword or an ordinary value.
172 fn keyword_or_value(&mut self) -> Token<'a> {
173 let word = self.take_bare();
174
175 // Nearly every bare word in a coordinate loop is a value, and the three
176 // keywords begin with three distinct letters. Deciding on the first
177 // byte turns three prefix comparisons per value into one.
178 match word.as_bytes().first() {
179 Some(b'd' | b'D') => {
180 if let Some(name) = strip_prefix_ignore_case(word, "data_") {
181 return Token::Block(name);
182 }
183 }
184 Some(b's' | b'S') => {
185 if let Some(name) = strip_prefix_ignore_case(word, "save_") {
186 return if name.is_empty() {
187 Token::FrameEnd
188 } else {
189 Token::FrameStart(name)
190 };
191 }
192 }
193 Some(b'l' | b'L') if word.eq_ignore_ascii_case("loop_") => {
194 return Token::Loop;
195 }
196 _ => {}
197 }
198 Token::Value(word, Quoting::Bare)
199 }
200
201 fn peek(&self) -> Option<u8> {
202 self.input.as_bytes().get(self.offset).copied()
203 }
204
205 fn remaining(&self) -> &'a str {
206 slice_from(self.input, self.offset)
207 }
208
209 /// Moves the cursor over `count` bytes, which may include newlines.
210 ///
211 /// A long run has its newlines found with a vectorised search; a short one
212 /// walks its bytes. Most advances in an `atom_site` loop are a single
213 /// separating space, where setting up a vectorised search costs more than
214 /// inspecting the byte.
215 fn advance(&mut self, count: usize) {
216 /// Shortest run for which a vectorised newline search pays for itself.
217 const BULK_THRESHOLD: usize = 32;
218
219 let bytes = self.input.as_bytes();
220 let end = self.offset.saturating_add(count).min(bytes.len());
221 let Some(skipped) = bytes.get(self.offset..end) else {
222 return;
223 };
224 if skipped.len() < BULK_THRESHOLD {
225 for (index, byte) in skipped.iter().enumerate() {
226 if *byte == b'\n' {
227 self.line += 1;
228 self.line_start = self.offset + index + 1;
229 }
230 }
231 } else {
232 let newlines = memchr_iter(b'\n', skipped).count();
233 if let Some(last) = memrchr(b'\n', skipped) {
234 self.line += newlines as u64;
235 self.line_start = self.offset + last + 1;
236 }
237 }
238 self.offset = end;
239 }
240
241 /// Moves the cursor over a run the caller has already established holds no
242 /// newline. Bare values are scanned once to find their end, and that scan
243 /// proves the absence of a newline, so this is a single addition.
244 fn advance_non_newline(&mut self, count: usize) {
245 self.offset += count;
246 }
247
248 /// Skips whitespace and comments.
249 ///
250 /// A comment runs to the end of its line, and a `#` inside a quoted value is
251 /// an ordinary character — which is why this only runs between tokens.
252 fn skip_trivia(&mut self) {
253 loop {
254 let bytes = self.remaining().as_bytes();
255 let spaces = found_or(
256 bytes.iter().position(|byte| !byte.is_ascii_whitespace()),
257 bytes.len(),
258 );
259 self.advance(spaces);
260
261 if self.peek() != Some(b'#') {
262 return;
263 }
264 let rest = self.remaining();
265 let line = found_or(memchr(b'\n', rest.as_bytes()), rest.len());
266 self.advance(line);
267 }
268 }
269
270 /// A run of non-whitespace.
271 fn take_bare(&mut self) -> &'a str {
272 let text = self.remaining();
273 let end = found_or(
274 text.bytes().position(|byte| byte.is_ascii_whitespace()),
275 text.len(),
276 );
277 self.advance_non_newline(end);
278 slice_to(text, end)
279 }
280
281 /// A value between quotes.
282 ///
283 /// The closing quote is the one followed by whitespace or the end of the
284 /// line, so an apostrophe inside a quoted word does not end it early.
285 fn take_quoted(&mut self, quote: u8) -> Result<&'a str, LexError> {
286 let opened_at = self.position();
287 self.advance(1);
288 let text = self.remaining();
289 let bytes = text.as_bytes();
290
291 let mut cursor = 0usize;
292 while cursor < bytes.len() {
293 let Some(offset) = memchr(quote, bytes_from(bytes, cursor)) else {
294 break;
295 };
296 let candidate = cursor + offset;
297 let closes = match bytes.get(candidate + 1) {
298 None => true,
299 Some(next) => next.is_ascii_whitespace(),
300 };
301 if closes {
302 self.advance(candidate + 1);
303 return Ok(slice_to(text, candidate));
304 }
305 if bytes.get(candidate).is_some_and(|byte| *byte == b'\n') {
306 break;
307 }
308 cursor = candidate + 1;
309 }
310 Err(LexError::UnterminatedQuote(opened_at))
311 }
312
313 /// A semicolon-delimited block.
314 ///
315 /// It begins with a semicolon in the first column and ends with the next
316 /// one, so a semicolon anywhere else is ordinary text.
317 fn take_text(&mut self) -> Result<&'a str, LexError> {
318 let opened_at = self.position();
319 self.advance(1);
320 // The rest of the opening line belongs to the value.
321 let text = self.remaining();
322 let bytes = text.as_bytes();
323
324 let mut cursor = 0usize;
325 loop {
326 let Some(offset) = memchr(b'\n', bytes_from(bytes, cursor)) else {
327 return Err(LexError::UnterminatedText(opened_at));
328 };
329 let line_start = cursor + offset + 1;
330 if bytes.get(line_start) == Some(&b';') {
331 let value = slice_to(text, cursor + offset);
332 self.advance(line_start + 1);
333 return Ok(value.trim_start_matches(['\r', '\n']));
334 }
335 cursor = line_start;
336 }
337 }
338}
339
340/// The text from `start` onwards, or nothing when `start` is past the end.
341fn slice_from(text: &str, start: usize) -> &str {
342 let Some(slice) = text.get(start..) else {
343 return "";
344 };
345 slice
346}
347
348/// The text up to `end`, or nothing when `end` does not fall on a boundary.
349fn slice_to(text: &str, end: usize) -> &str {
350 let Some(slice) = text.get(..end) else {
351 return "";
352 };
353 slice
354}
355
356/// The position a search found, or `len` when it found nothing.
357const fn found_or(found: Option<usize>, len: usize) -> usize {
358 match found {
359 Some(position) => position,
360 None => len,
361 }
362}
363
364/// The bytes from `start` onwards, or nothing when `start` is past the end.
365fn bytes_from(bytes: &[u8], start: usize) -> &[u8] {
366 match bytes.get(start..) {
367 Some(slice) => slice,
368 None => &[],
369 }
370}
371
372fn strip_prefix_ignore_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
373 let head = text.get(..prefix.len())?;
374 head.eq_ignore_ascii_case(prefix)
375 .then(|| text.get(prefix.len()..))?
376}
377
378#[cfg(test)]
379#[path = "tokenize_tests.rs"]
380mod tests;