use crate::scan::{py_splitlines_keepends, py_trim, split_eol};
pub const TRANSCRIPT_HEADING_FLOOR: usize = 2;
pub const TRANSCRIPT_HEADING_RATIO: f64 = 0.05;
#[must_use]
pub fn match_bare_speaker_heading(body: &str) -> bool {
let bytes = body.as_bytes();
if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
return false;
}
let mut end = 1;
while bytes.get(end).is_some_and(|b| is_bare_class_byte(*b)) {
end += 1;
}
if end - 1 > 39 {
return false;
}
ends_after_colon(body, bytes, end)
}
#[must_use]
pub fn match_timestamped_speaker_heading(body: &str) -> bool {
let bytes = body.as_bytes();
if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
return false;
}
let mut end = 1;
while bytes.get(end).is_some_and(|b| is_name_class_byte(*b)) {
end += 1;
}
if end - 1 > 19 || bytes.get(end) != Some(&b' ') {
return false;
}
let start = end + 1;
let mut run = 0;
while run < 2 && bytes.get(start + run).is_some_and(u8::is_ascii_digit) {
run += 1;
}
(1..=run).rev().any(|hours| {
let colon = start + hours;
bytes.get(colon) == Some(&b':')
&& bytes.get(colon + 1).is_some_and(u8::is_ascii_digit)
&& bytes.get(colon + 2).is_some_and(u8::is_ascii_digit)
&& ends_here(body, colon + 3)
})
}
#[must_use]
pub fn is_transcript_like_markdown(text: &str) -> bool {
let bodies: Vec<&str> = py_splitlines_keepends(text)
.iter()
.map(|line| py_trim(split_eol(line).0))
.collect();
let mut headings = 0;
let mut non_blank = 0;
for (index, body) in bodies.iter().enumerate() {
if !body.is_empty() {
non_blank += 1;
}
let next_body = bodies.get(index + 1).copied().unwrap_or("");
let counts = if match_bare_speaker_heading(body) {
next_body.is_empty()
} else if match_timestamped_speaker_heading(body) {
!next_body.is_empty()
} else {
false
};
if counts {
headings += 1;
}
}
if headings < TRANSCRIPT_HEADING_FLOOR || non_blank == 0 {
return false;
}
headings as f64 / non_blank as f64 >= TRANSCRIPT_HEADING_RATIO
}
fn is_bare_class_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b' ' | b'-')
}
fn is_name_class_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')
}
fn ends_after_colon(body: &str, bytes: &[u8], at: usize) -> bool {
bytes.get(at) == Some(&b':') && ends_here(body, at + 1)
}
fn ends_here(body: &str, at: usize) -> bool {
matches!(&body[at..], "" | "\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_heading_counts_characters_over_an_ascii_class() {
assert!(match_bare_speaker_heading("MC:"));
assert!(match_bare_speaker_heading("A:"));
assert!(match_bare_speaker_heading("A B C D E F:"));
assert!(match_bare_speaker_heading("A.B-C_1:"));
assert!(match_bare_speaker_heading(&format!("A{}:", "e".repeat(39))));
assert!(!match_bare_speaker_heading(&format!(
"A{}:",
"e".repeat(40)
)));
assert!(!match_bare_speaker_heading(&format!(
"A{}:",
"\u{e9}".repeat(39)
)));
assert!(!match_bare_speaker_heading("a:"));
assert!(!match_bare_speaker_heading("A"));
assert!(!match_bare_speaker_heading("A::"));
assert!(!match_bare_speaker_heading("A:b"));
assert!(!match_bare_speaker_heading(":"));
}
#[test]
fn a_timestamped_heading_is_ascii_digits_only() {
assert!(match_timestamped_speaker_heading("MC 0:15"));
assert!(match_timestamped_speaker_heading("MC 12:34"));
assert!(!match_timestamped_speaker_heading(
"MC \u{660}:\u{661}\u{665}"
));
assert!(!match_timestamped_speaker_heading(
"MC \u{967}\u{968}:\u{969}\u{969}"
));
}
#[test]
fn a_timestamp_takes_one_or_two_digits_then_exactly_two() {
assert!(match_timestamped_speaker_heading("MC 1:23"));
assert!(!match_timestamped_speaker_heading("MC 123:45"));
assert!(!match_timestamped_speaker_heading("MC 1:2"));
assert!(!match_timestamped_speaker_heading("MC 1:234"));
assert!(!match_timestamped_speaker_heading("MC1:23"));
assert!(!match_timestamped_speaker_heading("M C 1:23"));
assert!(match_timestamped_speaker_heading(&format!(
"A{} 1:23",
"e".repeat(19)
)));
assert!(!match_timestamped_speaker_heading(&format!(
"A{} 1:23",
"e".repeat(20)
)));
}
#[test]
fn the_end_anchor_takes_one_newline_and_only_a_newline() {
assert!(match_bare_speaker_heading("A:\n"));
assert!(match_timestamped_speaker_heading("MC 0:15\n"));
assert!(!match_timestamped_speaker_heading("MC 0:15\r"));
}
#[test]
fn two_headings_over_a_short_document_do_classify() {
assert!(is_transcript_like_markdown("MC:\n\na\nb\n\nJR:\n\nc\nd\n"));
assert!(is_transcript_like_markdown(
"MC 0:15\nhello\n\nJR 0:20\nthere\n"
));
}
#[test]
fn the_density_gate_keeps_prose_out() {
let mut doc = String::from("Concretely:\n\n");
for _ in 0..200 {
doc.push_str("A line of ordinary prose.\n");
}
doc.push_str("\nFinal note:\n");
assert!(!is_transcript_like_markdown(&doc));
}
#[test]
fn a_heading_needs_the_right_neighbor_to_count() {
assert!(!is_transcript_like_markdown("MC:\nJR:\n"));
assert!(!is_transcript_like_markdown("MC:\n\na\n"));
assert!(!is_transcript_like_markdown(""));
}
}