Skip to main content

badness_parser/bib/semantic/
signature.rs

1//! The built-in **BibTeX field/entry signature database**: which fields each entry
2//! type requires/allows, and a coarse category per field (name list, date,
3//! verbatim-ish, or plain literal). The bib analog of
4//! [`crate::semantic::signature`]. Meaning is assigned here rather than in the parser.
5//!
6//! Like the LaTeX side, the data is fully static, so it lives in a process-wide
7//! [`LazyLock`] loaded from one curated JSON file (`data/bib_fields.json`,
8//! [`include_str!`]-ed, [`serde`]-deserialized). It is consulted directly; there is
9//! no per-document overlay because entry types and field names are fixed.
10//! Categories are shared by formatter and linter consumers.
11
12use std::collections::HashMap;
13use std::sync::LazyLock;
14
15use serde::Deserialize;
16use smol_str::SmolStr;
17
18/// The coarse role of a field's value, used by the formatter and linter. Unlisted
19/// fields default to [`FieldCategory::Literal`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FieldCategory {
22    /// A `and`-separated list of person/organization names (`author`, `editor`, …).
23    Name,
24    /// A date or date component (`date`, `year`, `month`, `urldate`, …).
25    Date,
26    /// A value the formatter must not reshape (`url`, `doi`, `eprint`, `file`).
27    Verbatim,
28    /// Anything else — a plain literal/title field.
29    Literal,
30}
31
32/// The signature of a single field.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct FieldSig {
35    pub category: FieldCategory,
36}
37
38/// One entry in an entry type's *required* list: either a single mandatory field or
39/// a set of alternatives of which at least one must be present (e.g. `author` **or**
40/// `editor`).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum RequiredField {
43    One(SmolStr),
44    OneOf(Vec<SmolStr>),
45}
46
47/// The signature of an entry type: its required and optional fields. Field names are
48/// lowercased (BibTeX is case-insensitive).
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct EntrySig {
51    pub required: Vec<RequiredField>,
52    pub optional: Vec<SmolStr>,
53}
54
55/// The built-in field/entry signature database. Keys (entry types and field names)
56/// are stored lowercased; lookups lowercase the query.
57#[derive(Debug, Default, PartialEq, Eq)]
58pub struct BibFieldDb {
59    entries: HashMap<SmolStr, EntrySig>,
60    fields: HashMap<SmolStr, FieldSig>,
61    /// Classic-BibTeX input-field aliases: alias name -> canonical BibLaTeX field
62    /// (both lowercased). Biber resolves these on input, so the linter treats an
63    /// alias and its canonical field as interchangeable.
64    aliases: HashMap<SmolStr, SmolStr>,
65}
66
67impl BibFieldDb {
68    /// The signature of entry type `name`, if known.
69    pub fn entry(&self, name: &str) -> Option<&EntrySig> {
70        self.entries.get(name.to_lowercase().as_str())
71    }
72
73    /// The signature of field `name`, if it carries non-default metadata.
74    pub fn field(&self, name: &str) -> Option<&FieldSig> {
75        self.fields.get(name.to_lowercase().as_str())
76    }
77
78    /// The category of field `name`, defaulting to [`FieldCategory::Literal`] for an
79    /// unlisted field.
80    pub fn category(&self, name: &str) -> FieldCategory {
81        self.field(name)
82            .map_or(FieldCategory::Literal, |sig| sig.category)
83    }
84
85    /// The canonical BibLaTeX field name for `name`, resolving a classic-BibTeX alias
86    /// (e.g. `journal` -> `journaltitle`) one step. A non-alias returns lowercased
87    /// unchanged. Used to compare an entry's fields against required constraints
88    /// spelled in either convention.
89    pub fn canonical(&self, name: &str) -> SmolStr {
90        let lower = name.to_lowercase();
91        self.aliases
92            .get(lower.as_str())
93            .cloned()
94            .unwrap_or_else(|| SmolStr::new(lower))
95    }
96
97    /// The known entry type names.
98    pub fn entry_names(&self) -> impl Iterator<Item = &str> {
99        self.entries.keys().map(SmolStr::as_str)
100    }
101
102    /// The fields carrying explicit metadata.
103    pub fn field_names(&self) -> impl Iterator<Item = &str> {
104        self.fields.keys().map(SmolStr::as_str)
105    }
106}
107
108/// The process-wide built-in database, parsed once from the bundled JSON.
109pub fn builtin() -> &'static BibFieldDb {
110    &DB
111}
112
113const BIB_FIELDS_JSON: &str = include_str!("../../../data/bib_fields.json");
114
115static DB: LazyLock<BibFieldDb> =
116    LazyLock::new(|| parse(BIB_FIELDS_JSON).expect("bundled data/bib_fields.json must be valid"));
117
118// --- deserialization ------------------------------------------------------
119
120/// A `required` element: a single field name, or an array of alternatives.
121#[derive(Deserialize)]
122#[serde(untagged)]
123enum RawRequired {
124    One(String),
125    OneOf(Vec<String>),
126}
127
128#[derive(Deserialize, Default)]
129struct RawEntry {
130    #[serde(default)]
131    required: Vec<RawRequired>,
132    #[serde(default)]
133    optional: Vec<String>,
134}
135
136#[derive(Deserialize)]
137#[serde(rename_all = "lowercase")]
138enum RawCategory {
139    Name,
140    Date,
141    Verbatim,
142    Literal,
143}
144
145#[derive(Deserialize)]
146struct RawField {
147    category: RawCategory,
148}
149
150#[derive(Deserialize, Default)]
151struct RawDb {
152    #[serde(default)]
153    entries: HashMap<String, RawEntry>,
154    #[serde(default)]
155    fields: HashMap<String, RawField>,
156    #[serde(default)]
157    aliases: HashMap<String, String>,
158}
159
160fn lower(s: String) -> SmolStr {
161    SmolStr::new(s.to_lowercase())
162}
163
164impl From<RawRequired> for RequiredField {
165    fn from(raw: RawRequired) -> Self {
166        match raw {
167            RawRequired::One(name) => RequiredField::One(lower(name)),
168            RawRequired::OneOf(names) => {
169                RequiredField::OneOf(names.into_iter().map(lower).collect())
170            }
171        }
172    }
173}
174
175impl From<RawEntry> for EntrySig {
176    fn from(raw: RawEntry) -> Self {
177        EntrySig {
178            required: raw.required.into_iter().map(Into::into).collect(),
179            optional: raw.optional.into_iter().map(lower).collect(),
180        }
181    }
182}
183
184impl From<RawCategory> for FieldCategory {
185    fn from(raw: RawCategory) -> Self {
186        match raw {
187            RawCategory::Name => FieldCategory::Name,
188            RawCategory::Date => FieldCategory::Date,
189            RawCategory::Verbatim => FieldCategory::Verbatim,
190            RawCategory::Literal => FieldCategory::Literal,
191        }
192    }
193}
194
195impl From<RawField> for FieldSig {
196    fn from(raw: RawField) -> Self {
197        FieldSig {
198            category: raw.category.into(),
199        }
200    }
201}
202
203fn parse(json: &str) -> serde_json::Result<BibFieldDb> {
204    let raw: RawDb = serde_json::from_str(json)?;
205    Ok(BibFieldDb {
206        entries: raw
207            .entries
208            .into_iter()
209            .map(|(name, sig)| (lower(name), sig.into()))
210            .collect(),
211        fields: raw
212            .fields
213            .into_iter()
214            .map(|(name, sig)| (lower(name), sig.into()))
215            .collect(),
216        aliases: raw
217            .aliases
218            .into_iter()
219            .map(|(alias, canon)| (lower(alias), lower(canon)))
220            .collect(),
221    })
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn bundled_json_parses() {
230        let db = builtin();
231        assert!(db.entry_names().count() > 10);
232    }
233
234    #[test]
235    fn covers_the_full_biblatex_data_model() {
236        let db = builtin();
237        for ty in [
238            "software",
239            "reference",
240            "dataset",
241            "online",
242            "suppperiodical",
243        ] {
244            assert!(db.entry(ty).is_some(), "missing entry type `{ty}`");
245        }
246        assert!(
247            db.entry("software")
248                .unwrap()
249                .required
250                .contains(&RequiredField::One(SmolStr::new("title")))
251        );
252        for f in [
253            "langid",
254            "shortjournal",
255            "shorttitle",
256            "pubstate",
257            "urlyear",
258        ] {
259            assert!(db.field(f).is_some(), "missing field `{f}`");
260        }
261        assert_eq!(db.category("urlyear"), FieldCategory::Date);
262        assert_eq!(db.category("shortauthor"), FieldCategory::Name);
263    }
264
265    #[test]
266    fn new_data_model_types_use_oneof_date_constraints() {
267        let suppbook = builtin().entry("suppbook").expect("suppbook entry");
268        assert!(suppbook.required.iter().any(|r| matches!(
269            r,
270            RequiredField::OneOf(alts) if alts.iter().any(|a| a == "date")
271        )));
272    }
273
274    #[test]
275    fn existing_types_required_aligned_to_data_model() {
276        let db = builtin();
277        let one = |s: &str| RequiredField::One(SmolStr::new(s));
278
279        assert!(db.entry("book").unwrap().required.contains(&one("author")));
280
281        assert!(
282            db.entry("incollection")
283                .unwrap()
284                .required
285                .contains(&one("editor"))
286        );
287        assert!(
288            db.entry("periodical")
289                .unwrap()
290                .required
291                .contains(&one("editor"))
292        );
293
294        assert!(
295            db.entry("online")
296                .unwrap()
297                .required
298                .iter()
299                .any(|r| matches!(
300                    r,
301                    RequiredField::OneOf(alts)
302                        if alts.iter().any(|a| a == "url") && alts.iter().any(|a| a == "eprint")
303                ))
304        );
305
306        assert!(db.entry("misc").unwrap().required.iter().any(|r| matches!(
307            r, RequiredField::OneOf(alts) if alts.iter().any(|a| a == "date")
308        )));
309
310        assert!(
311            db.entry("mastersthesis")
312                .unwrap()
313                .required
314                .contains(&one("school"))
315        );
316    }
317
318    #[test]
319    fn article_required_fields() {
320        let article = builtin().entry("article").expect("article entry");
321        assert!(
322            article
323                .required
324                .contains(&RequiredField::One(SmolStr::new("author")))
325        );
326        assert!(
327            article
328                .required
329                .contains(&RequiredField::One(SmolStr::new("title")))
330        );
331        assert!(article.required.iter().any(|r| matches!(
332            r,
333            RequiredField::OneOf(alts) if alts.iter().any(|a| a == "date")
334        )));
335    }
336
337    #[test]
338    fn resolves_classic_bibtex_field_aliases() {
339        let db = builtin();
340        assert_eq!(db.canonical("journal"), SmolStr::new("journaltitle"));
341        assert_eq!(db.canonical("Journal"), SmolStr::new("journaltitle"));
342        assert_eq!(db.canonical("address"), SmolStr::new("location"));
343        assert_eq!(db.canonical("school"), SmolStr::new("institution"));
344        assert_eq!(db.canonical("journaltitle"), SmolStr::new("journaltitle"));
345        assert_eq!(db.canonical("Title"), SmolStr::new("title"));
346    }
347
348    #[test]
349    fn entry_lookup_is_case_insensitive() {
350        assert_eq!(builtin().entry("Article"), builtin().entry("article"));
351        assert!(builtin().entry("InProceedings").is_some());
352    }
353
354    #[test]
355    fn field_categories() {
356        let db = builtin();
357        assert_eq!(db.category("author"), FieldCategory::Name);
358        assert_eq!(db.category("Editor"), FieldCategory::Name);
359        assert_eq!(db.category("year"), FieldCategory::Date);
360        assert_eq!(db.category("url"), FieldCategory::Verbatim);
361        assert_eq!(db.category("doi"), FieldCategory::Verbatim);
362        assert_eq!(db.category("title"), FieldCategory::Literal);
363        assert_eq!(db.category("totallyunknownfield"), FieldCategory::Literal);
364    }
365}