Skip to main content

jsslint_core/bib/
parser.rs

1//! BibTeX parser — a from-scratch tolerant parser tuned to match
2//! `bibtexparser` v2's observed behavior (empirically verified, same
3//! methodology as `tex::parser`), not a general BibTeX grammar
4//! implementation.
5//!
6//! Empirically-verified rules this parser encodes:
7//!
8//! 1. Anything outside an `@...{...}` block is a comment (ignored),
9//!    including partial-line text before the first `@`.
10//! 2. `@string{name = value}` defines a macro; a later BARE-token field
11//!    value (no `{}`/`""` delimiters) matching a defined name resolves
12//!    to that string's value. `@comment{...}`/`@preamble{...}` blocks
13//!    are skipped entirely (their balanced-brace body is consumed but
14//!    not otherwise interpreted).
15//! 3. Field values may be `{...}` (nested-brace-balanced), `"..."`
16//!    (no nesting; a literal `\"` inside unescapes to `"`), or a bare
17//!    token (word/number, or an `@string` name) — parts joined by `#`
18//!    concatenate with no separator. The assembled value has
19//!    leading/trailing whitespace stripped (verified: `bibtexparser`
20//!    does this too — see `naming.py::_field_value_span`'s comment).
21//! 4. **Key uniqueness policy mirrors `core/parser.py::parse_bib_source`,
22//!    NOT raw `bibtexparser`**: the first entry for a given key goes to
23//!    `Library::entries`; a later entry reusing that key goes ONLY to
24//!    `Library::duplicate_block_keys` (never re-added to `entries`,
25//!    matching the Python comment "bibtexparser kept the first
26//!    occurrence... we must not double-flag them as parse errors").
27//! 5. **Duplicate-field-within-an-entry policy also mirrors
28//!    `parse_bib_source`, not raw `bibtexparser`**: last value wins per
29//!    field, the entry is recorded in `duplicate_field_keys` for
30//!    BIBTEX-005 to report, AND (unlike a duplicate key) the recovered
31//!    entry IS added to `Library::entries` too, so every other rule
32//!    still lints it — Python re-inserts it explicitly via
33//!    `RemoveEnclosingMiddleware` + `library.add(entry)` specifically
34//!    so a single internal-duplicate-field typo doesn't blind every
35//!    other bib rule to that entry.
36//!
37//! Known, deliberate gaps: no `crossref` inheritance, no `@string`
38//! forward-reference validation (an undefined bare token is kept
39//! literally, same fallback `bibtexparser` uses), no attempt at
40//! `bibtexparser`'s full failed-block taxonomy — only the two variants
41//! (`DuplicateBlockKeyBlock`, `DuplicateFieldKeyBlock`) the 13 bib
42//! rules actually consume are modeled.
43
44use super::model::{DuplicateBlockKey, DuplicateFieldKey, Entry, Field, Library, StringTable};
45use std::collections::HashSet;
46
47pub fn parse(source: &str) -> Library {
48    let chars: Vec<char> = source.chars().collect();
49    let parser = Parser { chars: &chars };
50    parser.run()
51}
52
53struct Parser<'a> {
54    chars: &'a [char],
55}
56
57impl<'a> Parser<'a> {
58    fn len(&self) -> usize {
59        self.chars.len()
60    }
61
62    fn run(&self) -> Library {
63        let mut library = Library::default();
64        let mut strings: StringTable = StringTable::new();
65        let mut seen_keys: HashSet<String> = HashSet::new();
66        let n = self.len();
67        let mut pos = 0usize;
68
69        while pos < n {
70            let Some(at) = self.find_from(pos, '@') else {
71                break;
72            };
73            let (directive_type, after_type) = self.read_word(at + 1);
74            let open = self.skip_ws(after_type);
75            let Some((open_delim, close_delim)) = self.opener_at(open) else {
76                pos = at + 1;
77                continue;
78            };
79            let block_start = open + 1;
80            let Some(block_end) = self.find_matching_brace(block_start, close_delim) else {
81                // Unclosed block: nothing more to parse.
82                break;
83            };
84
85            let type_lower = directive_type.to_ascii_lowercase();
86            match type_lower.as_str() {
87                "string" => self.parse_string_directive(block_start, block_end, &mut strings),
88                "comment" | "preamble" => {} // body intentionally ignored
89                _ => {
90                    // bibtexparser normalizes `entry.entry_type` to
91                    // lowercase at parse time regardless of source
92                    // casing (`@TECHREPORT{...}` still reports
93                    // `entry_type == "techreport"`) — verified
94                    // empirically, since pylatexenc's own docs don't
95                    // call this out. `type_lower` (already computed
96                    // above for the string/comment/preamble dispatch)
97                    // is exactly that normalized form.
98                    let entry = self.parse_entry_body(
99                        type_lower.clone(),
100                        at,
101                        block_start,
102                        block_end,
103                        &strings,
104                    );
105                    self.commit_entry(entry, &mut library, &mut seen_keys);
106                }
107            }
108
109            pos = block_end + 1;
110            let _ = open_delim;
111        }
112
113        library
114    }
115
116    fn commit_entry(
117        &self,
118        entry: EntryParse,
119        library: &mut Library,
120        seen_keys: &mut HashSet<String>,
121    ) {
122        let key_taken = !entry.entry.key.is_empty() && !seen_keys.insert(entry.entry.key.clone());
123        if key_taken {
124            library.duplicate_block_keys.push(DuplicateBlockKey {
125                key: entry.entry.key.clone(),
126                start_line: entry.entry.start_line,
127                entry: entry.entry,
128            });
129            return;
130        }
131        if !entry.duplicate_field_names.is_empty() {
132            library.duplicate_field_keys.push(DuplicateFieldKey {
133                start_line: entry.entry.start_line,
134                duplicate_keys: entry.duplicate_field_names.clone(),
135                entry: entry.entry.clone(),
136            });
137        }
138        library.entries.push(entry.entry);
139    }
140
141    fn parse_string_directive(&self, start: usize, end: usize, strings: &mut StringTable) {
142        let mut pos = self.skip_ws(start);
143        let (name, after_name) = self.read_ident(pos);
144        if name.is_empty() {
145            return;
146        }
147        pos = self.skip_ws(after_name);
148        if pos >= end || self.chars[pos] != '=' {
149            return;
150        }
151        pos = self.skip_ws(pos + 1);
152        let (value, _next) = self.parse_value(pos, end, strings);
153        strings.insert(name.to_ascii_lowercase(), value);
154    }
155
156    fn parse_entry_body(
157        &self,
158        entry_type: String,
159        at_pos: usize,
160        start: usize,
161        end: usize,
162        strings: &StringTable,
163    ) -> EntryParse {
164        let line_index = self.line_index_of(at_pos);
165        let mut pos = self.skip_ws(start);
166        let (key, after_key) = self.read_key(pos, end);
167        pos = self.skip_ws_or_comma(after_key, end);
168
169        let mut fields: Vec<Field> = Vec::new();
170        let mut seen_field_names: HashSet<String> = HashSet::new();
171        let mut duplicate_field_names: Vec<String> = Vec::new();
172
173        while pos < end {
174            let (field_name, after_name) = self.read_ident(pos);
175            if field_name.is_empty() {
176                break;
177            }
178            pos = self.skip_ws(after_name);
179            if pos >= end || self.chars[pos] != '=' {
180                break;
181            }
182            pos = self.skip_ws(pos + 1);
183            let (value, after_value) = self.parse_value(pos, end, strings);
184            pos = self.skip_ws(after_value);
185
186            let lower = field_name.to_ascii_lowercase();
187            if !seen_field_names.insert(lower.clone()) {
188                if !duplicate_field_names.contains(&lower) {
189                    duplicate_field_names.push(lower);
190                }
191                // Last value wins: drop any earlier field of this name.
192                fields.retain(|f| !f.key.eq_ignore_ascii_case(&field_name));
193            }
194            fields.push(Field {
195                key: field_name,
196                value,
197            });
198
199            pos = self.skip_ws(pos);
200            if pos < end && self.chars[pos] == ',' {
201                pos = self.skip_ws(pos + 1);
202            } else {
203                break;
204            }
205        }
206
207        duplicate_field_names.sort();
208        EntryParse {
209            entry: Entry {
210                key,
211                entry_type,
212                start_line: line_index,
213                fields,
214            },
215            duplicate_field_names,
216        }
217    }
218
219    /// Parses a `{...}`/`"..."`/bare-token value, possibly `#`-concatenated
220    /// with further parts, stopping at `end` regardless. Returns the
221    /// assembled (whitespace-trimmed) value and the position after it.
222    fn parse_value(&self, mut pos: usize, end: usize, strings: &StringTable) -> (String, usize) {
223        let mut out = String::new();
224        loop {
225            pos = self.skip_ws(pos);
226            if pos >= end {
227                break;
228            }
229            match self.chars[pos] {
230                '{' => {
231                    let Some(close) = self.find_matching_brace(pos + 1, '}') else {
232                        break;
233                    };
234                    out.push_str(&self.unescape_quotes(pos + 1, close));
235                    pos = close + 1;
236                }
237                '"' => {
238                    let Some(close) = self.find_unescaped_quote(pos + 1, end) else {
239                        break;
240                    };
241                    out.push_str(&self.unescape_quotes(pos + 1, close));
242                    pos = close + 1;
243                }
244                _ => {
245                    let (tok, after) = self.read_ident_or_number(pos);
246                    if tok.is_empty() {
247                        break;
248                    }
249                    if let Some(resolved) = strings.get(&tok.to_ascii_lowercase()) {
250                        out.push_str(resolved);
251                    } else {
252                        out.push_str(&tok);
253                    }
254                    pos = after;
255                }
256            }
257            let after_ws = self.skip_ws(pos);
258            if after_ws < end && self.chars[after_ws] == '#' {
259                pos = after_ws + 1;
260                continue;
261            }
262            break;
263        }
264        (out.trim().to_string(), pos)
265    }
266
267    /// Literal `\"` -> `"` inside a value's raw content — see module
268    /// docs point 3; observed in real bibtexparser output.
269    fn unescape_quotes(&self, start: usize, end: usize) -> String {
270        let mut out = String::with_capacity(end - start);
271        let mut i = start;
272        while i < end {
273            if self.chars[i] == '\\' && i + 1 < end && self.chars[i + 1] == '"' {
274                out.push('"');
275                i += 2;
276            } else {
277                out.push(self.chars[i]);
278                i += 1;
279            }
280        }
281        out
282    }
283
284    /// Entry key: everything up to the first `,` (or the block's end,
285    /// for a fieldless entry), trimmed.
286    fn read_key(&self, start: usize, end: usize) -> (String, usize) {
287        let mut i = start;
288        while i < end && self.chars[i] != ',' {
289            i += 1;
290        }
291        (
292            self.chars[start..i]
293                .iter()
294                .collect::<String>()
295                .trim()
296                .to_string(),
297            i,
298        )
299    }
300
301    fn skip_ws_or_comma(&self, mut pos: usize, end: usize) -> usize {
302        if pos < end && self.chars[pos] == ',' {
303            pos += 1;
304        }
305        self.skip_ws(pos)
306    }
307
308    fn skip_ws(&self, mut pos: usize) -> usize {
309        while pos < self.len() && self.chars[pos].is_whitespace() {
310            pos += 1;
311        }
312        pos
313    }
314
315    fn find_from(&self, start: usize, needle: char) -> Option<usize> {
316        (start..self.len()).find(|&i| self.chars[i] == needle)
317    }
318
319    /// Reads a bare word: letters only. Used for the `@type` directive
320    /// name, where BibTeX types are always alphabetic.
321    fn read_word(&self, start: usize) -> (String, usize) {
322        let n = self.len();
323        let mut i = start;
324        while i < n && self.chars[i].is_ascii_alphabetic() {
325            i += 1;
326        }
327        (self.chars[start..i].iter().collect(), i)
328    }
329
330    /// A field/string name or key: letters, digits, `-`, `_`, `:`, `.`.
331    fn read_ident(&self, start: usize) -> (String, usize) {
332        let n = self.len();
333        let mut i = start;
334        while i < n
335            && (self.chars[i].is_ascii_alphanumeric()
336                || matches!(self.chars[i], '-' | '_' | ':' | '.'))
337        {
338            i += 1;
339        }
340        (self.chars[start..i].iter().collect(), i)
341    }
342
343    fn read_ident_or_number(&self, start: usize) -> (String, usize) {
344        self.read_ident(start)
345    }
346
347    /// `{` or `(` at `pos` (after skipping to it); returns the matching
348    /// closer character. `None` if `pos` isn't an opener.
349    fn opener_at(&self, pos: usize) -> Option<(char, char)> {
350        if pos >= self.len() {
351            return None;
352        }
353        match self.chars[pos] {
354            '{' => Some(('{', '}')),
355            '(' => Some(('(', ')')),
356            _ => None,
357        }
358    }
359
360    /// `self.chars[start - 1]` must be the opener; returns the index of
361    /// the matching closer (brace-depth-balanced; quotes are NOT
362    /// treated specially here since this is only used for the
363    /// outermost entry/string/comment block, whose content is scanned
364    /// field-by-field separately).
365    fn find_matching_brace(&self, start: usize, closer: char) -> Option<usize> {
366        let opener = if closer == '}' { '{' } else { '(' };
367        let n = self.len();
368        let mut depth = 1i32;
369        let mut i = start;
370        while i < n {
371            if self.chars[i] == opener {
372                depth += 1;
373            } else if self.chars[i] == closer {
374                depth -= 1;
375                if depth == 0 {
376                    return Some(i);
377                }
378            }
379            i += 1;
380        }
381        None
382    }
383
384    fn find_unescaped_quote(&self, start: usize, end: usize) -> Option<usize> {
385        let mut i = start;
386        while i < end {
387            if self.chars[i] == '"' && (i == start || self.chars[i - 1] != '\\') {
388                return Some(i);
389            }
390            i += 1;
391        }
392        None
393    }
394
395    fn line_index_of(&self, pos: usize) -> u32 {
396        self.chars[..pos].iter().filter(|&&c| c == '\n').count() as u32
397    }
398}
399
400struct EntryParse {
401    entry: Entry,
402    duplicate_field_names: Vec<String>,
403}