1use std::ops::Range;
22
23use crate::Alternative;
24use crate::fold::Folded;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct Reference {
30 pub text: String,
32 pub span: Range<usize>,
35 pub alternatives: Vec<Alternative>,
38}
39
40pub fn split_references(text: &str) -> Vec<Reference> {
44 let folded = Folded::new(text);
45 let list = List::new(&folded.text).split_inline();
46 let groups = list.groups();
47 references(text, &folded, &list, groups)
48}
49
50#[derive(Debug, Clone, PartialEq)]
53#[non_exhaustive]
54pub struct IndentedLine {
55 pub text: String,
56 pub indent: f64,
57}
58
59impl IndentedLine {
60 pub fn new(text: impl Into<String>, indent: f64) -> Self {
61 IndentedLine {
62 text: text.into(),
63 indent,
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct LineReference {
72 pub text: String,
74 pub lines: Range<usize>,
76 pub alternatives: Vec<Alternative>,
77}
78
79pub fn split_lines(lines: &[IndentedLine]) -> Vec<LineReference> {
84 let text = lines
85 .iter()
86 .map(|l| {
89 l.text
90 .replace(['\r', '\n', '\u{85}', '\u{2028}', '\u{2029}'], " ")
91 })
92 .collect::<Vec<_>>()
93 .join("\n");
94 let folded = Folded::new(&text);
95 let list = List::new(&folded.text);
96 let groups = match starts_by_indent(lines) {
97 Some(starts) if list.numbered().is_none() => {
98 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
99 for (i, line) in list.lines.iter().enumerate() {
100 let Some(line) = line else { continue };
101 let text = &folded.text[line.clone()];
105 match groups.last_mut() {
106 Some(group)
107 if !starts[i]
108 || continues(text)
109 || group
110 .last()
111 .is_some_and(|l| folded.text[l.clone()].ends_with('-')) =>
112 {
113 group.push(line.clone())
114 }
115 _ => groups.push(vec![line.clone()]),
116 }
117 }
118 groups
119 }
120 _ => list.groups(),
121 };
122 let line_starts: Vec<usize> = std::iter::once(0)
123 .chain(text.match_indices('\n').map(|(i, _)| i + 1))
124 .collect();
125 let line_of = |byte: usize| line_starts.partition_point(|&s| s <= byte) - 1;
126 references(&text, &folded, &list, groups)
127 .into_iter()
128 .map(|r| LineReference {
129 lines: line_of(r.span.start)..line_of(r.span.end.saturating_sub(1)) + 1,
130 text: r.text,
131 alternatives: r.alternatives,
132 })
133 .collect()
134}
135
136fn starts_by_indent(lines: &[IndentedLine]) -> Option<Vec<bool>> {
140 let content: Vec<&IndentedLine> = lines.iter().filter(|l| !l.text.trim().is_empty()).collect();
141 let margin = content
142 .iter()
143 .map(|l| l.indent)
144 .fold(f64::INFINITY, f64::min);
145 let next = content
146 .iter()
147 .map(|l| l.indent)
148 .filter(|&i| i > margin + 1.0)
149 .fold(f64::INFINITY, f64::min);
150 if !next.is_finite() {
151 return None;
152 }
153 let at_margin = |l: &IndentedLine| l.indent - margin < (next - margin) / 2.0;
154 let first = content.iter().find(|l| !is_heading(l.text.trim()))?;
155 let starting = at_margin(first);
156 Some(
157 lines
158 .iter()
159 .map(|l| !l.text.trim().is_empty() && at_margin(l) == starting)
160 .collect(),
161 )
162}
163
164fn references(
166 text: &str,
167 folded: &Folded,
168 list: &List,
169 groups: Vec<Vec<Range<usize>>>,
170) -> Vec<Reference> {
171 groups
172 .into_iter()
173 .map(|g| {
174 let span = folded.original(&(g[0].start..g[g.len() - 1].end));
175 let written = &text[span.clone()];
176 let alternatives = crate::id::scan(written)
177 .iter()
178 .filter_map(|m| Some(Alternative::new(m.id.clone(), m.alternative(written)?)))
179 .collect();
180 Reference {
181 text: list.join(&g),
182 span,
183 alternatives,
184 }
185 })
186 .filter(|r| !is_heading(&r.text))
187 .collect()
188}
189
190struct List<'a> {
191 text: &'a str,
192 lines: Vec<Option<Range<usize>>>,
194}
195
196impl<'a> List<'a> {
197 fn new(text: &'a str) -> Self {
198 let mut lines = Vec::new();
199 let mut at = 0;
200 for raw in text.split_inclusive('\n') {
201 let trimmed = raw.trim();
202 lines.push((!trimmed.is_empty()).then(|| {
203 let start = at + (raw.len() - raw.trim_start().len());
204 start..start + trimmed.len()
205 }));
206 at += raw.len();
207 }
208 List { text, lines }
209 }
210
211 fn split_inline(mut self) -> Self {
218 let lines = std::mem::take(&mut self.lines);
219 for line in lines {
220 match line.as_ref().and_then(|l| self.inline_starts(l)) {
221 Some(starts) => {
222 let range = line.expect("has starts");
223 let bounds: Vec<usize> = std::iter::once(range.start)
224 .chain(starts)
225 .chain(std::iter::once(range.end))
226 .collect();
227 for w in bounds.windows(2) {
228 let piece = &self.text[w[0]..w[1]];
229 let start = w[0] + (piece.len() - piece.trim_start().len());
230 let end = w[0] + piece.trim_end().len();
231 if start < end {
232 self.lines.push(Some(start..end));
233 }
234 }
235 }
236 None => self.lines.push(line),
237 }
238 }
239 self
240 }
241
242 fn inline_starts(&self, line: &Range<usize>) -> Option<Vec<usize>> {
245 let text = self.line(line);
246 let mut markers: Vec<(usize, u32)> = Vec::new();
247 for (at, _) in text.match_indices('[') {
248 let Some((Marker::Number(n), used)) = marker(&text[at..]) else {
249 continue;
250 };
251 let follows_stop = text[..at].trim_end();
252 let next = text[at + used..].chars().next();
253 if (follows_stop.is_empty() || follows_stop.ends_with('.'))
254 && next.is_some_and(char::is_uppercase)
255 {
256 markers.push((at, n));
257 }
258 }
259 let mut runs: Vec<Vec<(usize, u32)>> = Vec::new();
261 for m in markers {
262 match runs
263 .iter_mut()
264 .find(|r| r.last().is_some_and(|l| l.1 + 1 == m.1))
265 {
266 Some(run) => run.push(m),
267 None => runs.push(vec![m]),
268 }
269 }
270 let run = runs.into_iter().max_by_key(Vec::len)?;
271 let at_start = run.first().is_some_and(|m| m.0 == 0);
272 if run.len() < 3 && !(run.len() == 2 && at_start) {
273 return None;
274 }
275 let starts: Vec<usize> = run
276 .iter()
277 .filter(|m| m.0 > 0)
278 .map(|m| line.start + m.0)
279 .collect();
280 let bounds: Vec<usize> = std::iter::once(line.start)
281 .chain(starts.iter().copied())
282 .chain(std::iter::once(line.end))
283 .collect();
284 let pieces = bounds.len() - 1;
287 let undated = bounds
288 .windows(2)
289 .filter(|w| !dated(&self.text[w[0]..w[1]]))
290 .count();
291 (undated == 0 || (undated == 1 && pieces >= 4)).then_some(starts)
292 }
293
294 fn groups(&self) -> Vec<Vec<Range<usize>>> {
297 self.numbered().unwrap_or_else(|| {
298 let paragraphs: Vec<Vec<Range<usize>>> = self
299 .lines
300 .split(Option::is_none)
301 .filter(|p| !p.is_empty())
302 .map(|p| p.iter().flatten().cloned().collect())
303 .collect();
304 let alone = paragraphs.len() == 1;
305 paragraphs
306 .into_iter()
307 .flat_map(|p| self.unnumbered(p, alone))
308 .collect()
309 })
310 }
311
312 fn line(&self, r: &Range<usize>) -> &'a str {
313 &self.text[r.clone()]
314 }
315
316 fn numbered(&self) -> Option<Vec<Vec<Range<usize>>>> {
319 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
320 let mut last: Option<Marker> = None;
321 for line in self.lines.iter().flatten() {
322 let starts = marker(self.line(line)).filter(|(m, _)| match (last, m) {
323 (None, _) => true,
324 (Some(Marker::Number(n)), Marker::Number(m)) => *m == n + 1,
325 (Some(Marker::Bullet), Marker::Bullet) => true,
326 _ => false,
327 });
328 match (starts, groups.last_mut()) {
329 (Some((m, used)), _) => {
330 last = Some(m);
331 let first = line.start + used..line.end;
333 groups.push(Vec::new());
334 if !first.is_empty() {
335 groups.last_mut().expect("just pushed").push(first);
336 }
337 }
338 (None, Some(group)) => group.push(line.clone()),
339 (None, None) if dated(self.line(line)) => groups.push(vec![line.clone()]),
342 (None, None) => {}
343 }
344 }
345 let first_marked = self
348 .lines
349 .iter()
350 .flatten()
351 .next()
352 .is_some_and(|l| marker(self.line(l)).is_some());
353 groups.retain(|g| !g.is_empty());
354 (groups.len() >= 2 || groups.len() == 1 && first_marked).then_some(groups)
355 }
356
357 fn unnumbered(&self, lines: Vec<Range<usize>>, alone: bool) -> Vec<Vec<Range<usize>>> {
361 let one_per_line = alone
362 && lines.iter().all(|l| dated(self.line(l)))
363 && lines.windows(2).all(|w| {
364 let (line, next) = (self.line(&w[0]), self.line(&w[1]));
365 ends_reference(line) && !continues(next) && !link_continues(line, next)
366 });
367 if one_per_line {
368 return lines.into_iter().map(|l| vec![l]).collect();
369 }
370 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
371 for line in lines {
372 let text = self.line(&line);
373 match groups.last_mut() {
374 Some(group)
377 if !(ends_sentence(&self.join(group))
378 && dated(&self.join(group))
379 && starts_with_author(text)) =>
380 {
381 group.push(line)
382 }
383 _ => groups.push(vec![line]),
384 }
385 }
386 groups
387 }
388
389 fn join(&self, group: &[Range<usize>]) -> String {
392 let mut out = String::new();
393 for line in group {
394 let next = self.line(line);
395 let first = next.chars().next().unwrap_or(' ');
396 if out.is_empty() {
397 } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(&out) {
399 out.pop();
400 } else if !link_continues(&out, next) {
401 out.push(' ');
402 }
403 out.push_str(next);
404 }
405 out
406 }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum Marker {
411 Number(u32),
412 Bullet,
413}
414
415fn marker(line: &str) -> Option<(Marker, usize)> {
417 let spaced = |rest: &str, used: usize, needs_space: bool| {
418 let gap = rest.len() - rest.trim_start().len();
419 (gap > 0 || !needs_space).then_some(used + gap)
420 };
421 if let Some(rest) = line.strip_prefix(['•', '–', '-', '*', '·']) {
422 return Some((Marker::Bullet, spaced(rest, line.len() - rest.len(), true)?));
423 }
424 let (open, close): (usize, &[char]) = match line.as_bytes().first()? {
425 b'[' => (1, &[']']),
426 b'(' => (1, &[')']),
427 _ => (0, &['.', ')']),
428 };
429 let inner = &line[open..];
430 let digits = inner.len() - inner.trim_start_matches(|c: char| c.is_ascii_digit()).len();
431 if !(1..=4).contains(&digits) {
432 return None;
433 }
434 let n: u32 = inner[..digits].parse().ok()?;
435 let rest = inner[digits..].strip_prefix(close)?;
436 Some((
438 Marker::Number(n),
439 spaced(rest, line.len() - rest.len(), open == 0)?,
440 ))
441}
442
443fn ends_reference(line: &str) -> bool {
446 ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
447}
448
449fn ends_sentence(line: &str) -> bool {
452 line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
453}
454
455fn dated(line: &str) -> bool {
457 let year = line
458 .split(|c: char| !c.is_ascii_digit())
459 .any(|n| n.len() == 4 && ("1600".."2100").contains(&n));
460 let lower = line.to_lowercase();
462 let undated = [
463 "n.d.",
464 "n. d.",
465 "no date",
466 "accessed",
467 "[cited",
468 "available from",
469 ]
470 .iter()
471 .any(|m| lower.contains(m));
472 year || undated || line.contains("doi") || line.contains("10.") || line.contains("http")
473}
474
475fn continues(line: &str) -> bool {
478 line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
479}
480
481fn starts_with_author(line: &str) -> bool {
484 let surname_len = line
485 .char_indices()
486 .take_while(|(_, c)| c.is_alphabetic() || "'’-".contains(*c))
487 .last()
488 .map_or(0, |(i, c)| i + c.len_utf8());
489 let surname = &line[..surname_len];
490 if surname.chars().count() < 2 || !surname.starts_with(char::is_uppercase) {
491 return false;
492 }
493 let rest = &line[surname_len..];
494 if let Some(after) = rest.strip_prefix(',') {
497 return after.trim_start().starts_with(char::is_uppercase);
498 }
499 let Some(rest) = rest.strip_prefix(' ') else {
501 return false;
502 };
503 let initials: String = rest
504 .chars()
505 .take_while(|c| c.is_uppercase() || *c == '.')
506 .collect();
507 let capitals = initials.chars().filter(|c| c.is_uppercase()).count();
508 let after = rest[initials.len()..].chars().next();
509 (1..=3).contains(&capitals) && after.is_none_or(|c| ", .;".contains(c))
510}
511
512fn hyphenated_word(text: &str) -> bool {
514 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
515 !in_link(text) && word.trim_end_matches('-').chars().all(char::is_alphabetic)
516}
517
518pub(crate) fn link_continues(text: &str, next: &str) -> bool {
526 let token = next.split_whitespace().next().unwrap_or_default();
527 let Some(first) = token.chars().next() else {
528 return false;
529 };
530 if !in_link(text) || !first.is_alphanumeric() || starts_link(token) || is_number(token) {
531 return false;
532 }
533 let suffix = |c: char| c.is_ascii_alphanumeric() || "._;()/:-<>".contains(c);
538 if next.trim() == token
539 && !text.ends_with('.')
540 && token.chars().all(suffix)
541 && token.bytes().any(|b| b.is_ascii_digit())
542 && !plain_number(token.trim_end_matches(|c: char| !c.is_alphanumeric()))
543 && crate::id::scan(token).is_empty()
544 {
545 return true;
546 }
547 let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
548 let doi = token.starts_with("10.") && token.contains('/');
551 if text.ends_with(['/', '-', '_']) {
552 true
553 } else if text.ends_with('.') {
554 lower_or_digit && !doi
555 } else {
556 let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
559 lower_or_digit
560 && !doi
561 && inner.parse::<f64>().is_err()
562 && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
563 }
564}
565
566fn plain_number(token: &str) -> bool {
569 let digits = token.bytes().all(|b| b.is_ascii_digit());
570 (token.contains('.') && token.parse::<f64>().is_ok())
571 || (digits && token.len() < 5 && !token.starts_with('0'))
572}
573
574fn starts_link(token: &str) -> bool {
576 let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
577 ["http://", "https://", "www."]
578 .iter()
579 .any(|p| lower.starts_with(p))
580 || token
581 .split_once(':')
582 .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
583}
584
585fn is_number(token: &str) -> bool {
587 token
588 .strip_suffix('.')
589 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
590}
591
592fn in_link(text: &str) -> bool {
594 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
595 let starts = |p: &str| {
596 word.get(..p.len())
597 .is_some_and(|h| h.eq_ignore_ascii_case(p))
598 };
599 starts("http") || starts("doi:") || word.starts_with("10.")
600}
601
602fn is_heading(text: &str) -> bool {
603 let t = text.trim_end_matches(':').trim().to_lowercase();
604 matches!(
605 t.as_str(),
606 "references"
607 | "reference list"
608 | "bibliography"
609 | "works cited"
610 | "literature cited"
611 | "sources"
612 | "citations"
613 )
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 fn texts(list: &str) -> Vec<String> {
621 split_references(list).into_iter().map(|r| r.text).collect()
622 }
623
624 #[test]
625 fn numbered_lists_split_at_each_number_in_sequence() {
626 let list = "References\n\
627 1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
628 2015;521:436-44.\n\
629 2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
630 assert_eq!(
631 texts(list),
632 [
633 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
634 "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
635 ]
636 );
637 }
638
639 #[test]
640 fn bracketed_and_parenthesised_numbers() {
641 let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
642 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
643 let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
644 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
645 }
646
647 #[test]
648 fn numbers_out_of_sequence_continue_the_reference() {
649 let list = "1. Smith J. A study of things. Journal\n\
650 2015. 12(3):1-9.\n\
651 2. Jones K. Another. 2021.\n";
652 assert_eq!(
653 texts(list),
654 [
655 "Smith J. A study of things. Journal 2015. 12(3):1-9.",
656 "Jones K. Another. 2021.",
657 ]
658 );
659 }
660
661 #[test]
662 fn bullets_mark_references() {
663 let list = "• Smith J. One. 2020.\n• Jones K. Two,\n continued. 2021.\n";
664 assert_eq!(
665 texts(list),
666 ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
667 );
668 }
669
670 #[test]
671 fn blank_lines_separate_wrapped_references() {
672 let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
673 Advances in Neural Information Processing Systems. 2017.\n\
674 \n\
675 LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
676 Nature. 2015;521:436-44.\n";
677 assert_eq!(
678 texts(list),
679 [
680 "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
681 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
682 ]
683 );
684 }
685
686 #[test]
687 fn one_per_line() {
688 let list = "Smith J. One. 2020.\n\
689 World Health Organization. Two. 2021.\n\
690 Jones K. Three. https://example.org/three\n";
691 assert_eq!(texts(list).len(), 3);
692 }
693
694 #[test]
695 fn wrapped_lines_without_blank_lines_split_at_authors() {
696 let list = "Smith, J. (2020). A study of\n\
697 things. Journal of Studies, 12, 1-9.\n\
698 Jones, K. (2021). Another study. Nature,\n\
699 521, 436.\n";
700 assert_eq!(
701 texts(list),
702 [
703 "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
704 "Jones, K. (2021). Another study. Nature, 521, 436.",
705 ]
706 );
707 }
708
709 #[test]
710 fn hyphenation_and_broken_links_are_mended() {
711 let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
712 2. Jones K. https://example.org/a-\nb/c\n";
713 assert_eq!(
714 texts(list),
715 [
716 "Smith J. Neurological findings. doi:10.1038/nature14539",
717 "Jones K. https://example.org/a-b/c",
718 ]
719 );
720 }
721
722 #[test]
723 fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
724 let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
725 2020.01.001\n\
726 [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
727 020-2649-2\n\
728 [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
729 ll.2021.02.003\n";
730 assert_eq!(
731 texts(list),
732 [
733 "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
734 "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
735 "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
736 ]
737 );
738 let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
740 Jones K. Folds. Nature. 2020.\n";
741 assert_eq!(texts(list).len(), 2);
742 assert_eq!(
744 texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
745 "Smith J. doi:10.1038/x. Nature. 2020."
746 );
747 }
748
749 #[test]
750 fn a_single_numbered_reference_loses_its_number() {
751 assert_eq!(
752 texts("1. LeCun Y. Deep learning. Nature. 2015."),
753 ["LeCun Y. Deep learning. Nature. 2015."]
754 );
755 assert_eq!(
757 texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
758 ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
759 );
760 }
761
762 #[test]
763 fn references_numbered_inline_split_at_each_number() {
764 let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
766 [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
767 [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
768 [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
769 assert_eq!(
770 texts(list),
771 [
772 "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
773 "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
774 "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
775 "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
776 ]
777 );
778 let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
781 [21] Finucane T. Two. Lancet. 2012;379:10. \
782 [22] Defense Health Board. Three. Falls Church, VA, n.d. \
783 [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
784 assert_eq!(texts(undated).len(), 4);
785 assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
786 assert_eq!(
788 texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
789 2
790 );
791 for prose in [
794 "Shown before [3] and since. [4] The trial. [5] Then 2019.",
795 "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
796 "As shown. [1] The first. [2] The second. [3] The third 2019.",
797 ] {
798 assert_eq!(texts(prose).len(), 1, "{prose}");
799 }
800 }
801
802 #[test]
803 fn unnumbered_vancouver_lists_split_however_lines_break() {
804 let list = "Benjamin EJ, Muntner P, Alonso A, et al. Heart disease\n\
807 and stroke statistics-2019 update. Circulation\n\
808 2019;139:e56-528. https://doi.org/10.1161/CIR.0000000000000659\n\
809 Schmieder RE, Kjeldsen SE, Julius S, et al. Reduced incidence\n\
810 of new-onset atrial fibrillation: the VALUE trial. J\n\
811 Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e3282f35c67\n\
812 Chokesuwattanaskul R, Thongprayoon C, Bathini T, Sharma K, Watthanasuntorn K, Lertjitbanjong\n\
813 P, et al. Incident atrial fibrillation in patients undergoing bariatric sur-\n\
814 gery: a systematic review and meta-analysis. Intern Med J 2020;50:810-7. https://doi.org/10.1111/imj.14436";
815 for sep in ["\n", "\r\n", "\r", "\u{2028}", "\u{2029}"] {
816 let found = texts(&list.replace('\n', sep));
817 assert_eq!(found.len(), 3, "{sep:?}: {found:?}");
818 assert!(
819 found[2].contains("bariatric surgery: a systematic"),
820 "{found:?}"
821 );
822 }
823 }
824
825 #[test]
826 fn a_doi_wrapped_onto_a_line_of_its_own_ends_its_reference() {
827 let list = "Benjamin EJ, Muntner P, et al. Heart disease\n\
829 and stroke statistics-2019 update. Circulation\n\
830 2019;139:e56-528. https://doi.org/10.1161/CIR.000000000\n\
831 0000659\n\
832 Schmieder RE, Kjeldsen SE, et al. Reduced incidence\n\
833 of new-onset atrial fibrillation: the VALUE trial. J\n\
834 Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e\n\
835 3282f35c67";
836 let found = texts(list);
837 assert_eq!(found.len(), 2, "{found:?}");
838 assert!(
839 found[0].ends_with("https://doi.org/10.1161/CIR.0000000000000659"),
840 "{found:?}"
841 );
842 assert!(
843 found[1].ends_with("https://doi.org/10.1097/HJH.0b013e3282f35c67"),
844 "{found:?}"
845 );
846 let dois: Vec<String> = crate::id::scan(list)
847 .iter()
848 .map(|m| m.id.to_string())
849 .collect();
850 assert_eq!(
851 dois,
852 [
853 "doi:10.1161/cir.0000000000000659",
854 "doi:10.1097/hjh.0b013e3282f35c67"
855 ]
856 );
857 }
858
859 #[test]
860 fn indents_never_start_a_reference_mid_word_or_in_lower_case() {
861 let lines = [
864 IndentedLine::new(
865 "Chokesuwattanaskul R, et al. Incident atrial fibrillation in bariatric sur-",
866 0.0,
867 ),
868 IndentedLine::new(
869 "gery: a systematic review. Intern Med J 2020;50:810-7.",
870 0.0,
871 ),
872 IndentedLine::new("Sardana M, et al. Obstructive sleep apnea (the SAVE", 0.0),
873 IndentedLine::new("trial). Circ Arrhythm Electrophysiol 2017;10:e005463.", 0.0),
874 IndentedLine::new("Smith J, et al. Another reference about", 0.0),
875 IndentedLine::new("the same thing. Lancet 2019;393:10-12.", 12.0),
876 ];
877 let found: Vec<String> = split_lines(&lines).into_iter().map(|r| r.text).collect();
878 assert_eq!(found.len(), 3, "{found:?}");
879 assert!(
880 found[0].contains("bariatric surgery: a systematic"),
881 "{found:?}"
882 );
883 assert!(found[1].contains("(the SAVE trial)."), "{found:?}");
884 }
885
886 #[test]
887 fn an_author_line_is_not_a_reference_of_its_own() {
888 let reference = "Finucane MM, Stevens GA, Cowan MJ, Danaei G, Lin JK, Paciorek CJ, et al.\n\
891 National, regional, and global trends in body-mass index since 1980: system-\n\
892 atic analysis of health examination surveys and epidemiological studies with\n\
893 960 country-years and 9 1 million participants. Lancet 2011;377:557\u{2013}67.\n\
894 http://dx.doi.org/10.1016/S0140-6736(10)62037-5.";
895 for text in [reference.to_owned(), reference.replacen('F', "", 1)] {
896 let found = texts(&text);
897 assert_eq!(found.len(), 1, "{found:?}");
898 assert!(found[0].contains("et al. National, regional"), "{found:?}");
899 }
900 let two =
902 format!("{reference}\nSmith J, Jones K, et al.\nA second title. BMJ 2019;365:l123.");
903 assert_eq!(texts(&two).len(), 2, "{:?}", texts(&two));
904 }
905
906 #[test]
907 fn markers_alone_on_their_line_number_the_next() {
908 assert_eq!(
909 texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
910 ["Smith J. One. 2020.", "Jones K. Two. 2021."]
911 );
912 }
913
914 #[test]
915 fn pdf_text_is_cleaned_and_spans_point_into_it() {
916 let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
917 2. Jones K. Two. 2021.";
918 let refs = split_references(list);
919 assert_eq!(
920 refs[0].text,
921 "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
922 );
923 assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
924 assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
925 }
926
927 #[test]
928 fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
929 let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
930 assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
931 let alt = &refs[0].alternatives[0];
932 assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
933 assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
934 assert!(refs[1].alternatives.is_empty());
935 }
936
937 #[test]
938 fn hanging_indents_mark_starts_that_text_cannot() {
939 let lines = [
942 IndentedLine::new("References", 72.4),
943 IndentedLine::new("World Health Organization. Global report.", 72.0),
944 IndentedLine::new("Geneva: WHO; 2020.", 90.2),
945 IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
946 ];
947 let refs = split_lines(&lines);
948 assert_eq!(
949 refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
950 [
951 "World Health Organization. Global report. Geneva: WHO; 2020.",
952 "Smith J. A study. Nature. 2021.",
953 ]
954 );
955 assert_eq!(refs[0].lines, 1..3);
956 assert_eq!(refs[1].lines, 3..4);
957 }
958
959 #[test]
960 fn first_line_indents_and_flat_lists() {
961 let lines = [
962 IndentedLine::new("Smith J. A study of", 90.0),
963 IndentedLine::new("Things. Nature. 2021.", 72.0),
964 IndentedLine::new("Jones K. Another. 2022.", 90.0),
965 ];
966 assert_eq!(split_lines(&lines).len(), 2);
967 let flat = [
969 IndentedLine::new("1. Smith J. One.", 72.0),
970 IndentedLine::new("2020.", 72.0),
971 IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
972 ];
973 let refs = split_lines(&flat);
974 assert_eq!(refs[0].text, "Smith J. One. 2020.");
975 assert_eq!(refs[1].lines, 2..3);
976 }
977
978 #[test]
979 fn spans_cover_the_reference_without_its_marker() {
980 let list = "1. Smith J. One.\r\n 2020.\r\n2. Jones K. Two. 2021.";
981 let refs = split_references(list);
982 assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n 2020.");
983 assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
984 }
985
986 #[test]
987 fn a_single_reference_is_one() {
988 assert_eq!(
989 texts("Smith J. A study.\nJournal 2020;1:2."),
990 ["Smith J. A study. Journal 2020;1:2."]
991 );
992 assert!(texts(" \n\n").is_empty());
993 }
994}