use std::ops::Range;
use super::syntax::clean_pasted;
use super::url::Url;
use super::{IdKind, WorkId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mention {
pub id: WorkId,
pub line: usize,
pub column: usize,
pub span: Range<usize>,
}
pub fn scan(text: &str) -> Vec<Mention> {
let mut found = Vec::new();
let mut line_start = 0;
for (n, line) in text.split('\n').enumerate() {
for (id, span) in scan_line(line) {
found.push(Mention {
id,
line: n + 1,
column: line[..span.start].chars().count() + 1,
span: line_start + span.start..line_start + span.end,
});
}
line_start += line.len() + 1;
}
found
}
fn scan_line(line: &str) -> Vec<(WorkId, Range<usize>)> {
let mut found = Vec::new();
let mut label: Option<(IdKind, usize)> = None;
for piece in pieces(line) {
let cleaned = clean_pasted(&line[piece.clone()]);
if cleaned.is_empty() {
continue;
}
let start = offset(line, cleaned);
let span = start..start + cleaned.len();
if let Some(kind) = label_kind(cleaned) {
label = Some((kind, start));
continue;
}
let labelled = label
.take()
.and_then(|(kind, at)| Some((WorkId::parse_as(kind, cleaned).ok()?, at)));
if let Some((id, at)) = labelled {
found.push((id, at..span.end));
} else if let Some(id) = unlabelled(cleaned) {
found.push((id, span));
}
}
found
}
fn pieces(line: &str) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
let mut chars = line.char_indices().peekable();
while let Some((i, c)) = chars.next() {
let link = c == ']' && chars.peek().is_some_and(|&(_, next)| next == '(');
if c.is_whitespace() || c == '|' || link {
let end = if link { i + 1 } else { i };
out.extend(start.take().map(|s| s..end));
} else {
start.get_or_insert(i);
}
}
out.extend(start.map(|s| s..line.len()));
out
}
fn label_kind(piece: &str) -> Option<IdKind> {
let word = piece.trim_end_matches(':').to_ascii_lowercase();
match word.as_str() {
"pmid" => Some(IdKind::Pmid),
"pmcid" => Some(IdKind::Pmcid),
"doi" => Some(IdKind::Doi),
"arxiv" => Some(IdKind::Arxiv),
"nbk" => Some(IdKind::Nbk),
"isbn" | "isbn-13" | "isbn-10" | "isbn13" | "isbn10" => Some(IdKind::Isbn),
_ => None,
}
}
fn unlabelled(piece: &str) -> Option<WorkId> {
let explicit = Url::parse(piece).is_some()
|| piece
.split_once(':')
.is_some_and(|(head, _)| IdKind::from_prefix(head).is_some());
match piece.parse::<WorkId>() {
Ok(id) if explicit || matches!(id.kind(), IdKind::Doi | IdKind::Pmcid | IdKind::Nbk) => {
Some(id)
}
Ok(_) => None,
Err(_) => {
let at = piece.find("10.")?;
let before = piece[..at].chars().next_back();
if before.is_some_and(char::is_alphanumeric) {
return None;
}
WorkId::parse_as(IdKind::Doi, &piece[at..]).ok()
}
}
}
fn offset(whole: &str, part: &str) -> usize {
part.as_ptr() as usize - whole.as_ptr() as usize
}
#[cfg(test)]
mod tests {
use super::*;
fn found(text: &str) -> Vec<(String, &str)> {
scan(text)
.into_iter()
.map(|m| (m.id.to_string(), &text[m.span]))
.collect()
}
#[test]
fn prose_punctuation_and_wrappers_are_not_part_of_a_mention() {
assert_eq!(
found("as shown (doi:10.1056/NEJMoa1302097). Then *10.1001/jama.2014.2135*, and"),
[
(
"doi:10.1056/nejmoa1302097".into(),
"doi:10.1056/NEJMoa1302097"
),
(
"doi:10.1001/jama.2014.2135".into(),
"10.1001/jama.2014.2135"
),
]
);
}
#[test]
fn brackets_inside_a_doi_stay() {
assert_eq!(
found("Lancet (10.1016/S0140-6736(10)60835-5)."),
[(
"doi:10.1016/s0140-6736(10)60835-5".into(),
"10.1016/S0140-6736(10)60835-5"
)]
);
}
#[test]
fn labels_join_their_values() {
assert_eq!(
found("PMID: 30252312; ISBN 978-0-262-03561-3, doi: 10.1/abcd"),
[
("pmid:30252312".into(), "PMID: 30252312"),
("isbn:9780262035613".into(), "ISBN 978-0-262-03561-3"),
]
);
assert_eq!(
found("DOI: 10.1056/NEJMoa1302097"),
[(
"doi:10.1056/nejmoa1302097".into(),
"DOI: 10.1056/NEJMoa1302097"
)]
);
}
#[test]
fn markdown_links_and_tables() {
assert_eq!(
found("[PEITHO](https://doi.org/10.1056/NEJMoa1302097) |PMC6716493| [[NBK526056]]"),
[
(
"doi:10.1056/nejmoa1302097".into(),
"https://doi.org/10.1056/NEJMoa1302097"
),
("pmcid:PMC6716493".into(), "PMC6716493"),
("nbk:NBK526056".into(), "NBK526056"),
]
);
}
#[test]
fn ambiguous_shapes_need_a_prefix_or_url() {
assert!(found("In 1706.03762 cases, W1 and 31452104 patients; p 2017.1234").is_empty());
assert_eq!(
found("arXiv:1706.03762v5 and https://pubmed.ncbi.nlm.nih.gov/31452104/"),
[
("arxiv:1706.03762v5".into(), "arXiv:1706.03762v5"),
(
"pmid:31452104".into(),
"https://pubmed.ncbi.nlm.nih.gov/31452104/"
),
]
);
}
#[test]
fn lines_columns_and_spans_locate_each_mention() {
let text = "Title\r\n café 10.1234/abcd\nsee PMC1";
let m = scan(text);
assert_eq!((m[0].line, m[0].column), (2, 8), "columns count characters");
assert_eq!(&text[m[0].span.clone()], "10.1234/abcd");
assert_eq!((m[1].line, m[1].column), (3, 5));
assert_eq!(&text[m[1].span.clone()], "PMC1");
}
}