use crate::scan::{is_python_space, py_trim_start};
#[must_use]
pub fn is_whole_line_bold(body: &str) -> bool {
let Some(rest) = py_trim_start(body).strip_prefix("**") else {
return false;
};
let label = up_to_first_star(rest);
if label.is_empty() {
return false;
}
let Some(tail) = rest[label.len()..].strip_prefix("**") else {
return false;
};
tail.chars().all(is_python_space)
}
#[must_use]
pub fn is_bold_colon_prefix(body: &str) -> bool {
let Some(rest) = body.strip_prefix("**") else {
return false;
};
let mut at = 0;
for c in rest.chars() {
if c == '*' {
break;
}
at += c.len_utf8();
let tail = &rest[at..];
if tail.starts_with(":**") || tail.starts_with("**:") {
return true;
}
}
false
}
#[must_use]
pub fn is_speaker_prefix(body: &str) -> bool {
let bytes = body.as_bytes();
let Some(first) = match_first_word(bytes) else {
return false;
};
let mut at = first;
let mut extra_words = 0;
loop {
if colon_then_whitespace(body, at) {
return true;
}
if extra_words == 3 {
return false;
}
let Some(next) = match_extra_word(bytes, at) else {
return false;
};
at = next;
extra_words += 1;
}
}
#[must_use]
pub fn is_bracketed_line(body: &str) -> bool {
let Some(rest) = body.strip_prefix('[') else {
return false;
};
let Some(end) = rest.find(['[', ']']) else {
return false;
};
if rest.as_bytes()[end] != b']' {
return false;
}
let tail = &rest[end + 1..];
tail.strip_prefix('.')
.unwrap_or(tail)
.chars()
.all(is_python_space)
}
#[must_use]
pub fn is_label_line(content: &str) -> bool {
let stripped = py_trim_start(content);
is_bold_colon_prefix(stripped)
|| is_speaker_prefix(stripped)
|| is_bracketed_line(stripped)
|| is_whole_line_bold(stripped)
}
fn up_to_first_star(s: &str) -> &str {
match s.find('*') {
Some(end) => &s[..end],
None => s,
}
}
fn is_speaker_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')
}
fn match_first_word(bytes: &[u8]) -> Option<usize> {
if !bytes.first()?.is_ascii_uppercase() {
return None;
}
Some(word_end(bytes, 1))
}
fn match_extra_word(bytes: &[u8], at: usize) -> Option<usize> {
if bytes.get(at) != Some(&b' ') || !bytes.get(at + 1)?.is_ascii_uppercase() {
return None;
}
Some(word_end(bytes, at + 2))
}
fn word_end(bytes: &[u8], at: usize) -> usize {
let mut end = at;
while bytes.get(end).is_some_and(|b| is_speaker_word_byte(*b)) {
end += 1;
}
end
}
fn colon_then_whitespace(body: &str, at: usize) -> bool {
if body.as_bytes().get(at) != Some(&b':') {
return false;
}
body[at + 1..].chars().next().is_some_and(is_python_space)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn speaker_prefixes_are_ascii_uppercase_only() {
assert!(is_speaker_prefix("Alex: hello"));
assert!(!is_speaker_prefix("\u{c9}mile: bonjour"));
assert!(!is_speaker_prefix("alex: hello"));
assert!(!is_speaker_prefix("1A: x"));
}
#[test]
fn a_speaker_prefix_allows_at_most_four_words() {
assert!(is_speaker_prefix("A: x"));
assert!(is_speaker_prefix("A B C: x"));
assert!(is_speaker_prefix("A B C D: x"));
assert!(!is_speaker_prefix("A B C D E: x"));
assert!(!is_speaker_prefix("A B: x"));
assert!(!is_speaker_prefix("A B C: x"));
}
#[test]
fn a_speaker_prefix_needs_a_colon_and_one_whitespace() {
assert!(is_speaker_prefix("A:\tx"));
assert!(!is_speaker_prefix("A:x"));
assert!(!is_speaker_prefix("A:"));
assert!(is_speaker_prefix("Dr.X: hi"));
assert!(is_speaker_prefix("A-B: x"));
assert!(is_speaker_prefix("A_1: x"));
}
#[test]
fn label_patterns_are_prefixes_not_whole_lines() {
assert!(is_bold_colon_prefix("**Remove:** the thing"));
assert!(is_bold_colon_prefix("**On X**: \"quote\""));
assert!(is_bold_colon_prefix("**a**:"));
assert!(is_bold_colon_prefix("**a:**"));
assert!(!is_bold_colon_prefix("**:** empty label"));
assert!(!is_bold_colon_prefix("**a**"));
assert!(is_bold_colon_prefix("** **: x"));
assert!(!is_bold_colon_prefix("**a*b:** x"));
assert!(!is_bold_colon_prefix(" **a:** x"));
assert!(is_label_line(" **a:** x"));
}
#[test]
fn a_negated_class_matches_a_newline_too() {
assert!(is_bold_colon_prefix("**a\nb:** x"));
assert!(is_whole_line_bold("**a\nb**"));
}
#[test]
fn whole_line_bold_wants_the_whole_line() {
assert!(is_whole_line_bold("**Title**"));
assert!(is_whole_line_bold(" **Title** "));
assert!(is_whole_line_bold("**a**\n"));
assert!(is_whole_line_bold("**a**\n\n"));
assert!(!is_whole_line_bold("**a** **b**"));
assert!(!is_whole_line_bold("**a***"));
assert!(!is_whole_line_bold("****"));
assert!(!is_whole_line_bold("**a**x"));
assert!(!is_whole_line_bold("*a*"));
}
#[test]
fn a_bracketed_line_takes_one_optional_trailing_dot() {
assert!(is_bracketed_line("[All agreed.]"));
assert!(is_bracketed_line("[All agreed.]."));
assert!(is_bracketed_line("[]"));
assert!(is_bracketed_line("[a] "));
assert!(is_bracketed_line("[a]\n"));
assert!(!is_bracketed_line("[a]x"));
assert!(!is_bracketed_line("[a].."));
assert!(!is_bracketed_line("[a[b]]"));
assert!(!is_bracketed_line(" [a]"));
assert!(is_label_line(" [a]"));
}
#[test]
fn a_label_line_is_any_of_the_four() {
assert!(is_label_line("Alex: \"utterance\""));
assert!(is_label_line("**Remove:** it"));
assert!(is_label_line("[stage direction]"));
assert!(is_label_line("**Title**"));
assert!(!is_label_line("ordinary prose"));
assert!(!is_label_line(""));
}
}