mise 2024.12.18

The front-end to your dev env
use eyre::{bail, ContextCompat, Result};
use heck::ToTitleCase;
use itertools::Itertools;
use std::collections::HashMap;
use std::fmt::Debug;

type Context = HashMap<String, String>;

pub fn render(tmpl: &str, ctx: &Context) -> Result<String> {
    let mut result = String::new();
    let mut in_tag = false;
    let mut tag = String::new();
    let chars = tmpl.chars().collect_vec();
    let mut i = 0;
    let parser = Parser { ctx };
    while i < chars.len() {
        let c = chars[i];
        let next = chars.get(i + 1).cloned().unwrap_or(' ');
        if !in_tag && c == '{' && next == '{' {
            in_tag = true;
            i += 1;
        } else if in_tag && c == '}' && next == '}' {
            in_tag = false;
            let tokens = lex(&tag)?;
            result += &parser.parse(tokens.iter().collect())?;
            tag.clear();
            i += 1;
        } else if in_tag {
            tag.push(c);
        } else {
            result.push(c);
        }
        i += 1;
    }
    Ok(result)
}

#[derive(Debug, Clone, PartialEq, strum::EnumIs)]
enum Token<'a> {
    Key(&'a str),
    String(&'a str),
    Func(&'a str),
    Whitespace(&'a str),
    Pipe,
}

fn lex(code: &str) -> Result<Vec<Token>> {
    let mut tokens = vec![];
    let mut code = code.trim();
    while !code.is_empty() {
        if code.starts_with(" ") {
            let end = code
                .chars()
                .enumerate()
                .find(|(_, c)| !c.is_whitespace())
                .map(|(i, _)| i);
            if let Some(end) = end {
                tokens.push(Token::Whitespace(&code[..end]));
                code = &code[end..];
            } else {
                break;
            }
        } else if code.starts_with("|") {
            tokens.push(Token::Pipe);
            code = &code[1..];
        } else if code.starts_with('"') {
            for (end, _) in code[1..].match_indices('"') {
                if code.chars().nth(end) != Some('\\') {
                    tokens.push(Token::String(&code[1..end + 1]));
                    code = &code[end + 2..];
                    break;
                }
            }
        } else if code.starts_with(".") {
            let end = code.split_whitespace().next().unwrap().len();
            tokens.push(Token::Key(&code[1..end]));
            code = &code[end..];
        } else if code.starts_with("|") {
            tokens.push(Token::Pipe);
            code = &code[1..];
        } else {
            let func = code.split_whitespace().next().unwrap();
            tokens.push(Token::Func(func));
            code = &code[func.len()..];
        }
    }
    Ok(tokens)
}

struct Parser<'a> {
    ctx: &'a Context,
}

impl Parser<'_> {
    fn parse(&self, tokens: Vec<&Token>) -> Result<String> {
        let mut s = String::new();
        let mut tokens = tokens.iter();
        let expect_whitespace = |t: Option<&&Token>| {
            if let Some(token) = t {
                if let Token::Whitespace(_) = token {
                    Ok(())
                } else {
                    bail!("expected whitespace, found: {token:?}");
                }
            } else {
                bail!("expected whitespace, found: end of input");
            }
        };
        let next_arg = |tokens: &mut std::slice::Iter<&Token>| -> Result<String> {
            expect_whitespace(tokens.next())?;
            let arg = tokens.next().wrap_err("missing argument")?;
            self.parse(vec![arg])
        };
        while let Some(token) = tokens.next() {
            match token {
                Token::Key(key) => {
                    if let Some(val) = self.ctx.get(*key) {
                        s = val.to_string()
                    } else {
                        bail!("unable to find key in context: {key}");
                    }
                }
                Token::String(str) => s = str.to_string(),
                Token::Func(func) => match *func {
                    "title" => {
                        let arg = next_arg(&mut tokens)?;
                        s = arg.to_title_case();
                    }
                    "trimV" => {
                        let arg = next_arg(&mut tokens)?;
                        s = arg.trim_start_matches('v').to_string();
                    }
                    "trimPrefix" => {
                        let prefix = next_arg(&mut tokens)?;
                        let str = next_arg(&mut tokens)?;
                        if let Some(str) = str.strip_prefix(&prefix) {
                            s = str.to_string();
                        } else {
                            s = str.to_string();
                        }
                    }
                    _ => bail!("unexpected function: {func}"),
                },
                Token::Whitespace(_) => {}
                Token::Pipe => {
                    let mut tokens = tokens.cloned().collect_vec();
                    let whitespace = Token::Whitespace(" ");
                    let str = Token::String(&s);
                    tokens.push(&whitespace);
                    tokens.push(&str);
                    return self.parse(tokens);
                }
            }
        }
        Ok(s)
    }
}

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

    #[test]
    fn test_render() {
        let tmpl = "Hello, {{.OS}}!";
        let mut ctx = HashMap::new();
        ctx.insert("OS".to_string(), "world".to_string());
        assert_eq!(render(tmpl, &ctx).unwrap(), "Hello, world!");
    }

    macro_rules! parse_tests {
    ($($name:ident: $value:expr,)*) => {
        $(
            #[test]
            fn $name() {
                let (input, expected, ctx): (&str, &str, HashMap<&str, &str>) = $value;
                let ctx = ctx.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
                let parser = Parser { ctx: &ctx };
                let tokens = lex(input).unwrap();
                assert_eq!(expected, parser.parse(tokens.iter().collect()).unwrap());
            }
        )*
    }}

    parse_tests!(
        test_parse_key: (".OS", "world", hashmap!{"OS" => "world"}),
        test_parse_string: ("\"world\"", "world", hashmap!{}),
        test_parse_title: (r#"title "world""#, "World", hashmap!{}),
        test_parse_trimv: (r#"trimV "v1.0.0""#, "1.0.0", hashmap!{}),
        test_parse_trim_prefix: (r#"trimPrefix "v" "v1.0.0""#, "1.0.0", hashmap!{}),
        test_parse_trim_prefix2: (r#"trimPrefix "v" "1.0.0""#, "1.0.0", hashmap!{}),
        test_parse_pipe: (r#"trimPrefix "foo-" "foo-v1.0.0" | trimV"#, "1.0.0", hashmap!{}),
    );

    #[test]
    fn test_parse_err() {
        let parser = Parser {
            ctx: &HashMap::new(),
        };
        let tokens = lex("foo").unwrap();
        assert!(parser.parse(tokens.iter().collect()).is_err());
    }

    #[test]
    fn test_lex() {
        assert_eq!(
            lex(r#"trimPrefix "foo-" "foo-v1.0.0" | trimV"#).unwrap(),
            vec![
                Token::Func("trimPrefix"),
                Token::Whitespace(" "),
                Token::String("foo-"),
                Token::Whitespace(" "),
                Token::String("foo-v1.0.0"),
                Token::Whitespace(" "),
                Token::Pipe,
                Token::Whitespace(" "),
                Token::Func("trimV"),
            ]
        );
    }
}