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