use crate::{Error, Result};
pub(super) fn parse_bullet_items(prose: &str, section: &str) -> Result<Vec<String>> {
let mut items = Vec::new();
for line in prose.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match classify_list_line(trimmed) {
ListLine::Item(content) => items.push(content.to_string()),
ListLine::EmptyMarker => {
return Err(Error::Parse(format!(
"empty bullet item in list section `{section}`"
)));
}
ListLine::NotAMarker => {
return Err(Error::Parse(format!(
"section `{section}` is a list section but contains non-list content: {trimmed}"
)));
}
}
}
if items.is_empty() {
return Err(Error::Parse(format!(
"section `{section}` is a list section but has no items"
)));
}
Ok(items)
}
pub(super) fn is_all_list_markers(prose: &str) -> bool {
let mut saw_marker = false;
for line in prose.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match classify_list_line(trimmed) {
ListLine::NotAMarker => return false,
ListLine::Item(_) | ListLine::EmptyMarker => saw_marker = true,
}
}
saw_marker
}
enum ListLine<'a> {
Item(&'a str),
EmptyMarker,
NotAMarker,
}
fn classify_list_line(trimmed: &str) -> ListLine<'_> {
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
return if rest.trim().is_empty() {
ListLine::EmptyMarker
} else {
ListLine::Item(rest)
};
}
if trimmed == "-" || trimmed == "*" {
return ListLine::EmptyMarker;
}
let digits = trimmed
.as_bytes()
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
if digits == 0 {
return ListLine::NotAMarker;
}
let after_digits = &trimmed[digits..];
let Some(after_punct) = after_digits
.strip_prefix('.')
.or_else(|| after_digits.strip_prefix(')'))
else {
return ListLine::NotAMarker;
};
if after_punct.is_empty() {
return ListLine::EmptyMarker;
}
if let Some(content) = after_punct.strip_prefix(' ') {
return if content.trim().is_empty() {
ListLine::EmptyMarker
} else {
ListLine::Item(content)
};
}
ListLine::NotAMarker
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_and_bare_markers_are_classified() {
assert!(matches!(
classify_list_line("- item"),
ListLine::Item("item")
));
assert!(matches!(
classify_list_line("1. item"),
ListLine::Item("item")
));
assert!(matches!(classify_list_line("-"), ListLine::EmptyMarker));
assert!(matches!(classify_list_line("1."), ListLine::EmptyMarker));
assert!(matches!(classify_list_line("1. "), ListLine::EmptyMarker));
assert!(matches!(classify_list_line("1)"), ListLine::EmptyMarker));
assert!(matches!(classify_list_line("1.foo"), ListLine::NotAMarker));
assert!(matches!(
classify_list_line("plain prose"),
ListLine::NotAMarker
));
}
#[test]
fn all_markers_predicate() {
assert!(is_all_list_markers("- a\n1. b"));
assert!(!is_all_list_markers("- a\nplain line"));
assert!(!is_all_list_markers(" \n "));
}
}