use std::sync::OnceLock;
use regex::Regex;
use crate::{error::Error, types::Section};
fn heading_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?m)^[ \t]*(?:(?:ARTICLE|CHAPTER|PART)\s+(?:[IVXLCDM]{1,8}|\d+)|(?:SECTION|Section)\s+\d+(?:\.\d+)*|[IVXLCDM]{1,5}\.\s+[A-Z]|[A-Z][A-Z &\-/]{2,}[A-Z])[ \t]*[:\.]?[ \t]*$",
)
.expect("heading regex is valid")
})
}
pub fn parse_text(input: &str) -> Result<Vec<Section>, Error> {
if input.is_empty() {
return Ok(Vec::new());
}
let re = heading_re();
let headings: Vec<_> = re.find_iter(input).collect();
if headings.len() < 2 {
return Ok(vec![Section {
heading: None,
depth: 0,
text: input.to_string(),
byte_range: 0..input.len(),
}]);
}
let mut sections = Vec::new();
let first_start = headings[0].start();
if first_start > 0 {
let preamble = input[..first_start].trim();
if !preamble.is_empty() {
sections.push(Section {
heading: None,
depth: 0,
text: preamble.to_string(),
byte_range: 0..first_start,
});
}
}
for (idx, m) in headings.iter().enumerate() {
let heading_text = m.as_str().trim().to_string();
let body_start = m.end();
let body_end = headings
.get(idx + 1)
.map(regex::Match::start)
.unwrap_or(input.len());
let body = input[body_start..body_end].trim();
sections.push(Section {
heading: Some(heading_text),
depth: 1,
text: body.to_string(),
byte_range: m.start()..body_end,
});
}
Ok(sections)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_section_pass_through() {
let input = "hello\nworld\n";
let sections = parse_text(input).unwrap();
assert_eq!(sections.len(), 1);
assert!(sections[0].heading.is_none());
assert_eq!(sections[0].depth, 0);
assert_eq!(sections[0].text, input);
}
#[test]
fn byte_range_covers_full_input() {
let input = "some plain text";
let sections = parse_text(input).unwrap();
assert_eq!(sections[0].byte_range, 0..input.len());
}
#[test]
fn empty_input_yields_no_sections() {
let sections = parse_text("").unwrap();
assert!(sections.is_empty());
}
#[test]
fn structural_article_splits() {
let input = "Preamble here.\n\nARTICLE I\nContent of article one.\n\nARTICLE II\nContent of article two.\n";
let sections = parse_text(input).unwrap();
assert!(
sections.len() >= 2,
"expected at least 2 sections, got {}",
sections.len()
);
let headings: Vec<_> = sections
.iter()
.filter_map(|s| s.heading.as_deref())
.collect();
assert!(
headings.iter().any(|h| h.contains("ARTICLE I")),
"ARTICLE I not found in {headings:?}"
);
assert!(
headings.iter().any(|h| h.contains("ARTICLE II")),
"ARTICLE II not found in {headings:?}"
);
}
#[test]
fn structural_section_numbering() {
let input = "Section 1\nIntroduction text.\n\nSection 2\nBody text here.\n";
let sections = parse_text(input).unwrap();
assert!(sections.len() >= 2, "got {}", sections.len());
assert!(
sections
.iter()
.any(|s| s.heading.as_deref() == Some("Section 1"))
);
assert!(
sections
.iter()
.any(|s| s.heading.as_deref() == Some("Section 2"))
);
}
#[test]
fn no_structural_headings_single_section() {
let input = "ARTICLE I\nOnly one article here.\n";
let sections = parse_text(input).unwrap();
assert_eq!(sections.len(), 1);
assert!(sections[0].heading.is_none());
}
}