use crate::ir::Ir;
use anyhow::{bail, Result};
use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
use regex::Regex;
use std::collections::HashMap;
use std::sync::LazyLock;
pub mod pkg;
pub mod react;
pub mod store;
pub mod swift;
pub mod ts;
pub trait Emitter {
fn emit(&self, ir: &Ir) -> String;
fn validate(&self, _ir: &Ir) -> Result<()> {
Ok(())
}
}
const RESERVED_KEYS: &[&str] = &[
"__proto__",
"prototype",
"constructor",
"toString",
"toLocaleString",
"valueOf",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
];
#[derive(Clone, Copy, PartialEq)]
pub enum Case {
Camel,
Snake,
Pascal,
Preserve,
}
impl Case {
pub fn parse(s: &str) -> Result<Case> {
Ok(match s {
"camel" => Case::Camel,
"snake" => Case::Snake,
"pascal" => Case::Pascal,
"preserve" => Case::Preserve,
other => bail!("unknown case '{other}' (use: camel | snake | pascal | preserve)"),
})
}
pub fn apply(self, s: &str) -> String {
match self {
Case::Camel => s.to_lower_camel_case(),
Case::Snake => s.to_snake_case(),
Case::Pascal => s.to_upper_camel_case(),
Case::Preserve => s.to_string(),
}
}
}
#[derive(Clone)]
pub struct Binding {
pub ty: String,
}
impl Binding {
pub fn new(raw: &str) -> Binding {
Binding {
ty: raw.to_upper_camel_case(),
}
}
pub fn factory(&self) -> String {
format!("create{}", self.ty)
}
pub fn hook(&self) -> String {
format!("use{}", self.ty)
}
pub fn getter(&self) -> String {
format!("get{}", self.ty)
}
}
#[derive(Clone)]
pub struct EmitOptions {
pub callable: bool,
pub core: String,
pub store: String,
pub case: Case,
pub binding: Binding,
}
pub fn emitter_for(lang: &str, opts: &EmitOptions) -> Option<Box<dyn Emitter>> {
match lang {
"typescript" | "ts" => Some(Box::new(ts::TsEmitter {
callable: opts.callable,
case: opts.case,
binding: opts.binding.clone(),
})),
"swift" => Some(Box::new(swift::SwiftEmitter {
case: opts.case,
binding: opts.binding.clone(),
})),
"store" => Some(Box::new(store::StoreEmitter {
core: opts.core.clone(),
binding: opts.binding.clone(),
})),
"react" => Some(Box::new(react::ReactEmitter {
store: opts.store.clone(),
binding: opts.binding.clone(),
})),
_ => None,
}
}
static PLACEHOLDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(\w+)\}\}").unwrap());
pub fn recase_placeholders(s: &str, case: Case) -> String {
if case == Case::Preserve {
return s.to_string();
}
PLACEHOLDER
.replace_all(s, |c: ®ex::Captures| {
format!("{{{{{}}}}}", case.apply(&c[1]))
})
.into_owned()
}
pub fn validate_idents(ir: &Ir, case: Case) -> Result<()> {
let mut siblings: HashMap<Vec<String>, HashMap<String, String>> = HashMap::new();
for m in &ir.messages {
for i in 0..m.path.len() {
let parent = m.path[..i].to_vec();
let seg = &m.path[i];
let cased = case.apply(seg);
check_ident(seg, &cased)?;
let group = siblings.entry(parent).or_default();
match group.get(&cased) {
Some(orig) if orig != seg => bail!(
"keys '{orig}' and '{seg}' both become '{cased}' under the chosen output case"
),
_ => {
group.insert(cased, seg.clone());
}
}
}
let mut params: HashMap<String, String> = HashMap::new();
for p in &m.params {
let cased = case.apply(&p.name);
check_ident(&p.name, &cased)?;
match params.get(&cased) {
Some(orig) if orig != &p.name => {
bail!("params '{orig}' and '{}' both become '{cased}'", p.name)
}
_ => {
params.insert(cased, p.name.clone());
}
}
}
}
Ok(())
}
fn check_ident(orig: &str, cased: &str) -> Result<()> {
let valid = cased
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& cased.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !valid {
bail!("key '{orig}' becomes '{cased}', which isn't a valid identifier");
}
if RESERVED_KEYS.contains(&cased) {
bail!("key '{orig}' becomes '{cased}', which collides with a built-in object member — rename it");
}
Ok(())
}
pub fn cat_char(name: &str) -> char {
match name {
"zero" => 'z',
"one" => '1',
"two" => '2',
"few" => 'f',
"many" => 'm',
_ => 'o',
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{Kind, Message};
use std::collections::BTreeMap;
fn leaf(path: &[&str]) -> Message {
Message {
path: path.iter().map(|s| s.to_string()).collect(),
params: vec![],
kind: Kind::Plain,
values: BTreeMap::new(),
}
}
fn ir(messages: Vec<Message>) -> Ir {
Ir {
canonical: "en".into(),
locales: vec!["en".into()],
messages,
plural_rules: BTreeMap::new(),
}
}
#[test]
fn case_apply_normalizes_any_input() {
assert_eq!(Case::Camel.apply("walker_today"), "walkerToday");
assert_eq!(Case::Camel.apply("greeting-text"), "greetingText");
assert_eq!(Case::Snake.apply("walkerToday"), "walker_today");
assert_eq!(Case::Pascal.apply("dog_count"), "DogCount");
assert_eq!(Case::Preserve.apply("dog_count"), "dog_count");
}
#[test]
fn recase_placeholders_track_the_output_case() {
assert_eq!(
recase_placeholders("Hi {{first_name}}", Case::Camel),
"Hi {{firstName}}"
);
assert_eq!(
recase_placeholders("Hi {{firstName}}", Case::Snake),
"Hi {{first_name}}"
);
}
#[test]
fn validate_flags_collisions_and_passes_clean() {
let collide = ir(vec![leaf(&["dog_count"]), leaf(&["dogCount"])]);
assert!(validate_idents(&collide, Case::Camel).is_err());
let clean = ir(vec![leaf(&["dog_count"]), leaf(&["walker_today"])]);
assert!(validate_idents(&clean, Case::Camel).is_ok());
}
#[test]
fn validate_rejects_invalid_identifiers() {
let bad = ir(vec![leaf(&["2fa"])]);
assert!(validate_idents(&bad, Case::Preserve).is_err());
}
#[test]
fn validate_rejects_reserved_object_keys() {
assert!(validate_idents(&ir(vec![leaf(&["toString"])]), Case::Camel).is_err());
assert!(validate_idents(&ir(vec![leaf(&["__proto__"])]), Case::Preserve).is_err());
assert!(validate_idents(&ir(vec![leaf(&["constructor"])]), Case::Camel).is_err());
}
}