pub mod config;
pub use config::OutputConfig;
use convert_case::{Case, Casing};
use globetrotter_model as model;
use quote::{format_ident, quote};
#[must_use]
pub fn preamble() -> String {
indoc::formatdoc!(
r"
//
// AUTOGENERATED. DO NOT EDIT.
// generated by globetrotter v{version}.
//
",
version = std::env!("CARGO_PKG_VERSION"),
)
}
fn argument_to_rust_field_name(name: &str) -> String {
let field_name = name.replace(' ', "").replace(['-', '.'], "_");
field_name.to_case(Case::Snake)
}
#[must_use]
pub fn key_to_rust_enum_variant(key: &str) -> String {
let variant_name = key.replace(' ', "").replace(['-', '.'], "_");
variant_name.to_case(Case::UpperCamel)
}
#[derive(Clone, Copy, Debug)]
struct Comparisons {
eq: bool,
partial_ord: bool,
ord: bool,
}
impl Comparisons {
const TOTAL: Self = Self {
eq: true,
partial_ord: true,
ord: true,
};
const PARTIAL: Self = Self {
eq: false,
partial_ord: true,
ord: false,
};
const EQ_ONLY: Self = Self {
eq: true,
partial_ord: false,
ord: false,
};
fn and(self, other: Self) -> Self {
Self {
eq: self.eq && other.eq,
partial_ord: self.partial_ord && other.partial_ord,
ord: self.ord && other.ord,
}
}
}
struct RustType {
tokens: proc_macro2::TokenStream,
borrows: bool,
comparisons: Comparisons,
}
impl RustType {
fn owned(tokens: proc_macro2::TokenStream) -> Self {
Self {
tokens,
borrows: false,
comparisons: Comparisons::TOTAL,
}
}
}
trait IntoRustType {
fn into_rust_type(self) -> RustType;
}
impl IntoRustType for model::ArgumentType {
fn into_rust_type(self) -> RustType {
match self {
Self::Number | Self::Integer => RustType::owned(quote! {i64}),
Self::Float => RustType {
tokens: quote! {f64},
borrows: false,
comparisons: Comparisons::PARTIAL,
},
Self::Boolean => RustType::owned(quote! {bool}),
Self::String | Self::Iso8601DateTimeString => RustType {
tokens: quote! {&'a str},
borrows: true,
comparisons: Comparisons::TOTAL,
},
Self::Any => RustType {
tokens: quote! {serde_json::Value},
borrows: false,
comparisons: Comparisons::EQ_ONLY,
},
}
}
}
#[derive(thiserror::Error, Debug)]
pub struct DuplicateIdentifierError {
identifier: String,
keys: Vec<String>,
}
impl std::fmt::Display for DuplicateIdentifierError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"duplicate identifier `{}` (used by {})",
self.identifier,
self.keys.join(", ")
)
}
}
#[derive(thiserror::Error, Debug)]
pub struct DuplicateFieldError {
field: String,
enum_variant: String,
arguments: Vec<String>,
key: String,
}
impl std::fmt::Display for DuplicateFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: duplicate field `{}` used by arguments {} of variant `{}`",
self.key,
self.field,
self.arguments
.iter()
.map(|arg| format!("{arg:?}"))
.collect::<Vec<_>>()
.join(", "),
self.enum_variant,
)
}
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
DuplicateIdentifier(#[from] DuplicateIdentifierError),
#[error(transparent)]
DuplicateField(#[from] DuplicateFieldError),
#[error("{0}")]
Syn(String),
}
struct Variant {
tokens: proc_macro2::TokenStream,
borrows: bool,
comparisons: Comparisons,
}
fn generate_variant(
safe_key: &str,
key: &str,
translation: &model::Translation,
) -> Result<Variant, Error> {
use itertools::Itertools;
let fields: Vec<_> = translation
.arguments
.iter()
.map(|(name, typ)| (argument_to_rust_field_name(name), name, typ))
.collect();
let duplicates: Vec<_> = fields
.iter()
.duplicates_by(|(safe_name, _, _)| safe_name)
.collect();
if let Some(first) = duplicates.first() {
let field = first.0.clone();
let arguments = duplicates
.into_iter()
.map(|(_, key, _)| (*key).clone())
.collect();
return Err(Error::from(DuplicateFieldError {
field,
arguments,
enum_variant: safe_key.to_string(),
key: key.to_string(),
}));
}
let mut borrows = false;
let mut comparisons = Comparisons::TOTAL;
let fields: Vec<_> = fields
.into_iter()
.map(|(safe_name, name, typ)| {
let field_ident = format_ident!("{safe_name}");
let typ = typ.into_rust_type();
borrows |= typ.borrows;
comparisons = comparisons.and(typ.comparisons);
let typ = typ.tokens;
quote! {
#[serde(rename = #name)]
#field_ident: #typ,
}
})
.collect();
let variant_name_ident = format_ident!("{safe_key}");
let tokens = quote! {
#variant_name_ident {
#(#fields)*
},
};
Ok(Variant {
tokens,
borrows,
comparisons,
})
}
pub fn generate_translation_enum(translations: &model::Translations) -> Result<String, Error> {
use itertools::Itertools;
let enum_variant_names: Vec<_> = translations
.0
.iter()
.map(|(key, translation)| (key_to_rust_enum_variant(key.as_ref()), key, translation))
.collect();
let duplicates: Vec<_> = enum_variant_names
.iter()
.duplicates_by(|(safe_key, _, _)| safe_key)
.collect();
if let Some(first) = duplicates.first() {
let identifier = first.0.clone();
let keys = duplicates
.into_iter()
.map(|(_, key, _)| key.to_string())
.collect();
return Err(DuplicateIdentifierError { identifier, keys }.into());
}
let mut uses_lifetime = false;
let mut comparisons = Comparisons::TOTAL;
let mut enum_variants = Vec::with_capacity(enum_variant_names.len());
for (safe_key, key, translation) in &enum_variant_names {
let variant = generate_variant(safe_key, key.as_ref(), translation)?;
uses_lifetime |= variant.borrows;
comparisons = comparisons.and(variant.comparisons);
enum_variants.push(variant.tokens);
}
let enum_variant_keys: Vec<_> = enum_variant_names
.iter()
.map(|(safe_key, key, _)| {
let variant_name_ident = format_ident!("{safe_key}");
let key = key.as_ref();
quote! {
Self::#variant_name_ident { .. } => #key,
}
})
.collect();
let generics: syn::Generics = if uses_lifetime {
syn::parse_quote!(<'a>)
} else {
syn::Generics::default()
};
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let eq = comparisons.eq.then(|| quote! { Eq, });
let partial_ord = comparisons.partial_ord.then(|| quote! { PartialOrd, });
let ord = comparisons.ord.then(|| quote! { Ord, });
let out = quote! {
#[derive(
Debug, Clone, PartialEq, #eq #partial_ord #ord ::serde::Serialize, ::serde::Deserialize,
)]
#[serde(untagged)]
pub enum Translation #generics {
#(#enum_variants)*
}
impl #impl_generics Translation #ty_generics #where_clause {
pub fn key(&self) -> &'static str {
match self {
#(#enum_variant_keys)*
}
}
}
};
let code = pretty_print(&out).map_err(|err| Error::Syn(err.to_string()))?;
let code = format!("{}\n{}", preamble(), code);
Ok(code)
}
fn pretty_print<T>(input: T) -> Result<String, syn::Error>
where
T: quote::ToTokens,
{
let file: syn::File = syn::parse2(quote! { #input })?;
Ok(prettyplease::unparse(&file))
}
#[cfg(test)]
mod tests {
use color_eyre::eyre;
use globetrotter_model::{self as model, diagnostics::Spanned};
use similar_asserts::assert_eq as sim_assert_eq;
#[test_util::test]
fn generate_enum_with_lifetime() -> eyre::Result<()> {
let translations = [
(
Spanned::dummy("test.one".to_string()),
model::Translation {
language: [(
model::Language::En,
Spanned::dummy("test.one in en".to_string()),
)]
.into_iter()
.collect(),
arguments: [].into_iter().collect(),
file_id: 0,
allow: std::collections::BTreeSet::new(),
},
),
(
Spanned::dummy("test.two".to_string()),
model::Translation {
language: [(
model::Language::En,
Spanned::dummy("test.two in en".to_string()),
)]
.into_iter()
.collect(),
arguments: [
("arg-one".to_string(), model::ArgumentType::String),
("ArgTwo".to_string(), model::ArgumentType::Number),
("Arg_Three".to_string(), model::ArgumentType::Any),
("ArgFour".to_string(), model::ArgumentType::Boolean),
("ArgFive".to_string(), model::ArgumentType::Integer),
]
.into_iter()
.collect(),
file_id: 0,
allow: std::collections::BTreeSet::new(),
},
),
];
let translations = model::Translations(translations.into_iter().collect());
let have = super::generate_translation_enum(&translations)?;
println!("{have}");
let want = indoc::indoc! {r#"
#[derive(Debug, Clone, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)]
#[serde(untagged)]
pub enum Translation<'a> {
TestOne {},
TestTwo {
#[serde(rename = "arg-one")]
arg_one: &'a str,
#[serde(rename = "ArgTwo")]
arg_two: i64,
#[serde(rename = "Arg_Three")]
arg_three: serde_json::Value,
#[serde(rename = "ArgFour")]
arg_four: bool,
#[serde(rename = "ArgFive")]
arg_five: i64,
},
}
impl<'a> Translation<'a> {
pub fn key(&self) -> &'static str {
match self {
Self::TestOne { .. } => "test.one",
Self::TestTwo { .. } => "test.two",
}
}
}
"# };
let want = format!("{}\n{}", super::preamble(), want);
sim_assert_eq!(have: have, want: want);
Ok(())
}
#[test_util::test]
fn generate_enum_with_float() -> eyre::Result<()> {
let translations = [(
Spanned::dummy("cart.total".to_string()),
model::Translation {
language: [(
model::Language::En,
Spanned::dummy("{{count}} items for {{price}}".to_string()),
)]
.into_iter()
.collect(),
arguments: [
("count".to_string(), model::ArgumentType::Integer),
("price".to_string(), model::ArgumentType::Float),
]
.into_iter()
.collect(),
file_id: 0,
allow: std::collections::BTreeSet::new(),
},
)];
let translations = model::Translations(translations.into_iter().collect());
let have = super::generate_translation_enum(&translations)?;
println!("{have}");
let want = indoc::indoc! {r#"
#[derive(Debug, Clone, PartialEq, PartialOrd, ::serde::Serialize, ::serde::Deserialize)]
#[serde(untagged)]
pub enum Translation {
CartTotal {
#[serde(rename = "count")]
count: i64,
#[serde(rename = "price")]
price: f64,
},
}
impl Translation {
pub fn key(&self) -> &'static str {
match self {
Self::CartTotal { .. } => "cart.total",
}
}
}
"# };
let want = format!("{}\n{}", super::preamble(), want);
sim_assert_eq!(have: have, want: want);
Ok(())
}
#[test_util::test]
fn generate_enum_without_lifetime() -> eyre::Result<()> {
let translations = [
(
Spanned::dummy("test.one".to_string()),
model::Translation {
language: [(
model::Language::En,
Spanned::dummy("test.one in en".to_string()),
)]
.into_iter()
.collect(),
arguments: [].into_iter().collect(),
file_id: 0,
allow: std::collections::BTreeSet::new(),
},
),
(
Spanned::dummy("test.two".to_string()),
model::Translation {
language: [(
model::Language::En,
Spanned::dummy("test.two in en".to_string()),
)]
.into_iter()
.collect(),
arguments: [("ArgTwo".to_string(), model::ArgumentType::Number)]
.into_iter()
.collect(),
file_id: 0,
allow: std::collections::BTreeSet::new(),
},
),
];
let translations = model::Translations(translations.into_iter().collect());
let have = super::generate_translation_enum(&translations)?;
println!("{have}");
let want = indoc::indoc! {r#"
#[derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
::serde::Serialize,
::serde::Deserialize,
)]
#[serde(untagged)]
pub enum Translation {
TestOne {},
TestTwo { #[serde(rename = "ArgTwo")] arg_two: i64 },
}
impl Translation {
pub fn key(&self) -> &'static str {
match self {
Self::TestOne { .. } => "test.one",
Self::TestTwo { .. } => "test.two",
}
}
}
"# };
let want = format!("{}\n{}", super::preamble(), want);
sim_assert_eq!(have: have, want: want);
Ok(())
}
}