platelet 0.1.2

HTML-first templating language
Documentation
use core::fmt;
use std::{
    collections::{HashMap, HashSet},
    fs::File,
    io::{self, Read},
    path::{Path, PathBuf},
    rc::Rc,
};

use renderer::{Filesystem, RenderError};
use serde_json::Value;

mod expression_eval;
mod expression_parser;
mod for_loop_parser;
mod for_loop_runner;
mod html;
mod html_parser;
mod rcdom;
pub mod renderer;
mod text_node;
mod types;

pub fn render_with_custom_filesystem<F, FilesystemError>(
    vars: &Value,
    filename: &String,
    filesystem: &F,
) -> Result<String, RenderError<FilesystemError>>
where
    F: Filesystem<FilesystemError>,
    FilesystemError: fmt::Debug,
{
    renderer::render(
        vars,
        Rc::new(HashMap::new()),
        &mut HashSet::new(),
        &filename,
        filesystem,
    )
    .map(|x| x.to_string())
}

pub(crate) struct SingleFile {
    data: String,
}

impl Filesystem<()> for SingleFile {
    fn move_to(&self, _current: &String, to: &String) -> Result<String, ()> {
        Ok(to.to_owned())
    }
    fn read(&self, _path: &String) -> Result<String, ()> {
        Ok(self.data.clone())
    }
}

pub fn render(vars: &Value, html: String) -> Result<String, RenderError<()>> {
    render_with_custom_filesystem(&vars, &"input".to_owned(), &SingleFile { data: html })
}

struct PathFilesystem {}

#[derive(Debug)]
pub enum PathFilesystemError {
    ReadError(String, io::Error),
    NoParent(String),
    FailedToStringifyPath(PathBuf),
    PathDoesNotExist(PathBuf),
}

impl Filesystem<PathFilesystemError> for PathFilesystem {
    fn read(&self, filename: &String) -> Result<String, PathFilesystemError> {
        let path: PathBuf = filename.try_into().unwrap();
        let mut file = File::open(path.clone())
            .map_err(|e| PathFilesystemError::ReadError(filename.to_owned(), e))?;
        let mut buf = String::new();
        file.read_to_string(&mut buf)
            .map_err(|e| PathFilesystemError::ReadError(filename.to_owned(), e))?;
        Ok(buf)
    }

    fn move_to(&self, current: &String, path: &String) -> Result<String, PathFilesystemError> {
        let current_path: PathBuf = current.try_into().unwrap();
        let new_path = current_path
            .parent()
            .ok_or(PathFilesystemError::NoParent(current.to_owned()))?
            .join(path);
        if new_path.exists() {
            Ok(new_path
                .to_str()
                .ok_or(PathFilesystemError::FailedToStringifyPath(
                    new_path.to_owned(),
                ))?
                .to_owned())
        } else {
            Err(PathFilesystemError::PathDoesNotExist(new_path))
        }
    }
}

pub fn render_file(
    vars: &Value,
    filename: &Path,
) -> Result<String, RenderError<PathFilesystemError>> {
    render_with_custom_filesystem(
        &vars,
        &filename.to_str().unwrap().to_owned(),
        &PathFilesystem {},
    )
}

#[cfg(test)]
mod render_test {

    use serde_json::json;

    use super::*;

    #[test]
    fn happy_path() {
        let result = render(
            &json!({ "hello": "hi" }),
            "<h1>{{ hello }} world".to_owned(),
        );
        assert_eq!(result, Ok("<h1>hi world</h1>".to_owned()));
    }

    #[test]
    fn for_loop_parser_error() {
        let result = render(
            &json!({ "hello": "hi" }),
            "<h1 pl-for='x, in [1,2,3]'>{{ hello }} world {{ x }}".to_owned(),
        );
        assert_eq!(
            result.unwrap_err().to_string(),
            (r#"FOR LOOP PARSER ERROR:
x, in [1,2,3]
^
invalid for loop
in input"#
                .to_owned())
        );
    }

    #[test]
    fn for_loop_exec_error() {
        let result = render(
            &json!({ "hello": "hi" }),
            "<h1 pl-for='x in 1'>{{ hello }} world {{ x }}".to_owned(),
        );
        assert_eq!(
            result.unwrap_err().to_string(),
            (r#"FOR LOOP EVALUATION ERROR: Expected array, found number
in input"#
                .to_owned())
        );
    }
}