use crate::matching::normalise;
const JOURNAL_STOPWORDS: &[&str] = &[
"the", "of", "and", "for", "in", "on", "de", "der", "des", "du", "la", "le", "und",
];
pub fn same_journal(a: &str, b: &str) -> bool {
let (a, b) = (significant(a), significant(b));
!a.is_empty() && (a == b || abbreviates(&a, &b) || abbreviates(&b, &a))
}
pub(crate) fn journal_in(words: &[String], journal: &str) -> bool {
let full = significant(journal);
if full.is_empty() {
return false;
}
let text: Vec<&str> = words
.iter()
.map(String::as_str)
.filter(|w| !JOURNAL_STOPWORDS.contains(w))
.collect();
text.windows(full.len())
.any(|run| run.iter().zip(&full).all(|(s, f)| abbreviates_word(s, f)))
|| (full.len() > 1 && text.iter().any(|w| is_initials(w, &full)))
}
fn significant(name: &str) -> Vec<String> {
normalise(name)
.split(' ')
.filter(|w| !w.is_empty() && !JOURNAL_STOPWORDS.contains(w))
.map(str::to_owned)
.collect()
}
fn abbreviates(short: &[String], full: &[String]) -> bool {
match short {
[one] if full.len() > 1 => is_initials(one, full),
_ => {
short.len() == full.len() && short.iter().zip(full).all(|(s, f)| abbreviates_word(s, f))
}
}
}
fn abbreviates_word(short: &str, full: &str) -> bool {
let mut full_chars = full.chars();
short.chars().next() == full.chars().next() && short.chars().all(|c| full_chars.any(|f| f == c))
}
fn is_initials(word: &str, full: &[String]) -> bool {
word.chars().count() == full.len() && word.chars().zip(full).all(|(c, f)| f.starts_with(c))
}
pub fn first_page(pages: &str) -> &str {
pages
.split(['-', '–', '—', ','])
.next()
.unwrap_or("")
.trim()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Locator {
pub volume: Option<String>,
pub page: Option<String>,
}
pub(crate) fn locator(text: &str) -> Locator {
let mut compact = String::with_capacity(text.len());
for c in text.chars() {
if c.is_whitespace() && compact.ends_with([';', ':']) {
continue;
}
if c == '(' && compact.trim_end().ends_with(|p: char| p.is_ascii_digit()) {
let trimmed = compact.trim_end().len();
compact.truncate(trimmed);
}
if matches!(c, ';' | ':') {
let trimmed = compact.trim_end().len();
compact.truncate(trimmed);
}
compact.push(c);
}
let tokens: Vec<&str> = compact.split_whitespace().collect();
let mut found = Locator::default();
for (i, token) in tokens.iter().enumerate() {
let token = token.trim_end_matches(['.', ',']);
if let Some((head, pages)) = token.split_once(':') {
let volume = head.rsplit(';').next().unwrap_or(head);
let styled = head.contains([';', '(']) || pages.contains(['-', '–', '—']);
if styled
&& let (Some(volume), Some(page)) = (volume_number(volume), page_number(pages))
{
return Locator {
volume: Some(volume),
page: Some(page),
};
}
}
if tokens[i].ends_with(',')
&& token.contains('(')
&& let Some(volume) = volume_number(token)
&& let Some(page) = tokens.get(i + 1).and_then(|p| page_number(p))
{
return Locator {
volume: Some(volume),
page: Some(page),
};
}
let label = token.to_ascii_lowercase();
let next = || tokens.get(i + 1).copied();
match label.trim_end_matches('.') {
"vol" | "volume" if found.volume.is_none() => {
found.volume = next().and_then(|v| volume_number(v.trim_end_matches([',', '.'])));
}
"pp" | "p" | "pages" | "page" if found.page.is_none() => {
found.page = next().and_then(page_number);
}
_ => {}
}
}
found
}
fn volume_number(s: &str) -> Option<String> {
let volume = s.split('(').next().unwrap_or(s);
(!volume.is_empty() && volume.len() <= 6 && volume.bytes().all(|b| b.is_ascii_digit()))
.then(|| volume.to_owned())
}
fn page_number(s: &str) -> Option<String> {
let page = first_page(s.trim_end_matches(['.', ',', ';']));
let digits = page.trim_start_matches(['e', 'E', 's', 'S']);
(!digits.is_empty() && digits.len() <= 8 && digits.bytes().all(|b| b.is_ascii_digit()))
.then(|| page.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn journals_match_their_abbreviations_and_initials() {
for (a, b) in [
("N Engl J Med", "New England Journal of Medicine"),
("N. Engl. J. Med.", "The New England journal of medicine"),
("Lancet", "The Lancet"),
("NEJM", "New England Journal of Medicine"),
("JAMA", "Journal of the American Medical Association"),
(
"Am J Respir Crit Care Med",
"American Journal of Respiratory and Critical Care Medicine",
),
(
"Proc Natl Acad Sci U S A",
"Proceedings of the National Academy of Sciences of the United States of America",
),
(
"Cochrane Database Syst Rev",
"Cochrane Database of Systematic Reviews",
),
("Circulation", "Circulation"),
] {
assert!(same_journal(a, b), "{a} ↔ {b}");
}
for (a, b) in [
("Lancet", "New England Journal of Medicine"),
("N Engl J Med", "Nature Medicine"),
("Ann Emerg Med", "Annals of Internal Medicine"),
("JAMA", "JAMA Internal Medicine"),
] {
assert!(!same_journal(a, b), "{a} ≠ {b}");
}
}
#[test]
fn journals_are_found_inside_references() {
let words =
|s: &str| -> Vec<String> { normalise(s).split(' ').map(str::to_owned).collect() };
let text = words("Daum RS, et al. N Engl J Med. 2017;376(26):2545-55.");
assert!(journal_in(&text, "New England Journal of Medicine"));
assert!(!journal_in(&text, "The Lancet"));
assert!(journal_in(&words("Lancet 2010;376:23"), "The Lancet"));
}
#[test]
fn locators_from_common_styles() {
let loc = |v: &str, p: &str| Locator {
volume: Some(v.into()),
page: Some(p.into()),
};
assert_eq!(
locator("Daum RS et al. N Engl J Med 2017;376:2545"),
loc("376", "2545")
);
assert_eq!(
locator("N Engl J Med. 2014;370(15):1402-11."),
loc("370", "1402")
);
assert_eq!(locator("Lancet. 2010; 376 (9734): 23-32"), loc("376", "23"));
assert_eq!(
locator("Meyer, G. (2014). Fibrinolysis. NEJM, 370(15), 1402–1411."),
loc("370", "1402")
);
assert_eq!(
locator("Nature, vol. 521, pp. 436-444, 2015"),
loc("521", "436")
);
assert_eq!(locator("JAMA. 2014;311(11):e1117."), loc("311", "e1117"));
assert_eq!(locator("Nature 521:436–444 (2015)"), loc("521", "436"));
assert_eq!(
locator("Vaswani 2017. doi:10.48550/arXiv.1706.03762 at 10:30"),
Locator::default()
);
}
}