hiraku-engine 0.1.0

Hiraku visual-novel engine
use bevy::prelude::Resource;
use hiraku_script::{SymbolId, SymbolInterner, hson::HsonValue};
use thiserror::Error;

use crate::{
    data::evaluate_hson_map,
    vfs::{HdpVfs, VfsError},
};

pub const DEFAULT_GLOSSARY_PATH: &str = "glossary.hson";

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TermId(SymbolId);

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TermDefinition {
    pub id: String,
    pub name: String,
    pub description: String,
}

#[derive(Clone, Debug, Default, Resource)]
pub struct TermCatalog {
    interner: SymbolInterner,
    terms: Vec<TermDefinition>,
}

impl TermCatalog {
    pub fn resolve(&self, id: &str) -> Option<TermId> {
        self.interner.get(id).map(TermId)
    }

    pub fn get(&self, id: TermId) -> Option<&TermDefinition> {
        self.terms.get(id.0.0 as usize)
    }

    fn insert(&mut self, term: TermDefinition) -> Result<TermId, GlossaryError> {
        if self.interner.get(&term.id).is_some() {
            return Err(GlossaryError::DuplicateTerm(term.id));
        }
        let id = TermId(self.interner.intern(term.id.clone()));
        debug_assert_eq!(id.0.0 as usize, self.terms.len());
        self.terms.push(term);
        Ok(id)
    }
}

#[derive(Debug, Error)]
pub enum GlossaryError {
    #[error(transparent)]
    Vfs(#[from] VfsError),
    #[error("invalid glossary `{path}`: {message}")]
    Invalid { path: String, message: String },
    #[error("term `{0}` is defined more than once")]
    DuplicateTerm(String),
}

pub fn load_term_catalog(vfs: &HdpVfs) -> Result<TermCatalog, GlossaryError> {
    let path = vfs.resolve_path(Some(vfs.settings_path()), DEFAULT_GLOSSARY_PATH);
    if !vfs.exists(&path) {
        return Ok(TermCatalog::default());
    }
    let source = vfs.read_text(&path)?;
    parse_term_catalog(&path, &source)
}

pub(crate) fn parse_term_catalog(path: &str, source: &str) -> Result<TermCatalog, GlossaryError> {
    let mut root = evaluate_hson_map(path, source).map_err(|error| GlossaryError::Invalid {
        path: path.to_string(),
        message: error.to_string(),
    })?;
    let terms = root
        .remove("terms")
        .ok_or_else(|| invalid(path, "missing `terms` list"))?;
    if !root.is_empty() {
        return Err(invalid(
            path,
            format!(
                "unknown field(s): {}",
                root.keys().cloned().collect::<Vec<_>>().join(", ")
            ),
        ));
    }
    let HsonValue::Array(terms) = terms else {
        return Err(invalid(path, "`terms` must be a list"));
    };
    let mut catalog = TermCatalog::default();
    for (index, value) in terms.into_iter().enumerate() {
        let HsonValue::Map(mut term) = value else {
            return Err(invalid(
                path,
                format!("term at index {index} must be a map"),
            ));
        };
        let id = take_string(&mut term, "id", path, index)?;
        let name = take_string(&mut term, "name", path, index)?;
        let description = take_string(&mut term, "description", path, index)?;
        if !term.is_empty() {
            return Err(invalid(
                path,
                format!(
                    "term `{id}` has unknown field(s): {}",
                    term.keys().cloned().collect::<Vec<_>>().join(", ")
                ),
            ));
        }
        catalog.insert(TermDefinition {
            id,
            name,
            description,
        })?;
    }
    Ok(catalog)
}

fn take_string(
    map: &mut hiraku_script::hson::HsonMap,
    key: &str,
    path: &str,
    index: usize,
) -> Result<String, GlossaryError> {
    map.remove(key)
        .and_then(|value| value.as_str().map(ToOwned::to_owned))
        .ok_or_else(|| {
            invalid(
                path,
                format!("term at index {index} requires string `{key}`"),
            )
        })
}

fn invalid(path: &str, message: impl Into<String>) -> GlossaryError {
    GlossaryError::Invalid {
        path: path.to_string(),
        message: message.into(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn interns_user_defined_string_term_ids() {
        let catalog = parse_term_catalog(
            "memory://glossary.hson",
            r#".{ terms: [
                .{ id: "ether", name: "Ether", description: "A fictional substance." },
                .{ id: "academy", name: "Academy", description: "Alice's school." },
            ] }"#,
        )
        .expect("glossary should parse");

        let ether = catalog.resolve("ether").expect("term should be interned");
        assert_eq!(catalog.get(ether).expect("term exists").name, "Ether");
        assert!(catalog.resolve("unknown").is_none());
    }
}