badness_parser/bib/semantic/
signature.rs1use std::collections::HashMap;
16use std::sync::LazyLock;
17
18use serde::Deserialize;
19use smol_str::SmolStr;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FieldCategory {
25 Name,
27 Date,
29 Verbatim,
31 Literal,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct FieldSig {
38 pub category: FieldCategory,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum RequiredField {
46 One(SmolStr),
47 OneOf(Vec<SmolStr>),
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct EntrySig {
54 pub required: Vec<RequiredField>,
55 pub optional: Vec<SmolStr>,
56}
57
58#[derive(Debug, Default, PartialEq, Eq)]
61pub struct BibFieldDb {
62 entries: HashMap<SmolStr, EntrySig>,
63 fields: HashMap<SmolStr, FieldSig>,
64 aliases: HashMap<SmolStr, SmolStr>,
68}
69
70impl BibFieldDb {
71 pub fn entry(&self, name: &str) -> Option<&EntrySig> {
73 self.entries.get(name.to_lowercase().as_str())
74 }
75
76 pub fn field(&self, name: &str) -> Option<&FieldSig> {
78 self.fields.get(name.to_lowercase().as_str())
79 }
80
81 pub fn category(&self, name: &str) -> FieldCategory {
84 self.field(name)
85 .map_or(FieldCategory::Literal, |sig| sig.category)
86 }
87
88 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 pub fn entry_names(&self) -> impl Iterator<Item = &str> {
102 self.entries.keys().map(SmolStr::as_str)
103 }
104
105 pub fn field_names(&self) -> impl Iterator<Item = &str> {
107 self.fields.keys().map(SmolStr::as_str)
108 }
109}
110
111pub 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#[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 let db = builtin();
235 assert!(db.entry_names().count() > 10);
236 }
237
238 #[test]
239 fn covers_the_full_biblatex_data_model() {
240 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 assert!(
254 db.entry("software")
255 .unwrap()
256 .required
257 .contains(&RequiredField::One(SmolStr::new("title")))
258 );
259 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 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 assert!(db.entry("book").unwrap().required.contains(&one("author")));
292
293 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 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 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 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 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 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 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 assert_eq!(db.category("title"), FieldCategory::Literal);
383 assert_eq!(db.category("totallyunknownfield"), FieldCategory::Literal);
384 }
385}