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 lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
526 let doi = token.starts_with("10.") && token.contains('/');
529 if text.ends_with(['/', '-', '_']) {
530 true
531 } else if text.ends_with('.') {
532 lower_or_digit && !doi
533 } else {
534 let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
537 lower_or_digit
538 && !doi
539 && inner.parse::<f64>().is_err()
540 && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
541 }
542}
543
544fn starts_link(token: &str) -> bool {
546 let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
547 ["http://", "https://", "www."]
548 .iter()
549 .any(|p| lower.starts_with(p))
550 || token
551 .split_once(':')
552 .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
553}
554
555fn is_number(token: &str) -> bool {
557 token
558 .strip_suffix('.')
559 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
560}
561
562fn in_link(text: &str) -> bool {
564 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
565 let starts = |p: &str| {
566 word.get(..p.len())
567 .is_some_and(|h| h.eq_ignore_ascii_case(p))
568 };
569 starts("http") || starts("doi:") || word.starts_with("10.")
570}
571
572fn is_heading(text: &str) -> bool {
573 let t = text.trim_end_matches(':').trim().to_lowercase();
574 matches!(
575 t.as_str(),
576 "references"
577 | "reference list"
578 | "bibliography"
579 | "works cited"
580 | "literature cited"
581 | "sources"
582 | "citations"
583 )
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 fn texts(list: &str) -> Vec<String> {
591 split_references(list).into_iter().map(|r| r.text).collect()
592 }
593
594 #[test]
595 fn numbered_lists_split_at_each_number_in_sequence() {
596 let list = "References\n\
597 1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
598 2015;521:436-44.\n\
599 2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
600 assert_eq!(
601 texts(list),
602 [
603 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
604 "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
605 ]
606 );
607 }
608
609 #[test]
610 fn bracketed_and_parenthesised_numbers() {
611 let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
612 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
613 let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
614 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
615 }
616
617 #[test]
618 fn numbers_out_of_sequence_continue_the_reference() {
619 let list = "1. Smith J. A study of things. Journal\n\
620 2015. 12(3):1-9.\n\
621 2. Jones K. Another. 2021.\n";
622 assert_eq!(
623 texts(list),
624 [
625 "Smith J. A study of things. Journal 2015. 12(3):1-9.",
626 "Jones K. Another. 2021.",
627 ]
628 );
629 }
630
631 #[test]
632 fn bullets_mark_references() {
633 let list = "• Smith J. One. 2020.\n• Jones K. Two,\n continued. 2021.\n";
634 assert_eq!(
635 texts(list),
636 ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
637 );
638 }
639
640 #[test]
641 fn blank_lines_separate_wrapped_references() {
642 let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
643 Advances in Neural Information Processing Systems. 2017.\n\
644 \n\
645 LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
646 Nature. 2015;521:436-44.\n";
647 assert_eq!(
648 texts(list),
649 [
650 "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
651 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
652 ]
653 );
654 }
655
656 #[test]
657 fn one_per_line() {
658 let list = "Smith J. One. 2020.\n\
659 World Health Organization. Two. 2021.\n\
660 Jones K. Three. https://example.org/three\n";
661 assert_eq!(texts(list).len(), 3);
662 }
663
664 #[test]
665 fn wrapped_lines_without_blank_lines_split_at_authors() {
666 let list = "Smith, J. (2020). A study of\n\
667 things. Journal of Studies, 12, 1-9.\n\
668 Jones, K. (2021). Another study. Nature,\n\
669 521, 436.\n";
670 assert_eq!(
671 texts(list),
672 [
673 "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
674 "Jones, K. (2021). Another study. Nature, 521, 436.",
675 ]
676 );
677 }
678
679 #[test]
680 fn hyphenation_and_broken_links_are_mended() {
681 let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
682 2. Jones K. https://example.org/a-\nb/c\n";
683 assert_eq!(
684 texts(list),
685 [
686 "Smith J. Neurological findings. doi:10.1038/nature14539",
687 "Jones K. https://example.org/a-b/c",
688 ]
689 );
690 }
691
692 #[test]
693 fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
694 let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
695 2020.01.001\n\
696 [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
697 020-2649-2\n\
698 [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
699 ll.2021.02.003\n";
700 assert_eq!(
701 texts(list),
702 [
703 "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
704 "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
705 "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
706 ]
707 );
708 let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
710 Jones K. Folds. Nature. 2020.\n";
711 assert_eq!(texts(list).len(), 2);
712 assert_eq!(
714 texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
715 "Smith J. doi:10.1038/x. Nature. 2020."
716 );
717 }
718
719 #[test]
720 fn a_single_numbered_reference_loses_its_number() {
721 assert_eq!(
722 texts("1. LeCun Y. Deep learning. Nature. 2015."),
723 ["LeCun Y. Deep learning. Nature. 2015."]
724 );
725 assert_eq!(
727 texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
728 ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
729 );
730 }
731
732 #[test]
733 fn references_numbered_inline_split_at_each_number() {
734 let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
736 [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
737 [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
738 [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
739 assert_eq!(
740 texts(list),
741 [
742 "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
743 "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
744 "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
745 "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
746 ]
747 );
748 let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
751 [21] Finucane T. Two. Lancet. 2012;379:10. \
752 [22] Defense Health Board. Three. Falls Church, VA, n.d. \
753 [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
754 assert_eq!(texts(undated).len(), 4);
755 assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
756 assert_eq!(
758 texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
759 2
760 );
761 for prose in [
764 "Shown before [3] and since. [4] The trial. [5] Then 2019.",
765 "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
766 "As shown. [1] The first. [2] The second. [3] The third 2019.",
767 ] {
768 assert_eq!(texts(prose).len(), 1, "{prose}");
769 }
770 }
771
772 #[test]
773 fn unnumbered_vancouver_lists_split_however_lines_break() {
774 let list = "Benjamin EJ, Muntner P, Alonso A, et al. Heart disease\n\
777 and stroke statistics-2019 update. Circulation\n\
778 2019;139:e56-528. https://doi.org/10.1161/CIR.0000000000000659\n\
779 Schmieder RE, Kjeldsen SE, Julius S, et al. Reduced incidence\n\
780 of new-onset atrial fibrillation: the VALUE trial. J\n\
781 Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e3282f35c67\n\
782 Chokesuwattanaskul R, Thongprayoon C, Bathini T, Sharma K, Watthanasuntorn K, Lertjitbanjong\n\
783 P, et al. Incident atrial fibrillation in patients undergoing bariatric sur-\n\
784 gery: a systematic review and meta-analysis. Intern Med J 2020;50:810-7. https://doi.org/10.1111/imj.14436";
785 for sep in ["\n", "\r\n", "\r", "\u{2028}", "\u{2029}"] {
786 let found = texts(&list.replace('\n', sep));
787 assert_eq!(found.len(), 3, "{sep:?}: {found:?}");
788 assert!(
789 found[2].contains("bariatric surgery: a systematic"),
790 "{found:?}"
791 );
792 }
793 }
794
795 #[test]
796 fn indents_never_start_a_reference_mid_word_or_in_lower_case() {
797 let lines = [
800 IndentedLine::new(
801 "Chokesuwattanaskul R, et al. Incident atrial fibrillation in bariatric sur-",
802 0.0,
803 ),
804 IndentedLine::new(
805 "gery: a systematic review. Intern Med J 2020;50:810-7.",
806 0.0,
807 ),
808 IndentedLine::new("Sardana M, et al. Obstructive sleep apnea (the SAVE", 0.0),
809 IndentedLine::new("trial). Circ Arrhythm Electrophysiol 2017;10:e005463.", 0.0),
810 IndentedLine::new("Smith J, et al. Another reference about", 0.0),
811 IndentedLine::new("the same thing. Lancet 2019;393:10-12.", 12.0),
812 ];
813 let found: Vec<String> = split_lines(&lines).into_iter().map(|r| r.text).collect();
814 assert_eq!(found.len(), 3, "{found:?}");
815 assert!(
816 found[0].contains("bariatric surgery: a systematic"),
817 "{found:?}"
818 );
819 assert!(found[1].contains("(the SAVE trial)."), "{found:?}");
820 }
821
822 #[test]
823 fn markers_alone_on_their_line_number_the_next() {
824 assert_eq!(
825 texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
826 ["Smith J. One. 2020.", "Jones K. Two. 2021."]
827 );
828 }
829
830 #[test]
831 fn pdf_text_is_cleaned_and_spans_point_into_it() {
832 let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
833 2. Jones K. Two. 2021.";
834 let refs = split_references(list);
835 assert_eq!(
836 refs[0].text,
837 "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
838 );
839 assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
840 assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
841 }
842
843 #[test]
844 fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
845 let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
846 assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
847 let alt = &refs[0].alternatives[0];
848 assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
849 assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
850 assert!(refs[1].alternatives.is_empty());
851 }
852
853 #[test]
854 fn hanging_indents_mark_starts_that_text_cannot() {
855 let lines = [
858 IndentedLine::new("References", 72.4),
859 IndentedLine::new("World Health Organization. Global report.", 72.0),
860 IndentedLine::new("Geneva: WHO; 2020.", 90.2),
861 IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
862 ];
863 let refs = split_lines(&lines);
864 assert_eq!(
865 refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
866 [
867 "World Health Organization. Global report. Geneva: WHO; 2020.",
868 "Smith J. A study. Nature. 2021.",
869 ]
870 );
871 assert_eq!(refs[0].lines, 1..3);
872 assert_eq!(refs[1].lines, 3..4);
873 }
874
875 #[test]
876 fn first_line_indents_and_flat_lists() {
877 let lines = [
878 IndentedLine::new("Smith J. A study of", 90.0),
879 IndentedLine::new("Things. Nature. 2021.", 72.0),
880 IndentedLine::new("Jones K. Another. 2022.", 90.0),
881 ];
882 assert_eq!(split_lines(&lines).len(), 2);
883 let flat = [
885 IndentedLine::new("1. Smith J. One.", 72.0),
886 IndentedLine::new("2020.", 72.0),
887 IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
888 ];
889 let refs = split_lines(&flat);
890 assert_eq!(refs[0].text, "Smith J. One. 2020.");
891 assert_eq!(refs[1].lines, 2..3);
892 }
893
894 #[test]
895 fn spans_cover_the_reference_without_its_marker() {
896 let list = "1. Smith J. One.\r\n 2020.\r\n2. Jones K. Two. 2021.";
897 let refs = split_references(list);
898 assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n 2020.");
899 assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
900 }
901
902 #[test]
903 fn a_single_reference_is_one() {
904 assert_eq!(
905 texts("Smith J. A study.\nJournal 2020;1:2."),
906 ["Smith J. A study. Journal 2020;1:2."]
907 );
908 assert!(texts(" \n\n").is_empty());
909 }
910}