markdown_prose_hooks/
transcript.rs1use crate::scan::{py_splitlines_keepends, py_trim, split_eol};
17
18pub const TRANSCRIPT_HEADING_FLOOR: usize = 2;
20
21pub const TRANSCRIPT_HEADING_RATIO: f64 = 0.05;
27
28#[must_use]
32pub fn match_bare_speaker_heading(body: &str) -> bool {
33 let bytes = body.as_bytes();
34 if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
35 return false;
36 }
37 let mut end = 1;
38 while bytes.get(end).is_some_and(|b| is_bare_class_byte(*b)) {
39 end += 1;
40 }
41 if end - 1 > 39 {
45 return false;
46 }
47 ends_after_colon(body, bytes, end)
48}
49
50#[must_use]
60pub fn match_timestamped_speaker_heading(body: &str) -> bool {
61 let bytes = body.as_bytes();
62 if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
63 return false;
64 }
65 let mut end = 1;
66 while bytes.get(end).is_some_and(|b| is_name_class_byte(*b)) {
69 end += 1;
70 }
71 if end - 1 > 19 || bytes.get(end) != Some(&b' ') {
72 return false;
73 }
74 let start = end + 1;
75 let mut run = 0;
76 while run < 2 && bytes.get(start + run).is_some_and(u8::is_ascii_digit) {
77 run += 1;
78 }
79 (1..=run).rev().any(|hours| {
82 let colon = start + hours;
83 bytes.get(colon) == Some(&b':')
84 && bytes.get(colon + 1).is_some_and(u8::is_ascii_digit)
85 && bytes.get(colon + 2).is_some_and(u8::is_ascii_digit)
86 && ends_here(body, colon + 3)
87 })
88}
89
90#[must_use]
92pub fn is_transcript_like_markdown(text: &str) -> bool {
93 let bodies: Vec<&str> = py_splitlines_keepends(text)
94 .iter()
95 .map(|line| py_trim(split_eol(line).0))
96 .collect();
97 let mut headings = 0;
98 let mut non_blank = 0;
99 for (index, body) in bodies.iter().enumerate() {
100 if !body.is_empty() {
101 non_blank += 1;
102 }
103 let next_body = bodies.get(index + 1).copied().unwrap_or("");
104 let counts = if match_bare_speaker_heading(body) {
108 next_body.is_empty()
109 } else if match_timestamped_speaker_heading(body) {
110 !next_body.is_empty()
111 } else {
112 false
113 };
114 if counts {
115 headings += 1;
116 }
117 }
118 if headings < TRANSCRIPT_HEADING_FLOOR || non_blank == 0 {
119 return false;
120 }
121 headings as f64 / non_blank as f64 >= TRANSCRIPT_HEADING_RATIO
124}
125
126fn is_bare_class_byte(b: u8) -> bool {
128 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b' ' | b'-')
129}
130
131fn is_name_class_byte(b: u8) -> bool {
133 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')
134}
135
136fn ends_after_colon(body: &str, bytes: &[u8], at: usize) -> bool {
138 bytes.get(at) == Some(&b':') && ends_here(body, at + 1)
139}
140
141fn ends_here(body: &str, at: usize) -> bool {
148 matches!(&body[at..], "" | "\n")
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn a_bare_heading_counts_characters_over_an_ascii_class() {
157 assert!(match_bare_speaker_heading("MC:"));
158 assert!(match_bare_speaker_heading("A:"));
159 assert!(match_bare_speaker_heading("A B C D E F:"));
160 assert!(match_bare_speaker_heading("A.B-C_1:"));
161 assert!(match_bare_speaker_heading(&format!("A{}:", "e".repeat(39))));
163 assert!(!match_bare_speaker_heading(&format!(
164 "A{}:",
165 "e".repeat(40)
166 )));
167 assert!(!match_bare_speaker_heading(&format!(
169 "A{}:",
170 "\u{e9}".repeat(39)
171 )));
172 assert!(!match_bare_speaker_heading("a:"));
173 assert!(!match_bare_speaker_heading("A"));
174 assert!(!match_bare_speaker_heading("A::"));
175 assert!(!match_bare_speaker_heading("A:b"));
176 assert!(!match_bare_speaker_heading(":"));
177 }
178
179 #[test]
180 fn a_timestamped_heading_is_ascii_digits_only() {
181 assert!(match_timestamped_speaker_heading("MC 0:15"));
184 assert!(match_timestamped_speaker_heading("MC 12:34"));
185 assert!(!match_timestamped_speaker_heading(
186 "MC \u{660}:\u{661}\u{665}"
187 ));
188 assert!(!match_timestamped_speaker_heading(
189 "MC \u{967}\u{968}:\u{969}\u{969}"
190 ));
191 }
192
193 #[test]
194 fn a_timestamp_takes_one_or_two_digits_then_exactly_two() {
195 assert!(match_timestamped_speaker_heading("MC 1:23"));
197 assert!(!match_timestamped_speaker_heading("MC 123:45"));
198 assert!(!match_timestamped_speaker_heading("MC 1:2"));
199 assert!(!match_timestamped_speaker_heading("MC 1:234"));
200 assert!(!match_timestamped_speaker_heading("MC1:23"));
201 assert!(!match_timestamped_speaker_heading("M C 1:23"));
203 assert!(match_timestamped_speaker_heading(&format!(
204 "A{} 1:23",
205 "e".repeat(19)
206 )));
207 assert!(!match_timestamped_speaker_heading(&format!(
208 "A{} 1:23",
209 "e".repeat(20)
210 )));
211 }
212
213 #[test]
214 fn the_end_anchor_takes_one_newline_and_only_a_newline() {
215 assert!(match_bare_speaker_heading("A:\n"));
216 assert!(match_timestamped_speaker_heading("MC 0:15\n"));
217 assert!(!match_timestamped_speaker_heading("MC 0:15\r"));
218 }
219
220 #[test]
221 fn two_headings_over_a_short_document_do_classify() {
222 assert!(is_transcript_like_markdown("MC:\n\na\nb\n\nJR:\n\nc\nd\n"));
223 assert!(is_transcript_like_markdown(
224 "MC 0:15\nhello\n\nJR 0:20\nthere\n"
225 ));
226 }
227
228 #[test]
229 fn the_density_gate_keeps_prose_out() {
230 let mut doc = String::from("Concretely:\n\n");
231 for _ in 0..200 {
232 doc.push_str("A line of ordinary prose.\n");
233 }
234 doc.push_str("\nFinal note:\n");
235 assert!(!is_transcript_like_markdown(&doc));
236 }
237
238 #[test]
239 fn a_heading_needs_the_right_neighbor_to_count() {
240 assert!(!is_transcript_like_markdown("MC:\nJR:\n"));
243 assert!(!is_transcript_like_markdown("MC:\n\na\n"));
244 assert!(!is_transcript_like_markdown(""));
245 }
246}