1use 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#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct Reference {
32 pub text: String,
34 pub span: Range<usize>,
37 pub alternatives: Vec<Alternative>,
40}
41
42pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
72#[non_exhaustive]
73pub struct LineReference {
74 pub text: String,
76 pub lines: Range<usize>,
78 pub alternatives: Vec<Alternative>,
79}
80
81pub fn split_lines(lines: &[IndentedLine]) -> Vec<LineReference> {
86 let text = lines
87 .iter()
88 .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 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
134fn 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
162fn 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 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 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 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 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 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 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 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 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 (None, None) if dated(self.line(line)) => groups.push(vec![line.clone()]),
340 (None, None) => {}
341 }
342 }
343 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 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 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 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 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
399fn push_line(out: &mut String, next: &str) -> bool {
403 let first = next.chars().next().unwrap_or(' ');
404 let apart = if out.is_empty() {
405 true
407 } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(out) {
408 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
421struct 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 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 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
460fn 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 Some((
483 Marker::Number(n),
484 spaced(rest, line.len() - rest.len(), open == 0)?,
485 ))
486}
487
488fn ends_reference(line: &str) -> bool {
491 ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
492}
493
494fn ends_sentence(line: &str) -> bool {
497 line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
498}
499
500fn 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 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
520fn continues(line: &str) -> bool {
523 line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
524}
525
526fn 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 if let Some(after) = rest.strip_prefix(',') {
542 return after.trim_start().starts_with(char::is_uppercase);
543 }
544 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
557fn 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
563pub(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 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 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 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
611fn 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
618fn 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
628fn is_number(token: &str) -> bool {
630 token.strip_suffix('.').is_some_and(is_digits)
631}
632
633fn 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 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 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 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 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 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 assert_eq!(
825 texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
826 2
827 );
828 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 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 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 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 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 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 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 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 #[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}