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);
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 groups(&self) -> Vec<Vec<Range<usize>>> {
197 self.numbered().unwrap_or_else(|| {
198 let paragraphs: Vec<Vec<Range<usize>>> = self
199 .lines
200 .split(Option::is_none)
201 .filter(|p| !p.is_empty())
202 .map(|p| p.iter().flatten().cloned().collect())
203 .collect();
204 let alone = paragraphs.len() == 1;
205 paragraphs
206 .into_iter()
207 .flat_map(|p| self.unnumbered(p, alone))
208 .collect()
209 })
210 }
211
212 fn line(&self, r: &Range<usize>) -> &'a str {
213 &self.text[r.clone()]
214 }
215
216 fn numbered(&self) -> Option<Vec<Vec<Range<usize>>>> {
219 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
220 let mut last: Option<Marker> = None;
221 for line in self.lines.iter().flatten() {
222 let starts = marker(self.line(line)).filter(|(m, _)| match (last, m) {
223 (None, _) => true,
224 (Some(Marker::Number(n)), Marker::Number(m)) => *m == n + 1,
225 (Some(Marker::Bullet), Marker::Bullet) => true,
226 _ => false,
227 });
228 match (starts, groups.last_mut()) {
229 (Some((m, used)), _) => {
230 last = Some(m);
231 let first = line.start + used..line.end;
233 groups.push(Vec::new());
234 if !first.is_empty() {
235 groups.last_mut().expect("just pushed").push(first);
236 }
237 }
238 (None, Some(group)) => group.push(line.clone()),
239 (None, None) => {}
240 }
241 }
242 let first_marked = self
245 .lines
246 .iter()
247 .flatten()
248 .next()
249 .is_some_and(|l| marker(self.line(l)).is_some());
250 groups.retain(|g| !g.is_empty());
251 (groups.len() >= 2 || groups.len() == 1 && first_marked).then_some(groups)
252 }
253
254 fn unnumbered(&self, lines: Vec<Range<usize>>, alone: bool) -> Vec<Vec<Range<usize>>> {
258 let one_per_line = alone
259 && lines.iter().all(|l| dated(self.line(l)))
260 && lines.windows(2).all(|w| {
261 let (line, next) = (self.line(&w[0]), self.line(&w[1]));
262 ends_reference(line) && !continues(next) && !link_continues(line, next)
263 });
264 if one_per_line {
265 return lines.into_iter().map(|l| vec![l]).collect();
266 }
267 let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
268 for line in lines {
269 let text = self.line(&line);
270 match groups.last_mut() {
271 Some(group) if !(ends_sentence(&self.join(group)) && starts_with_author(text)) => {
272 group.push(line)
273 }
274 _ => groups.push(vec![line]),
275 }
276 }
277 groups
278 }
279
280 fn join(&self, group: &[Range<usize>]) -> String {
283 let mut out = String::new();
284 for line in group {
285 let next = self.line(line);
286 let first = next.chars().next().unwrap_or(' ');
287 if out.is_empty() {
288 } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(&out) {
290 out.pop();
291 } else if !link_continues(&out, next) {
292 out.push(' ');
293 }
294 out.push_str(next);
295 }
296 out
297 }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301enum Marker {
302 Number(u32),
303 Bullet,
304}
305
306fn marker(line: &str) -> Option<(Marker, usize)> {
308 let spaced = |rest: &str, used: usize, needs_space: bool| {
309 let gap = rest.len() - rest.trim_start().len();
310 (gap > 0 || !needs_space).then_some(used + gap)
311 };
312 if let Some(rest) = line.strip_prefix(['•', '–', '-', '*', '·']) {
313 return Some((Marker::Bullet, spaced(rest, line.len() - rest.len(), true)?));
314 }
315 let (open, close): (usize, &[char]) = match line.as_bytes().first()? {
316 b'[' => (1, &[']']),
317 b'(' => (1, &[')']),
318 _ => (0, &['.', ')']),
319 };
320 let inner = &line[open..];
321 let digits = inner.len() - inner.trim_start_matches(|c: char| c.is_ascii_digit()).len();
322 if !(1..=4).contains(&digits) {
323 return None;
324 }
325 let n: u32 = inner[..digits].parse().ok()?;
326 let rest = inner[digits..].strip_prefix(close)?;
327 Some((
329 Marker::Number(n),
330 spaced(rest, line.len() - rest.len(), open == 0)?,
331 ))
332}
333
334fn ends_reference(line: &str) -> bool {
337 ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
338}
339
340fn ends_sentence(line: &str) -> bool {
343 line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
344}
345
346fn dated(line: &str) -> bool {
348 let year = line
349 .split(|c: char| !c.is_ascii_digit())
350 .any(|n| n.len() == 4 && ("1600".."2100").contains(&n));
351 year || line.contains("doi") || line.contains("10.") || line.contains("http")
352}
353
354fn continues(line: &str) -> bool {
357 line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
358}
359
360fn starts_with_author(line: &str) -> bool {
363 let surname_len = line
364 .char_indices()
365 .take_while(|(_, c)| c.is_alphabetic() || "'’-".contains(*c))
366 .last()
367 .map_or(0, |(i, c)| i + c.len_utf8());
368 let surname = &line[..surname_len];
369 if surname.chars().count() < 2 || !surname.starts_with(char::is_uppercase) {
370 return false;
371 }
372 let rest = &line[surname_len..];
373 if rest.starts_with(',') {
374 return true;
375 }
376 let Some(rest) = rest.strip_prefix(' ') else {
378 return false;
379 };
380 let initials: String = rest
381 .chars()
382 .take_while(|c| c.is_uppercase() || *c == '.')
383 .collect();
384 let capitals = initials.chars().filter(|c| c.is_uppercase()).count();
385 let after = rest[initials.len()..].chars().next();
386 (1..=3).contains(&capitals) && after.is_none_or(|c| ", .;".contains(c))
387}
388
389fn hyphenated_word(text: &str) -> bool {
391 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
392 !in_link(text) && word.trim_end_matches('-').chars().all(char::is_alphabetic)
393}
394
395pub(crate) fn link_continues(text: &str, next: &str) -> bool {
403 let token = next.split_whitespace().next().unwrap_or_default();
404 let Some(first) = token.chars().next() else {
405 return false;
406 };
407 if !in_link(text) || !first.is_alphanumeric() || starts_link(token) || is_number(token) {
408 return false;
409 }
410 let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
411 let doi = token.starts_with("10.") && token.contains('/');
414 if text.ends_with(['/', '-', '_']) {
415 true
416 } else if text.ends_with('.') {
417 lower_or_digit && !doi
418 } else {
419 let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
422 lower_or_digit
423 && !doi
424 && inner.parse::<f64>().is_err()
425 && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
426 }
427}
428
429fn starts_link(token: &str) -> bool {
431 let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
432 ["http://", "https://", "www."]
433 .iter()
434 .any(|p| lower.starts_with(p))
435 || token
436 .split_once(':')
437 .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
438}
439
440fn is_number(token: &str) -> bool {
442 token
443 .strip_suffix('.')
444 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
445}
446
447fn in_link(text: &str) -> bool {
449 let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
450 let starts = |p: &str| {
451 word.get(..p.len())
452 .is_some_and(|h| h.eq_ignore_ascii_case(p))
453 };
454 starts("http") || starts("doi:") || word.starts_with("10.")
455}
456
457fn is_heading(text: &str) -> bool {
458 let t = text.trim_end_matches(':').trim().to_lowercase();
459 matches!(
460 t.as_str(),
461 "references"
462 | "reference list"
463 | "bibliography"
464 | "works cited"
465 | "literature cited"
466 | "sources"
467 | "citations"
468 )
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 fn texts(list: &str) -> Vec<String> {
476 split_references(list).into_iter().map(|r| r.text).collect()
477 }
478
479 #[test]
480 fn numbered_lists_split_at_each_number_in_sequence() {
481 let list = "References\n\
482 1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
483 2015;521:436-44.\n\
484 2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
485 assert_eq!(
486 texts(list),
487 [
488 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
489 "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
490 ]
491 );
492 }
493
494 #[test]
495 fn bracketed_and_parenthesised_numbers() {
496 let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
497 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
498 let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
499 assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
500 }
501
502 #[test]
503 fn numbers_out_of_sequence_continue_the_reference() {
504 let list = "1. Smith J. A study of things. Journal\n\
505 2015. 12(3):1-9.\n\
506 2. Jones K. Another. 2021.\n";
507 assert_eq!(
508 texts(list),
509 [
510 "Smith J. A study of things. Journal 2015. 12(3):1-9.",
511 "Jones K. Another. 2021.",
512 ]
513 );
514 }
515
516 #[test]
517 fn bullets_mark_references() {
518 let list = "• Smith J. One. 2020.\n• Jones K. Two,\n continued. 2021.\n";
519 assert_eq!(
520 texts(list),
521 ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
522 );
523 }
524
525 #[test]
526 fn blank_lines_separate_wrapped_references() {
527 let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
528 Advances in Neural Information Processing Systems. 2017.\n\
529 \n\
530 LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
531 Nature. 2015;521:436-44.\n";
532 assert_eq!(
533 texts(list),
534 [
535 "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
536 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
537 ]
538 );
539 }
540
541 #[test]
542 fn one_per_line() {
543 let list = "Smith J. One. 2020.\n\
544 World Health Organization. Two. 2021.\n\
545 Jones K. Three. https://example.org/three\n";
546 assert_eq!(texts(list).len(), 3);
547 }
548
549 #[test]
550 fn wrapped_lines_without_blank_lines_split_at_authors() {
551 let list = "Smith, J. (2020). A study of\n\
552 things. Journal of Studies, 12, 1-9.\n\
553 Jones, K. (2021). Another study. Nature,\n\
554 521, 436.\n";
555 assert_eq!(
556 texts(list),
557 [
558 "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
559 "Jones, K. (2021). Another study. Nature, 521, 436.",
560 ]
561 );
562 }
563
564 #[test]
565 fn hyphenation_and_broken_links_are_mended() {
566 let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
567 2. Jones K. https://example.org/a-\nb/c\n";
568 assert_eq!(
569 texts(list),
570 [
571 "Smith J. Neurological findings. doi:10.1038/nature14539",
572 "Jones K. https://example.org/a-b/c",
573 ]
574 );
575 }
576
577 #[test]
578 fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
579 let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
580 2020.01.001\n\
581 [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
582 020-2649-2\n\
583 [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
584 ll.2021.02.003\n";
585 assert_eq!(
586 texts(list),
587 [
588 "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
589 "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
590 "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
591 ]
592 );
593 let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
595 Jones K. Folds. Nature. 2020.\n";
596 assert_eq!(texts(list).len(), 2);
597 assert_eq!(
599 texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
600 "Smith J. doi:10.1038/x. Nature. 2020."
601 );
602 }
603
604 #[test]
605 fn a_single_numbered_reference_loses_its_number() {
606 assert_eq!(
607 texts("1. LeCun Y. Deep learning. Nature. 2015."),
608 ["LeCun Y. Deep learning. Nature. 2015."]
609 );
610 assert_eq!(
612 texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
613 ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
614 );
615 }
616
617 #[test]
618 fn markers_alone_on_their_line_number_the_next() {
619 assert_eq!(
620 texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
621 ["Smith J. One. 2020.", "Jones K. Two. 2021."]
622 );
623 }
624
625 #[test]
626 fn pdf_text_is_cleaned_and_spans_point_into_it() {
627 let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
628 2. Jones K. Two. 2021.";
629 let refs = split_references(list);
630 assert_eq!(
631 refs[0].text,
632 "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
633 );
634 assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
635 assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
636 }
637
638 #[test]
639 fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
640 let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
641 assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
642 let alt = &refs[0].alternatives[0];
643 assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
644 assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
645 assert!(refs[1].alternatives.is_empty());
646 }
647
648 #[test]
649 fn hanging_indents_mark_starts_that_text_cannot() {
650 let lines = [
653 IndentedLine::new("References", 72.4),
654 IndentedLine::new("World Health Organization. Global report.", 72.0),
655 IndentedLine::new("Geneva: WHO; 2020.", 90.2),
656 IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
657 ];
658 let refs = split_lines(&lines);
659 assert_eq!(
660 refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
661 [
662 "World Health Organization. Global report. Geneva: WHO; 2020.",
663 "Smith J. A study. Nature. 2021.",
664 ]
665 );
666 assert_eq!(refs[0].lines, 1..3);
667 assert_eq!(refs[1].lines, 3..4);
668 }
669
670 #[test]
671 fn first_line_indents_and_flat_lists() {
672 let lines = [
673 IndentedLine::new("Smith J. A study of", 90.0),
674 IndentedLine::new("Things. Nature. 2021.", 72.0),
675 IndentedLine::new("Jones K. Another. 2022.", 90.0),
676 ];
677 assert_eq!(split_lines(&lines).len(), 2);
678 let flat = [
680 IndentedLine::new("1. Smith J. One.", 72.0),
681 IndentedLine::new("2020.", 72.0),
682 IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
683 ];
684 let refs = split_lines(&flat);
685 assert_eq!(refs[0].text, "Smith J. One. 2020.");
686 assert_eq!(refs[1].lines, 2..3);
687 }
688
689 #[test]
690 fn spans_cover_the_reference_without_its_marker() {
691 let list = "1. Smith J. One.\r\n 2020.\r\n2. Jones K. Two. 2021.";
692 let refs = split_references(list);
693 assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n 2020.");
694 assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
695 }
696
697 #[test]
698 fn a_single_reference_is_one() {
699 assert_eq!(
700 texts("Smith J. A study.\nJournal 2020;1:2."),
701 ["Smith J. A study. Journal 2020;1:2."]
702 );
703 assert!(texts(" \n\n").is_empty());
704 }
705}