use strum::EnumCount;
const UNICODE_BULLETS: &[char] = &[
'•', '◦', '▪', '▫', '■', '□', '●', '○', '⁃', '⁌', '⁍', '◆', '◇', '★', '☆', '➤', '➢', '➣', '▶',
'▸', '►',
];
const MIN_GAP_BYTES: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumCount)]
enum MarkerFamily {
Tier1, Bullet, Numeric, LetterParen, LetterDot, Roman, }
impl MarkerFamily {
fn idx(self) -> usize {
self as usize
}
fn allowed_inline(self) -> bool {
!matches!(self, MarkerFamily::Bullet)
}
}
#[derive(Debug, Clone, Copy)]
struct Candidate {
pos: usize,
family: MarkerFamily,
first_byte: u8,
line_start: bool,
}
impl Candidate {
fn line_start(pos: usize, hit: MarkerHit) -> Self {
Self {
pos,
family: hit.family,
first_byte: hit.first_byte,
line_start: true,
}
}
fn inline(pos: usize, hit: MarkerHit) -> Self {
Self {
pos,
family: hit.family,
first_byte: hit.first_byte,
line_start: false,
}
}
fn is_roman_letter_shape(&self) -> bool {
matches!(
self.family,
MarkerFamily::LetterDot | MarkerFamily::LetterParen
) && is_roman_byte(self.first_byte)
}
}
#[derive(Debug, Clone, Copy)]
struct MarkerHit {
family: MarkerFamily,
first_byte: u8,
}
pub(crate) fn detect_list_items(paragraph: &str) -> Vec<usize> {
let bytes = paragraph.as_bytes();
let mut candidates: Vec<Candidate> = Vec::with_capacity((paragraph.len() / 50).max(1));
let mut line_start = 0usize;
for nl_pos in memchr::memchr_iter(b'\n', bytes) {
scan_line(paragraph, line_start, nl_pos + 1, &mut candidates);
line_start = nl_pos + 1;
}
if line_start < bytes.len() {
scan_line(paragraph, line_start, bytes.len(), &mut candidates);
}
finalise(candidates)
}
fn scan_line(text: &str, line_start: usize, line_end: usize, out: &mut Vec<Candidate>) {
let bytes = text.as_bytes();
let content_start = first_non_ws(bytes, line_start, line_end);
if let Some(hit) = classify_line(&text[content_start..line_end]) {
out.push(Candidate::line_start(line_start, hit));
}
let mut cursor = content_start;
while let Some(rel) = memchr::memchr2(b' ', b'\t', &bytes[cursor..line_end]) {
let pos = first_non_ws(bytes, cursor + rel, line_end);
if pos >= line_end || matches!(bytes[pos], b'\n' | b'\r') {
break;
}
if let Some(hit) = classify_marker_at(&text[pos..line_end]) {
out.push(Candidate::inline(pos, hit));
}
cursor = pos;
}
}
fn first_non_ws(bytes: &[u8], start: usize, end: usize) -> usize {
let mut i = start;
while i < end && is_horiz_ws(bytes[i]) {
i += 1;
}
i
}
fn classify_line(line: &str) -> Option<MarkerHit> {
let first_byte = *line.as_bytes().first()?;
let (family, len) = consume_marker(line)?;
next_content_char(&line[len..])?;
Some(MarkerHit { family, first_byte })
}
fn classify_marker_at(s: &str) -> Option<MarkerHit> {
let first_byte = *s.as_bytes().first()?;
let (family, len) = consume_marker(s)?;
if is_bare_dot_closer(s.as_bytes(), family, len) {
return None;
}
let next = next_content_char(&s[len..])?;
if next.is_lowercase() {
return None;
}
if !family.allowed_inline() {
return None;
}
Some(MarkerHit { family, first_byte })
}
fn is_bare_dot_closer(bytes: &[u8], family: MarkerFamily, marker_len: usize) -> bool {
bytes[marker_len - 1] == b'.'
&& matches!(
family,
MarkerFamily::Numeric | MarkerFamily::LetterDot | MarkerFamily::Roman
)
}
fn next_content_char(after_marker: &str) -> Option<char> {
let bytes = after_marker.as_bytes();
let n = first_non_ws(bytes, 0, bytes.len());
if n == 0 {
return None;
}
let c = after_marker[n..].chars().next()?;
(c != '\n' && c != '\r').then_some(c)
}
fn consume_marker(s: &str) -> Option<(MarkerFamily, usize)> {
None.or_else(|| match_unicode_bullet(s).map(|n| (MarkerFamily::Tier1, n)))
.or_else(|| match_paren_form(s).map(|n| (MarkerFamily::Tier1, n)))
.or_else(|| match_roman(s).map(|n| (MarkerFamily::Roman, n)))
.or_else(|| match_numeric(s).map(|n| (MarkerFamily::Numeric, n)))
.or_else(|| match_ascii_bullet(s).map(|n| (MarkerFamily::Bullet, n)))
.or_else(|| match_letter_paren(s).map(|n| (MarkerFamily::LetterParen, n)))
.or_else(|| match_letter_dot(s).map(|n| (MarkerFamily::LetterDot, n)))
}
fn match_unicode_bullet(s: &str) -> Option<usize> {
let c = s.chars().next()?;
UNICODE_BULLETS.contains(&c).then(|| c.len_utf8())
}
fn match_ascii_bullet(s: &str) -> Option<usize> {
match s.as_bytes().first()? {
b'*' | b'+' | b'-' => Some(1),
_ => None,
}
}
fn match_numeric(s: &str) -> Option<usize> {
let b = s.as_bytes();
let n = b.iter().take_while(|c| c.is_ascii_digit()).count();
if n == 0 {
return None;
}
closer_len_at(b, n).map(|cl| n + cl)
}
fn match_roman(s: &str) -> Option<usize> {
let b = s.as_bytes();
let n = b.iter().take_while(|&&c| is_roman_byte(c)).count();
if n < 2 {
return None;
}
closer_len_at(b, n).map(|cl| n + cl)
}
fn match_letter_paren(s: &str) -> Option<usize> {
let b = s.as_bytes();
if b.len() < 2 || !b[0].is_ascii_alphabetic() {
return None;
}
match b[1] {
b')' => Some(2),
b'.' if b.get(2) == Some(&b')') => Some(3),
_ => None,
}
}
fn match_letter_dot(s: &str) -> Option<usize> {
let b = s.as_bytes();
(b.len() >= 2 && b[0].is_ascii_lowercase() && b[1] == b'.').then_some(2)
}
fn match_paren_form(s: &str) -> Option<usize> {
let inside = s.strip_prefix('(')?;
let close = inside.find(')')?;
let inner = &inside.as_bytes()[..close];
let short_numeric = (1..=2).contains(&inner.len()) && inner.iter().all(|b| b.is_ascii_digit());
let single_letter = inner.len() == 1 && inner[0].is_ascii_alphabetic();
let roman = !inner.is_empty() && inner.iter().all(|&b| is_roman_byte(b));
(short_numeric || single_letter || roman).then_some(close + 2)
}
fn closer_len_at(b: &[u8], pos: usize) -> Option<usize> {
match b.get(pos)? {
b'.' if b.get(pos + 1) == Some(&b')') => Some(2),
b'.' | b')' => Some(1),
_ => None,
}
}
fn is_horiz_ws(b: u8) -> bool {
b == b' ' || b == b'\t'
}
fn is_roman_byte(b: u8) -> bool {
matches!(
b.to_ascii_lowercase(),
b'i' | b'v' | b'x' | b'l' | b'c' | b'd' | b'm'
)
}
fn finalise(mut candidates: Vec<Candidate>) -> Vec<usize> {
if candidates.is_empty() {
return Vec::new();
}
sort_and_dedup(&mut candidates);
promote_roman_letters(&mut candidates);
gap_prune_per_family(&mut candidates);
let Some(winner) = pick_winner(&candidates) else {
return Vec::new();
};
candidates
.into_iter()
.filter(|c| c.family == winner)
.map(|c| c.pos)
.collect()
}
fn sort_and_dedup(candidates: &mut Vec<Candidate>) {
candidates.sort_by_key(|c| (c.pos, !c.line_start));
candidates.dedup_by_key(|c| c.pos);
}
fn promote_roman_letters(candidates: &mut [Candidate]) {
if !candidates.iter().any(|c| c.family == MarkerFamily::Roman) {
return;
}
for c in candidates {
if c.is_roman_letter_shape() {
c.family = MarkerFamily::Roman;
}
}
}
fn gap_prune_per_family(candidates: &mut Vec<Candidate>) {
let mut family_last = [usize::MAX; MarkerFamily::COUNT];
candidates.retain(|c| {
let last = family_last[c.family.idx()];
let keep = last == usize::MAX || c.pos - last >= MIN_GAP_BYTES;
if keep {
family_last[c.family.idx()] = c.pos;
}
keep
});
}
fn pick_winner(candidates: &[Candidate]) -> Option<MarkerFamily> {
let mut counts = [0u32; MarkerFamily::COUNT];
let mut first_pos = [usize::MAX; MarkerFamily::COUNT];
let mut tier1_at_line_start = false;
for c in candidates {
let i = c.family.idx();
counts[i] += 1;
if first_pos[i] == usize::MAX {
first_pos[i] = c.pos;
}
if c.family == MarkerFamily::Tier1 && c.line_start {
tier1_at_line_start = true;
}
}
if tier1_at_line_start || counts[MarkerFamily::Tier1.idx()] >= 2 {
return Some(MarkerFamily::Tier1);
}
const TIER2: &[MarkerFamily] = &[
MarkerFamily::Bullet,
MarkerFamily::Numeric,
MarkerFamily::LetterParen,
MarkerFamily::LetterDot,
MarkerFamily::Roman,
];
TIER2
.iter()
.copied()
.filter(|&f| counts[f.idx()] >= 2)
.max_by_key(|&f| (counts[f.idx()], std::cmp::Reverse(first_pos[f.idx()])))
}
#[cfg(test)]
mod fixture {
use crate::languages::English;
use crate::languages::tests::run_language_tests;
#[test]
fn segments_lists() {
run_language_tests(English {}, "tests/lists.txt");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn detect(s: &str) -> Vec<usize> {
detect_list_items(s)
}
#[test]
fn inline_numeric_dot_only_not_handled() {
let starts = detect("1. The first item. 2. The second item.");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn inline_letter_dot_not_handled() {
let starts = detect("a. The first item b. The second item");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn inline_roman_dot_not_handled() {
let starts = detect("ii. The first item iii. The second item");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn inline_year_parens_not_a_list() {
let s = "Examples include 'Sonar Tari' ( 1894 ), 'Chitra' ( 1896 ), \
and 'Katha O Kahini' ( 1900 ).";
assert!(detect(s).is_empty(), "got {:?}", detect(s));
let s2 = "Works (1894), (1896), and (1900) are notable.";
assert!(detect(s2).is_empty(), "got {:?}", detect(s2));
}
#[test]
fn ee_cummings_no_segmentation() {
let starts = detect("From\ne. e. cummings, with love.");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn uppercase_letter_dot_excluded() {
let starts = detect("Reviewed by\nA. Smith and\nB. Jones.");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn lone_lowercase_letter_dot_no_siblings() {
let starts = detect("The answer is a.\nFollow up later.");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn lone_numeric_after_wrap() {
let starts = detect("The total was\n1. Two hundred dollars exactly.");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn eg_at_line_start() {
let starts = detect("e.g. one\ne.g. two\n");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn marker_only_lines() {
let starts = detect("*\n*\n*\n");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn no_markers_plain_prose() {
let starts = detect("Hello world. This is a test. Three sentences here.");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn empty_paragraph() {
let starts = detect("");
assert!(starts.is_empty(), "got {starts:?}");
}
#[test]
fn inline_unicode_bullet_with_decoration() {
let s = "• 9. The first item • 10. The second item";
let starts = detect(s);
assert_eq!(starts.len(), 2);
assert_eq!(starts[0], 0);
assert_eq!(&s[starts[1]..starts[1] + 3], "•");
}
}