Skip to main content

aspero_core/
references.rs

1//! Splitting a pasted reference list into one citation each, offline.
2//!
3//! Lists arrive numbered (`1.`, `[1]`, `(1)`), bulleted, separated by blank
4//! lines, or one per line, with lines wrapped by PDFs and word processors.
5//! The heuristics, in order:
6//!
7//! 1. **Numbered or bulleted**: two or more lines start with a marker
8//!    (numbers in sequence, so `2015. Title` inside a reference doesn't
9//!    count). Each marker starts a reference; other lines continue it.
10//! 2. **Paragraphs**: blank lines separate references; within one, a line
11//!    starts another reference only if it looks like an author list after
12//!    a line that ended a sentence.
13//! 3. **Lines**: one reference per line when every line has a year or an
14//!    identifier, ends as a reference ends, and doesn't start in lower
15//!    case; otherwise wrapped, split as within a paragraph.
16//!
17//! Wrapped lines are joined with a space; a word hyphenated across lines
18//! is mended, and DOIs and URLs broken after `/` or `-` are rejoined.
19//! Headings such as "References" are dropped.
20
21use std::ops::Range;
22
23use crate::Alternative;
24use crate::fold::Folded;
25use crate::id::{is_digits, starts_with_ci};
26use crate::work::PLAUSIBLE_YEARS;
27
28/// One reference found in a list.
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct Reference {
32    /// On one line, without its number or bullet.
33    pub text: String,
34    /// Byte range in the list: from after the marker to the end of its
35    /// last line.
36    pub span: Range<usize>,
37    /// DOIs broken across lines at a hyphen, read without it: to try if
38    /// the DOI as printed doesn't exist.
39    pub alternatives: Vec<Alternative>,
40}
41
42/// The references in `text`, in order. Text from PDFs is cleaned first
43/// (ligatures, invisible characters, look-alike dashes and quotes); spans
44/// point into `text` as given.
45pub fn split_references(text: &str) -> Vec<Reference> {
46    let folded = Folded::new(text);
47    let list = List::new(&folded.text).split_inline();
48    let groups = list.groups();
49    references(text, &folded, &list, groups)
50}
51
52/// A line of a reference list as laid out, with its distance from the
53/// left edge (any unit, such as PDF points).
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub struct IndentedLine {
57    pub text: String,
58    pub indent: f64,
59}
60
61impl IndentedLine {
62    pub fn new(text: impl Into<String>, indent: f64) -> Self {
63        IndentedLine {
64            text: text.into(),
65            indent,
66        }
67    }
68}
69
70/// One reference found in laid-out lines.
71#[derive(Debug, Clone, PartialEq, Eq)]
72#[non_exhaustive]
73pub struct LineReference {
74    /// On one line, without its number or bullet.
75    pub text: String,
76    /// Indices of the lines it spans.
77    pub lines: Range<usize>,
78    pub alternatives: Vec<Alternative>,
79}
80
81/// The references in laid-out lines (as from a PDF), using the layout
82/// plain text loses: with a hanging indent, references start at the
83/// margin; with a first-line indent, indented. Numbered lists, and lines
84/// all at one indent, are split by the text rules of [`split_references`].
85pub fn split_lines(lines: &[IndentedLine]) -> Vec<LineReference> {
86    let text = lines
87        .iter()
88        // One line each, whatever breaks they hold.
89        .map(|l| l.text.replace(crate::fold::LINE_BREAKS, " "))
90        .collect::<Vec<_>>()
91        .join("\n");
92    let folded = Folded::new(&text);
93    let list = List::new(&folded.text);
94    let groups = match starts_by_indent(lines) {
95        Some(starts) if list.numbered().is_none() => {
96            let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
97            for (i, line) in list.lines.iter().enumerate() {
98                let Some(line) = line else { continue };
99                // Whatever the indent, a line in lower case or closing a
100                // bracket, or after a word broken at a hyphen, continues
101                // the reference ("sur-" then "gery: a systematic…").
102                let text = &folded.text[line.clone()];
103                match groups.last_mut() {
104                    Some(group)
105                        if !starts[i]
106                            || continues(text)
107                            || group
108                                .last()
109                                .is_some_and(|l| folded.text[l.clone()].ends_with('-')) =>
110                    {
111                        group.push(line.clone())
112                    }
113                    _ => groups.push(vec![line.clone()]),
114                }
115            }
116            groups
117        }
118        _ => list.groups(),
119    };
120    let line_starts: Vec<usize> = std::iter::once(0)
121        .chain(text.match_indices('\n').map(|(i, _)| i + 1))
122        .collect();
123    let line_of = |byte: usize| line_starts.partition_point(|&s| s <= byte) - 1;
124    references(&text, &folded, &list, groups)
125        .into_iter()
126        .map(|r| LineReference {
127            lines: line_of(r.span.start)..line_of(r.span.end.saturating_sub(1)) + 1,
128            text: r.text,
129            alternatives: r.alternatives,
130        })
131        .collect()
132}
133
134/// Which lines start a reference, by indent: `None` if the lines don't
135/// differ enough to tell. Indents within half the gap to the next level
136/// count as the same, so jitter in a PDF's positions doesn't matter.
137fn starts_by_indent(lines: &[IndentedLine]) -> Option<Vec<bool>> {
138    let content: Vec<&IndentedLine> = lines.iter().filter(|l| !l.text.trim().is_empty()).collect();
139    let margin = content
140        .iter()
141        .map(|l| l.indent)
142        .fold(f64::INFINITY, f64::min);
143    let next = content
144        .iter()
145        .map(|l| l.indent)
146        .filter(|&i| i > margin + 1.0)
147        .fold(f64::INFINITY, f64::min);
148    if !next.is_finite() {
149        return None;
150    }
151    let at_margin = |l: &IndentedLine| l.indent - margin < (next - margin) / 2.0;
152    let first = content.iter().find(|l| !is_heading(l.text.trim()))?;
153    let starting = at_margin(first);
154    Some(
155        lines
156            .iter()
157            .map(|l| !l.text.trim().is_empty() && at_margin(l) == starting)
158            .collect(),
159    )
160}
161
162/// Groups of lines (ranges in `folded`) as references in `text`.
163fn references(
164    text: &str,
165    folded: &Folded,
166    list: &List,
167    groups: Vec<Vec<Range<usize>>>,
168) -> Vec<Reference> {
169    groups
170        .into_iter()
171        .map(|g| {
172            let span = folded.original(&(g[0].start..g[g.len() - 1].end));
173            let written = &text[span.clone()];
174            let alternatives = crate::id::scan(written)
175                .iter()
176                .filter_map(|m| Some(Alternative::new(m.id.clone(), m.alternative(written)?)))
177                .collect();
178            Reference {
179                text: list.join(&g),
180                span,
181                alternatives,
182            }
183        })
184        .filter(|r| !is_heading(&r.text))
185        .collect()
186}
187
188struct List<'a> {
189    text: &'a str,
190    /// Every line trimmed, as a byte range; `None` for blank ones.
191    lines: Vec<Option<Range<usize>>>,
192}
193
194impl<'a> List<'a> {
195    fn new(text: &'a str) -> Self {
196        let mut lines = Vec::new();
197        let mut at = 0;
198        for raw in text.split_inclusive('\n') {
199            let trimmed = raw.trim();
200            lines.push((!trimmed.is_empty()).then(|| {
201                let start = at + (raw.len() - raw.trim_start().len());
202                start..start + trimmed.len()
203            }));
204            at += raw.len();
205        }
206        List { text, lines }
207    }
208
209    /// Lines holding references numbered inline, as a PDF's list copied
210    /// onto one line ("… 01159.x. [21] Finucane … [22] …"), split before
211    /// each number. Only `[n]` markers in sequence count: three or more,
212    /// or two with one starting the line, each starting the line or after
213    /// a full stop and before a capital, and every piece dated. Citations
214    /// in prose ("as shown. [3] The") rarely meet all of these.
215    fn split_inline(mut self) -> Self {
216        let lines = std::mem::take(&mut self.lines);
217        for line in lines {
218            match line.as_ref().and_then(|l| self.inline_starts(l)) {
219                Some(starts) => {
220                    let range = line.expect("has starts");
221                    let bounds: Vec<usize> = std::iter::once(range.start)
222                        .chain(starts)
223                        .chain(std::iter::once(range.end))
224                        .collect();
225                    for w in bounds.windows(2) {
226                        let piece = &self.text[w[0]..w[1]];
227                        let start = w[0] + (piece.len() - piece.trim_start().len());
228                        let end = w[0] + piece.trim_end().len();
229                        if start < end {
230                            self.lines.push(Some(start..end));
231                        }
232                    }
233                }
234                None => self.lines.push(line),
235            }
236        }
237        self
238    }
239
240    /// Where a line's inline-numbered references start, after its own
241    /// start: see [`List::split_inline`].
242    fn inline_starts(&self, line: &Range<usize>) -> Option<Vec<usize>> {
243        let text = self.line(line);
244        let mut markers: Vec<(usize, u32)> = Vec::new();
245        for (at, _) in text.match_indices('[') {
246            let Some((Marker::Number(n), used)) = marker(&text[at..]) else {
247                continue;
248            };
249            let follows_stop = text[..at].trim_end();
250            let next = text[at + used..].chars().next();
251            if (follows_stop.is_empty() || follows_stop.ends_with('.'))
252                && next.is_some_and(char::is_uppercase)
253            {
254                markers.push((at, n));
255            }
256        }
257        // The longest run numbered in sequence.
258        let mut runs: Vec<Vec<(usize, u32)>> = Vec::new();
259        for m in markers {
260            match runs
261                .iter_mut()
262                .find(|r| r.last().is_some_and(|l| l.1 + 1 == m.1))
263            {
264                Some(run) => run.push(m),
265                None => runs.push(vec![m]),
266            }
267        }
268        let run = runs.into_iter().max_by_key(Vec::len)?;
269        let at_start = run.first().is_some_and(|m| m.0 == 0);
270        if run.len() < 3 && !(run.len() == 2 && at_start) {
271            return None;
272        }
273        let starts: Vec<usize> = run
274            .iter()
275            .filter(|m| m.0 > 0)
276            .map(|m| line.start + m.0)
277            .collect();
278        let bounds: Vec<usize> = std::iter::once(line.start)
279            .chain(starts.iter().copied())
280            .chain(std::iter::once(line.end))
281            .collect();
282        // Every piece dated, or all but one of four or more: a reference
283        // the style or copy left without a date doesn't undo the list.
284        let pieces = bounds.len() - 1;
285        let undated = bounds
286            .windows(2)
287            .filter(|w| !dated(&self.text[w[0]..w[1]]))
288            .count();
289        (undated == 0 || (undated == 1 && pieces >= 4)).then_some(starts)
290    }
291
292    /// Groups by the text rules: numbers or bullets, else paragraphs, else
293    /// lines.
294    fn groups(&self) -> Vec<Vec<Range<usize>>> {
295        self.numbered().unwrap_or_else(|| {
296            let paragraphs: Vec<Vec<Range<usize>>> = self
297                .lines
298                .split(Option::is_none)
299                .filter(|p| !p.is_empty())
300                .map(|p| p.iter().flatten().cloned().collect())
301                .collect();
302            let alone = paragraphs.len() == 1;
303            paragraphs
304                .into_iter()
305                .flat_map(|p| self.unnumbered(p, alone))
306                .collect()
307        })
308    }
309
310    fn line(&self, r: &Range<usize>) -> &'a str {
311        &self.text[r.clone()]
312    }
313
314    /// Groups by number or bullet, if two or more lines start with one.
315    /// Lines before the first marker are dropped (a heading, usually).
316    fn numbered(&self) -> Option<Vec<Vec<Range<usize>>>> {
317        let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
318        let mut last: Option<Marker> = None;
319        for line in self.lines.iter().flatten() {
320            let starts = marker(self.line(line)).filter(|(m, _)| match (last, m) {
321                (None, _) => true,
322                (Some(Marker::Number(n)), Marker::Number(m)) => *m == n + 1,
323                (Some(Marker::Bullet), Marker::Bullet) => true,
324                _ => false,
325            });
326            match (starts, groups.last_mut()) {
327                (Some((m, used)), _) => {
328                    last = Some(m);
329                    // A marker alone on its line: the text follows.
330                    let first = line.start + used..line.end;
331                    groups.push(Vec::new());
332                    if !first.is_empty() {
333                        groups.last_mut().expect("just pushed").push(first);
334                    }
335                }
336                (None, Some(group)) => group.push(line.clone()),
337                // Before the first number: a heading, or the end of a
338                // reference the copy began in the middle of.
339                (None, None) if dated(self.line(line)) => groups.push(vec![line.clone()]),
340                (None, None) => {}
341            }
342        }
343        // One marker counts only on the first line ("1. Smith J. …"): later,
344        // it may be a wrapped line ("2. ed. Berlin").
345        let first_marked = self
346            .lines
347            .iter()
348            .flatten()
349            .next()
350            .is_some_and(|l| marker(self.line(l)).is_some());
351        groups.retain(|g| !g.is_empty());
352        (groups.len() >= 2 || groups.len() == 1 && first_marked).then_some(groups)
353    }
354
355    /// Groups lines without markers: each its own reference if `alone`
356    /// (the whole list) and they read as one per line; otherwise split
357    /// where an author list follows a finished sentence.
358    fn unnumbered(&self, lines: Vec<Range<usize>>, alone: bool) -> Vec<Vec<Range<usize>>> {
359        let one_per_line = alone
360            && lines.iter().all(|l| dated(self.line(l)))
361            && lines.windows(2).all(|w| {
362                let (line, next) = (self.line(&w[0]), self.line(&w[1]));
363                ends_reference(line) && !continues(next) && !link_continues(line, next)
364            });
365        if one_per_line {
366            return lines.into_iter().map(|l| vec![l]).collect();
367        }
368        // Each group joined as it grows, not again for every line.
369        let mut groups: Vec<(Vec<Range<usize>>, Joined)> = Vec::new();
370        for line in lines {
371            let text = self.line(&line);
372            match groups.last_mut() {
373                // A reference so far without a year or identifier (an author
374                // line ending "et al.") still needs its title and the rest.
375                Some((group, joined))
376                    if !(ends_sentence(&joined.text)
377                        && joined.dated
378                        && starts_with_author(text)) =>
379                {
380                    group.push(line);
381                    joined.push(text);
382                }
383                _ => groups.push((vec![line], Joined::new(text))),
384            }
385        }
386        groups.into_iter().map(|(group, _)| group).collect()
387    }
388
389    /// A group's lines as one: see [`push_line`].
390    fn join(&self, group: &[Range<usize>]) -> String {
391        let mut out = String::new();
392        for line in group {
393            push_line(&mut out, self.line(line));
394        }
395        out
396    }
397}
398
399/// Appends a line to lines joined so far: after a space, or with none
400/// where a word, DOI or URL was broken across lines. False if it runs on
401/// from the last character, so a number there may now be longer.
402fn push_line(out: &mut String, next: &str) -> bool {
403    let first = next.chars().next().unwrap_or(' ');
404    let apart = if out.is_empty() {
405        // Nothing to join to.
406        true
407    } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(out) {
408        // Letters either side, so no number runs on.
409        out.pop();
410        true
411    } else if !link_continues(out, next) {
412        out.push(' ');
413        true
414    } else {
415        false
416    };
417    out.push_str(next);
418    apart
419}
420
421/// Lines joined as [`List::join`] does, and whether they are [`dated`],
422/// kept up to date as each line is added.
423struct Joined {
424    text: String,
425    dated: bool,
426}
427
428impl Joined {
429    fn new(line: &str) -> Self {
430        Joined {
431            text: line.to_string(),
432            dated: dated(line),
433        }
434    }
435
436    /// Adds a line. Undated text before it stays undated, so only the new
437    /// line and the end before it are read: as much as the longest mark
438    /// (14 characters) could overlap, back to the start of a number.
439    fn push(&mut self, line: &str) {
440        let tail = self.text.char_indices().rev().nth(15).map_or(0, |(i, _)| i);
441        let tail = self.text[..tail]
442            .trim_end_matches(|c: char| c.is_ascii_digit())
443            .len();
444        let apart = push_line(&mut self.text, line);
445        if !self.dated {
446            self.dated = dated(&self.text[tail..]);
447        } else if !apart {
448            // A year running on ("2019" then "5") is a year no longer.
449            self.dated = dated(&self.text);
450        }
451    }
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455enum Marker {
456    Number(u32),
457    Bullet,
458}
459
460/// The marker a line starts with, and its length with the space after.
461fn marker(line: &str) -> Option<(Marker, usize)> {
462    let spaced = |rest: &str, used: usize, needs_space: bool| {
463        let gap = rest.len() - rest.trim_start().len();
464        (gap > 0 || !needs_space).then_some(used + gap)
465    };
466    if let Some(rest) = line.strip_prefix(['•', '–', '-', '*', '·']) {
467        return Some((Marker::Bullet, spaced(rest, line.len() - rest.len(), true)?));
468    }
469    let (open, close): (usize, &[char]) = match line.as_bytes().first()? {
470        b'[' => (1, &[']']),
471        b'(' => (1, &[')']),
472        _ => (0, &['.', ')']),
473    };
474    let inner = &line[open..];
475    let digits = inner.len() - inner.trim_start_matches(|c: char| c.is_ascii_digit()).len();
476    if !(1..=4).contains(&digits) {
477        return None;
478    }
479    let n: u32 = inner[..digits].parse().ok()?;
480    let rest = inner[digits..].strip_prefix(close)?;
481    // `[1]Smith` is unambiguous; `1.` needs a space, so `1.5` isn't one.
482    Some((
483        Marker::Number(n),
484        spaced(rest, line.len() - rest.len(), open == 0)?,
485    ))
486}
487
488/// Ends as a reference does: a full stop, page numbers, a closing bracket,
489/// or an identifier.
490fn ends_reference(line: &str) -> bool {
491    ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
492}
493
494/// Ends with a full stop (or `?`, `!`), or with a whole DOI or URL, as
495/// references often do without one.
496fn ends_sentence(line: &str) -> bool {
497    line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
498}
499
500/// Has a year (1600 to 2100) or an identifier, as a whole reference does.
501fn dated(line: &str) -> bool {
502    let year = line
503        .split(|c: char| !c.is_ascii_digit())
504        .any(|n| n.len() == 4 && n.parse().is_ok_and(|y| PLAUSIBLE_YEARS.contains(&y)));
505    // What styles write for a work without a year, or a web page's date.
506    let lower = line.to_lowercase();
507    let undated = [
508        "n.d.",
509        "n. d.",
510        "no date",
511        "accessed",
512        "[cited",
513        "available from",
514    ]
515    .iter()
516    .any(|m| lower.contains(m));
517    year || undated || line.contains("doi") || line.contains("10.") || line.contains("http")
518}
519
520/// Starts as a wrapped line's continuation does: in lower case, or with
521/// punctuation.
522fn continues(line: &str) -> bool {
523    line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
524}
525
526/// Starts with a surname and initials or a comma, as author lists do:
527/// "Smith J,", "Smith JA.", "Smith, J.", "SMITH, J.", "O'Neil, K.".
528fn starts_with_author(line: &str) -> bool {
529    let surname_len = line
530        .char_indices()
531        .take_while(|(_, c)| c.is_alphabetic() || "'’-".contains(*c))
532        .last()
533        .map_or(0, |(i, c)| i + c.len_utf8());
534    let surname = &line[..surname_len];
535    if surname.chars().count() < 2 || !surname.starts_with(char::is_uppercase) {
536        return false;
537    }
538    let rest = &line[surname_len..];
539    // "Smith, J." or "Smith, John": a capital after the comma, never a
540    // word in lower case ("National, regional, and global trends").
541    if let Some(after) = rest.strip_prefix(',') {
542        return after.trim_start().starts_with(char::is_uppercase);
543    }
544    // Initials: one to three capitals, maybe dotted, then a break.
545    let Some(rest) = rest.strip_prefix(' ') else {
546        return false;
547    };
548    let initials: String = rest
549        .chars()
550        .take_while(|c| c.is_uppercase() || *c == '.')
551        .collect();
552    let capitals = initials.chars().filter(|c| c.is_uppercase()).count();
553    let after = rest[initials.len()..].chars().next();
554    (1..=3).contains(&capitals) && after.is_none_or(|c| ", .;".contains(c))
555}
556
557/// A word broken with a hyphen at the end of `text`: letters before it.
558fn hyphenated_word(text: &str) -> bool {
559    let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
560    !in_link(text) && word.trim_end_matches('-').chars().all(char::is_alphabetic)
561}
562
563/// `next` continues a DOI or URL that `text` ends with, broken where PDFs
564/// break them: after `/`, `-` or `_` (`10.1038/s41586-` + `020-2649-2`),
565/// after a dot before a digit or lower case (`j.cell.` + `2020.01.001`),
566/// or mid-word before more of the link (`j.ce` + `ll.2020.01.001`, not
567/// `2015` or `2015.`).
568/// Hyphens are kept as printed: DOIs have real ones, and typesetters
569/// rarely add their own inside a link.
570pub(crate) fn link_continues(text: &str, next: &str) -> bool {
571    let token = next.split_whitespace().next().unwrap_or_default();
572    let Some(first) = token.chars().next() else {
573        return false;
574    };
575    if !in_link(text) || !first.is_alphanumeric() || starts_link(token) || is_number(token) {
576        return false;
577    }
578    // A line that is one word of a DOI suffix's characters, digits among
579    // them, is the rest of the link, however the line broke: PDFs wrap a
580    // suffix anywhere (`…/HJH.0b013e` then `3282f35c67`). Not a number,
581    // nor an identifier of its own.
582    let suffix = |c: char| c.is_ascii_alphanumeric() || "._;()/:-<>".contains(c);
583    if next.trim() == token
584        && !text.ends_with('.')
585        && token.chars().all(suffix)
586        && token.bytes().any(|b| b.is_ascii_digit())
587        && !plain_number(token.trim_end_matches(|c: char| !c.is_alphanumeric()))
588        && crate::id::scan(token).is_empty()
589    {
590        return true;
591    }
592    let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
593    // A DOI of its own, unless the line broke right before it
594    // (`https://doi.org/` then `10.…`).
595    let doi = token.starts_with("10.") && token.contains('/');
596    if text.ends_with(['/', '-', '_']) {
597        true
598    } else if text.ends_with('.') {
599        lower_or_digit && !doi
600    } else {
601        // Mid-word: only before more of a link, never a word, a
602        // hyphenated word or a number.
603        let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
604        lower_or_digit
605            && !doi
606            && inner.parse::<f64>().is_err()
607            && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
608    }
609}
610
611/// A number that reads as itself ("3.14", "2019", "797"), not a run of a
612/// DOI's digits ("0000659", "00489").
613fn plain_number(token: &str) -> bool {
614    (token.contains('.') && token.parse::<f64>().is_ok())
615        || (is_digits(token) && token.len() < 5 && !token.starts_with('0'))
616}
617
618/// `token` is a link of its own, not the rest of one.
619fn starts_link(token: &str) -> bool {
620    ["http://", "https://", "www."]
621        .iter()
622        .any(|p| starts_with_ci(token, p))
623        || token
624            .split_once(':')
625            .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
626}
627
628/// `token` numbers a list item (`18.`).
629fn is_number(token: &str) -> bool {
630    token.strip_suffix('.').is_some_and(is_digits)
631}
632
633/// `text` ends inside a DOI or URL.
634fn in_link(text: &str) -> bool {
635    let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
636    starts_with_ci(word, "http") || starts_with_ci(word, "doi:") || word.starts_with("10.")
637}
638
639fn is_heading(text: &str) -> bool {
640    let t = text.trim_end_matches(':').trim().to_lowercase();
641    matches!(
642        t.as_str(),
643        "references"
644            | "reference list"
645            | "bibliography"
646            | "works cited"
647            | "literature cited"
648            | "sources"
649            | "citations"
650    )
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    fn texts(list: &str) -> Vec<String> {
658        split_references(list).into_iter().map(|r| r.text).collect()
659    }
660
661    #[test]
662    fn numbered_lists_split_at_each_number_in_sequence() {
663        let list = "References\n\
664            1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
665            2015;521:436-44.\n\
666            2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
667        assert_eq!(
668            texts(list),
669            [
670                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
671                "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
672            ]
673        );
674    }
675
676    #[test]
677    fn bracketed_and_parenthesised_numbers() {
678        let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
679        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
680        let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
681        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
682    }
683
684    #[test]
685    fn numbers_out_of_sequence_continue_the_reference() {
686        let list = "1. Smith J. A study of things. Journal\n\
687            2015. 12(3):1-9.\n\
688            2. Jones K. Another. 2021.\n";
689        assert_eq!(
690            texts(list),
691            [
692                "Smith J. A study of things. Journal 2015. 12(3):1-9.",
693                "Jones K. Another. 2021.",
694            ]
695        );
696    }
697
698    #[test]
699    fn bullets_mark_references() {
700        let list = "• Smith J. One. 2020.\n• Jones K. Two,\n  continued. 2021.\n";
701        assert_eq!(
702            texts(list),
703            ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
704        );
705    }
706
707    #[test]
708    fn blank_lines_separate_wrapped_references() {
709        let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
710            Advances in Neural Information Processing Systems. 2017.\n\
711            \n\
712            LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
713            Nature. 2015;521:436-44.\n";
714        assert_eq!(
715            texts(list),
716            [
717                "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
718                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
719            ]
720        );
721    }
722
723    #[test]
724    fn one_per_line() {
725        let list = "Smith J. One. 2020.\n\
726            World Health Organization. Two. 2021.\n\
727            Jones K. Three. https://example.org/three\n";
728        assert_eq!(texts(list).len(), 3);
729    }
730
731    #[test]
732    fn wrapped_lines_without_blank_lines_split_at_authors() {
733        let list = "Smith, J. (2020). A study of\n\
734            things. Journal of Studies, 12, 1-9.\n\
735            Jones, K. (2021). Another study. Nature,\n\
736            521, 436.\n";
737        assert_eq!(
738            texts(list),
739            [
740                "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
741                "Jones, K. (2021). Another study. Nature, 521, 436.",
742            ]
743        );
744    }
745
746    #[test]
747    fn hyphenation_and_broken_links_are_mended() {
748        let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
749            2. Jones K. https://example.org/a-\nb/c\n";
750        assert_eq!(
751            texts(list),
752            [
753                "Smith J. Neurological findings. doi:10.1038/nature14539",
754                "Jones K. https://example.org/a-b/c",
755            ]
756        );
757    }
758
759    #[test]
760    fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
761        let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
762            2020.01.001\n\
763            [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
764            020-2649-2\n\
765            [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
766            ll.2021.02.003\n";
767        assert_eq!(
768            texts(list),
769            [
770                "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
771                "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
772                "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
773            ]
774        );
775        // Unnumbered, the continuation isn't a reference of its own.
776        let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
777            Jones K. Folds. Nature. 2020.\n";
778        assert_eq!(texts(list).len(), 2);
779        // A link that ends the line, then a new sentence, stays apart.
780        assert_eq!(
781            texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
782            "Smith J. doi:10.1038/x. Nature. 2020."
783        );
784    }
785
786    #[test]
787    fn a_single_numbered_reference_loses_its_number() {
788        assert_eq!(
789            texts("1. LeCun Y. Deep learning. Nature. 2015."),
790            ["LeCun Y. Deep learning. Nature. 2015."]
791        );
792        // A number further down is part of the reference.
793        assert_eq!(
794            texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
795            ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
796        );
797    }
798
799    #[test]
800    fn references_numbered_inline_split_at_each_number() {
801        // Dogfood: a PDF's list copied by Preview onto one line.
802        let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
803            [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
804            [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
805            [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
806        assert_eq!(
807            texts(list),
808            [
809                "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
810                "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
811                "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
812                "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
813            ]
814        );
815        // Undated, as styles write it ("n.d."), or one piece of four
816        // without a date.
817        let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
818            [21] Finucane T. Two. Lancet. 2012;379:10. \
819            [22] Defense Health Board. Three. Falls Church, VA, n.d. \
820            [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
821        assert_eq!(texts(undated).len(), 4);
822        assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
823        // Two, when the first starts the line.
824        assert_eq!(
825            texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
826            2
827        );
828        // In-text citations: out of sequence, not after a full stop, or
829        // pieces without a year.
830        for prose in [
831            "Shown before [3] and since. [4] The trial. [5] Then 2019.",
832            "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
833            "As shown. [1] The first. [2] The second. [3] The third 2019.",
834        ] {
835            assert_eq!(texts(prose).len(), 1, "{prose}");
836        }
837    }
838
839    #[test]
840    fn unnumbered_vancouver_lists_split_however_lines_break() {
841        // #40: copied from a journal PDF; Preview's copies break lines
842        // with `\r` or Unicode separators.
843        let list = "Benjamin EJ, Muntner P, Alonso A, et al. Heart disease\n\
844            and stroke statistics-2019 update. Circulation\n\
845            2019;139:e56-528. https://doi.org/10.1161/CIR.0000000000000659\n\
846            Schmieder RE, Kjeldsen SE, Julius S, et al. Reduced incidence\n\
847            of new-onset atrial fibrillation: the VALUE trial. J\n\
848            Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e3282f35c67\n\
849            Chokesuwattanaskul R, Thongprayoon C, Bathini T, Sharma K, Watthanasuntorn K, Lertjitbanjong\n\
850            P, et al. Incident atrial fibrillation in patients undergoing bariatric sur-\n\
851            gery: a systematic review and meta-analysis. Intern Med J 2020;50:810-7. https://doi.org/10.1111/imj.14436";
852        for sep in ["\n", "\r\n", "\r", "\u{2028}", "\u{2029}"] {
853            let found = texts(&list.replace('\n', sep));
854            assert_eq!(found.len(), 3, "{sep:?}: {found:?}");
855            assert!(
856                found[2].contains("bariatric surgery: a systematic"),
857                "{found:?}"
858            );
859        }
860    }
861
862    #[test]
863    fn a_doi_wrapped_onto_a_line_of_its_own_ends_its_reference() {
864        // #40, as copied: each DOI's end alone on the next line.
865        let list = "Benjamin EJ, Muntner P, et al. Heart disease\n\
866            and stroke statistics-2019 update. Circulation\n\
867            2019;139:e56-528. https://doi.org/10.1161/CIR.000000000\n\
868            0000659\n\
869            Schmieder RE, Kjeldsen SE, et al. Reduced incidence\n\
870            of new-onset atrial fibrillation: the VALUE trial. J\n\
871            Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e\n\
872            3282f35c67";
873        let found = texts(list);
874        assert_eq!(found.len(), 2, "{found:?}");
875        assert!(
876            found[0].ends_with("https://doi.org/10.1161/CIR.0000000000000659"),
877            "{found:?}"
878        );
879        assert!(
880            found[1].ends_with("https://doi.org/10.1097/HJH.0b013e3282f35c67"),
881            "{found:?}"
882        );
883        let dois: Vec<String> = crate::id::scan(list)
884            .iter()
885            .map(|m| m.id.to_string())
886            .collect();
887        assert_eq!(
888            dois,
889            [
890                "doi:10.1161/cir.0000000000000659",
891                "doi:10.1097/hjh.0b013e3282f35c67"
892            ]
893        );
894    }
895
896    #[test]
897    fn indents_never_start_a_reference_mid_word_or_in_lower_case() {
898        // Hanging indents, with lines a PDF's layout put at the margin
899        // although they continue the reference.
900        let lines = [
901            IndentedLine::new(
902                "Chokesuwattanaskul R, et al. Incident atrial fibrillation in bariatric sur-",
903                0.0,
904            ),
905            IndentedLine::new(
906                "gery: a systematic review. Intern Med J 2020;50:810-7.",
907                0.0,
908            ),
909            IndentedLine::new("Sardana M, et al. Obstructive sleep apnea (the SAVE", 0.0),
910            IndentedLine::new("trial). Circ Arrhythm Electrophysiol 2017;10:e005463.", 0.0),
911            IndentedLine::new("Smith J, et al. Another reference about", 0.0),
912            IndentedLine::new("the same thing. Lancet 2019;393:10-12.", 12.0),
913        ];
914        let found: Vec<String> = split_lines(&lines).into_iter().map(|r| r.text).collect();
915        assert_eq!(found.len(), 3, "{found:?}");
916        assert!(
917            found[0].contains("bariatric surgery: a systematic"),
918            "{found:?}"
919        );
920        assert!(found[1].contains("(the SAVE trial)."), "{found:?}");
921    }
922
923    #[test]
924    fn an_author_line_is_not_a_reference_of_its_own() {
925        // #44: the authors on a line of their own, ending "et al.", then a
926        // title starting "National, regional, …".
927        let reference = "Finucane MM, Stevens GA, Cowan MJ, Danaei G, Lin JK, Paciorek CJ, et al.\n\
928            National, regional, and global trends in body-mass index since 1980: system-\n\
929            atic analysis of health examination surveys and epidemiological studies with\n\
930            960 country-years and 9 1 million participants. Lancet 2011;377:557\u{2013}67.\n\
931            http://dx.doi.org/10.1016/S0140-6736(10)62037-5.";
932        for text in [reference.to_owned(), reference.replacen('F', "", 1)] {
933            let found = texts(&text);
934            assert_eq!(found.len(), 1, "{found:?}");
935            assert!(found[0].contains("et al. National, regional"), "{found:?}");
936        }
937        // A list of such references still splits.
938        let two =
939            format!("{reference}\nSmith J, Jones K, et al.\nA second title. BMJ 2019;365:l123.");
940        assert_eq!(texts(&two).len(), 2, "{:?}", texts(&two));
941    }
942
943    #[test]
944    fn markers_alone_on_their_line_number_the_next() {
945        assert_eq!(
946            texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
947            ["Smith J. One. 2020.", "Jones K. Two. 2021."]
948        );
949    }
950
951    #[test]
952    fn pdf_text_is_cleaned_and_spans_point_into_it() {
953        let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
954            2. Jones K. Two. 2021.";
955        let refs = split_references(list);
956        assert_eq!(
957            refs[0].text,
958            "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
959        );
960        assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
961        assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
962    }
963
964    #[test]
965    fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
966        let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
967        assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
968        let alt = &refs[0].alternatives[0];
969        assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
970        assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
971        assert!(refs[1].alternatives.is_empty());
972    }
973
974    #[test]
975    fn hanging_indents_mark_starts_that_text_cannot() {
976        // Each line ends a sentence and the next starts with a capital,
977        // so the text alone reads three references.
978        let lines = [
979            IndentedLine::new("References", 72.4),
980            IndentedLine::new("World Health Organization. Global report.", 72.0),
981            IndentedLine::new("Geneva: WHO; 2020.", 90.2),
982            IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
983        ];
984        let refs = split_lines(&lines);
985        assert_eq!(
986            refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
987            [
988                "World Health Organization. Global report. Geneva: WHO; 2020.",
989                "Smith J. A study. Nature. 2021.",
990            ]
991        );
992        assert_eq!(refs[0].lines, 1..3);
993        assert_eq!(refs[1].lines, 3..4);
994    }
995
996    #[test]
997    fn first_line_indents_and_flat_lists() {
998        let lines = [
999            IndentedLine::new("Smith J. A study of", 90.0),
1000            IndentedLine::new("Things. Nature. 2021.", 72.0),
1001            IndentedLine::new("Jones K. Another. 2022.", 90.0),
1002        ];
1003        assert_eq!(split_lines(&lines).len(), 2);
1004        // One indent throughout: the text rules decide.
1005        let flat = [
1006            IndentedLine::new("1. Smith J. One.", 72.0),
1007            IndentedLine::new("2020.", 72.0),
1008            IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
1009        ];
1010        let refs = split_lines(&flat);
1011        assert_eq!(refs[0].text, "Smith J. One. 2020.");
1012        assert_eq!(refs[1].lines, 2..3);
1013    }
1014
1015    #[test]
1016    fn spans_cover_the_reference_without_its_marker() {
1017        let list = "1. Smith J. One.\r\n   2020.\r\n2. Jones K. Two. 2021.";
1018        let refs = split_references(list);
1019        assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n   2020.");
1020        assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
1021    }
1022
1023    #[test]
1024    fn a_single_reference_is_one() {
1025        assert_eq!(
1026            texts("Smith J. A study.\nJournal 2020;1:2."),
1027            ["Smith J. A study. Journal 2020;1:2."]
1028        );
1029        assert!(texts("  \n\n").is_empty());
1030    }
1031
1032    #[test]
1033    fn a_year_running_on_in_a_link_is_undated() {
1034        let mut joined = Joined::new("DOI:10/2019");
1035        assert!(joined.dated);
1036        joined.push("05");
1037        assert_eq!(joined.text, "DOI:10/201905");
1038        assert!(!joined.dated);
1039    }
1040
1041    proptest::proptest! {
1042        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(4000))]
1043        /// Joining line by line agrees with joining the lines afresh.
1044        #[test]
1045        fn joined_as_it_grows_matches_join(
1046            lines in proptest::collection::vec(proptest::collection::vec(
1047                proptest::sample::select(vec![
1048                    "a", "Smith", " ", "-", "/", ".", "é", "\u{3a3}", "\u{212a}", "\u{130}",
1049                    "2019", "0", "5", "n.", "d.", "no", "date", "available", "from", "[cited",
1050                    "DOI:", "HTTP", "10.", "10/",
1051                ]),
1052                1..5,
1053            ), 1..12),
1054        ) {
1055            let text = lines.iter().map(|l| l.concat()).collect::<Vec<_>>().join("\n");
1056            let list = List::new(&text);
1057            let ranges: Vec<Range<usize>> = list.lines.iter().flatten().cloned().collect();
1058            let Some((first, rest)) = ranges.split_first() else { return Ok(()) };
1059            let mut joined = Joined::new(list.line(first));
1060            for (i, line) in rest.iter().enumerate() {
1061                joined.push(list.line(line));
1062                let fresh = list.join(&ranges[..i + 2]);
1063                proptest::prop_assert_eq!(&joined.text, &fresh);
1064                proptest::prop_assert_eq!(joined.dated, dated(&fresh));
1065            }
1066        }
1067    }
1068}