Skip to main content

badness_parser/bib/semantic/
builder.rs

1//! Builds the bib [`Model`](super::Model) in a single CST walk, then a resolve pass.
2//!
3//! Mirrors [`crate::semantic::builder`]: one `root.descendants()` pass collects
4//! entries, `@string` definitions, and `@string` uses; then [`resolve`] flags
5//! duplicate cite keys and marks each use resolved or unresolved. The model
6//! exposes facts; consumers decide how to diagnose them.
7
8use std::collections::HashSet;
9
10use smol_str::SmolStr;
11
12use crate::bib::ast;
13use crate::bib::semantic::Model;
14use crate::bib::semantic::entry::{Entry, StringDef, StringUse};
15use crate::bib::syntax::{SyntaxKind, SyntaxNode};
16
17/// Month abbreviations BibTeX/biber predefine as `@string` macros. A bare use of one
18/// is always resolved, so whitelisting them avoids false "undefined string" findings.
19pub const MONTH_MACROS: [&str; 12] = [
20    "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
21];
22
23/// Build the model from a bib parse-tree root.
24pub fn build(root: &SyntaxNode) -> Model {
25    let mut model = Model::default();
26    for node in root.descendants() {
27        match node.kind() {
28            SyntaxKind::ENTRY => collect_entry(&node, &mut model),
29            SyntaxKind::STRING_ENTRY => collect_string(&node, &mut model),
30            _ => {}
31        }
32    }
33    resolve(&mut model);
34    model
35}
36
37/// Record a regular entry (when it has a key) and any `@string` uses in its values.
38fn collect_entry(entry: &SyntaxNode, model: &mut Model) {
39    if let Some((key, key_range)) = ast::cite_key(entry) {
40        let entry_type = ast::entry_type(entry).unwrap_or_default().to_lowercase();
41        let title = field_cleaned(entry, "title");
42        let authors = field_cleaned(entry, "author").or_else(|| field_cleaned(entry, "editor"));
43        model.entries.push(Entry {
44            entry_type: SmolStr::new(entry_type),
45            key: SmolStr::new(key),
46            title,
47            authors,
48            key_range,
49            range: entry.text_range(),
50            duplicate: false,
51        });
52    }
53    collect_uses(entry, model);
54}
55
56/// The cleaned value of `entry`'s first field named `want` (case-insensitive), or
57/// `None` when absent or empty. Feeds the cached `title`/`authors` facts.
58fn field_cleaned(entry: &SyntaxNode, want: &str) -> Option<SmolStr> {
59    ast::fields(entry)
60        .filter(|f| ast::field_name(f).is_some_and(|n| n.eq_ignore_ascii_case(want)))
61        .find_map(|f| ast::field_value(&f))
62        .map(|v| ast::value_text_cleaned(&v))
63        .filter(|s| !s.is_empty())
64        .map(SmolStr::new)
65}
66
67/// Record an `@string` definition and any `@string` uses in its (concatenated) value.
68fn collect_string(string_entry: &SyntaxNode, model: &mut Model) {
69    if let Some((name, range)) = ast::string_def_name(string_entry) {
70        model.string_defs.push(StringDef {
71            name: SmolStr::new(name.to_lowercase()),
72            range,
73        });
74    }
75    collect_uses(string_entry, model);
76}
77
78/// Collect the bare-macro uses across every field value of `node`.
79fn collect_uses(node: &SyntaxNode, model: &mut Model) {
80    for field in ast::fields(node) {
81        let Some(value) = ast::field_value(&field) else {
82            continue;
83        };
84        for (name, range) in ast::value_macro_uses(&value) {
85            model.string_uses.push(StringUse {
86                name: SmolStr::new(name.to_lowercase()),
87                range,
88                resolved: false,
89            });
90        }
91    }
92}
93
94/// Flag duplicate cite keys and mark each `@string` use resolved or not.
95fn resolve(model: &mut Model) {
96    // Duplicate cite keys (case-insensitive; the first occurrence stays `false`).
97    let mut seen: HashSet<SmolStr> = HashSet::new();
98    for entry in &mut model.entries {
99        let folded = SmolStr::new(entry.key.to_lowercase());
100        if !seen.insert(folded) {
101            entry.duplicate = true;
102        }
103    }
104
105    // Undefined `@string` uses: defined names are the in-file defs plus the predefined
106    // month macros. Whole-file set, order-independent (no forward-reference rule).
107    let mut defined: HashSet<SmolStr> = model.string_defs.iter().map(|d| d.name.clone()).collect();
108    defined.extend(MONTH_MACROS.iter().map(|m| SmolStr::new(*m)));
109    for string_use in &mut model.string_uses {
110        string_use.resolved = defined.contains(&string_use.name);
111    }
112}