use crate::report::Container;
use std::ops::Range;
pub(crate) struct Containers {
fenced: Vec<Range<usize>>,
blockquote: Vec<Range<usize>>,
quoted: Vec<Range<usize>>,
heading: Vec<Range<usize>>,
}
pub(crate) fn fenced_ranges(src: &str) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut open: Option<usize> = None;
for (start, line) in lines_with_offsets(src) {
if line.trim_start().starts_with("```") {
match open.take() {
Some(s) => out.push(s..start + line.len()),
None => open = Some(start),
}
}
}
if let Some(s) = open {
out.push(s..src.len());
}
out
}
fn lines_with_offsets(src: &str) -> impl Iterator<Item = (usize, &str)> {
let mut at = 0usize;
std::iter::from_fn(move || {
if at >= src.len() {
return None;
}
let start = at;
let rest = &src[at..];
let (line, advance) = match rest.find('\n') {
Some(n) => (&rest[..n], n + 1),
None => (rest, rest.len()),
};
at += advance;
let line = line.strip_suffix('\r').unwrap_or(line);
Some((start, line))
})
}
fn title_case_heading(line: &str) -> bool {
let t = line.trim();
if t.is_empty() || t.len() > 60 {
return false;
}
if t.ends_with(['.', '!', '?', ',', ';', ':']) {
return false;
}
let stop = [
"a", "an", "the", "and", "or", "of", "in", "on", "for", "to", "with", "at", "by", "vs",
];
let mut words = t.split_whitespace();
let Some(first) = words.next() else {
return false;
};
let leads_upper = |w: &str| {
w.chars()
.next()
.map(|c| c.is_uppercase() || c.is_ascii_digit())
.unwrap_or(false)
};
if !leads_upper(first) {
return false;
}
words.all(|w| leads_upper(w) || stop.contains(&w.to_ascii_lowercase().as_str()))
}
impl Containers {
pub(crate) fn scan(src: &str) -> Self {
let fenced = fenced_ranges(src);
let mut blockquote = Vec::new();
let mut heading = Vec::new();
let mut quoted = Vec::new();
let mut open_straight: Option<usize> = None;
let mut open_curly: Option<usize> = None;
for (start, line) in lines_with_offsets(src) {
if line.trim().is_empty() {
open_straight = None;
open_curly = None;
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('>') {
blockquote.push(start..start + line.len());
} else if trimmed.starts_with('#') || title_case_heading(line) {
heading.push(start..start + line.len());
}
for (i, c) in line.char_indices() {
let abs = start + i;
match c {
'"' => match open_straight.take() {
Some(s) => quoted.push(s..abs + c.len_utf8()),
None => open_straight = Some(abs),
},
'\u{201C}' => {
if open_curly.is_none() {
open_curly = Some(abs);
}
}
'\u{201D}' => {
if let Some(s) = open_curly.take() {
quoted.push(s..abs + c.len_utf8());
}
}
_ => {}
}
}
}
quoted.sort_by_key(|r: &Range<usize>| (r.start, r.end));
let mut merged: Vec<Range<usize>> = Vec::new();
for r in quoted {
match merged.last_mut() {
Some(m) if r.start <= m.end => m.end = m.end.max(r.end),
_ => merged.push(r),
}
}
Containers {
fenced,
blockquote,
quoted: merged,
heading,
}
}
pub(crate) fn classify(&self, at: usize) -> Container {
if covers(&self.fenced, at) {
Container::FencedCode
} else if covers(&self.blockquote, at) {
Container::Blockquote
} else if covers(&self.quoted, at) {
Container::Quoted
} else if covers(&self.heading, at) {
Container::Heading
} else {
Container::Prose
}
}
}
fn covers(ranges: &[Range<usize>], at: usize) -> bool {
let i = ranges.partition_point(|r| r.end <= at);
ranges.get(i).map(|r| r.start <= at).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fence_toggle_and_unclosed_fence() {
let src = "prose\n```\ncode here\n```\nafter\n```\ntail";
let f = fenced_ranges(src);
assert_eq!(f.len(), 2);
assert!(src[f[0].clone()].contains("code here"));
assert_eq!(f[1].end, src.len(), "unclosed fence runs to EOF");
assert!(!f[0].contains(&src.find("after").unwrap()));
}
#[test]
fn classification_precedence_and_reset() {
let src = "# Heading Line\n\nplain prose \"a quoted span\" more\n\n> quoted block\n\nAn \"unbalanced quote\n\nclean prose after the blank line\n";
let c = Containers::scan(src);
assert_eq!(c.classify(0), Container::Heading);
assert_eq!(c.classify(src.find("plain").unwrap()), Container::Prose);
assert_eq!(
c.classify(src.find("quoted span").unwrap()),
Container::Quoted
);
assert_eq!(
c.classify(src.find("> quoted").unwrap()),
Container::Blockquote
);
assert_eq!(
c.classify(src.find("clean prose").unwrap()),
Container::Prose
);
}
#[test]
fn nested_and_interleaved_quotes_classify_safely() {
let src = "He said \"we delve into \u{201C}nested\u{201D} data\" today.\n\
Then \u{201C}curly holds \"straight inside\" still\u{201D} here.\n\
Interleaved \"straight \u{201C}then curly\" closes\u{201D} after.\n\
Plain prose closes the document.\n";
let c = Containers::scan(src);
for w in c.quoted.windows(2) {
assert!(w[0].end <= w[1].start, "overlap survived: {:?}", c.quoted);
}
assert_eq!(c.classify(src.find("delve").unwrap()), Container::Quoted);
assert_eq!(c.classify(src.find("nested").unwrap()), Container::Quoted);
assert_eq!(
c.classify(src.find(" data").unwrap() + 1),
Container::Quoted
);
assert_eq!(c.classify(src.find("today").unwrap()), Container::Prose);
assert_eq!(
c.classify(src.find("curly holds").unwrap()),
Container::Quoted
);
assert_eq!(c.classify(src.find("here").unwrap()), Container::Prose);
assert_eq!(
c.classify(src.find("then curly").unwrap()),
Container::Quoted
);
assert_eq!(c.classify(src.find("after").unwrap()), Container::Prose);
assert_eq!(
c.classify(src.find("Plain prose").unwrap()),
Container::Prose
);
assert_eq!(c.classify(src.len().saturating_sub(1)), Container::Prose);
}
#[test]
fn title_case_heading_is_short_and_unpunctuated() {
assert!(title_case_heading("Quarterly Revenue Summary"));
assert!(title_case_heading("Roadmap for the Next Phase"));
assert!(!title_case_heading("The vendor sent the revised quote."));
assert!(!title_case_heading("plain lowercase line"));
assert!(!title_case_heading("Hi Omar,"));
}
}