Skip to main content

mos_bib/
parser.rs

1//! Hand-rolled recursive-descent parser for the minimal BibTeX subset.
2//!
3//! The grammar is intentionally tiny:
4//!
5//! ```text
6//! bibtex := ws* (entry ws*)*
7//! entry  := '@' type '{' key (',' fields)? '}'
8//! fields := field (',' field)* ','?
9//! field  := name '=' value
10//! value  := '{' .. '}' | '"' .. '"' | bare
11//! ```
12//!
13//! Entry types and field names are lowercased; citation keys are kept
14//! verbatim. Brace values balance nested `{}` by naive counting, so
15//! `{The {LaTeX} Companion}` is captured whole. Values are stored as raw
16//! text with their outer delimiters; no `TeX` decoding, no `@string` /
17//! `@preamble` macro expansion, no `#` concatenation, no name parsing.
18
19use std::collections::BTreeMap;
20
21use crate::error::{BibParseError, BibParseErrorKind};
22use crate::record::{BibEntry, Bibliography};
23
24/// Parse `input` as a minimal BibTeX database.
25///
26/// Returns a [`Bibliography`] whose entries are keyed by citation key. A
27/// duplicate citation key is rejected with [`BibParseErrorKind::DuplicateKey`]
28/// at the duplicate key's offset, so later resolver work can report it before
29/// any source-location context is lost; a repeated field name inside one entry
30/// keeps its last value. Parsing stops at the first malformed entry and returns
31/// a [`BibParseError`] pinpointing the byte offset; well-formed input never
32/// panics.
33///
34/// # Errors
35///
36/// Returns a [`BibParseError`] when the input is not a sequence of
37/// well-formed `@type{key, field = value, ...}` entries separated by
38/// whitespace: for example a missing `@`, entry type, `{`, citation key, or
39/// `=`, or an unterminated brace/quote value.
40///
41/// # Examples
42///
43/// ```
44/// use mos_bib::parse_bibtex;
45///
46/// # fn main() -> Result<(), mos_bib::BibParseError> {
47/// let bib = parse_bibtex("@article{rivest1978, author = {Ron Rivest}, year = 1978}")?;
48/// assert_eq!(bib.entries["rivest1978"].fields["year"], "1978");
49/// # Ok(())
50/// # }
51/// ```
52pub fn parse_bibtex(input: &str) -> Result<Bibliography, BibParseError> {
53    let mut parser = Parser::new(input);
54    let mut entries = BTreeMap::new();
55    parser.skip_whitespace();
56    while !parser.at_end() {
57        let parsed = parser.parse_entry()?;
58        if entries.contains_key(&parsed.entry.key) {
59            return Err(BibParseError::new(
60                BibParseErrorKind::DuplicateKey,
61                parsed.key_offset,
62            ));
63        }
64        entries.insert(parsed.entry.key.clone(), parsed.entry);
65        parser.skip_whitespace();
66    }
67    Ok(Bibliography { entries })
68}
69
70struct ParsedEntry {
71    entry: BibEntry,
72    key_offset: usize,
73}
74
75struct ParsedKey {
76    text: String,
77    offset: usize,
78}
79
80/// A byte cursor over the BibTeX source. All structural delimiters
81/// (`@ { } " , =`) and whitespace are ASCII, so scanning byte-by-byte never
82/// splits a multi-byte UTF-8 sequence and every recorded offset lands on a
83/// `char` boundary.
84struct Parser<'a> {
85    src: &'a str,
86    bytes: &'a [u8],
87    pos: usize,
88}
89
90impl<'a> Parser<'a> {
91    const fn new(src: &'a str) -> Self {
92        Self {
93            src,
94            bytes: src.as_bytes(),
95            pos: 0,
96        }
97    }
98
99    const fn at_end(&self) -> bool {
100        self.pos >= self.bytes.len()
101    }
102
103    fn peek(&self) -> Option<u8> {
104        self.bytes.get(self.pos).copied()
105    }
106
107    const fn bump(&mut self) {
108        self.pos += 1;
109    }
110
111    fn skip_whitespace(&mut self) {
112        while let Some(b) = self.peek() {
113            if b.is_ascii_whitespace() {
114                self.bump();
115            } else {
116                break;
117            }
118        }
119    }
120
121    const fn error_here(&self, kind: BibParseErrorKind) -> BibParseError {
122        BibParseError::new(kind, self.pos)
123    }
124
125    const fn error_at(offset: usize, kind: BibParseErrorKind) -> BibParseError {
126        BibParseError::new(kind, offset)
127    }
128
129    /// Consume `byte` if it is next; otherwise fail with `kind`.
130    fn expect_byte(&mut self, byte: u8, kind: BibParseErrorKind) -> Result<(), BibParseError> {
131        if self.peek() == Some(byte) {
132            self.bump();
133            Ok(())
134        } else {
135            Err(self.error_here(kind))
136        }
137    }
138
139    /// Consume a run of identifier bytes, returning the lowercased text.
140    /// Returns `None` (consuming nothing) when no identifier byte is next.
141    fn take_identifier(&mut self) -> Option<String> {
142        let start = self.pos;
143        while let Some(b) = self.peek() {
144            if is_identifier_byte(b) {
145                self.bump();
146            } else {
147                break;
148            }
149        }
150        if self.pos == start {
151            None
152        } else {
153            Some(self.src[start..self.pos].to_ascii_lowercase())
154        }
155    }
156
157    fn parse_entry(&mut self) -> Result<ParsedEntry, BibParseError> {
158        self.expect_byte(b'@', BibParseErrorKind::ExpectedAt)?;
159        self.skip_whitespace();
160        let entry_type = self
161            .take_identifier()
162            .ok_or_else(|| self.error_here(BibParseErrorKind::ExpectedEntryType))?;
163        self.skip_whitespace();
164        self.expect_byte(b'{', BibParseErrorKind::ExpectedOpenBrace)?;
165        self.skip_whitespace();
166        let key = self.parse_key()?;
167        self.skip_whitespace();
168        let mut fields = BTreeMap::new();
169        match self.peek() {
170            Some(b'}') => self.bump(),
171            Some(b',') => {
172                self.bump();
173                self.parse_fields(&mut fields)?;
174            }
175            Some(_) => return Err(self.error_here(BibParseErrorKind::ExpectedCommaOrCloseBrace)),
176            None => return Err(self.error_here(BibParseErrorKind::UnterminatedEntry)),
177        }
178        let key_span = key.offset..key.offset + key.text.len();
179        Ok(ParsedEntry {
180            entry: BibEntry {
181                entry_type,
182                key: key.text,
183                key_span,
184                fields,
185            },
186            key_offset: key.offset,
187        })
188    }
189
190    /// A citation key runs verbatim until a structural delimiter or
191    /// whitespace. It must be non-empty.
192    fn parse_key(&mut self) -> Result<ParsedKey, BibParseError> {
193        let start = self.pos;
194        while let Some(b) = self.peek() {
195            if is_key_byte(b) {
196                self.bump();
197            } else {
198                break;
199            }
200        }
201        if self.pos == start {
202            return Err(self.error_here(BibParseErrorKind::ExpectedKey));
203        }
204        Ok(ParsedKey {
205            text: self.src[start..self.pos].to_owned(),
206            offset: start,
207        })
208    }
209
210    /// Parse the comma-separated field list up to and including the closing
211    /// `}`. At least one field is required after the key's comma, so
212    /// `@type{key,}` is rejected; a trailing comma *after* a field is accepted.
213    fn parse_fields(&mut self, fields: &mut BTreeMap<String, String>) -> Result<(), BibParseError> {
214        let mut saw_field = false;
215        loop {
216            self.skip_whitespace();
217            match self.peek() {
218                // A `}` ends the list. After the key's comma we still owe a
219                // field, so `@type{key,}` (no field yet) is rejected; once a
220                // field has been seen this is the normal / trailing-comma end.
221                Some(b'}') if saw_field => {
222                    self.bump();
223                    return Ok(());
224                }
225                Some(b'}') => return Err(self.error_here(BibParseErrorKind::ExpectedFieldName)),
226                None => return Err(self.error_here(BibParseErrorKind::UnterminatedEntry)),
227                _ => {}
228            }
229            let name = self
230                .take_identifier()
231                .ok_or_else(|| self.error_here(BibParseErrorKind::ExpectedFieldName))?;
232            self.skip_whitespace();
233            self.expect_byte(b'=', BibParseErrorKind::ExpectedEquals)?;
234            self.skip_whitespace();
235            let value = self.parse_value()?;
236            // Last field wins on a repeated (post-lowercasing) field name.
237            fields.insert(name, value);
238            saw_field = true;
239            self.skip_whitespace();
240            match self.peek() {
241                Some(b',') => self.bump(),
242                Some(b'}') => {
243                    self.bump();
244                    return Ok(());
245                }
246                None => return Err(self.error_here(BibParseErrorKind::UnterminatedEntry)),
247                Some(_) => {
248                    return Err(self.error_here(BibParseErrorKind::ExpectedCommaOrCloseBrace));
249                }
250            }
251        }
252    }
253
254    fn parse_value(&mut self) -> Result<String, BibParseError> {
255        match self.peek() {
256            Some(b'{') => self.parse_braced(),
257            Some(b'"') => self.parse_quoted(),
258            Some(b) if is_bare_value_byte(b) => Ok(self.take_bare_value()),
259            _ => Err(self.error_here(BibParseErrorKind::ExpectedValue)),
260        }
261    }
262
263    /// Capture a `{...}` value, balancing nested braces by naive counting.
264    /// The text is returned verbatim, outer braces included.
265    fn parse_braced(&mut self) -> Result<String, BibParseError> {
266        let open_offset = self.pos;
267        self.bump(); // consume '{'
268        let mut depth = 1_usize;
269        while let Some(b) = self.peek() {
270            match b {
271                b'{' => depth += 1,
272                b'}' => {
273                    depth -= 1;
274                    if depth == 0 {
275                        self.bump(); // consume closing '}'
276                        return Ok(self.src[open_offset..self.pos].to_owned());
277                    }
278                }
279                _ => {}
280            }
281            self.bump();
282        }
283        Err(Self::error_at(
284            open_offset,
285            BibParseErrorKind::UnterminatedValue,
286        ))
287    }
288
289    /// Capture a `"..."` value, reading to the next unescaped `"` outside
290    /// braced TeX groups. The text is returned verbatim, outer quotes
291    /// included: the brace tracking only keeps common quoted TeX accents like
292    /// `{\"o}` from ending the value.
293    fn parse_quoted(&mut self) -> Result<String, BibParseError> {
294        let open_offset = self.pos;
295        self.bump(); // consume opening '"'
296        let mut depth = 0_usize;
297        while let Some(b) = self.peek() {
298            match b {
299                b'\\' => {
300                    self.bump();
301                    if !self.at_end() {
302                        self.bump();
303                    }
304                    continue;
305                }
306                b'{' => depth += 1,
307                b'}' if depth > 0 => depth -= 1,
308                b'"' if depth == 0 => {
309                    self.bump(); // consume closing '"'
310                    return Ok(self.src[open_offset..self.pos].to_owned());
311                }
312                _ => {}
313            }
314            self.bump();
315        }
316        Err(Self::error_at(
317            open_offset,
318            BibParseErrorKind::UnterminatedValue,
319        ))
320    }
321
322    /// Capture an unquoted value (e.g. `1984`) as a single token. The caller
323    /// has already confirmed the first byte is a bare-value byte, so the
324    /// result is non-empty. `@string` macros are not resolved.
325    fn take_bare_value(&mut self) -> String {
326        let start = self.pos;
327        while let Some(b) = self.peek() {
328            if is_bare_value_byte(b) {
329                self.bump();
330            } else {
331                break;
332            }
333        }
334        self.src[start..self.pos].to_owned()
335    }
336}
337
338/// Bytes allowed in an entry type or field name.
339const fn is_identifier_byte(b: u8) -> bool {
340    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+' | b'.' | b':' | b'/')
341}
342
343/// Bytes allowed in a citation key: anything but a structural delimiter or
344/// whitespace.
345const fn is_key_byte(b: u8) -> bool {
346    !b.is_ascii_whitespace() && !matches!(b, b',' | b'{' | b'}' | b'"' | b'=' | b'@')
347}
348
349/// Bytes allowed in a bare (unquoted, unbraced) value.
350const fn is_bare_value_byte(b: u8) -> bool {
351    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+' | b'.' | b':' | b'/')
352}