badness_parser/bib/semantic/
signature.rs1use std::collections::HashMap;
13use std::sync::LazyLock;
14
15use serde::Deserialize;
16use smol_str::SmolStr;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FieldCategory {
22 Name,
24 Date,
26 Verbatim,
28 Literal,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct FieldSig {
35 pub category: FieldCategory,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum RequiredField {
43 One(SmolStr),
44 OneOf(Vec<SmolStr>),
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct EntrySig {
51 pub required: Vec<RequiredField>,
52 pub optional: Vec<SmolStr>,
53}
54
55#[derive(Debug, Default, PartialEq, Eq)]
58pub struct BibFieldDb {
59 entries: HashMap<SmolStr, EntrySig>,
60 fields: HashMap<SmolStr, FieldSig>,
61 aliases: HashMap<SmolStr, SmolStr>,
65}
66
67impl BibFieldDb {
68 pub fn entry(&self, name: &str) -> Option<&EntrySig> {
70 self.entries.get(name.to_lowercase().as_str())
71 }
72
73 pub fn field(&self, name: &str) -> Option<&FieldSig> {
75 self.fields.get(name.to_lowercase().as_str())
76 }
77
78 pub fn category(&self, name: &str) -> FieldCategory {
81 self.field(name)
82 .map_or(FieldCategory::Literal, |sig| sig.category)
83 }
84
85 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 pub fn entry_names(&self) -> impl Iterator<Item = &str> {
99 self.entries.keys().map(SmolStr::as_str)
100 }
101
102 pub fn field_names(&self) -> impl Iterator<Item = &str> {
104 self.fields.keys().map(SmolStr::as_str)
105 }
106}
107
108pub 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#[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}