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