Skip to main content

xil_core/script/
text.rs

1//! Markdown normalization and line classification. Port of the standalone
2//! text helpers in `XILP001_script_parser.py`.
3
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8/// Direction subtypes, in the order `classify_direction` tests them.
9pub const DIRECTION_TYPES: [&str; 8] = [
10    "SFX",
11    "MUSIC",
12    "AMBIENCE",
13    "BEAT",
14    "VINTAGE FILTER",
15    "FILM AUDIO",
16    "SPEAKERPHONE",
17    "PHONE FILTER",
18];
19
20static ESCAPE_ANY: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\(.)").unwrap());
21static HEADING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^#{1,6}\s*").unwrap());
22static SCENE_HEAD: LazyLock<Regex> =
23    LazyLock::new(|| Regex::new(r"^SCENE (\d+[A-Za-z]*):\s*(.+)").unwrap());
24static SCENE_START: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r"^SCENE \d+[A-Za-z]*:").unwrap());
26static DIVIDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^={3,}$|^-{3,}$").unwrap());
27static BRACKETS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]").unwrap());
28static BRACKET_STRIP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\[[^\]]+\]").unwrap());
29
30/// Remove markdown backslash escapes.
31pub fn strip_markdown_escapes(text: &str) -> String {
32    let text = text
33        .replace("\\[", "[")
34        .replace("\\]", "]")
35        .replace("\\===", "===")
36        .replace("\\=", "=");
37    ESCAPE_ANY.replace_all(&text, "$1").into_owned()
38}
39
40/// Remove heading prefixes, bold markers and trailing whitespace, per line.
41pub fn strip_markdown_formatting(text: &str) -> String {
42    text.split('\n')
43        .map(|line| {
44            HEADING
45                .replace(line, "")
46                .replace("**", "")
47                .trim_end()
48                .to_string()
49        })
50        .collect::<Vec<_>>()
51        .join("\n")
52}
53
54/// Classify bracket-interior text into a sound category.
55pub fn classify_direction(text: &str) -> Option<&'static str> {
56    let t = text.trim();
57    for dt in DIRECTION_TYPES {
58        if t.starts_with(dt) {
59            return Some(dt);
60        }
61    }
62    if t == "BEAT" || t == "LONG BEAT" {
63        return Some("BEAT");
64    }
65    if t == "INTRO MUSIC" || t == "OUTRO MUSIC" {
66        return Some("MUSIC");
67    }
68    None
69}
70
71/// `[SFX: ...]`-style line: starts with `[` and contains `]`.
72pub fn is_stage_direction(line: &str) -> bool {
73    line.starts_with('[') && line.contains(']')
74}
75
76/// Every bracketed segment on a line, in order.
77pub fn find_brackets(line: &str) -> Vec<String> {
78    BRACKETS
79        .captures_iter(line)
80        .map(|c| c[1].to_string())
81        .collect()
82}
83
84/// Drop bracketed directions from a scene-header line.
85pub fn strip_brackets(line: &str) -> String {
86    BRACKET_STRIP
87        .replace_all(line.trim(), "")
88        .trim()
89        .to_string()
90}
91
92pub fn is_scene_header(line: &str) -> bool {
93    SCENE_START.is_match(line)
94}
95
96/// `(scene_number, scene_name)` from a `SCENE N: Name` line.
97pub fn parse_scene_header(line: &str) -> (Option<String>, Option<String>) {
98    match SCENE_HEAD.captures(line) {
99        Some(c) => (Some(c[1].to_string()), Some(c[2].trim().to_string())),
100        None => (None, None),
101    }
102}
103
104pub fn is_divider(line: &str) -> bool {
105    DIVIDER.is_match(line.trim())
106}
107
108/// Headers that begin the post-script metadata block.
109pub fn is_metadata_section(line: &str) -> bool {
110    matches!(
111        line.trim(),
112        "PRODUCTION NOTES:"
113            | "SOCIAL MEDIA PROMPT:"
114            | "KEY CHANGES FROM ORIGINAL:"
115            | "ACCESSIBILITY NOTES:"
116            | "VOICES NEEDED THIS EPISODE:"
117            | "KEY SOUND EFFECTS:"
118            | "MUSIC CUES:"
119    )
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn escapes_then_formatting() {
128        assert_eq!(
129            strip_markdown_escapes(r"\[SFX\] \=\== a\.b \~ \*"),
130            "[SFX] === a.b ~ *"
131        );
132        assert_eq!(
133            strip_markdown_formatting("## Head\n**bold** text  \nplain"),
134            "Head\nbold text\nplain"
135        );
136        assert_eq!(
137            strip_markdown_formatting("####### seven hashes"),
138            "# seven hashes"
139        );
140    }
141
142    #[test]
143    fn direction_classification() {
144        assert_eq!(classify_direction("SFX: DOOR"), Some("SFX"));
145        assert_eq!(classify_direction("  BEAT  "), Some("BEAT"));
146        assert_eq!(classify_direction("LONG BEAT"), Some("BEAT"));
147        assert_eq!(classify_direction("BEAT — 3 SECONDS"), Some("BEAT"));
148        assert_eq!(classify_direction("INTRO MUSIC"), Some("MUSIC"));
149        assert_eq!(classify_direction("OUTRO MUSIC"), Some("MUSIC"));
150        assert_eq!(
151            classify_direction("PHONE FILTER: ENGAGES"),
152            Some("PHONE FILTER")
153        );
154        assert_eq!(classify_direction("drawn out"), None);
155        assert_eq!(classify_direction(""), None);
156    }
157
158    #[test]
159    fn scene_and_divider_shapes() {
160        assert!(is_scene_header("SCENE 5A: THE ALLEY"));
161        assert!(!is_scene_header("SCENE: THE ALLEY"));
162        assert_eq!(
163            parse_scene_header("SCENE 12: THE  ROOM "),
164            (Some("12".into()), Some("THE  ROOM".into()))
165        );
166        assert_eq!(
167            parse_scene_header("SCENE 3:"),
168            (None, None),
169            "a name is required"
170        );
171        assert!(is_divider("==="));
172        assert!(is_divider("  ----- "));
173        assert!(!is_divider("--"));
174    }
175
176    #[test]
177    fn brackets_found_and_stripped() {
178        assert_eq!(
179            find_brackets("[MUSIC: A] and [SFX: B]"),
180            vec!["MUSIC: A", "SFX: B"]
181        );
182        assert_eq!(
183            find_brackets("[]"),
184            Vec::<String>::new(),
185            "empty brackets do not match"
186        );
187        assert_eq!(
188            strip_brackets("SCENE 1: ROOM [AMBIENCE: hum]"),
189            "SCENE 1: ROOM"
190        );
191        assert!(is_stage_direction("[BEAT]"));
192        assert!(!is_stage_direction("text [BEAT]"));
193    }
194}