Skip to content

Commit e12c4db

Browse files
committed
feat: Add BibTeX/BibLaTeX parser and serializer
Delegates parsing to the biblatex crate and maps entries to the unified Record type. Handles typed and permissive date extraction (ISO dates, year-only, chunks fallback), typed and verbatim page ranges, person name formatting as "Last, First", journaltitle/journal fallback, PermissiveType volume, and citation key in extras.
1 parent 8782c2d commit e12c4db

4 files changed

Lines changed: 840 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ exclude = [
2222
pedantic = "warn"
2323

2424
[dependencies]
25+
biblatex = "0.11"
2526
quick-xml = "0.39"
2627
winnow = "1"
2728

src/bibtex.rs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
//! Parser and serializer for the `BibTeX`/`BibLaTeX` bibliographic format.
2+
//!
3+
//! Delegates parsing to the [`biblatex`](https://docs.rs/biblatex) crate and maps
4+
//! entries to the unified [`Record`] type.
5+
//!
6+
//! # References
7+
//!
8+
//! - [BibTeX format](https://www.bibtex.com/g/bibtex-format/)
9+
//! - [`BibLaTeX` guide](https://www.overleaf.com/learn/latex/Bibliography_management_with_biblatex)
10+
11+
use std::collections::HashMap;
12+
use std::fmt::Write as _;
13+
14+
use biblatex::{Bibliography, ChunksExt, Date, DateValue, PermissiveType, Person};
15+
16+
use crate::parse_util::check_empty;
17+
use crate::{Error, PublicationDate, Record};
18+
19+
/// Parse `BibTeX`/`BibLaTeX` text into zero or more [`Record`]s.
20+
///
21+
/// # Errors
22+
///
23+
/// Returns [`Error::EmptyInput`] if the input is empty or whitespace-only.
24+
/// Returns [`Error::MalformedSyntax`] if the input cannot be parsed.
25+
/// Returns [`Error::MissingRequiredField`] if an entry has no title.
26+
pub fn parse(input: &str) -> Result<Vec<Record>, Error> {
27+
check_empty(input)?;
28+
29+
let bib = Bibliography::parse(input).map_err(|e| Error::MalformedSyntax {
30+
line: 0,
31+
message: e.to_string(),
32+
})?;
33+
34+
bib.iter().map(record_from_entry).collect()
35+
}
36+
37+
/// Serialize [`Record`]s to `BibTeX` format.
38+
#[must_use]
39+
pub fn serialize(records: &[Record]) -> String {
40+
let mut out = String::new();
41+
42+
for (i, record) in records.iter().enumerate() {
43+
if i > 0 {
44+
out.push('\n');
45+
}
46+
47+
let key = record.extras.get("key").map_or("unknown", String::as_str);
48+
49+
let _ = writeln!(out, "@article{{{key},");
50+
51+
emit(&mut out, "title", &record.title);
52+
53+
if !record.authors.is_empty() {
54+
emit(&mut out, "author", &record.authors.join(" and "));
55+
}
56+
57+
if let Some(date) = record.date {
58+
emit(&mut out, "year", &date.year.to_string());
59+
if let Some(m) = date.month {
60+
emit(&mut out, "month", &m.to_string());
61+
}
62+
}
63+
64+
if let Some(journal) = &record.journal {
65+
emit(&mut out, "journal", journal);
66+
}
67+
68+
if let Some(doi) = &record.doi {
69+
emit(&mut out, "doi", doi);
70+
}
71+
72+
if let Some(pages) = &record.pages {
73+
emit(&mut out, "pages", pages);
74+
}
75+
76+
if let Some(volume) = &record.volume {
77+
emit(&mut out, "volume", volume);
78+
}
79+
80+
if let Some(number) = &record.number {
81+
emit(&mut out, "number", number);
82+
}
83+
84+
if let Some(abstract_text) = &record.abstract_text {
85+
emit(&mut out, "abstract", abstract_text);
86+
}
87+
88+
if let Some(isbn) = &record.isbn {
89+
emit(&mut out, "isbn", isbn);
90+
}
91+
92+
out.push_str("}\n");
93+
}
94+
95+
out
96+
}
97+
98+
fn emit(out: &mut String, field: &str, value: &str) {
99+
let _ = writeln!(out, " {field} = {{{value}}},");
100+
}
101+
102+
fn record_from_entry(entry: &biblatex::Entry) -> Result<Record, Error> {
103+
let mut extras = HashMap::new();
104+
extras.insert("key".into(), entry.key.clone());
105+
106+
let title = entry
107+
.get("title")
108+
.map(ChunksExt::format_verbatim)
109+
.unwrap_or_default();
110+
111+
if title.is_empty() {
112+
return Err(Error::MissingRequiredField {
113+
tag: "title",
114+
line: 0,
115+
});
116+
}
117+
118+
let authors = entry
119+
.get_as::<Vec<Person>>("author")
120+
.ok()
121+
.map(|people| people.iter().map(format_person).collect())
122+
.unwrap_or_default();
123+
124+
let date = parse_date(entry);
125+
126+
let journal = entry
127+
.get("journaltitle")
128+
.or(entry.get("journal"))
129+
.map(ChunksExt::format_verbatim);
130+
131+
let doi = entry.get_as::<String>("doi").ok();
132+
133+
let pages = entry
134+
.get_as::<PermissiveType<Vec<std::ops::Range<u32>>>>("pages")
135+
.ok()
136+
.map(|p| match p {
137+
PermissiveType::Typed(ranges) => ranges
138+
.iter()
139+
.map(|r| {
140+
if r.start == r.end {
141+
r.start.to_string()
142+
} else {
143+
format!("{}-{}", r.start, r.end)
144+
}
145+
})
146+
.collect::<Vec<_>>()
147+
.join(", "),
148+
PermissiveType::Chunks(c) => c.format_verbatim(),
149+
});
150+
151+
let volume = entry
152+
.get_as::<PermissiveType<i64>>("volume")
153+
.ok()
154+
.map(|v| match v {
155+
PermissiveType::Typed(n) => n.to_string(),
156+
PermissiveType::Chunks(c) => c.format_verbatim(),
157+
});
158+
159+
let number = entry.get("number").map(ChunksExt::format_verbatim);
160+
let abstract_text = entry.get("abstract").map(ChunksExt::format_verbatim);
161+
let isbn = entry.get("isbn").map(ChunksExt::format_verbatim);
162+
163+
Ok(Record {
164+
title,
165+
authors,
166+
date,
167+
journal,
168+
doi,
169+
pages,
170+
volume,
171+
number,
172+
abstract_text,
173+
isbn,
174+
extras,
175+
})
176+
}
177+
178+
/// Format a `Person` as "Last, First" (the convention used by `Record.authors`).
179+
fn format_person(person: &Person) -> String {
180+
let mut parts = String::new();
181+
if !person.prefix.is_empty() {
182+
parts.push_str(&person.prefix);
183+
parts.push(' ');
184+
}
185+
parts.push_str(&person.name);
186+
if !person.given_name.is_empty() {
187+
parts.push_str(", ");
188+
parts.push_str(&person.given_name);
189+
}
190+
if !person.suffix.is_empty() {
191+
parts.push_str(", ");
192+
parts.push_str(&person.suffix);
193+
}
194+
parts
195+
}
196+
197+
fn parse_date(entry: &biblatex::Entry) -> Option<PublicationDate> {
198+
if let Ok(date) = entry.get_as::<PermissiveType<Date>>("date") {
199+
return match date {
200+
PermissiveType::Typed(d) => {
201+
let dt = match d.value {
202+
DateValue::At(dt)
203+
| DateValue::After(dt)
204+
| DateValue::Before(dt)
205+
| DateValue::Between(dt, _) => dt,
206+
};
207+
// biblatex uses 0-indexed month and day.
208+
Some(PublicationDate {
209+
year: dt.year,
210+
month: dt.month.map(|m| m + 1),
211+
day: dt.day.map(|d| d + 1),
212+
})
213+
}
214+
PermissiveType::Chunks(c) => {
215+
let s = c.format_verbatim();
216+
let year = s.split_whitespace().next()?.parse().ok()?;
217+
Some(PublicationDate {
218+
year,
219+
month: None,
220+
day: None,
221+
})
222+
}
223+
};
224+
}
225+
226+
let year_str = entry.get("year")?.format_verbatim();
227+
let year = year_str.trim().parse().ok()?;
228+
Some(PublicationDate {
229+
year,
230+
month: None,
231+
day: None,
232+
})
233+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//!
33
//! `biblio` provides a unified [`Record`] type that captures the metadata common to
44
//! `RIS`, `NBIB`, `EndNote XML`, `BibTeX`, and `BibLaTeX` files.
5+
pub mod bibtex;
56
pub mod endnote_xml;
67
mod error;
78
pub mod nbib;

0 commit comments

Comments
 (0)