use crate::rich_string::RichString;
use crate::screenplay::Dialogue;
use crate::screenplay::DialogueElement;
use crate::screenplay::Element;
use crate::screenplay::Screenplay;
use crate::screenplay::TitlePage;
use std::iter::Peekable;
use std::str::Lines;
#[must_use]
pub fn parse(src: &str) -> Screenplay {
let cleaned = preprocess_source(src);
Parser::new(&cleaned).parse()
}
struct Parser<'a> {
lines: Peekable<Lines<'a>>,
state: State,
elements: Vec<Element>,
title_page: Option<TitlePage>,
}
impl<'a> Parser<'a> {
fn new(src: &'a str) -> Self {
Self {
lines: src.lines().peekable(),
state: State::Default,
elements: Vec::new(),
title_page: None,
}
}
fn parse(mut self) -> Screenplay {
self.parse_title();
while let Some(line) = self.lines.next() {
let trimmed = line.trim();
if trimmed.is_empty() && !line.starts_with(" ") {
self.state = State::Default;
continue;
}
match self.state {
State::Default => {
if self.try_section(trimmed)
|| self.try_page_break(trimmed)
|| self.try_synopsis(trimmed)
|| self.try_forced_action(trimmed)
|| self.try_centered(trimmed)
|| self.try_lyrics(trimmed)
|| self.try_heading(trimmed)
|| self.try_transition(trimmed)
|| self.try_dialogue_start(trimmed)
|| self.try_action(line)
{}
}
State::InDialogue => {
let curr_dialogue = self
.get_last_dialogue()
.expect("Must exist since we are in dialogue block");
if trimmed.starts_with('(') {
curr_dialogue
.elements
.push(DialogueElement::Parenthetical(RichString::from(trimmed)));
continue;
}
curr_dialogue
.elements
.push(DialogueElement::Line(RichString::from(trimmed)));
}
State::InBlock => {
if self.try_centered(trimmed)
|| self.try_lyrics(trimmed)
|| self.try_action(line)
{}
}
}
}
Screenplay::new(self.title_page, self.elements)
}
fn try_<'s, P, H>(&mut self, line: &'s str, predicate: P, handle: H) -> bool
where
P: FnOnce(&mut Self, &'s str) -> Option<&'s str>,
H: FnOnce(&mut Self, &'s str),
{
let Some(new_line) = predicate(self, line) else {
return false;
};
handle(self, new_line);
true
}
fn try_section(&mut self, line: &str) -> bool {
self.try_(
line,
|_, s| s.trim_start().starts_with("#").then_some(s),
|_, _| return,
)
}
fn try_synopsis(&mut self, line: &str) -> bool {
self.try_(
line,
|_, s| s.trim_start().strip_prefix('='),
|this, inner| {
if this.state == State::InBlock
&& let Some(Element::Synopsis(rs)) = this.elements.last_mut()
{
rs.push_str("\n");
rs.push_str(inner);
return;
}
let rs = RichString::from(inner);
this.elements.push(Element::Synopsis(rs));
this.state = State::InBlock;
},
)
}
fn try_page_break(&mut self, line: &str) -> bool {
self.try_(
line,
|_, s| s.trim_start().starts_with("===").then_some(s),
|this, _| this.elements.push(Element::PageBreak),
)
}
fn try_forced_action(&mut self, line: &str) -> bool {
self.try_(
line,
|_, s| s.trim_start().strip_prefix('!'),
|this, inner| {
this.elements.push(Element::Action(RichString::from(inner)));
this.state = State::InBlock;
},
)
}
fn try_centered(&mut self, line: &str) -> bool {
self.try_(
line,
|_, s| s.trim().strip_prefix('>').and_then(|u| u.strip_suffix('<')),
|this, inner| {
let inner = inner.trim();
if this.state == State::InBlock
&& let Some(Element::CenteredText(rs)) = this.elements.last_mut()
{
rs.push_str("\n");
rs.push_str(inner);
return;
}
let rs = RichString::from(inner);
this.elements.push(Element::CenteredText(rs));
this.state = State::InBlock;
},
)
}
fn try_lyrics(&mut self, line: &str) -> bool {
self.try_(
line,
|_, s| s.trim_start().strip_prefix('~'),
|this, inner| {
if this.state == State::InBlock
&& let Some(Element::Lyrics(rs)) = this.elements.last_mut()
{
rs.push_str("\n");
rs.push_str(inner);
return;
}
let rs = RichString::from(inner);
this.elements.push(Element::Lyrics(rs));
this.state = State::InBlock;
},
)
}
fn try_action(&mut self, line: &str) -> bool {
self.try_(
line,
|_, line| Some(line),
|this, inner| {
if this.state == State::InBlock
&& let Some(Element::Action(rs)) = this.elements.last_mut()
{
rs.push_str("\n");
rs.push_str(inner);
return;
}
let rs = RichString::from(inner);
this.elements.push(Element::Action(rs));
this.state = State::InBlock;
},
)
}
#[inline]
fn starts_with_ascii_ci(s: &str, pat: &str) -> bool {
let n = pat.len();
s.as_bytes()
.get(..n)
.is_some_and(|head| head.eq_ignore_ascii_case(pat.as_bytes()))
}
fn try_heading(&mut self, line: &str) -> bool {
self.try_(
line,
|this, line| {
let trimmed = line.trim_start();
let mut it = trimmed.chars();
if matches!(
(it.next(), it.next()),
(Some('.'), Some(c)) if c.is_alphanumeric()
) {
return Some(
trimmed
.strip_prefix('.')
.expect("Already checked that it exists"),
);
}
let pats = ["INT", "EXT", "EST", "I/E"];
(pats
.iter()
.any(|p| Parser::starts_with_ascii_ci(trimmed, p))
&& this.next_line_is_empty())
.then_some(trimmed)
},
|this, inner| {
let mut number = None;
let mut inner = inner;
if let Some(start) = inner.trim_end().strip_suffix('#')
&& let Some((new_inner, numbering)) = start.rsplit_once('#')
&& numbering
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '.')
{
number = Some(numbering.to_string());
inner = new_inner.trim_end();
}
this.elements.push(Element::Heading {
slug: RichString::from(inner),
number,
});
this.lines.next();
},
)
}
fn get_last_dialogue(&mut self) -> Option<&mut Dialogue> {
let (Some(Element::Dialogue(curr_dialogue))
| Some(Element::DualDialogue(_, curr_dialogue))) = self.elements.last_mut()
else {
return None;
};
Some(curr_dialogue)
}
fn insert_empty_dialogue<'s>(&mut self, inner: &'s str) -> &'s str {
let new_dialogue = Dialogue::new();
if let Some(stripped) = inner.trim_end().strip_suffix('^')
&& let Some(&Element::Dialogue(_)) = self.elements.last()
&& let Some(Element::Dialogue(d)) = self.elements.pop()
{
self.elements.push(Element::DualDialogue(d, new_dialogue));
return stripped;
}
self.elements.push(Element::Dialogue(new_dialogue));
inner
}
fn try_dialogue_start(&mut self, line: &str) -> bool {
self.try_(
line,
|this, line| {
let trimmed = line.trim_start();
if let Some(inner) = trimmed.strip_prefix('@') {
return Some(inner);
}
let head = trimmed.split_once('(').map_or(trimmed, |(h, _)| h);
let has_alpha = head.chars().any(char::is_alphabetic);
let has_lower = head.chars().any(char::is_lowercase);
(has_alpha && !has_lower && !this.next_line_is_empty()).then_some(trimmed)
},
|this, inner| {
let mut inner = this.insert_empty_dialogue(inner);
let curr_dialogue = this
.get_last_dialogue()
.expect("Just pushed to list, must exist");
if let Some((head, tail)) = inner.split_once('(')
&& let Some((extension, _)) = tail.split_once(')')
{
curr_dialogue.extension = Some(RichString::from(extension));
inner = head.trim_end();
}
curr_dialogue.character = RichString::from(inner);
this.state = State::InDialogue;
},
)
}
fn try_transition(&mut self, line: &str) -> bool {
self.try_(
line,
|this, line| {
if let Some(inner) = line.trim_start().strip_prefix('>')
&& !line.trim_end().ends_with('<')
{
return Some(inner);
}
let transition_ending = line.ends_with("TO:");
let has_lower = line.chars().any(char::is_lowercase);
let transition_elem = transition_ending && !has_lower;
(transition_elem && this.next_line_is_empty()).then_some(line)
},
|this, inner| {
this.elements
.push(Element::Transition(RichString::from(inner)));
this.lines.next();
},
)
}
fn parse_title(&mut self) {
let mut tp = TitlePage::new();
while let Some(line) = self.lines.peek() {
let Some((key, val)) = line.split_once(':') else {
break;
};
self.lines.next();
let mut values = Vec::new();
if val.trim().is_empty() {
values = self.take_indented_block();
} else {
values.push(RichString::from(val));
}
match key.trim().to_ascii_uppercase().as_str() {
"TITLE" => tp.title = values,
"CREDIT" => tp.credit = values,
"AUTHOR" | "AUTHORS" => tp.authors = values,
"SOURCE" => tp.source = values,
"DRAFT DATE" => tp.draft_date = values,
"CONTACT" => tp.contact = values,
_ => (),
}
}
if self.next_line_is_empty() {
self.lines.next();
}
if !tp.title.is_empty()
|| !tp.credit.is_empty()
|| !tp.authors.is_empty()
|| !tp.source.is_empty()
|| !tp.draft_date.is_empty()
|| !tp.contact.is_empty()
{
self.title_page = Some(tp);
}
}
fn take_indented_block(&mut self) -> Vec<RichString> {
let mut out = Vec::new();
while let Some(next) = self.lines.peek().copied() {
if next.starts_with(" ") {
self.lines.next();
out.push(RichString::from(next.trim()));
} else {
break;
}
}
out
}
fn next_line_is_empty(&mut self) -> bool {
self.lines.peek().is_none_or(|s| s.trim().is_empty())
}
}
fn preprocess_source(src: &str) -> String {
let bytes = src.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
let mut in_boneyard = false;
while i < bytes.len() {
let b = bytes[i];
if in_boneyard {
if b == b'\n' {
out.push(b'\n');
i += 1;
continue;
}
if i + 1 < bytes.len() && b == b'*' && bytes[i + 1] == b'/' {
in_boneyard = false;
i += 2;
continue;
}
i += 1;
} else {
if i + 1 < bytes.len() && b == b'/' && bytes[i + 1] == b'*' {
in_boneyard = true;
i += 2;
continue;
}
if b == b'\t' {
out.extend_from_slice(b" ");
i += 1;
continue;
}
out.push(b);
i += 1;
}
}
let mut final_out = Vec::with_capacity(out.len());
i = 0;
let mut in_note = false;
let mut note_buffer = Vec::new();
while i < out.len() {
let b = out[i];
if in_note {
if b == b'\n' {
if i + 3 < out.len()
&& out[i + 1] == b' '
&& out[i + 2] == b' '
&& out[i + 3] == b'\n'
{
note_buffer.push(b'\n');
note_buffer.push(b' ');
note_buffer.push(b' ');
note_buffer.push(b'\n');
i += 4;
continue;
}
if i + 1 < out.len() && out[i + 1] == b'\n' {
final_out.append(&mut note_buffer);
final_out.push(b'\n');
final_out.push(b'\n');
i += 2;
continue;
}
note_buffer.push(b'\n');
i += 1;
continue;
}
if i + 1 < out.len() && b == b']' && out[i + 1] == b']' {
in_note = false;
note_buffer = Vec::new();
i += 2;
continue;
}
note_buffer.push(b);
i += 1;
} else {
if i + 1 < out.len() && b == b'[' && out[i + 1] == b'[' {
in_note = true;
note_buffer = vec![b'[', b'['];
i += 2;
continue;
}
final_out.push(b);
i += 1;
}
}
final_out.append(&mut note_buffer);
String::from_utf8(final_out).expect("Valid UTF-8 after preprocessing")
}
#[derive(Debug, PartialEq, Eq)]
enum State {
Default,
InDialogue,
InBlock,
}
#[cfg(test)]
mod tests {
use super::*;
fn parser_tester(input: &str, correct: Screenplay) {
let parsed = parse(input);
assert_eq!(parsed, correct)
}
#[test]
fn parses_heading_without_number() {
let input = "InT. OUTSIDE - DAY";
let correct = Screenplay::new(
None,
vec![Element::Heading {
slug: input.into(),
number: None,
}],
);
parser_tester(input, correct)
}
#[test]
fn parses_heading_with_number() {
let input = "INT. OUTSIDE - DAY #S.1#";
let correct = Screenplay::new(
None,
vec![Element::Heading {
slug: "INT. OUTSIDE - DAY".into(),
number: Some("S.1".to_string()),
}],
);
parser_tester(input, correct)
}
#[test]
fn parses_heading_forced() {
let input = ".OUTSIDE - DAY";
let correct = Screenplay::new(
None,
vec![Element::Heading {
slug: "OUTSIDE - DAY".into(),
number: None,
}],
);
parser_tester(input, correct)
}
#[test]
fn parses_heading_forced_with_number() {
let input = ".OUTSIDE - DAY #S.1#";
let correct = Screenplay::new(
None,
vec![Element::Heading {
slug: "OUTSIDE - DAY".into(),
number: Some("S.1".to_string()),
}],
);
parser_tester(input, correct)
}
#[test]
fn parses_action() {
let input = "They look at the test output - it's all failing.";
let correct = Screenplay::new(None, vec![Element::Action(input.into())]);
parser_tester(input, correct)
}
#[test]
fn parses_action_forced() {
let input = "!INT. They look at the test output - it's all failing.";
let correct = Screenplay::new(
None,
vec![Element::Action(
"INT. They look at the test output - it's all failing.".into(),
)],
);
parser_tester(input, correct)
}
#[test]
fn parses_dialogue_without_extension() {
let input = r#"
CHAR
(sad)
Nooo!
(angry)
I am angry.
"#;
let correct = Screenplay::new(
None,
vec![Element::Dialogue(Dialogue {
character: "CHAR".into(),
extension: None,
elements: vec![
DialogueElement::Parenthetical("(sad)".into()),
DialogueElement::Line("Nooo!".into()),
DialogueElement::Parenthetical("(angry)".into()),
DialogueElement::Line("I am angry.".into()),
],
})],
);
parser_tester(input, correct)
}
#[test]
fn parses_dialogue_with_extension() {
let input = r#"
CHAR (V.O)
(sad)
Nooo!
"#;
let correct = Screenplay::new(
None,
vec![Element::Dialogue(Dialogue {
character: "CHAR".into(),
extension: Some("V.O".into()),
elements: vec![
DialogueElement::Parenthetical("(sad)".into()),
DialogueElement::Line("Nooo!".into()),
],
})],
);
parser_tester(input, correct)
}
#[test]
fn parses_dialogue_without_extension_forced() {
let input = r#"
@char
(sad)
Nooo!
(angry)
I am angry.
"#;
let correct = Screenplay::new(
None,
vec![Element::Dialogue(Dialogue {
character: "char".into(),
extension: None,
elements: vec![
DialogueElement::Parenthetical("(sad)".into()),
DialogueElement::Line("Nooo!".into()),
DialogueElement::Parenthetical("(angry)".into()),
DialogueElement::Line("I am angry.".into()),
],
})],
);
parser_tester(input, correct)
}
#[test]
fn parses_dialogue_with_extension_forced() {
let input = r#"
@char (V.O)
(sad)
Nooo!
"#;
let correct = Screenplay::new(
None,
vec![Element::Dialogue(Dialogue {
character: "char".into(),
extension: Some("V.O".into()),
elements: vec![
DialogueElement::Parenthetical("(sad)".into()),
DialogueElement::Line("Nooo!".into()),
],
})],
);
parser_tester(input, correct)
}
#[test]
fn parses_dual_dialogue() {
let input = r#"
@CHaR
(sad)
Nooo!
CHOR (V.O) ^
YES!
"#;
let correct = Screenplay::new(
None,
vec![Element::DualDialogue(
Dialogue {
character: "CHaR".into(),
extension: None,
elements: vec![
DialogueElement::Parenthetical("(sad)".into()),
DialogueElement::Line("Nooo!".into()),
],
},
Dialogue {
character: "CHOR".into(),
extension: Some("V.O".into()),
elements: vec![DialogueElement::Line("YES!".into())],
},
)],
);
parser_tester(input, correct)
}
#[test]
fn parses_lyrics() {
let input = "~Hey ho let's go";
let correct = Screenplay::new(None, vec![Element::Lyrics("Hey ho let's go".into())]);
parser_tester(input, correct)
}
#[test]
fn parses_transition() {
let input = "\nCUT TO:\n";
let correct = Screenplay::new(None, vec![Element::Transition("CUT TO:".into())]);
parser_tester(input, correct)
}
#[test]
fn parses_transition_forced() {
let input = ">Camera does a spin";
let correct = Screenplay::new(None, vec![Element::Transition("Camera does a spin".into())]);
parser_tester(input, correct)
}
#[test]
fn parses_centered() {
let input = "> The end <";
let correct = Screenplay::new(None, vec![Element::CenteredText("The end".into())]);
parser_tester(input, correct)
}
#[test]
fn parses_pagebreak_with_3_equals() {
let input = "===";
let correct = Screenplay::new(None, vec![Element::PageBreak]);
parser_tester(input, correct)
}
#[test]
fn parses_pagebreak_with_8_equals() {
let input = "========";
let correct = Screenplay::new(None, vec![Element::PageBreak]);
parser_tester(input, correct)
}
#[test]
fn parses_synopsis() {
let input = "=In this scene everyone gets cake.";
let correct = Screenplay::new(
None,
vec![Element::Synopsis(
"In this scene everyone gets cake.".into(),
)],
);
parser_tester(input, correct)
}
#[test]
fn does_not_parse_section() {
let input = r"
# Act 1
INT. HOUSE
## Montage
House is empty.";
let correct = Screenplay::new(
None,
vec![
Element::Heading {
slug: "INT. HOUSE".into(),
number: None,
},
Element::Action("House is empty.".into()),
],
);
parser_tester(input, correct)
}
#[test]
fn filters_out_boneyard() {
let input = r"
INT. HOUSE
/* This is a boneyard
and should not be parsed
, you understand?*/
House is empty.";
let correct = Screenplay::new(
None,
vec![
Element::Heading {
slug: "INT. HOUSE".into(),
number: None,
},
Element::Action("House is empty.".into()),
],
);
parser_tester(input, correct)
}
#[test]
fn filters_out_boneyard_inlined() {
let input = "The house is /*extremely full*/empty.";
let correct = Screenplay::new(None, vec![Element::Action("The house is empty.".into())]);
parser_tester(input, correct)
}
#[test]
fn filters_out_boneyard_unended() {
let input = r"
INT. HOUSE
/* This is a boneyard
and should not be parsed
, you understand?
House is empty.";
let correct = Screenplay::new(
None,
vec![Element::Heading {
slug: "INT. HOUSE".into(),
number: None,
}],
);
parser_tester(input, correct)
}
#[test]
fn filters_out_note_multiline() {
let input = r"
INT. HOUSE
[[ This is a note
and should not be parsed
, you understand?]]
House is empty.";
let correct = Screenplay::new(
None,
vec![
Element::Heading {
slug: "INT. HOUSE".into(),
number: None,
},
Element::Action("House is empty.".into()),
],
);
parser_tester(input, correct)
}
#[test]
fn filters_out_note_inlined() {
let input = "The house is [[should it be full?]]empty.";
let correct = Screenplay::new(None, vec![Element::Action("The house is empty.".into())]);
parser_tester(input, correct)
}
#[test]
fn filters_out_note_inlined_multiline() {
let input = r"
INT. HOUSE
The house [[ This is a note
and should not be parsed
, you understand?]]is empty.";
let correct = Screenplay::new(
None,
vec![
Element::Heading {
slug: "INT. HOUSE".into(),
number: None,
},
Element::Action("The house is empty.".into()),
],
);
parser_tester(input, correct)
}
#[test]
fn filters_out_note_multiline_empty_newline() {
let input = r"
INT. HOUSE
The house [[This is a note
and should not be parsed
, you understand?]]is empty.";
let correct = Screenplay::new(
None,
vec![
Element::Heading {
slug: "INT. HOUSE".into(),
number: None,
},
Element::Action("The house is empty.".into()),
],
);
parser_tester(input, correct)
}
#[test]
fn not_filters_out_unended_note_multiline() {
let input = r"
INT. HOUSE
The house [[wow
no";
let correct = Screenplay::new(
None,
vec![
Element::Heading {
slug: "INT. HOUSE".into(),
number: None,
},
Element::Action("The house [[wow".into()),
Element::Action("no".into()),
],
);
parser_tester(input, correct)
}
#[test]
fn not_filters_out_unended_note() {
let input = "This is [[ not right";
let correct = Screenplay::new(None, vec![Element::Action(input.into())]);
parser_tester(input, correct)
}
}