socratic 0.0.1

A dialog system for games in rust
Documentation
use std::collections::HashMap;
use std::str::FromStr;

use crate::error::{BaseParseError, ParseError};
use crate::lexing::{AtomOr, Atoms, Data, Line};
use crate::{Dialog, DialogNode, DialogTree};

type Res<I, O, E = BaseParseError> = Result<(I, O), E>;

fn not_blank_dialog_line(mut s: &str) -> Res<&str, Line<&str, &str, &str>> {
    loop {
        let (new_s, line) = crate::lexing::line(s)?;
        s = new_s;
        if let Data::Blank = line.data {
            continue;
        }
        return Ok((s, line));
    }
}

fn at_same_indent(s: &str, indent: usize) -> Res<&str, Line<&str, &str, &str>> {
    let (s, line) = not_blank_dialog_line(s)?;
    if line.indent > indent {
        return Err(BaseParseError::UnexpectedIndent);
    }
    if line.indent < indent {
        return Err(BaseParseError::UnexpectedDedent);
    }
    Ok((s, line))
}

#[allow(clippy::type_complexity)]
fn character_says(s: &str, indent: usize) -> Res<&str, (&str, Vec<AtomOr<&str, &str>>)> {
    let (s, line) = at_same_indent(s, indent)?;
    if let Data::CharacterSays { character, text } = line.data {
        Ok((s, (character, text)))
    } else {
        Err(BaseParseError::UnexpectedLine(
            "character_says",
            line.into_strings(),
        ))
    }
}

fn message(s: &str, indent: usize) -> Res<&str, Vec<AtomOr<&str, &str>>> {
    let (s, line) = at_same_indent(s, indent)?;
    if let Data::JustText(text) = line.data {
        Ok((s, text))
    } else {
        Err(BaseParseError::UnexpectedLine(
            "message",
            line.into_strings(),
        ))
    }
}

#[allow(clippy::type_complexity)]
fn response<DA: FromStr, IF: FromStr, TE: FromStr>(
    s: &str,
    indent: usize,
) -> Res<
    &str,
    (Vec<AtomOr<&str, &str>>, Option<IF>, DialogTree<DA, IF, TE>),
    ParseError<DA::Err, IF::Err, TE::Err>,
> {
    let (mut s, line) = at_same_indent(s, indent)?;
    if let Data::Response {
        text,
        only_if,
        go_to,
    } = line.data
    {
        let resp = (
            text,
            only_if
                .map(|i| i.parse().map_err(ParseError::IF))
                .transpose()?,
            if let Some(gt) = go_to {
                DialogTree {
                    nodes: vec![DialogNode::GoTo(gt.into())],
                }
            } else {
                let new_indent = not_blank_dialog_line(s)
                    .map(|(_, Line { indent, .. })| indent)
                    .unwrap_or(indent + 1);
                if new_indent == indent {
                    DialogTree { nodes: Vec::new() }
                } else if let Ok((new_s, tree)) = dialog_tree(s, new_indent) {
                    s = new_s;
                    tree
                } else {
                    DialogTree { nodes: Vec::new() }
                }
            },
        );
        Ok((s, resp))
    } else {
        Err(ParseError::Base(BaseParseError::UnexpectedLine(
            "response",
            line.into_strings(),
        )))
    }
}

#[allow(clippy::type_complexity)]
fn responses<DA: FromStr, IF: FromStr, TE: FromStr>(
    mut s: &str,
    indent: usize,
) -> Res<&str, Vec<(Vec<AtomOr<&str, &str>>, Option<IF>, DialogTree<DA, IF, TE>)>> {
    let mut responses = Vec::new();
    while let Ok((new_s, resp)) = response(s, indent) {
        s = new_s;
        responses.push(resp);
    }
    if responses.is_empty() {
        return Err(BaseParseError::UnexpectedLine(
            "responses",
            not_blank_dialog_line(s)?.1.into_strings(),
        ));
    }
    Ok((s, responses))
}

enum Cond {
    If,
    Elif,
    Else,
}

#[allow(clippy::type_complexity)]
fn conditional<DA: FromStr, IF: FromStr, TE: FromStr>(
    s: &str,
    indent: usize,
) -> Res<&str, (Cond, Option<IF>, DialogTree<DA, IF, TE>), ParseError<DA::Err, IF::Err, TE::Err>> {
    let (mut s, line) = at_same_indent(s, indent)?;
    let (ty, cond) = match line.data {
        Data::If(cond) => (Cond::If, Some(cond)),
        Data::Elif(cond) => (Cond::Elif, Some(cond)),
        Data::Else => (Cond::Else, None),
        _ => {
            return Err(ParseError::Base(BaseParseError::UnexpectedLine(
                "response",
                line.into_strings(),
            )))
        }
    };
    let new_indent = not_blank_dialog_line(s)
        .map(|(_, Line { indent, .. })| indent)
        .unwrap_or(indent + 1);
    let subtree = if let Ok((new_s, tree)) = dialog_tree(s, new_indent) {
        s = new_s;
        tree
    } else {
        DialogTree { nodes: Vec::new() }
    };
    Ok((
        s,
        (
            ty,
            cond.map(|c| c.parse().map_err(ParseError::IF))
                .transpose()?,
            subtree,
        ),
    ))
}

#[allow(clippy::type_complexity)]
fn conditionals<DA: FromStr, IF: FromStr, TE: FromStr>(
    mut s: &str,
    indent: usize,
) -> Res<&str, Vec<(Option<IF>, DialogTree<DA, IF, TE>)>> {
    let mut saw_else = false;
    let mut conditionals = Vec::new();
    while let Ok((new_s, (ty, cond, tree))) = conditional(s, indent) {
        let valid = match (ty, conditionals.len(), saw_else) {
            (Cond::If, 0, false) => true,
            (Cond::Elif, i, false) if i > 0 => true,
            (Cond::Else, i, false) if i > 0 => {
                saw_else = true;
                true
            }
            _ => false,
        };
        if !valid {
            return Err(BaseParseError::UnexpectedLine(
                "If line",
                not_blank_dialog_line(s)?.1.into_strings(),
            ));
        }
        s = new_s;
        conditionals.push((cond, tree));
    }
    if conditionals.is_empty() {
        return Err(BaseParseError::UnexpectedLine(
            "conditionals",
            not_blank_dialog_line(s)?.1.into_strings(),
        ));
    }
    Ok((s, conditionals))
}

fn go_to(s: &str, indent: usize) -> Res<&str, &str> {
    let (s, line) = at_same_indent(s, indent)?;
    if let Data::GoTo(gt) = line.data {
        Ok((s, gt))
    } else {
        Err(BaseParseError::UnexpectedLine("go_to", line.into_strings()))
    }
}

fn do_action(s: &str, indent: usize) -> Res<&str, &str, BaseParseError> {
    let (s, line) = at_same_indent(s, indent)?;
    if let Data::DoAction(action) = line.data {
        Ok((s, action))
    } else {
        Err(BaseParseError::UnexpectedLine(
            "do_action",
            line.into_strings(),
        ))
    }
}

#[allow(clippy::type_complexity)]
fn dialog_node<DA: FromStr, IF: FromStr, TE: FromStr>(
    s: &str,
    indent: usize,
) -> Res<&str, DialogNode<DA, IF, TE>, ParseError<DA::Err, IF::Err, TE::Err>> {
    if let Ok((s, (character, text))) = character_says(s, indent) {
        Ok((
            s,
            DialogNode::CharacterSays(character.into(), text.parse().map_err(ParseError::TE)?),
        ))
    } else if let Ok((s, text)) = message(s, indent) {
        Ok((
            s,
            DialogNode::Message(text.parse().map_err(ParseError::TE)?),
        ))
    } else if let Ok((s, responses)) = responses::<DA, IF, TE>(s, indent) {
        Ok((
            s,
            DialogNode::Responses(
                responses
                    .into_iter()
                    .map(|(text, i, gt)| Ok((text.parse().map_err(ParseError::TE)?, i, gt)))
                    .collect::<Result<_, ParseError<DA::Err, IF::Err, TE::Err>>>()?,
            ),
        ))
    } else if let Ok((s, conditionals)) = conditionals(s, indent) {
        Ok((s, DialogNode::Conditional(conditionals)))
    } else if let Ok((s, gt)) = go_to(s, indent) {
        Ok((s, DialogNode::GoTo(gt.into())))
    } else if let Ok((s, action)) = do_action(s, indent) {
        Ok((
            s,
            DialogNode::DoAction(action.parse().map_err(ParseError::DoAction)?),
        ))
    } else {
        Err(ParseError::Base(BaseParseError::NoDialogNodeFound(
            not_blank_dialog_line(s)?.1.into_strings(),
        )))
    }
}

#[allow(clippy::type_complexity)]
fn dialog_tree<DA: FromStr, IF: FromStr, TE: FromStr>(
    mut s: &str,
    indent: usize,
) -> Res<&str, DialogTree<DA, IF, TE>, ParseError<DA::Err, IF::Err, TE::Err>> {
    let mut nodes = Vec::new();
    loop {
        match dialog_node(s, indent) {
            Ok((new_s, node)) => {
                s = new_s;
                nodes.push(node);
            }
            Err(e) => match e {
                ParseError::DoAction(_) | ParseError::IF(_) | ParseError::TE(_) => {
                    tracing::info!("DoAction or IF parse error.");
                    return Err(e);
                }
                _ => {
                    tracing::info!("Base error");
                    break;
                }
            },
        }
    }
    if nodes.is_empty() {
        return Err(ParseError::Base(BaseParseError::EmptyDialogTree));
    }
    Ok((s, DialogTree { nodes }))
}

fn get_section(s: &str) -> Res<&str, &str> {
    let (s, line) = not_blank_dialog_line(s)?;
    if let Data::SectionStart(section_name) = line.data {
        if line.indent == 0 {
            Ok((s, section_name))
        } else {
            Err(BaseParseError::IndentedSectionStartError)
        }
    } else {
        Err(BaseParseError::ExpectedSectionStart(line.into_strings()))
    }
}

#[allow(clippy::type_complexity)]
pub fn dialog<DA: FromStr, IF: FromStr, TE: FromStr>(
    mut s: &str,
) -> Result<Dialog<DA, IF, TE>, ParseError<DA::Err, IF::Err, TE::Err>> {
    let mut sections = HashMap::new();
    loop {
        let (new_s, section) = get_section(s)?;
        let (new_s, tree) = dialog_tree(new_s, 0)?;
        if sections.contains_key(section) {
            return Err(ParseError::Base(BaseParseError::DuplicateSectionKey(
                section.into(),
            )));
        }
        sections.insert(section.into(), tree);
        s = new_s;

        if s.is_empty() {
            return Ok(Dialog { sections });
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::Atom;

    use super::*;
    use pretty_assertions::assert_eq;
    use test_log::test;

    #[test]
    fn test_dialog_section() -> Result<(), anyhow::Error> {
        assert_eq!(
            dialog::<String, String, String>(
                r#":: title
Ch1: Hello! `terp`
- Hello!
- Fuck you
    Ch1: doot ~doot~
"#
            ),
            Ok(Dialog {
                sections: HashMap::from([(
                    "title".into(),
                    DialogTree {
                        nodes: vec![
                            DialogNode::CharacterSays(
                                "Ch1".into(),
                                vec![
                                    AtomOr::Atom(Atom::Text("Hello! ".into())),
                                    AtomOr::Interpolate("terp".into())
                                ]
                            ),
                            DialogNode::Responses(vec![
                                (
                                    vec![AtomOr::Atom(Atom::Text("Hello!".into()))],
                                    None,
                                    DialogTree { nodes: vec![] }
                                ),
                                (
                                    vec![AtomOr::Atom(Atom::Text("Fuck you".into()))],
                                    None,
                                    DialogTree {
                                        nodes: vec![DialogNode::CharacterSays(
                                            "Ch1".into(),
                                            vec![
                                                AtomOr::Atom(Atom::Text("doot ".into())),
                                                AtomOr::Atom(Atom::Wave("doot".into()))
                                            ]
                                        )]
                                    }
                                )
                            ])
                        ]
                    }
                )])
            })
        );

        Ok(())
    }
}