Skip to main content

mos_csl/
from_bibtex.rs

1//! Map parsed BibTeX records ([`mos_bib::BibEntry`]) into CSL [`Item`]s.
2//!
3//! This is a best-effort, infallible mapping: BibTeX entry types become the
4//! closest CSL [`ItemType`] (unknown → [`ItemType::Document`]), and recognised
5//! BibTeX fields become CSL variables. Unrecognised fields are dropped, as CSL
6//! processors do.
7//!
8//! Name handling is intentionally minimal: `author`/`editor` are split on
9//! whitespace-delimited `and` tokens, and per name a `Last, First` comma form
10//! or `First Last` form becomes family/given. Single-token names are kept as a
11//! [`literal`](Name::literal). Full BibTeX name parsing (protected institutional
12//! names, von/Jr particles) and `month` handling are future refinements.
13
14use std::collections::BTreeMap;
15
16use mos_bib::{BibEntry, Bibliography, unwrap_value};
17
18use crate::item::{
19    Date, DateVariable, Item, ItemType, Name, NameVariable, NumberVariable, StandardVariable,
20};
21
22/// Map one BibTeX entry to a CSL [`Item`].
23#[must_use]
24pub fn item_from_bib_entry(entry: &BibEntry) -> Item {
25    let mut item = Item::new(entry.key.clone(), item_type_for(&entry.entry_type));
26    for (field, value) in &entry.fields {
27        apply_field(&mut item, &entry.entry_type, field, unwrap_value(value));
28    }
29    item
30}
31
32/// Map a whole [`Bibliography`] to CSL items keyed by citation key.
33#[must_use]
34pub fn library_from_bibliography(bibliography: &Bibliography) -> BTreeMap<String, Item> {
35    bibliography
36        .entries
37        .iter()
38        .map(|(key, entry)| (key.clone(), item_from_bib_entry(entry)))
39        .collect()
40}
41
42/// BibTeX entry type (already lowercased by `mos-bib`) → closest CSL type.
43fn item_type_for(entry_type: &str) -> ItemType {
44    match entry_type {
45        "article" => ItemType::ArticleJournal,
46        "book" | "proceedings" => ItemType::Book,
47        "booklet" => ItemType::Pamphlet,
48        "inbook" | "incollection" => ItemType::Chapter,
49        "conference" | "inproceedings" => ItemType::PaperConference,
50        "manual" | "techreport" => ItemType::Report,
51        "mastersthesis" | "phdthesis" | "thesis" => ItemType::Thesis,
52        "unpublished" => ItemType::Manuscript,
53        "online" | "electronic" => ItemType::Webpage,
54        _ => ItemType::Document,
55    }
56}
57
58/// Place one recognised BibTeX field onto the item; drop unknown fields.
59fn apply_field(item: &mut Item, entry_type: &str, field: &str, value: &str) {
60    // Recognised string ("standard") fields, grouped by their CSL target.
61    let standard = match field {
62        "title" => Some(StandardVariable::Title),
63        "journal" | "booktitle" => Some(StandardVariable::ContainerTitle),
64        "publisher" | "school" | "institution" => Some(StandardVariable::Publisher),
65        "address" if is_conference_entry(entry_type) => Some(StandardVariable::EventPlace),
66        "address" => Some(StandardVariable::PublisherPlace),
67        "series" => Some(StandardVariable::CollectionTitle),
68        "note" => Some(StandardVariable::Note),
69        "abstract" => Some(StandardVariable::Abstract),
70        "keywords" => Some(StandardVariable::Keyword),
71        "doi" => Some(StandardVariable::Doi),
72        "url" => Some(StandardVariable::Url),
73        "isbn" => Some(StandardVariable::Isbn),
74        "issn" => Some(StandardVariable::Issn),
75        "language" => Some(StandardVariable::Language),
76        _ => None,
77    };
78    if let Some(variable) = standard {
79        item.standard.insert(variable, value.to_owned());
80        return;
81    }
82
83    // Recognised number fields.
84    let number = match field {
85        "volume" => Some(NumberVariable::Volume),
86        "number" if is_report_entry(entry_type) => Some(NumberVariable::Number),
87        "number" => Some(NumberVariable::Issue),
88        "pages" => Some(NumberVariable::Page),
89        "edition" => Some(NumberVariable::Edition),
90        "chapter" => Some(NumberVariable::ChapterNumber),
91        _ => None,
92    };
93    if let Some(variable) = number {
94        item.number.insert(variable, value.to_owned());
95        return;
96    }
97
98    // Name and date fields; anything else is dropped, as CSL processors do.
99    match field {
100        "author" => {
101            item.name.insert(NameVariable::Author, parse_names(value));
102        }
103        "editor" => {
104            item.name.insert(NameVariable::Editor, parse_names(value));
105        }
106        "year" => {
107            item.date.insert(DateVariable::Issued, parse_year(value));
108        }
109        _ => {}
110    }
111}
112
113fn is_conference_entry(entry_type: &str) -> bool {
114    matches!(entry_type, "conference" | "inproceedings")
115}
116
117fn is_report_entry(entry_type: &str) -> bool {
118    matches!(entry_type, "manual" | "techreport")
119}
120
121/// Split a BibTeX name list on whitespace-delimited `and` tokens.
122fn parse_names(value: &str) -> Vec<Name> {
123    let mut names = Vec::new();
124    let mut token_start = 0;
125    let mut search_start = 0;
126
127    while let Some(relative_start) = value[search_start..].find("and") {
128        let and_start = search_start + relative_start;
129        let and_end = and_start + "and".len();
130        if is_name_separator(value, and_start, and_end) {
131            push_name(&mut names, &value[token_start..and_start]);
132            token_start = and_end;
133        }
134        search_start = and_end;
135    }
136
137    push_name(&mut names, &value[token_start..]);
138    names
139}
140
141fn is_name_separator(value: &str, start: usize, end: usize) -> bool {
142    let before = value[..start].chars().next_back();
143    let after = value[end..].chars().next();
144    before.is_some_and(char::is_whitespace) && after.is_some_and(char::is_whitespace)
145}
146
147fn push_name(names: &mut Vec<Name>, token: &str) {
148    let trimmed = token.trim();
149    if !trimmed.is_empty() {
150        names.push(parse_one_name(trimmed));
151    }
152}
153
154/// `Last, First` and `First Last` forms become family/given; a single-token
155/// name stays literal, braces and all.
156fn parse_one_name(token: &str) -> Name {
157    match token.split_once(',') {
158        Some((family, given)) => Name::person(family.trim(), given.trim()),
159        None => parse_name_without_comma(token),
160    }
161}
162
163fn parse_name_without_comma(token: &str) -> Name {
164    match token.rsplit_once(char::is_whitespace) {
165        Some((given, family)) => Name::person(family.trim(), given.trim()),
166        None => Name::literal(token),
167    }
168}
169
170/// Parse a BibTeX `year` into an `issued` [`Date`]; a non-numeric year is kept
171/// as a literal.
172fn parse_year(value: &str) -> Date {
173    value
174        .trim()
175        .parse::<i32>()
176        .map_or_else(|_err| Date::literal(value), Date::year)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn entry(entry_type: &str, key: &str, fields: &[(&str, &str)]) -> BibEntry {
184        BibEntry {
185            entry_type: entry_type.to_owned(),
186            key: key.to_owned(),
187            key_span: 0..key.len(),
188            fields: fields
189                .iter()
190                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
191                .collect(),
192        }
193    }
194
195    #[test]
196    fn maps_article_type_and_core_fields() {
197        let bib_entry = entry(
198            "article",
199            "knuth1984",
200            &[
201                ("title", "Literate Programming"),
202                ("year", "1984"),
203                ("journal", "The Computer Journal"),
204            ],
205        );
206        let item = item_from_bib_entry(&bib_entry);
207        assert_eq!(item.id, "knuth1984");
208        assert_eq!(item.kind, ItemType::ArticleJournal);
209        assert_eq!(
210            item.standard
211                .get(&StandardVariable::Title)
212                .map(String::as_str),
213            Some("Literate Programming")
214        );
215        assert_eq!(
216            item.standard
217                .get(&StandardVariable::ContainerTitle)
218                .map(String::as_str),
219            Some("The Computer Journal")
220        );
221        assert_eq!(
222            item.date.get(&DateVariable::Issued),
223            Some(&Date::year(1984))
224        );
225    }
226
227    #[test]
228    fn unwraps_outer_value_delimiters_before_mapping() {
229        let bib_entry = entry(
230            "article",
231            "knuth1984",
232            &[
233                ("title", "{The {TeX}book}"),
234                ("journal", r#""The Computer Journal""#),
235                ("year", "{1984}"),
236                ("volume", r#""27""#),
237                ("author", "{Knuth, Donald E. and Ada Lovelace}"),
238            ],
239        );
240        let item = item_from_bib_entry(&bib_entry);
241        assert_eq!(
242            item.standard
243                .get(&StandardVariable::Title)
244                .map(String::as_str),
245            Some("The {TeX}book")
246        );
247        assert_eq!(
248            item.standard
249                .get(&StandardVariable::ContainerTitle)
250                .map(String::as_str),
251            Some("The Computer Journal")
252        );
253        assert_eq!(
254            item.date.get(&DateVariable::Issued),
255            Some(&Date::year(1984))
256        );
257        assert_eq!(
258            item.number.get(&NumberVariable::Volume).map(String::as_str),
259            Some("27")
260        );
261        assert_eq!(
262            item.name.get(&NameVariable::Author),
263            Some(&vec![
264                Name::person("Knuth", "Donald E."),
265                Name::person("Lovelace", "Ada")
266            ])
267        );
268    }
269
270    #[test]
271    fn splits_authors_on_and_and_comma() {
272        let bib_entry = entry(
273            "book",
274            "k",
275            &[("author", "Knuth, Donald E. and Ada Lovelace")],
276        );
277        let item = item_from_bib_entry(&bib_entry);
278        let authors = item
279            .name
280            .get(&NameVariable::Author)
281            .expect("authors present");
282        assert_eq!(authors.len(), 2);
283        assert_eq!(authors[0], Name::person("Knuth", "Donald E."));
284        assert_eq!(authors[1], Name::person("Lovelace", "Ada"));
285    }
286
287    #[test]
288    fn splits_names_on_whitespace_delimited_and_tokens() {
289        let bib_entry = entry(
290            "book",
291            "k",
292            &[("author", "Knuth, Donald E.  and\n  Ada Lovelace")],
293        );
294        let item = item_from_bib_entry(&bib_entry);
295        let authors = item
296            .name
297            .get(&NameVariable::Author)
298            .expect("authors present");
299        assert_eq!(authors.len(), 2);
300        assert_eq!(authors[0], Name::person("Knuth", "Donald E."));
301        assert_eq!(authors[1], Name::person("Lovelace", "Ada"));
302    }
303
304    #[test]
305    fn unknown_type_is_document_and_unknown_fields_drop() {
306        let bib_entry = entry("flibble", "k", &[("title", "T"), ("nonsense", "x")]);
307        let item = item_from_bib_entry(&bib_entry);
308        assert_eq!(item.kind, ItemType::Document);
309        assert!(item.standard.contains_key(&StandardVariable::Title));
310        assert_eq!(item.standard.len(), 1, "unknown field should be dropped");
311    }
312
313    #[test]
314    fn non_numeric_year_becomes_a_literal_date() {
315        let bib_entry = entry("misc", "k", &[("year", "in press")]);
316        let item = item_from_bib_entry(&bib_entry);
317        assert_eq!(
318            item.date.get(&DateVariable::Issued),
319            Some(&Date::literal("in press"))
320        );
321    }
322
323    #[test]
324    fn maps_bibtex_entry_type_groups() {
325        let cases = [
326            ("article", ItemType::ArticleJournal),
327            ("book", ItemType::Book),
328            ("proceedings", ItemType::Book),
329            ("booklet", ItemType::Pamphlet),
330            ("inbook", ItemType::Chapter),
331            ("incollection", ItemType::Chapter),
332            ("conference", ItemType::PaperConference),
333            ("inproceedings", ItemType::PaperConference),
334            ("manual", ItemType::Report),
335            ("techreport", ItemType::Report),
336            ("mastersthesis", ItemType::Thesis),
337            ("phdthesis", ItemType::Thesis),
338            ("thesis", ItemType::Thesis),
339            ("unpublished", ItemType::Manuscript),
340            ("online", ItemType::Webpage),
341            ("electronic", ItemType::Webpage),
342            ("misc", ItemType::Document),
343        ];
344
345        for (entry_type, expected) in cases {
346            let item = item_from_bib_entry(&entry(entry_type, "k", &[]));
347            assert_eq!(item.kind, expected, "entry type: {entry_type}");
348        }
349    }
350
351    fn mapped_field_group_item() -> Item {
352        let bib_entry = entry(
353            "book",
354            "k",
355            &[
356                ("title", "Title"),
357                ("booktitle", "Container"),
358                ("publisher", "Publisher"),
359                ("school", "School"),
360                ("institution", "Institution"),
361                ("address", "Place"),
362                ("series", "Series"),
363                ("note", "Note"),
364                ("abstract", "Abstract"),
365                ("keywords", "Keywords"),
366                ("doi", "10.0/demo"),
367                ("url", "https://example.invalid"),
368                ("isbn", "ISBN"),
369                ("issn", "ISSN"),
370                ("language", "en"),
371                ("volume", "2"),
372                ("number", "4"),
373                ("pages", "10-20"),
374                ("edition", "3"),
375                ("chapter", "7"),
376            ],
377        );
378        item_from_bib_entry(&bib_entry)
379    }
380
381    #[test]
382    fn maps_standard_field_groups() {
383        let item = mapped_field_group_item();
384
385        assert_eq!(
386            item.standard
387                .get(&StandardVariable::Title)
388                .map(String::as_str),
389            Some("Title")
390        );
391        assert_eq!(
392            item.standard
393                .get(&StandardVariable::ContainerTitle)
394                .map(String::as_str),
395            Some("Container")
396        );
397        assert_eq!(
398            item.standard
399                .get(&StandardVariable::Publisher)
400                .map(String::as_str),
401            Some("School")
402        );
403        assert_eq!(
404            item.standard
405                .get(&StandardVariable::PublisherPlace)
406                .map(String::as_str),
407            Some("Place")
408        );
409        assert_eq!(
410            item.standard
411                .get(&StandardVariable::CollectionTitle)
412                .map(String::as_str),
413            Some("Series")
414        );
415        assert_eq!(
416            item.standard
417                .get(&StandardVariable::Note)
418                .map(String::as_str),
419            Some("Note")
420        );
421        assert_eq!(
422            item.standard
423                .get(&StandardVariable::Abstract)
424                .map(String::as_str),
425            Some("Abstract")
426        );
427        assert_eq!(
428            item.standard
429                .get(&StandardVariable::Keyword)
430                .map(String::as_str),
431            Some("Keywords")
432        );
433        assert_eq!(
434            item.standard
435                .get(&StandardVariable::Doi)
436                .map(String::as_str),
437            Some("10.0/demo")
438        );
439        assert_eq!(
440            item.standard
441                .get(&StandardVariable::Url)
442                .map(String::as_str),
443            Some("https://example.invalid")
444        );
445        assert_eq!(
446            item.standard
447                .get(&StandardVariable::Isbn)
448                .map(String::as_str),
449            Some("ISBN")
450        );
451        assert_eq!(
452            item.standard
453                .get(&StandardVariable::Issn)
454                .map(String::as_str),
455            Some("ISSN")
456        );
457        assert_eq!(
458            item.standard
459                .get(&StandardVariable::Language)
460                .map(String::as_str),
461            Some("en")
462        );
463    }
464
465    #[test]
466    fn maps_number_field_groups() {
467        let item = mapped_field_group_item();
468
469        assert_eq!(
470            item.number.get(&NumberVariable::Volume).map(String::as_str),
471            Some("2")
472        );
473        assert_eq!(
474            item.number.get(&NumberVariable::Issue).map(String::as_str),
475            Some("4")
476        );
477        assert_eq!(
478            item.number.get(&NumberVariable::Page).map(String::as_str),
479            Some("10-20")
480        );
481        assert_eq!(
482            item.number
483                .get(&NumberVariable::Edition)
484                .map(String::as_str),
485            Some("3")
486        );
487        assert_eq!(
488            item.number
489                .get(&NumberVariable::ChapterNumber)
490                .map(String::as_str),
491            Some("7")
492        );
493    }
494
495    #[test]
496    fn maps_report_number_to_number_not_issue() {
497        let item = item_from_bib_entry(&entry("techreport", "k", &[("number", "TR-7")]));
498        assert_eq!(
499            item.number.get(&NumberVariable::Number).map(String::as_str),
500            Some("TR-7")
501        );
502        assert!(!item.number.contains_key(&NumberVariable::Issue));
503    }
504
505    #[test]
506    fn maps_conference_address_to_event_place() {
507        let item = item_from_bib_entry(&entry("inproceedings", "k", &[("address", "Paris")]));
508        assert_eq!(
509            item.standard
510                .get(&StandardVariable::EventPlace)
511                .map(String::as_str),
512            Some("Paris")
513        );
514        assert!(
515            !item
516                .standard
517                .contains_key(&StandardVariable::PublisherPlace)
518        );
519    }
520
521    #[test]
522    fn maps_editors_and_skips_empty_name_tokens() {
523        let bib_entry = entry(
524            "book",
525            "k",
526            &[
527                ("author", " Ada Lovelace and  and Turing, Alan "),
528                ("editor", "Knuth, Donald"),
529            ],
530        );
531        let item = item_from_bib_entry(&bib_entry);
532        assert_eq!(
533            item.name.get(&NameVariable::Author),
534            Some(&vec![
535                Name::person("Lovelace", "Ada"),
536                Name::person("Turing", "Alan")
537            ])
538        );
539        assert_eq!(
540            item.name.get(&NameVariable::Editor),
541            Some(&vec![Name::person("Knuth", "Donald")])
542        );
543    }
544
545    #[test]
546    fn maps_whole_bibliography_by_key() {
547        let bibliography = Bibliography {
548            entries: [
549                ("a".to_owned(), entry("article", "a", &[("title", "First")])),
550                ("b".to_owned(), entry("book", "b", &[("title", "Second")])),
551            ]
552            .into_iter()
553            .collect(),
554        };
555
556        let library = library_from_bibliography(&bibliography);
557        assert_eq!(library.len(), 2);
558        assert_eq!(
559            library.get("a").map(|item| item.kind),
560            Some(ItemType::ArticleJournal)
561        );
562        assert_eq!(library.get("b").map(|item| item.kind), Some(ItemType::Book));
563    }
564
565    #[test]
566    fn keeps_single_token_names_literal() {
567        let item = item_from_bib_entry(&entry("book", "k", &[("author", "Plato")]));
568        assert_eq!(
569            item.name.get(&NameVariable::Author),
570            Some(&vec![Name::literal("Plato")])
571        );
572    }
573}