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| l.text.replace(['\r', '\n'], " "))
87 .collect::<Vec<_>>()
88 .join("\n");
89 let folded = Folded::new(&text);
90 let list = List::new(&folded.text);
91 let groups = match starts_by_indent(lines) {
92 Some(starts) if list.numbered().is_none() => {
93 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
94 for (i, line) in list.lines.iter().enumerate() {
95 let Some(line) = line else { continue };
96 match groups.last_mut() {
97 Some(group) if !starts[i] => group.push(line.clone()),
98 _ => groups.push(vec![line.clone()]),
99 }
100 }
101 groups
102 }
103 _ => list.groups(),
104 };
105 let line_starts: Vec<usize> = std::iter::once(0)
106 .chain(text.match_indices('\n').map(|(i, _)| i + 1))
107 .collect();
108 let line_of = |byte: usize| line_starts.partition_point(|&s| s <= byte) - 1;
109 references(&text, &folded, &list, groups)
110 .into_iter()
111 .map(|r| LineReference {
112 lines: line_of(r.span.start)..line_of(r.span.end.saturating_sub(1)) + 1,
113 text: r.text,
114 alternatives: r.alternatives,
115 })
116 .collect()
117}
118
119fn starts_by_indent(lines: &[IndentedLine]) -> Option<Vec<bool>> {
123 let content: Vec<&IndentedLine> = lines.iter().filter(|l| !l.text.trim().is_empty()).collect();
124 let margin = content
125 .iter()
126 .map(|l| l.indent)
127 .fold(f64::INFINITY, f64::min);
128 let next = content
129 .iter()
130 .map(|l| l.indent)
131 .filter(|&i| i > margin + 1.0)
132 .fold(f64::INFINITY, f64::min);
133 if !next.is_finite() {
134 return None;
135 }
136 let at_margin = |l: &IndentedLine| l.indent - margin < (next - margin) / 2.0;
137 let first = content.iter().find(|l| !is_heading(l.text.trim()))?;
138 let starting = at_margin(first);
139 Some(
140 lines
141 .iter()
142 .map(|l| !l.text.trim().is_empty() && at_margin(l) == starting)
143 .collect(),
144 )
145}
146
147fn references(
149 text: &str,
150 folded: &Folded,
151 list: &List,
152 groups: Vec<Vec<Range<usize>>>,
153) -> Vec<Reference> {
154 groups
155 .into_iter()
156 .map(|g| {
157 let span = folded.original(&(g[0].start..g[g.len() - 1].end));
158 let written = &text[span.clone()];
159 let alternatives = crate::id::scan(written)
160 .iter()
161 .filter_map(|m| Some(Alternative::new(m.id.clone(), m.alternative(written)?)))
162 .collect();
163 Reference {
164 text: list.join(&g),
165 span,
166 alternatives,
167 }
168 })
169 .filter(|r| !is_heading(&r.text))
170 .collect()
171}
172
173struct List<'a> {
174 text: &'a str,
175 lines: Vec<Option<Range<usize>>>,
177}
178
179impl<'a> List<'a> {
180 fn new(text: &'a str) -> Self {
181 let mut lines = Vec::new();
182 let mut at = 0;
183 for raw in text.split_inclusive('\n') {
184 let trimmed = raw.trim();
185 lines.push((!trimmed.is_empty()).then(|| {
186 let start = at + (raw.len() - raw.trim_start().len());
187 start..start + trimmed.len()
188 }));
189 at += raw.len();
190 }
191 List { text, lines }
192 }
193
194 fn split_inline(mut self) -> Self {
201 let lines = std::mem::take(&mut self.lines);
202 for line in lines {
203 match line.as_ref().and_then(|l| self.inline_starts(l)) {
204 Some(starts) => {
205 let range = line.expect("has starts");
206 let bounds: Vec<usize> = std::iter::once(range.start)
207 .chain(starts)
208 .chain(std::iter::once(range.end))
209 .collect();
210 for w in bounds.windows(2) {
211 let piece = &self.text[w[0]..w[1]];
212 let start = w[0] + (piece.len() - piece.trim_start().len());
213 let end = w[0] + piece.trim_end().len();
214 if start < end {
215 self.lines.push(Some(start..end));
216 }
217 }
218 }
219 None => self.lines.push(line),
220 }
221 }
222 self
223 }
224
225 fn inline_starts(&self, line: &Range<usize>) -> Option<Vec<usize>> {
228 let text = self.line(line);
229 let mut markers: Vec<(usize, u32)> = Vec::new();
230 for (at, _) in text.match_indices('[') {
231 let Some((Marker::Number(n), used)) = marker(&text[at..]) else {
232 continue;
233 };
234 let follows_stop = text[..at].trim_end();
235 let next = text[at + used..].chars().next();
236 if (follows_stop.is_empty() || follows_stop.ends_with('.'))
237 && next.is_some_and(char::is_uppercase)
238 {
239 markers.push((at, n));
240 }
241 }
242 let mut runs: Vec<Vec<(usize, u32)>> = Vec::new();
244 for m in markers {
245 match runs
246 .iter_mut()
247 .find(|r| r.last().is_some_and(|l| l.1 + 1 == m.1))
248 {
249 Some(run) => run.push(m),
250 None => runs.push(vec![m]),
251 }
252 }
253 let run = runs.into_iter().max_by_key(Vec::len)?;
254 let at_start = run.first().is_some_and(|m| m.0 == 0);
255 if run.len() < 3 && !(run.len() == 2 && at_start) {
256 return None;
257 }
258 let starts: Vec<usize> = run
259 .iter()
260 .filter(|m| m.0 > 0)
261 .map(|m| line.start + m.0)
262 .collect();
263 let bounds: Vec<usize> = std::iter::once(line.start)
264 .chain(starts.iter().copied())
265 .chain(std::iter::once(line.end))
266 .collect();
267 let pieces = bounds.len() - 1;
270 let undated = bounds
271 .windows(2)
272 .filter(|w| !dated(&self.text[w[0]..w[1]]))
273 .count();
274 (undated == 0 || (undated == 1 && pieces >= 4)).then_some(starts)
275 }
276
277 fn groups(&self) -> Vec<Vec<Range<usize>>> {
280 self.numbered().unwrap_or_else(|| {
281 let paragraphs: Vec<Vec<Range<usize>>> = self
282 .lines
283 .split(Option::is_none)
284 .filter(|p| !p.is_empty())
285 .map(|p| p.iter().flatten().cloned().collect())
286 .collect();
287 let alone = paragraphs.len() == 1;
288 paragraphs
289 .into_iter()
290 .flat_map(|p| self.unnumbered(p, alone))
291 .collect()
292 })
293 }
294
295 fn line(&self, r: &Range<usize>) -> &'a str {
296 &self.text[r.clone()]
297 }
298
299 fn numbered(&self) -> Option<Vec<Vec<Range<usize>>>> {
302 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
303 let mut last: Option<Marker> = None;
304 for line in self.lines.iter().flatten() {
305 let starts = marker(self.line(line)).filter(|(m, _)| match (last, m) {
306 (None, _) => true,
307 (Some(Marker::Number(n)), Marker::Number(m)) => *m == n + 1,
308 (Some(Marker::Bullet), Marker::Bullet) => true,
309 _ => false,
310 });
311 match (starts, groups.last_mut()) {
312 (Some((m, used)), _) => {
313 last = Some(m);
314 let first = line.start + used..line.end;
316 groups.push(Vec::new());
317 if !first.is_empty() {
318 groups.last_mut().expect("just pushed").push(first);
319 }
320 }
321 (None, Some(group)) => group.push(line.clone()),
322 (None, None) if dated(self.line(line)) => groups.push(vec![line.clone()]),
325 (None, None) => {}
326 }
327 }
328 let first_marked = self
331 .lines
332 .iter()
333 .flatten()
334 .next()
335 .is_some_and(|l| marker(self.line(l)).is_some());
336 groups.retain(|g| !g.is_empty());
337 (groups.len() >= 2 || groups.len() == 1 && first_marked).then_some(groups)
338 }
339
340 fn unnumbered(&self, lines: Vec<Range<usize>>, alone: bool) -> Vec<Vec<Range<usize>>> {
344 let one_per_line = alone
345 && lines.iter().all(|l| dated(self.line(l)))
346 && lines.windows(2).all(|w| {
347 let (line, next) = (self.line(&w[0]), self.line(&w[1]));
348 ends_reference(line) && !continues(next) && !link_continues(line, next)
349 });
350 if one_per_line {
351 return lines.into_iter().map(|l| vec![l]).collect();
352 }
353 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
354 for line in lines {
355 let text = self.line(&line);
356 match groups.last_mut() {
357 Some(group) if !(ends_sentence(&self.join(group)) && starts_with_author(text)) => {
358 group.push(line)
359 }
360 _ => groups.push(vec![line]),
361 }
362 }
363 groups
364 }
365
366 fn join(&self, group: &[Range<usize>]) -> String {
369 let mut out = String::new();
370 for line in group {
371 let next = self.line(line);
372 let first = next.chars().next().unwrap_or(' ');
373 if out.is_empty() {
374 } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(&out) {
376 out.pop();
377 } else if !link_continues(&out, next) {
378 out.push(' ');
379 }
380 out.push_str(next);
381 }
382 out
383 }
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387enum Marker {
388 Number(u32),
389 Bullet,
390}
391
392fn marker(line: &str) -> Option<(Marker, usize)> {
394 let spaced = |rest: &str, used: usize, needs_space: bool| {
395 let gap = rest.len() - rest.trim_start().len();
396 (gap > 0 || !needs_space).then_some(used + gap)
397 };
398 if let Some(rest) = line.strip_prefix(['•', '–', '-', '*', '·']) {
399 return Some((Marker::Bullet, spaced(rest, line.len() - rest.len(), true)?));
400 }
401 let (open, close): (usize, &[char]) = match line.as_bytes().first()? {
402 b'[' => (1, &[']']),
403 b'(' => (1, &[')']),
404 _ => (0, &['.', ')']),
405 };
406 let inner = &line[open..];
407 let digits = inner.len() - inner.trim_start_matches(|c: char| c.is_ascii_digit()).len();
408 if !(1..=4).contains(&digits) {
409 return None;
410 }
411 let n: u32 = inner[..digits].parse().ok()?;
412 let rest = inner[digits..].strip_prefix(close)?;
413 Some((
415 Marker::Number(n),
416 spaced(rest, line.len() - rest.len(), open == 0)?,
417 ))
418}
419
420fn ends_reference(line: &str) -> bool {
423 ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
424}
425
426fn ends_sentence(line: &str) -> bool {
429 line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
430}
431
432fn dated(line: &str) -> bool {
434 let year = line
435 .split(|c: char| !c.is_ascii_digit())
436 .any(|n| n.len() == 4 && ("1600".."2100").contains(&n));
437 let lower = line.to_lowercase();
439 let undated = [
440 "n.d.",
441 "n. d.",
442 "no date",
443 "accessed",
444 "[cited",
445 "available from",
446 ]
447 .iter()
448 .any(|m| lower.contains(m));
449 year || undated || line.contains("doi") || line.contains("10.") || line.contains("http")
450}
451
452fn continues(line: &str) -> bool {
455 line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
456}
457
458fn starts_with_author(line: &str) -> bool {
461 let surname_len = line
462 .char_indices()
463 .take_while(|(_, c)| c.is_alphabetic() || "'’-".contains(*c))
464 .last()
465 .map_or(0, |(i, c)| i + c.len_utf8());
466 let surname = &line[..surname_len];
467 if surname.chars().count() < 2 || !surname.starts_with(char::is_uppercase) {
468 return false;
469 }
470 let rest = &line[surname_len..];
471 if rest.starts_with(',') {
472 return true;
473 }
474 let Some(rest) = rest.strip_prefix(' ') else {
476 return false;
477 };
478 let initials: String = rest
479 .chars()
480 .take_while(|c| c.is_uppercase() || *c == '.')
481 .collect();
482 let capitals = initials.chars().filter(|c| c.is_uppercase()).count();
483 let after = rest[initials.len()..].chars().next();
484 (1..=3).contains(&capitals) && after.is_none_or(|c| ", .;".contains(c))
485}
486
487fn hyphenated_word(text: &str) -> bool {
489 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
490 !in_link(text) && word.trim_end_matches('-').chars().all(char::is_alphabetic)
491}
492
493pub(crate) fn link_continues(text: &str, next: &str) -> bool {
501 let token = next.split_whitespace().next().unwrap_or_default();
502 let Some(first) = token.chars().next() else {
503 return false;
504 };
505 if !in_link(text) || !first.is_alphanumeric() || starts_link(token) || is_number(token) {
506 return false;
507 }
508 let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
509 let doi = token.starts_with("10.") && token.contains('/');
512 if text.ends_with(['/', '-', '_']) {
513 true
514 } else if text.ends_with('.') {
515 lower_or_digit && !doi
516 } else {
517 let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
520 lower_or_digit
521 && !doi
522 && inner.parse::<f64>().is_err()
523 && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
524 }
525}
526
527fn starts_link(token: &str) -> bool {
529 let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
530 ["http://", "https://", "www."]
531 .iter()
532 .any(|p| lower.starts_with(p))
533 || token
534 .split_once(':')
535 .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
536}
537
538fn is_number(token: &str) -> bool {
540 token
541 .strip_suffix('.')
542 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
543}
544
545fn in_link(text: &str) -> bool {
547 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
548 let starts = |p: &str| {
549 word.get(..p.len())
550 .is_some_and(|h| h.eq_ignore_ascii_case(p))
551 };
552 starts("http") || starts("doi:") || word.starts_with("10.")
553}
554
555fn is_heading(text: &str) -> bool {
556 let t = text.trim_end_matches(':').trim().to_lowercase();
557 matches!(
558 t.as_str(),
559 "references"
560 | "reference list"
561 | "bibliography"
562 | "works cited"
563 | "literature cited"
564 | "sources"
565 | "citations"
566 )
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 fn texts(list: &str) -> Vec<String> {
574 split_references(list).into_iter().map(|r| r.text).collect()
575 }
576
577 #[test]
578 fn numbered_lists_split_at_each_number_in_sequence() {
579 let list = "References\n\
580 1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
581 2015;521:436-44.\n\
582 2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
583 assert_eq!(
584 texts(list),
585 [
586 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
587 "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
588 ]
589 );
590 }
591
592 #[test]
593 fn bracketed_and_parenthesised_numbers() {
594 let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
595 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
596 let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
597 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
598 }
599
600 #[test]
601 fn numbers_out_of_sequence_continue_the_reference() {
602 let list = "1. Smith J. A study of things. Journal\n\
603 2015. 12(3):1-9.\n\
604 2. Jones K. Another. 2021.\n";
605 assert_eq!(
606 texts(list),
607 [
608 "Smith J. A study of things. Journal 2015. 12(3):1-9.",
609 "Jones K. Another. 2021.",
610 ]
611 );
612 }
613
614 #[test]
615 fn bullets_mark_references() {
616 let list = "• Smith J. One. 2020.\n• Jones K. Two,\n continued. 2021.\n";
617 assert_eq!(
618 texts(list),
619 ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
620 );
621 }
622
623 #[test]
624 fn blank_lines_separate_wrapped_references() {
625 let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
626 Advances in Neural Information Processing Systems. 2017.\n\
627 \n\
628 LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
629 Nature. 2015;521:436-44.\n";
630 assert_eq!(
631 texts(list),
632 [
633 "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
634 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
635 ]
636 );
637 }
638
639 #[test]
640 fn one_per_line() {
641 let list = "Smith J. One. 2020.\n\
642 World Health Organization. Two. 2021.\n\
643 Jones K. Three. https://example.org/three\n";
644 assert_eq!(texts(list).len(), 3);
645 }
646
647 #[test]
648 fn wrapped_lines_without_blank_lines_split_at_authors() {
649 let list = "Smith, J. (2020). A study of\n\
650 things. Journal of Studies, 12, 1-9.\n\
651 Jones, K. (2021). Another study. Nature,\n\
652 521, 436.\n";
653 assert_eq!(
654 texts(list),
655 [
656 "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
657 "Jones, K. (2021). Another study. Nature, 521, 436.",
658 ]
659 );
660 }
661
662 #[test]
663 fn hyphenation_and_broken_links_are_mended() {
664 let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
665 2. Jones K. https://example.org/a-\nb/c\n";
666 assert_eq!(
667 texts(list),
668 [
669 "Smith J. Neurological findings. doi:10.1038/nature14539",
670 "Jones K. https://example.org/a-b/c",
671 ]
672 );
673 }
674
675 #[test]
676 fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
677 let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
678 2020.01.001\n\
679 [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
680 020-2649-2\n\
681 [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
682 ll.2021.02.003\n";
683 assert_eq!(
684 texts(list),
685 [
686 "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
687 "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
688 "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
689 ]
690 );
691 let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
693 Jones K. Folds. Nature. 2020.\n";
694 assert_eq!(texts(list).len(), 2);
695 assert_eq!(
697 texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
698 "Smith J. doi:10.1038/x. Nature. 2020."
699 );
700 }
701
702 #[test]
703 fn a_single_numbered_reference_loses_its_number() {
704 assert_eq!(
705 texts("1. LeCun Y. Deep learning. Nature. 2015."),
706 ["LeCun Y. Deep learning. Nature. 2015."]
707 );
708 assert_eq!(
710 texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
711 ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
712 );
713 }
714
715 #[test]
716 fn references_numbered_inline_split_at_each_number() {
717 let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
719 [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
720 [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
721 [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
722 assert_eq!(
723 texts(list),
724 [
725 "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
726 "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
727 "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
728 "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
729 ]
730 );
731 let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
734 [21] Finucane T. Two. Lancet. 2012;379:10. \
735 [22] Defense Health Board. Three. Falls Church, VA, n.d. \
736 [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
737 assert_eq!(texts(undated).len(), 4);
738 assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
739 assert_eq!(
741 texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
742 2
743 );
744 for prose in [
747 "Shown before [3] and since. [4] The trial. [5] Then 2019.",
748 "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
749 "As shown. [1] The first. [2] The second. [3] The third 2019.",
750 ] {
751 assert_eq!(texts(prose).len(), 1, "{prose}");
752 }
753 }
754
755 #[test]
756 fn markers_alone_on_their_line_number_the_next() {
757 assert_eq!(
758 texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
759 ["Smith J. One. 2020.", "Jones K. Two. 2021."]
760 );
761 }
762
763 #[test]
764 fn pdf_text_is_cleaned_and_spans_point_into_it() {
765 let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
766 2. Jones K. Two. 2021.";
767 let refs = split_references(list);
768 assert_eq!(
769 refs[0].text,
770 "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
771 );
772 assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
773 assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
774 }
775
776 #[test]
777 fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
778 let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
779 assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
780 let alt = &refs[0].alternatives[0];
781 assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
782 assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
783 assert!(refs[1].alternatives.is_empty());
784 }
785
786 #[test]
787 fn hanging_indents_mark_starts_that_text_cannot() {
788 let lines = [
791 IndentedLine::new("References", 72.4),
792 IndentedLine::new("World Health Organization. Global report.", 72.0),
793 IndentedLine::new("Geneva: WHO; 2020.", 90.2),
794 IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
795 ];
796 let refs = split_lines(&lines);
797 assert_eq!(
798 refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
799 [
800 "World Health Organization. Global report. Geneva: WHO; 2020.",
801 "Smith J. A study. Nature. 2021.",
802 ]
803 );
804 assert_eq!(refs[0].lines, 1..3);
805 assert_eq!(refs[1].lines, 3..4);
806 }
807
808 #[test]
809 fn first_line_indents_and_flat_lists() {
810 let lines = [
811 IndentedLine::new("Smith J. A study of", 90.0),
812 IndentedLine::new("Things. Nature. 2021.", 72.0),
813 IndentedLine::new("Jones K. Another. 2022.", 90.0),
814 ];
815 assert_eq!(split_lines(&lines).len(), 2);
816 let flat = [
818 IndentedLine::new("1. Smith J. One.", 72.0),
819 IndentedLine::new("2020.", 72.0),
820 IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
821 ];
822 let refs = split_lines(&flat);
823 assert_eq!(refs[0].text, "Smith J. One. 2020.");
824 assert_eq!(refs[1].lines, 2..3);
825 }
826
827 #[test]
828 fn spans_cover_the_reference_without_its_marker() {
829 let list = "1. Smith J. One.\r\n 2020.\r\n2. Jones K. Two. 2021.";
830 let refs = split_references(list);
831 assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n 2020.");
832 assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
833 }
834
835 #[test]
836 fn a_single_reference_is_one() {
837 assert_eq!(
838 texts("Smith J. A study.\nJournal 2020;1:2."),
839 ["Smith J. A study. Journal 2020;1:2."]
840 );
841 assert!(texts(" \n\n").is_empty());
842 }
843}