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) if !(ends_sentence(&self.join(group)) && starts_with_author(text)) => {
375 group.push(line)
376 }
377 _ => groups.push(vec![line]),
378 }
379 }
380 groups
381 }
382
383 fn join(&self, group: &[Range<usize>]) -> String {
386 let mut out = String::new();
387 for line in group {
388 let next = self.line(line);
389 let first = next.chars().next().unwrap_or(' ');
390 if out.is_empty() {
391 } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(&out) {
393 out.pop();
394 } else if !link_continues(&out, next) {
395 out.push(' ');
396 }
397 out.push_str(next);
398 }
399 out
400 }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404enum Marker {
405 Number(u32),
406 Bullet,
407}
408
409fn marker(line: &str) -> Option<(Marker, usize)> {
411 let spaced = |rest: &str, used: usize, needs_space: bool| {
412 let gap = rest.len() - rest.trim_start().len();
413 (gap > 0 || !needs_space).then_some(used + gap)
414 };
415 if let Some(rest) = line.strip_prefix(['•', '–', '-', '*', '·']) {
416 return Some((Marker::Bullet, spaced(rest, line.len() - rest.len(), true)?));
417 }
418 let (open, close): (usize, &[char]) = match line.as_bytes().first()? {
419 b'[' => (1, &[']']),
420 b'(' => (1, &[')']),
421 _ => (0, &['.', ')']),
422 };
423 let inner = &line[open..];
424 let digits = inner.len() - inner.trim_start_matches(|c: char| c.is_ascii_digit()).len();
425 if !(1..=4).contains(&digits) {
426 return None;
427 }
428 let n: u32 = inner[..digits].parse().ok()?;
429 let rest = inner[digits..].strip_prefix(close)?;
430 Some((
432 Marker::Number(n),
433 spaced(rest, line.len() - rest.len(), open == 0)?,
434 ))
435}
436
437fn ends_reference(line: &str) -> bool {
440 ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
441}
442
443fn ends_sentence(line: &str) -> bool {
446 line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
447}
448
449fn dated(line: &str) -> bool {
451 let year = line
452 .split(|c: char| !c.is_ascii_digit())
453 .any(|n| n.len() == 4 && ("1600".."2100").contains(&n));
454 let lower = line.to_lowercase();
456 let undated = [
457 "n.d.",
458 "n. d.",
459 "no date",
460 "accessed",
461 "[cited",
462 "available from",
463 ]
464 .iter()
465 .any(|m| lower.contains(m));
466 year || undated || line.contains("doi") || line.contains("10.") || line.contains("http")
467}
468
469fn continues(line: &str) -> bool {
472 line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
473}
474
475fn starts_with_author(line: &str) -> bool {
478 let surname_len = line
479 .char_indices()
480 .take_while(|(_, c)| c.is_alphabetic() || "'’-".contains(*c))
481 .last()
482 .map_or(0, |(i, c)| i + c.len_utf8());
483 let surname = &line[..surname_len];
484 if surname.chars().count() < 2 || !surname.starts_with(char::is_uppercase) {
485 return false;
486 }
487 let rest = &line[surname_len..];
488 if rest.starts_with(',') {
489 return true;
490 }
491 let Some(rest) = rest.strip_prefix(' ') else {
493 return false;
494 };
495 let initials: String = rest
496 .chars()
497 .take_while(|c| c.is_uppercase() || *c == '.')
498 .collect();
499 let capitals = initials.chars().filter(|c| c.is_uppercase()).count();
500 let after = rest[initials.len()..].chars().next();
501 (1..=3).contains(&capitals) && after.is_none_or(|c| ", .;".contains(c))
502}
503
504fn hyphenated_word(text: &str) -> bool {
506 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
507 !in_link(text) && word.trim_end_matches('-').chars().all(char::is_alphabetic)
508}
509
510pub(crate) fn link_continues(text: &str, next: &str) -> bool {
518 let token = next.split_whitespace().next().unwrap_or_default();
519 let Some(first) = token.chars().next() else {
520 return false;
521 };
522 if !in_link(text) || !first.is_alphanumeric() || starts_link(token) || is_number(token) {
523 return false;
524 }
525 let suffix = |c: char| c.is_ascii_alphanumeric() || "._;()/:-<>".contains(c);
530 if next.trim() == token
531 && !text.ends_with('.')
532 && token.chars().all(suffix)
533 && token.bytes().any(|b| b.is_ascii_digit())
534 && !plain_number(token.trim_end_matches(|c: char| !c.is_alphanumeric()))
535 && crate::id::scan(token).is_empty()
536 {
537 return true;
538 }
539 let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
540 let doi = token.starts_with("10.") && token.contains('/');
543 if text.ends_with(['/', '-', '_']) {
544 true
545 } else if text.ends_with('.') {
546 lower_or_digit && !doi
547 } else {
548 let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
551 lower_or_digit
552 && !doi
553 && inner.parse::<f64>().is_err()
554 && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
555 }
556}
557
558fn plain_number(token: &str) -> bool {
561 let digits = token.bytes().all(|b| b.is_ascii_digit());
562 (token.contains('.') && token.parse::<f64>().is_ok())
563 || (digits && token.len() < 5 && !token.starts_with('0'))
564}
565
566fn starts_link(token: &str) -> bool {
568 let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
569 ["http://", "https://", "www."]
570 .iter()
571 .any(|p| lower.starts_with(p))
572 || token
573 .split_once(':')
574 .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
575}
576
577fn is_number(token: &str) -> bool {
579 token
580 .strip_suffix('.')
581 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
582}
583
584fn in_link(text: &str) -> bool {
586 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
587 let starts = |p: &str| {
588 word.get(..p.len())
589 .is_some_and(|h| h.eq_ignore_ascii_case(p))
590 };
591 starts("http") || starts("doi:") || word.starts_with("10.")
592}
593
594fn is_heading(text: &str) -> bool {
595 let t = text.trim_end_matches(':').trim().to_lowercase();
596 matches!(
597 t.as_str(),
598 "references"
599 | "reference list"
600 | "bibliography"
601 | "works cited"
602 | "literature cited"
603 | "sources"
604 | "citations"
605 )
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 fn texts(list: &str) -> Vec<String> {
613 split_references(list).into_iter().map(|r| r.text).collect()
614 }
615
616 #[test]
617 fn numbered_lists_split_at_each_number_in_sequence() {
618 let list = "References\n\
619 1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
620 2015;521:436-44.\n\
621 2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
622 assert_eq!(
623 texts(list),
624 [
625 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
626 "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
627 ]
628 );
629 }
630
631 #[test]
632 fn bracketed_and_parenthesised_numbers() {
633 let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
634 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
635 let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
636 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
637 }
638
639 #[test]
640 fn numbers_out_of_sequence_continue_the_reference() {
641 let list = "1. Smith J. A study of things. Journal\n\
642 2015. 12(3):1-9.\n\
643 2. Jones K. Another. 2021.\n";
644 assert_eq!(
645 texts(list),
646 [
647 "Smith J. A study of things. Journal 2015. 12(3):1-9.",
648 "Jones K. Another. 2021.",
649 ]
650 );
651 }
652
653 #[test]
654 fn bullets_mark_references() {
655 let list = "• Smith J. One. 2020.\n• Jones K. Two,\n continued. 2021.\n";
656 assert_eq!(
657 texts(list),
658 ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
659 );
660 }
661
662 #[test]
663 fn blank_lines_separate_wrapped_references() {
664 let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
665 Advances in Neural Information Processing Systems. 2017.\n\
666 \n\
667 LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
668 Nature. 2015;521:436-44.\n";
669 assert_eq!(
670 texts(list),
671 [
672 "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
673 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
674 ]
675 );
676 }
677
678 #[test]
679 fn one_per_line() {
680 let list = "Smith J. One. 2020.\n\
681 World Health Organization. Two. 2021.\n\
682 Jones K. Three. https://example.org/three\n";
683 assert_eq!(texts(list).len(), 3);
684 }
685
686 #[test]
687 fn wrapped_lines_without_blank_lines_split_at_authors() {
688 let list = "Smith, J. (2020). A study of\n\
689 things. Journal of Studies, 12, 1-9.\n\
690 Jones, K. (2021). Another study. Nature,\n\
691 521, 436.\n";
692 assert_eq!(
693 texts(list),
694 [
695 "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
696 "Jones, K. (2021). Another study. Nature, 521, 436.",
697 ]
698 );
699 }
700
701 #[test]
702 fn hyphenation_and_broken_links_are_mended() {
703 let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
704 2. Jones K. https://example.org/a-\nb/c\n";
705 assert_eq!(
706 texts(list),
707 [
708 "Smith J. Neurological findings. doi:10.1038/nature14539",
709 "Jones K. https://example.org/a-b/c",
710 ]
711 );
712 }
713
714 #[test]
715 fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
716 let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
717 2020.01.001\n\
718 [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
719 020-2649-2\n\
720 [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
721 ll.2021.02.003\n";
722 assert_eq!(
723 texts(list),
724 [
725 "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
726 "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
727 "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
728 ]
729 );
730 let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
732 Jones K. Folds. Nature. 2020.\n";
733 assert_eq!(texts(list).len(), 2);
734 assert_eq!(
736 texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
737 "Smith J. doi:10.1038/x. Nature. 2020."
738 );
739 }
740
741 #[test]
742 fn a_single_numbered_reference_loses_its_number() {
743 assert_eq!(
744 texts("1. LeCun Y. Deep learning. Nature. 2015."),
745 ["LeCun Y. Deep learning. Nature. 2015."]
746 );
747 assert_eq!(
749 texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
750 ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
751 );
752 }
753
754 #[test]
755 fn references_numbered_inline_split_at_each_number() {
756 let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
758 [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
759 [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
760 [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
761 assert_eq!(
762 texts(list),
763 [
764 "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
765 "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
766 "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
767 "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
768 ]
769 );
770 let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
773 [21] Finucane T. Two. Lancet. 2012;379:10. \
774 [22] Defense Health Board. Three. Falls Church, VA, n.d. \
775 [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
776 assert_eq!(texts(undated).len(), 4);
777 assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
778 assert_eq!(
780 texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
781 2
782 );
783 for prose in [
786 "Shown before [3] and since. [4] The trial. [5] Then 2019.",
787 "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
788 "As shown. [1] The first. [2] The second. [3] The third 2019.",
789 ] {
790 assert_eq!(texts(prose).len(), 1, "{prose}");
791 }
792 }
793
794 #[test]
795 fn unnumbered_vancouver_lists_split_however_lines_break() {
796 let list = "Benjamin EJ, Muntner P, Alonso A, et al. Heart disease\n\
799 and stroke statistics-2019 update. Circulation\n\
800 2019;139:e56-528. https://doi.org/10.1161/CIR.0000000000000659\n\
801 Schmieder RE, Kjeldsen SE, Julius S, et al. Reduced incidence\n\
802 of new-onset atrial fibrillation: the VALUE trial. J\n\
803 Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e3282f35c67\n\
804 Chokesuwattanaskul R, Thongprayoon C, Bathini T, Sharma K, Watthanasuntorn K, Lertjitbanjong\n\
805 P, et al. Incident atrial fibrillation in patients undergoing bariatric sur-\n\
806 gery: a systematic review and meta-analysis. Intern Med J 2020;50:810-7. https://doi.org/10.1111/imj.14436";
807 for sep in ["\n", "\r\n", "\r", "\u{2028}", "\u{2029}"] {
808 let found = texts(&list.replace('\n', sep));
809 assert_eq!(found.len(), 3, "{sep:?}: {found:?}");
810 assert!(
811 found[2].contains("bariatric surgery: a systematic"),
812 "{found:?}"
813 );
814 }
815 }
816
817 #[test]
818 fn a_doi_wrapped_onto_a_line_of_its_own_ends_its_reference() {
819 let list = "Benjamin EJ, Muntner P, et al. Heart disease\n\
821 and stroke statistics-2019 update. Circulation\n\
822 2019;139:e56-528. https://doi.org/10.1161/CIR.000000000\n\
823 0000659\n\
824 Schmieder RE, Kjeldsen SE, et al. Reduced incidence\n\
825 of new-onset atrial fibrillation: the VALUE trial. J\n\
826 Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e\n\
827 3282f35c67";
828 let found = texts(list);
829 assert_eq!(found.len(), 2, "{found:?}");
830 assert!(
831 found[0].ends_with("https://doi.org/10.1161/CIR.0000000000000659"),
832 "{found:?}"
833 );
834 assert!(
835 found[1].ends_with("https://doi.org/10.1097/HJH.0b013e3282f35c67"),
836 "{found:?}"
837 );
838 let dois: Vec<String> = crate::id::scan(list)
839 .iter()
840 .map(|m| m.id.to_string())
841 .collect();
842 assert_eq!(
843 dois,
844 [
845 "doi:10.1161/cir.0000000000000659",
846 "doi:10.1097/hjh.0b013e3282f35c67"
847 ]
848 );
849 }
850
851 #[test]
852 fn indents_never_start_a_reference_mid_word_or_in_lower_case() {
853 let lines = [
856 IndentedLine::new(
857 "Chokesuwattanaskul R, et al. Incident atrial fibrillation in bariatric sur-",
858 0.0,
859 ),
860 IndentedLine::new(
861 "gery: a systematic review. Intern Med J 2020;50:810-7.",
862 0.0,
863 ),
864 IndentedLine::new("Sardana M, et al. Obstructive sleep apnea (the SAVE", 0.0),
865 IndentedLine::new("trial). Circ Arrhythm Electrophysiol 2017;10:e005463.", 0.0),
866 IndentedLine::new("Smith J, et al. Another reference about", 0.0),
867 IndentedLine::new("the same thing. Lancet 2019;393:10-12.", 12.0),
868 ];
869 let found: Vec<String> = split_lines(&lines).into_iter().map(|r| r.text).collect();
870 assert_eq!(found.len(), 3, "{found:?}");
871 assert!(
872 found[0].contains("bariatric surgery: a systematic"),
873 "{found:?}"
874 );
875 assert!(found[1].contains("(the SAVE trial)."), "{found:?}");
876 }
877
878 #[test]
879 fn markers_alone_on_their_line_number_the_next() {
880 assert_eq!(
881 texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
882 ["Smith J. One. 2020.", "Jones K. Two. 2021."]
883 );
884 }
885
886 #[test]
887 fn pdf_text_is_cleaned_and_spans_point_into_it() {
888 let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
889 2. Jones K. Two. 2021.";
890 let refs = split_references(list);
891 assert_eq!(
892 refs[0].text,
893 "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
894 );
895 assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
896 assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
897 }
898
899 #[test]
900 fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
901 let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
902 assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
903 let alt = &refs[0].alternatives[0];
904 assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
905 assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
906 assert!(refs[1].alternatives.is_empty());
907 }
908
909 #[test]
910 fn hanging_indents_mark_starts_that_text_cannot() {
911 let lines = [
914 IndentedLine::new("References", 72.4),
915 IndentedLine::new("World Health Organization. Global report.", 72.0),
916 IndentedLine::new("Geneva: WHO; 2020.", 90.2),
917 IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
918 ];
919 let refs = split_lines(&lines);
920 assert_eq!(
921 refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
922 [
923 "World Health Organization. Global report. Geneva: WHO; 2020.",
924 "Smith J. A study. Nature. 2021.",
925 ]
926 );
927 assert_eq!(refs[0].lines, 1..3);
928 assert_eq!(refs[1].lines, 3..4);
929 }
930
931 #[test]
932 fn first_line_indents_and_flat_lists() {
933 let lines = [
934 IndentedLine::new("Smith J. A study of", 90.0),
935 IndentedLine::new("Things. Nature. 2021.", 72.0),
936 IndentedLine::new("Jones K. Another. 2022.", 90.0),
937 ];
938 assert_eq!(split_lines(&lines).len(), 2);
939 let flat = [
941 IndentedLine::new("1. Smith J. One.", 72.0),
942 IndentedLine::new("2020.", 72.0),
943 IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
944 ];
945 let refs = split_lines(&flat);
946 assert_eq!(refs[0].text, "Smith J. One. 2020.");
947 assert_eq!(refs[1].lines, 2..3);
948 }
949
950 #[test]
951 fn spans_cover_the_reference_without_its_marker() {
952 let list = "1. Smith J. One.\r\n 2020.\r\n2. Jones K. Two. 2021.";
953 let refs = split_references(list);
954 assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n 2020.");
955 assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
956 }
957
958 #[test]
959 fn a_single_reference_is_one() {
960 assert_eq!(
961 texts("Smith J. A study.\nJournal 2020;1:2."),
962 ["Smith J. A study. Journal 2020;1:2."]
963 );
964 assert!(texts(" \n\n").is_empty());
965 }
966}