fa18n 0.1.0

Modern i18n support library base on faml
Documentation
use anyhow::anyhow;
use regex::Regex;
use std::{collections::HashMap, io::Write};

fn get_subfiles(path: impl AsRef<std::path::Path>) -> Vec<String> {
    let mut subfolders = vec![];
    if let Ok(mut entries) = std::fs::read_dir(path) {
        while let Some(Ok(entry)) = entries.next() {
            if let Ok(file_type) = entry.file_type() {
                if file_type.is_file() {
                    subfolders.push(entry.file_name().to_string_lossy().to_string());
                }
            }
        }
    }
    subfolders
}

pub trait StrintExt {
    fn snake_to_camel(&self) -> String;
    fn camel_to_snake(&self) -> String;
}

impl StrintExt for String {
    fn snake_to_camel(&self) -> String {
        self[..].snake_to_camel()
    }
    fn camel_to_snake(&self) -> String {
        self[..].camel_to_snake()
    }
}

impl StrintExt for str {
    fn snake_to_camel(&self) -> String {
        self.split('_')
            .map(|word| {
                word.chars()
                    .next()
                    .unwrap()
                    .to_uppercase()
                    .collect::<String>()
                    + &word[1..]
            })
            .collect::<Vec<_>>()
            .join("")
    }

    fn camel_to_snake(&self) -> String {
        self.chars()
            .enumerate()
            .map(|(i, c)| match i != 0 && c.is_uppercase() {
                true => format!("_{c}"),
                false => format!("{c}"),
            })
            .collect::<Vec<_>>()
            .join("")
            .to_lowercase()
    }
}

pub struct I18nItem {
    pub content: HashMap<String, String>,
    pub args: HashMap<String, String>,
}

impl I18nItem {
    fn parse_args(content: &str) -> HashMap<String, String> {
        let mut args = HashMap::new();
        // {key:type}
        let re = Regex::new(r"\{([^}:]+):([^}]+)\}").unwrap();
        for cap in re.captures_iter(content) {
            let key = cap[1].to_string();
            let value = cap[2].to_string();
            args.insert(key, value);
        }
        args
    }

    pub fn new() -> Self {
        let content = HashMap::new();
        let args = HashMap::new();
        Self { content, args }
    }

    pub fn apply(&mut self, locale: String, content: String) -> anyhow::Result<()> {
        let args = Self::parse_args(&content);
        if self.content.is_empty() {
            self.args = args;
        } else {
            if self.args != args {
                Err(anyhow!("args mismatch"))?;
            }
        }
        self.content.insert(locale, content);
        Ok(())
    }
}

pub fn generate_rust(in_path: &str, out_file: &str) -> anyhow::Result<()> {
    let root = {
        let mut root = faml::FamlExpr::new();
        for in_file in get_subfiles(in_path) {
            let mut in_file_tmp = std::path::PathBuf::new();
            in_file_tmp.push(in_path);
            in_file_tmp.push(in_file);
            let faml_str = std::fs::read_to_string(in_file_tmp)?;
            let in_root = faml::FamlExpr::from_str(&faml_str)?;
            root.apply(in_root)?;
        }
        root.evaluate()?
            .as_map()
            .ok_or_else(|| anyhow::anyhow!("root is not a map"))?
    };
    let mut code = "// Automatically generated by fa18n\n".to_string();
    code.push_str("// Do not edit this file manually");
    for (group_name /* i18n */, group) in root {
        let group_name = group_name.snake_to_camel();
        let mut locales: Vec<String> = vec![];
        let mut ret_items = HashMap::new();
        let group = group
            .as_map()
            .ok_or_else(|| anyhow!("group is not a map"))?;
        for (locale /* en */, items) in group {
            let locale = locale.snake_to_camel();
            if let Err(p) = locales.binary_search_by(|p| p.cmp(&locale)) {
                locales.insert(p, locale.clone());
            }
            //
            let items = items
                .as_map()
                .ok_or_else(|| anyhow!("items is not a map"))?;
            for (key, value) in items {
                let key = key.snake_to_camel();
                ret_items
                    .entry(key)
                    .or_insert_with(I18nItem::new)
                    .apply(locale.clone(), value.as_str())?;
            }
        }

        if !code.is_empty() {
            code.push_str("\n\n\n");
        }

        code.push_str("#[derive(Clone, Copy)]\n");
        code.push_str(&format!("pub enum {group_name}Locale {{\n"));
        for locale in locales {
            code.push_str(&format!("    {locale},\n"));
        }
        code.push_str("}\n\n");
        //
        code.push_str(&format!("pub enum {group_name} {{\n"));
        for (key, item) in &ret_items {
            code.push_str(&format!(
                "    {key}{},\n",
                item.to_enum_args(QuoteType::Brace)?
            ));
        }
        code.push_str("}\n\n");
        //
        code.push_str(&format!("impl {group_name} {{\n"));
        code.push_str(&format!(
            "    pub fn translate(&self, locale: {group_name}Locale) -> String {{\n"
        ));
        code.push_str("        match self {\n");
        for (key, item) in &ret_items {
            let args = item.to_enum_args_slim();
            code.push_str(&format!(
                "            {group_name}::{key}{args} => match locale {{\n"
            ));
            for (locale, content) in &item.content {
                match item.args.is_empty() {
                    true => code.push_str(&format!(
                        "                {group_name}Locale::{locale} => \"{content}\".to_string(),\n"
                    )),
                    false => {
                        let mut new_content = content.clone();
                        for (key, value) in &item.args {
                            new_content = new_content
                                .replace(&format!("{{{key}:{value}}}"), &format!("{{{key}}}"));
                        }
                        code.push_str(&format!(
                            "                {group_name}Locale::{locale} => format!(\"{new_content}\"),\n"
                        ))
                    }
                }
            }
            code.push_str("            }\n");
        }
        code.push_str("        }\n");
        code.push_str("    }\n\n");
        //
        for (key, item) in &ret_items {
            let snake_key = key.camel_to_snake();
            code.push_str(&format!(
                "    pub fn {snake_key}({}) -> {group_name} {{\n",
                item.to_enum_args(QuoteType::None)?
            ));
            let sargs = item.to_enum_args_slim();
            code.push_str(&format!("        {group_name}::{key}{sargs}\n"));
            code.push_str("    }\n\n");
        }
        code.push_str("}\n\n");
    }
    let mut file = std::fs::File::create(out_file)?;
    file.write_all(code.as_bytes())?;
    file.flush()?;
    Ok(())
}

enum QuoteType {
    None,
    Brace,
}

impl I18nItem {
    pub fn to_enum_args(&self, qtype: QuoteType) -> anyhow::Result<String> {
        if self.args.is_empty() {
            return Ok("".to_string());
        }
        let mut ret = match qtype {
            QuoteType::None => "".to_string(),
            QuoteType::Brace => "{ ".to_string(),
        };
        for (arg, atype) in &self.args {
            let atype = match &atype[..] {
                "int" => "i64",
                "float" => "f64",
                "str" => "String",
                _ => Err(anyhow!("unknown arg type: {atype}"))?,
            };
            ret.push_str(&format!("{arg}: {atype}, "));
        }
        ret.pop();
        ret.pop();
        ret.push_str(match qtype {
            QuoteType::None => "",
            QuoteType::Brace => " }",
        });
        Ok(ret)
    }

    pub fn to_enum_args_slim(&self) -> String {
        if self.args.is_empty() {
            return "".to_string();
        }
        let mut ret = " {".to_string();
        for (arg, _) in &self.args {
            ret.push_str(&format!(" {arg}, "));
        }
        ret.pop();
        ret.pop();
        ret.push_str(" }");
        ret
    }
}