feat: improvement
Pipeline / check (pull_request) Successful in 5m0s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-27 01:11:09 -04:00
parent 5ac1e317c0
commit c6d6ee10b6
18 changed files with 2538 additions and 108 deletions
+330
View File
@@ -0,0 +1,330 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Pulling a citation apart when it was written as prose.
//!
//! A bibliography assembled by hand tends to collect entries like
//!
//! ```text
//! note: 'Nucleic Acids Res 25:3389-3402. doi:10.1093/nar/25.17.3389'
//! ```
//!
//! which is a complete citation in a field that means "anything else worth
//! saying". Nothing can use it: a reading list cannot link the DOI, an export to
//! Hayagriva or BibTeX has no journal to put in `parent` or `journal`, and the
//! `container`, `volume`, `pages`, and `doi` fields sit empty beside it.
//!
//! [`parse`] takes such a note apart. What it cannot account for it leaves in
//! the note, which is the important half of the contract: a note reading
//! `'Bioinformatics 18:440-445. Origin of spaced seeds.'` yields the journal,
//! the volume, the pages, and a note that still says where spaced seeds came
//! from. Nothing is discarded and nothing is invented — an issue number that was
//! never written down stays absent, even when a publisher's DOI happens to
//! encode one.
/// The parts of a citation recovered from a note.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Parsed {
/// The journal, proceedings, or book the work appeared in.
pub container: Option<String>,
/// The volume.
pub volume: Option<String>,
/// The page range, as `first-last`.
pub pages: Option<String>,
/// The DOI, bare.
pub doi: Option<String>,
/// What the note still says after the citation is removed.
pub note: Option<String>,
}
/// Takes a citation apart, leaving the rest of the note alone.
///
/// # Arguments
///
/// * `note` - the note as written.
/// * `year` - the record's year, which is how a trailing year is recognized as
/// part of a conference name rather than part of the title of the venue.
///
/// # Returns
///
/// The parts found. Every field is independently optional: a note that carries
/// only a DOI yields only a DOI.
pub fn parse(note: &str, year: Option<u32>) -> Parsed {
let mut out = Parsed::default();
let mut rest = note.trim().to_string();
if let Some((container, volume, pages, tail)) = citation(&rest) {
out.container = Some(container);
out.volume = Some(volume);
out.pages = Some(pages);
rest = tail;
}
if let Some((doi, tail)) = doi(&rest) {
out.doi = Some(doi);
rest = tail;
}
// A venue with no volume or pages — a conference, usually — is named by the
// clause that ends in the year the work was published.
if out.container.is_none() {
if let Some(y) = year {
if let Some((container, tail)) = venue(&rest, y) {
out.container = Some(container);
rest = tail;
}
}
}
// The year belongs to the record, not to the name of the venue.
if let (Some(container), Some(y)) = (&out.container, year) {
let suffix = format!(" {y}");
if let Some(trimmed) = container.strip_suffix(&suffix) {
out.container = Some(trimmed.trim_end().to_string());
}
}
let rest = rest.trim().trim_start_matches('.').trim().to_string();
out.note = (!rest.is_empty()).then_some(rest);
out
}
/// Finds `Journal 25:3389-3402` or `Journal 48, 443-453` at the start.
///
/// The volume is the first digit run that follows a space and is followed by a
/// separator and a page range. Requiring the whole shape is what keeps a year in
/// a conference name (`Proc. FOCS 2000.`) from being read as a volume.
///
/// # Returns
///
/// The container, volume, page range, and whatever followed.
fn citation(text: &str) -> Option<(String, String, String, String)> {
let bytes = text.as_bytes();
let mut at = 0;
while at < bytes.len() {
// A volume follows a space, so that a digit inside a name is not one.
if !(bytes[at].is_ascii_digit() && at > 0 && bytes[at - 1] == b' ') {
at += 1;
continue;
}
let volume_start = at;
let volume_end = digits(bytes, volume_start);
let mut cursor = spaces(bytes, volume_end);
// The separator between volume and pages is a colon or a comma.
if cursor < bytes.len() && (bytes[cursor] == b':' || bytes[cursor] == b',') {
cursor = spaces(bytes, cursor + 1);
let first_start = cursor;
let first_end = digits(bytes, first_start);
if first_end > first_start {
let dash = text[first_end..]
.strip_prefix('-')
.or_else(|| text[first_end..].strip_prefix('\u{2013}'));
if let Some(after_dash) = dash {
let last_offset = text.len() - after_dash.len();
let last_end = digits(bytes, last_offset);
if last_end > last_offset {
let container = text[..volume_start].trim_end_matches([' ', ',']);
if !container.is_empty() {
return Some((
container.to_string(),
text[volume_start..volume_end].to_string(),
format!(
"{}-{}",
&text[first_start..first_end],
&text[last_offset..last_end]
),
text[last_end..].to_string(),
));
}
}
}
}
}
at = volume_end;
}
None
}
/// Finds a `doi:10.…` anywhere in the text.
///
/// # Returns
///
/// The DOI and the text with it removed.
fn doi(text: &str) -> Option<(String, String)> {
let lower = text.to_ascii_lowercase();
let at = lower.find("doi:")?;
let after = text[at + 4..].trim_start();
let offset = text.len() - after.len();
let end = after
.find(char::is_whitespace)
.map(|n| offset + n)
.unwrap_or(text.len());
let doi = text[offset..end].trim_end_matches('.');
if !doi.starts_with("10.") {
return None;
}
let mut remainder = String::from(text[..at].trim_end());
let tail = text[end..].trim();
if !tail.is_empty() {
if !remainder.is_empty() {
remainder.push(' ');
}
remainder.push_str(tail);
}
Some((doi.to_string(), remainder))
}
/// Finds a leading clause ending in the publication year: `Proc. FOCS 2000.`
///
/// # Returns
///
/// The clause without its trailing period, and whatever followed.
fn venue(text: &str, year: u32) -> Option<(String, String)> {
let needle = format!("{year}.");
let at = text.find(&needle)?;
let clause = text[..at + needle.len() - 1].trim();
if clause.is_empty() {
return None;
}
Some((
clause.to_string(),
text[at + needle.len()..].trim().to_string(),
))
}
/// The end of a run of ASCII digits starting at `from`.
fn digits(bytes: &[u8], from: usize) -> usize {
let mut at = from;
while at < bytes.len() && bytes[at].is_ascii_digit() {
at += 1;
}
at
}
/// The end of a run of spaces starting at `from`.
fn spaces(bytes: &[u8], from: usize) -> usize {
let mut at = from;
while at < bytes.len() && bytes[at] == b' ' {
at += 1;
}
at
}
/// Whether a note still looks like it is carrying a citation.
///
/// Used by validation to say so, rather than leaving a note that a reading list
/// cannot link and an export cannot use.
///
/// # Arguments
///
/// * `note` - the note as written.
///
/// # Returns
///
/// `true` when a volume and page range, or a DOI, can be found in it.
pub fn looks_like_a_citation(note: &str) -> bool {
citation(note.trim()).is_some() || doi(note.trim()).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
/// Every article note in a real course bibliography, which is where the
/// shapes below come from. Two separator styles, DOIs in three positions,
/// a conference with no volume, and prose that has to survive.
#[test]
fn a_journal_citation_comes_apart() {
let p = parse(
"Nucleic Acids Res 25:3389-3402. doi:10.1093/nar/25.17.3389",
Some(1997),
);
assert_eq!(p.container.as_deref(), Some("Nucleic Acids Res"));
assert_eq!(p.volume.as_deref(), Some("25"));
assert_eq!(p.pages.as_deref(), Some("3389-3402"));
assert_eq!(p.doi.as_deref(), Some("10.1093/nar/25.17.3389"));
assert_eq!(p.note, None);
// The DOI encodes volume 25, issue 17. Nothing infers the issue from
// it: a field nobody wrote down stays empty.
}
#[test]
fn an_abbreviation_keeps_its_final_period() {
let p = parse("J. Mol. Biol. 48, 443-453.", Some(1970));
assert_eq!(p.container.as_deref(), Some("J. Mol. Biol."));
assert_eq!(p.volume.as_deref(), Some("48"));
assert_eq!(p.pages.as_deref(), Some("443-453"));
assert_eq!(p.note, None);
}
#[test]
fn prose_after_a_citation_stays_in_the_note() {
let p = parse(
"Bioinformatics 18:440-445. Origin of spaced seeds.",
Some(2002),
);
assert_eq!(p.container.as_deref(), Some("Bioinformatics"));
assert_eq!(p.note.as_deref(), Some("Origin of spaced seeds."));
// A caveat the author wrote is the last thing to throw away.
let p = parse("J Mol Biol 215:403-410. Verify before use.", Some(1990));
assert_eq!(p.note.as_deref(), Some("Verify before use."));
let p = parse(
"Bioinformatics 25:2078-2079. doi:10.1093/bioinformatics/btp352. Author list is the \
core set plus the 1000 Genomes Data Processing Subgroup; verify.",
Some(2009),
);
assert_eq!(p.doi.as_deref(), Some("10.1093/bioinformatics/btp352"));
assert_eq!(
p.note.as_deref(),
Some(
"Author list is the core set plus the 1000 Genomes Data Processing Subgroup; \
verify."
)
);
}
#[test]
fn a_conference_has_a_year_where_a_volume_would_be() {
let p = parse(
"Proc. FOCS 2000. doi:10.1109/SFCS.2000.892127. The FM-index. Theory background.",
Some(2000),
);
assert_eq!(p.container.as_deref(), Some("Proc. FOCS"));
// No volume and no pages were written, so none are invented — and the
// 2000 in the DOI is not mistaken for either.
assert_eq!(p.volume, None);
assert_eq!(p.pages, None);
assert_eq!(p.doi.as_deref(), Some("10.1109/SFCS.2000.892127"));
assert_eq!(p.note.as_deref(), Some("The FM-index. Theory background."));
}
#[test]
fn a_note_with_nothing_to_find_is_left_whole() {
let p = parse("Origin of the MAPQ score.", Some(2008));
assert_eq!(p.container, None);
assert_eq!(p.note.as_deref(), Some("Origin of the MAPQ score."));
}
#[test]
fn a_multi_word_journal_is_not_cut_at_a_number() {
let p = parse("Advances in Mathematics 20, 367-387.", Some(1976));
assert_eq!(p.container.as_deref(), Some("Advances in Mathematics"));
assert_eq!(p.volume.as_deref(), Some("20"));
}
#[test]
fn what_validation_looks_for() {
assert!(looks_like_a_citation("Nat Methods 12:59-60."));
assert!(looks_like_a_citation("doi:10.1038/nmeth.3176"));
assert!(!looks_like_a_citation("Origin of minimizers."));
assert!(!looks_like_a_citation("Verify before use."));
// A page range with no volume is not a citation shape.
assert!(!looks_like_a_citation("see pages 12-14"));
}
}