twee-tools 0.2.1

A compiler for Twine stories
use std::{fs::File, io::{stderr, Read, Write}, path::{Path, PathBuf}};

use glob::MatchOptions;
use serde::Deserialize;
use serde_json::{Map, Value};
use thiserror::Error;
use twee_parser::{parse_archive, parse_twee3, Passage, Story, Warning};





#[derive(Deserialize)]
pub struct Config {
    pub output: Option<String>,
    pub style: Vec<String>,
    pub script: Vec<String>,
    pub main: String,
    pub prebuild: Vec<String>
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("Could not open file: {0}")]
    FileNotFound(String),
    #[error("Could not open directory: {0}")]
    DirNotFound(String),
    #[error("Unknown story format: {0}")]
    UnknownStoryFormat(String),
    #[error("Prebuild command exited with error")]
    PrebuildError
}

pub(crate) fn read_file<P>(p: P) -> anyhow::Result<String>  where P: AsRef<Path> {
    let mut f = File::open(p)?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)  
}


pub(crate) fn print_warning(w: Warning) {
    writeln!(stderr(), "Warning: {}",
    match w {
        Warning::StoryMetadataMalformed => "Story metadata is not valid JSON and has been discarded.".to_owned(),
        Warning::StoryTitleMissing => "Story title is missing.".to_owned(),
        Warning::PassageMetadataMalformed(p) => format!("Passage \"{}\" metadata is not valid JSON and has been discarded.", p),
        Warning::PassageTagsMalformed(p) => format!("Passage \"{}\" tags are not valid and have been discarded.", p),
        Warning::PassageDuplicated(p) => format!("Passage \"{}\" is duplicated, using the last occurrence.", p),
        Warning::PassageNameMissing => "Passage name is missing, passage has been discarded.".to_owned(),
    }).unwrap();
}

fn glob(s: &str, parent: PathBuf) -> std::result::Result<Vec<PathBuf>, anyhow::Error> {
    let mut res = vec![];
    for r in glob::glob_with(s, MatchOptions {
        case_sensitive: true,
        require_literal_separator: true,
        require_literal_leading_dot: true,
    })? {
        if let Ok(r) = r {
            res.push(parent.join(r));
        }
    }
    Ok(res)
}

fn process_story_fragment(story: &mut Story, path: &Path, included: &mut Vec<PathBuf>) -> anyhow::Result<()> {
    for p in &mut story.passages {
        if let Some(i) = p.tags.iter().position(|t| t == "twee-cmd") {
            p.tags.remove(i);
            if let Some(contents) = serde_json::from_str::<serde_json::Value>(&p.content)?.as_array() {
                p.content = String::new();
                for v in contents {
                    match v {
                        Value::String(s) => {
                            p.content += s;
                        },
                        Value::Object(m) => {
                            if let Some(s) = m.get("include").and_then(|i| i.as_str()) {
                                let files = glob(s, path.parent().unwrap().to_path_buf())?;
                                if files.len() == 0 {
                                    writeln!(stderr(), "Warning: No matching file found for pattern: {}", s)?;
                                }
                                for f in files {
                                    p.content += &read_file(&f)?;
                                }
                                continue;
                            }
                            writeln!(stderr(), "Warning: [twee-cmd] entry was not a recognized command and has been discarded")?;
                        }
                        _ => {
                            writeln!(stderr(), "Warning: [twee-cmd] entry was neither a string nor an object and has been discarded")?;
                        }
                    }
                }
            } else {
                writeln!(stderr(), "Warning: [twee-cmd] passage is not a JSON array and has been discarded")?;
            }
        }
        if let Some(Value::String(f)) = p.meta.get("include") {
            let files = glob(f, path.parent().unwrap().to_path_buf())?;
            if files.len() == 0 {
                writeln!(stderr(), "Warning: No matching file found for pattern: {}", f)?;
            }
            p.content = String::new();
            for f in files {
                p.content += &read_file(&f)?;
            }
            p.meta.remove("include");
        }
        if let Some(Value::Array(f)) = p.meta.get("include") {
            p.content = String::new();
            for f in f {
                if let Some(s) = f.as_str() {
                        let files = glob(s, path.parent().unwrap().to_path_buf())?;
                        if files.len() == 0 {
                            writeln!(stderr(), "Warning: No matching file found for pattern: {}", s)?;
                        }
                        for f in files {
                            p.content += &read_file(&f)?;
                        }
                } else {
                    writeln!(stderr(), "Warning: include entry wasn't a string and has been ignored: {}", serde_json::to_string(f)?)?;
                }
            }
            p.meta.remove("include");
        }
        if let Some(Value::String(f)) = p.meta.get("include-before") {
            p.content = read_file(f)? + &p.content;
            p.meta.remove("include-before");
        }
        if let Some(Value::String(f)) = p.meta.get("include-after") {
            p.content += &read_file(f)?;
            p.meta.remove("include-after");
        }
        if let Some(Value::String(f)) = p.meta.get("prepend") {
            p.content = f.clone() + &p.content;
            p.meta.remove("prepend");
        }
        if let Some(Value::String(f)) = p.meta.get("append") {
            p.content += &f;
            p.meta.remove("append");
        }
    }
    if let Some(p) = story.passages.iter().position(|p| p.name == "TweeTools") {
        let p = story.passages.remove(p);
        if let Some(contents) = serde_json::from_str::<serde_json::Value>(&p.content)?.as_object() {
            if let Some(includes) = contents.get("include").and_then(|i| i.as_array()) {
                for i in includes {
                    if let Some(s) = i.as_str() {
                        let files = glob(s, path.parent().unwrap().to_path_buf())?;
                        if files.len() == 0 {
                            writeln!(stderr(), "Warning: No matching file found for pattern: {}", s)?;
                        }
                        for twee in files {
                            if ! included.contains(&twee.canonicalize()?) {
                                let (mut part, warnings) = parse_twee3(&read_file(&twee)?)?;
                                for w in warnings {
                                    match &w {
                                        Warning::StoryMetadataMalformed => {},
                                        Warning::StoryTitleMissing => {},
                                        _ => print_warning(w)
                                    }
                                }
                                included.push(twee.canonicalize()?);
                                process_story_fragment(&mut part, &twee, included)?;
                            }
                        }
                    } else {
                        writeln!(stderr(), "Warning: include entry wasn't a string and has been ignored: {}", serde_json::to_string(i)?)?;
                    }
                }
            }
            if let Some(includes) = contents.get("include-archive").and_then(|i| i.as_array()) {
                for i in includes {
                    if let Some(f) = i.as_str() {
                        let f = PathBuf::from(f.to_string());
                        let stories = parse_archive(&read_file(&f)?)?;
                        for s in stories {
                            let (mut part, warnings) = s;
                            for w in warnings {
                                match &w {
                                    Warning::StoryMetadataMalformed => {},
                                    Warning::StoryTitleMissing => {},
                                    _ => print_warning(w)
                                }
                            }
                            included.push(f.canonicalize()?);
                            process_story_fragment(&mut part, &f, included)?;
                        }
                    } else {
                        writeln!(stderr(), "Warning: include entry wasn't a string and has been ignored: {}", serde_json::to_string(i)?)?;
                    }
                }
            }
        } else {
            writeln!(stderr(), "Warning: TweeTools passage is not a JSON object and has been discarded")?;
        }
    }
    Ok(())
}

pub fn build_story(config: &Config, debug: bool) -> Result<Story, anyhow::Error> {
    
    
    let twee = read_file(&config.main)?;
    let (mut story, warnings) = parse_twee3(&twee)?;
    if debug {
        story.meta.insert("options".to_string(), "debug".into());
    }
    for w in warnings {
        print_warning(w);
    }
    if story.title.is_empty() {
        story.title = "Story".to_string();
    }
    let mut included = vec![PathBuf::from(config.main.clone()).canonicalize()?];
    process_story_fragment(&mut story, Path::new(&config.main), &mut included)?;
    
    let mut i = 0;
    for f in &config.script {
        i += 1;
        story.passages.push(Passage {
            name: "script".to_string() + &i.to_string(),
            tags: vec!["script".to_string()],
            meta: Map::new(),
            content: read_file(f)?
        });
    }
    let mut i = 0;
    for f in &config.style {
        i += 1;
        story.passages.push(Passage {
            name: "stylesheet".to_string() + &i.to_string(),
            tags: vec!["stylesheet".to_string()],
            meta: Map::new(),
            content: read_file(f)?
        });
    }
    Ok(story)
}