globetrotter_rust/
lib.rs

1//! Rust bindings code generation for globetrotter translations.
2
3/// Rust code generation configuration types.
4pub mod config;
5
6pub use config::OutputConfig;
7
8use convert_case::{Case, Casing};
9use globetrotter_model as model;
10use globetrotter_model::ext::iter::TryUnzipExt;
11use quote::{format_ident, quote};
12
13/// Common header inserted at the top of generated Rust files.
14#[must_use]
15pub fn preamble() -> String {
16    indoc::formatdoc!(
17        r"
18            //
19            // AUTOGENERATED. DO NOT EDIT.
20            // generated by globetrotter v{version}.
21            //
22        ",
23        version = std::env!("CARGO_PKG_VERSION"),
24    )
25}
26
27fn argument_to_rust_field_name(name: &str) -> String {
28    let field_name = name.replace(' ', "").replace(['-', '.'], "_");
29    field_name.to_case(Case::Snake)
30}
31
32fn key_to_rust_enum_variant(key: &str) -> String {
33    let variant_name = key.replace(' ', "").replace(['-', '.'], "_");
34    variant_name.to_case(Case::UpperCamel)
35}
36
37trait IntoTokenStream {
38    fn into_token_stream(self) -> (proc_macro2::TokenStream, bool);
39}
40
41impl IntoTokenStream for model::ArgumentType {
42    fn into_token_stream(self) -> (proc_macro2::TokenStream, bool) {
43        match self {
44            Self::Number => {
45                let tokens = quote! {i64};
46                (tokens, false)
47            }
48            // TODO(roman): create our own globetrotter type for this
49            Self::String | Self::Iso8601DateTimeString => {
50                let tokens = quote! {&'a str};
51                (tokens, true)
52            }
53            Self::Any => {
54                let tokens = quote! {serde_json::Value};
55                (tokens, false)
56            }
57        }
58    }
59}
60
61/// Error raised when multiple translation keys map to the same Rust enum variant
62/// identifier.
63#[derive(thiserror::Error, Debug)]
64pub struct DuplicateIdentifierError {
65    identifier: String,
66    keys: Vec<String>,
67}
68
69impl std::fmt::Display for DuplicateIdentifierError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "duplicate identifier `{}` (used by {})",
74            self.identifier,
75            self.keys.join(", ")
76        )
77    }
78}
79
80/// Error raised when multiple translation arguments would produce the same
81/// Rust struct field name.
82#[derive(thiserror::Error, Debug)]
83pub struct DuplicateFieldError {
84    field: String,
85    enum_variant: String,
86    arguments: Vec<String>,
87    key: String,
88}
89
90impl std::fmt::Display for DuplicateFieldError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(
93            f,
94            "{}: duplicate field `{}` used by arguments {} of variant `{}`",
95            self.key,
96            self.field,
97            self.arguments
98                .iter()
99                .map(|arg| format!("{arg:?}"))
100                .collect::<Vec<_>>()
101                .join(", "),
102            self.enum_variant,
103        )
104    }
105}
106
107/// Errors that can occur while generating Rust translation bindings.
108#[derive(thiserror::Error, Debug)]
109pub enum Error {
110    /// Duplicate Rust enum identifier derived from translation keys.
111    #[error(transparent)]
112    DuplicateIdentifier(#[from] DuplicateIdentifierError),
113    /// Duplicate Rust struct field derived from translation arguments.
114    #[error(transparent)]
115    DuplicateField(#[from] DuplicateFieldError),
116    /// Error originating from the `syn` crate when pretty-printing generated code.
117    #[error("{0}")]
118    Syn(String),
119}
120
121/// Generate a Rust `Translation` enum for the given translations model.
122///
123/// The generated code includes a `key` method that maps each variant back to
124/// its original translation key.
125///
126/// # Errors
127///
128/// Returns an error if translation keys or argument names would result in
129/// duplicate Rust identifiers, or if the generated code cannot be parsed by
130/// `syn` for pretty-printing.
131pub fn generate_translation_enum(translations: &model::Translations) -> Result<String, Error> {
132    use itertools::Itertools;
133
134    // Can we use https://github.com/rust-phf/rust-phf at compile time?
135
136    let enum_variant_names: Vec<_> = translations
137        .0
138        .iter()
139        .map(|(key, translation)| (key_to_rust_enum_variant(key.as_ref()), key, translation))
140        .collect();
141
142    // Find duplicate identifiers
143    let duplicates: Vec<_> = enum_variant_names
144        .iter()
145        .duplicates_by(|(safe_key, _, _)| safe_key)
146        .collect();
147
148    if let Some(first) = duplicates.first() {
149        let identifier = first.0.clone();
150        let keys = duplicates
151            .into_iter()
152            .map(|(_, key, _)| key.to_string())
153            .collect();
154        return Err(DuplicateIdentifierError { identifier, keys }.into());
155    }
156
157    let enum_variants = enum_variant_names
158        .iter()
159        .map(|(safe_key, key, translation)| {
160            let fields: Vec<_> = translation
161                .arguments
162                .iter()
163                .map(|(name, typ)| (argument_to_rust_field_name(name), name, typ))
164                .collect();
165
166            // Check for duplicate field names
167            let duplicates: Vec<_> = fields
168                .iter()
169                .duplicates_by(|(safe_name, _, _)| safe_name)
170                .collect();
171
172            if let Some(first) = duplicates.first() {
173                let field = first.0.clone();
174                let arguments = duplicates
175                    .into_iter()
176                    .map(|(_, key, _)| (*key).clone())
177                    .collect();
178                return Err(Error::from(DuplicateFieldError {
179                    field,
180                    arguments,
181                    enum_variant: safe_key.clone(),
182                    key: key.to_string(),
183                }));
184            }
185
186            let fields = fields.into_iter().map(|(safe_name, name, typ)| {
187                let field_ident = format_ident!("{safe_name}");
188                let (typ, uses_lifetime) = typ.into_token_stream();
189                let tokens = quote! {
190                    #[serde(rename = #name)]
191                    #field_ident: #typ,
192                };
193                (tokens, uses_lifetime)
194            });
195
196            let (fields, uses_lifetime): (Vec<_>, Vec<_>) = fields.unzip();
197            let uses_lifetime = uses_lifetime.iter().any(|v| *v);
198
199            let variant_name_ident = format_ident!("{safe_key}");
200            let tokens = quote! {
201                #variant_name_ident {
202                    #(#fields)*
203                },
204            };
205            Ok((tokens, uses_lifetime))
206        });
207
208    let (enum_variants, uses_lifetime): (Vec<_>, Vec<_>) = enum_variants.try_unzip()?;
209    let uses_lifetime = uses_lifetime.iter().any(|v| *v);
210
211    let enum_variant_keys: Vec<_> = enum_variant_names
212        .iter()
213        .map(|(safe_key, key, _)| {
214            let variant_name_ident = format_ident!("{safe_key}");
215            let key = key.as_ref();
216            quote! {
217                Self::#variant_name_ident { .. } => #key,
218            }
219        })
220        .collect();
221
222    // Build generics only if needed
223    let generics: syn::Generics = if uses_lifetime {
224        syn::parse_quote!(<'a>)
225    } else {
226        syn::Generics::default()
227    };
228    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
229
230    let out = quote! {
231        #[derive(
232            Debug, Clone, PartialEq, Eq, PartialOrd, Ord, ::serde::Serialize, ::serde::Deserialize,
233        )]
234        #[serde(untagged)]
235        pub enum Translation #generics {
236            #(#enum_variants)*
237        }
238
239        impl #impl_generics Translation #ty_generics #where_clause {
240            pub fn key(&self) -> &'static str {
241                match self {
242                    #(#enum_variant_keys)*
243                }
244            }
245        }
246    };
247    let code = pretty_print(&out).map_err(|err| Error::Syn(err.to_string()))?;
248    let code = format!("{}\n{}", preamble(), code);
249    Ok(code)
250}
251
252fn pretty_print<T>(input: T) -> Result<String, syn::Error>
253where
254    T: quote::ToTokens,
255{
256    let file: syn::File = syn::parse2(quote! { #input })?;
257    Ok(prettyplease::unparse(&file))
258}
259
260#[cfg(test)]
261mod tests {
262    use color_eyre::eyre;
263    use globetrotter_model::{self as model, diagnostics::Spanned};
264    use similar_asserts::assert_eq as sim_assert_eq;
265
266    static INIT: std::sync::Once = std::sync::Once::new();
267
268    /// Initialize test
269    ///
270    /// This ensures `color_eyre` is setup once.
271    pub fn init() {
272        INIT.call_once(|| {
273            color_eyre::install().ok();
274        });
275    }
276
277    #[test]
278    fn generate_enum_with_lifetime() -> eyre::Result<()> {
279        crate::tests::init();
280
281        let translations = [
282            (
283                Spanned::dummy("test.one".to_string()),
284                model::Translation {
285                    language: [(
286                        model::Language::En,
287                        Spanned::dummy("test.one in en".to_string()),
288                    )]
289                    .into_iter()
290                    .collect(),
291                    arguments: [].into_iter().collect(),
292                    file_id: 0,
293                },
294            ),
295            (
296                Spanned::dummy("test.two".to_string()),
297                model::Translation {
298                    language: [(
299                        model::Language::En,
300                        Spanned::dummy("test.two in en".to_string()),
301                    )]
302                    .into_iter()
303                    .collect(),
304                    arguments: [
305                        ("arg-one".to_string(), model::ArgumentType::String),
306                        ("ArgTwo".to_string(), model::ArgumentType::Number),
307                        ("Arg_Three".to_string(), model::ArgumentType::Any),
308                    ]
309                    .into_iter()
310                    .collect(),
311                    file_id: 0,
312                },
313            ),
314        ];
315        let translations = model::Translations(translations.into_iter().collect());
316        let have = super::generate_translation_enum(&translations)?;
317        println!("{have}");
318
319        let want = indoc::indoc! {r#"
320            #[derive(
321                Debug,
322                Clone,
323                PartialEq,
324                Eq,
325                PartialOrd,
326                Ord,
327                ::serde::Serialize,
328                ::serde::Deserialize,
329            )]
330            #[serde(untagged)]
331            pub enum Translation<'a> {
332                TestOne {},
333                TestTwo {
334                    #[serde(rename = "arg-one")]
335                    arg_one: &'a str,
336                    #[serde(rename = "ArgTwo")]
337                    arg_two: i64,
338                    #[serde(rename = "Arg_Three")]
339                    arg_three: serde_json::Value,
340                },
341            }
342            impl<'a> Translation<'a> {
343                pub fn key(&self) -> &'static str {
344                    match self {
345                        Self::TestOne { .. } => "test.one",
346                        Self::TestTwo { .. } => "test.two",
347                    }
348                }
349            }
350        "# };
351        let want = format!("{}\n{}", super::preamble(), want);
352        sim_assert_eq!(have: have, want: want);
353        Ok(())
354    }
355
356    #[test]
357    fn generate_enum_without_lifetime() -> eyre::Result<()> {
358        crate::tests::init();
359
360        let translations = [
361            (
362                Spanned::dummy("test.one".to_string()),
363                model::Translation {
364                    language: [(
365                        model::Language::En,
366                        Spanned::dummy("test.one in en".to_string()),
367                    )]
368                    .into_iter()
369                    .collect(),
370                    arguments: [].into_iter().collect(),
371                    file_id: 0,
372                },
373            ),
374            (
375                Spanned::dummy("test.two".to_string()),
376                model::Translation {
377                    language: [(
378                        model::Language::En,
379                        Spanned::dummy("test.two in en".to_string()),
380                    )]
381                    .into_iter()
382                    .collect(),
383                    arguments: [("ArgTwo".to_string(), model::ArgumentType::Number)]
384                        .into_iter()
385                        .collect(),
386                    file_id: 0,
387                },
388            ),
389        ];
390        let translations = model::Translations(translations.into_iter().collect());
391        let have = super::generate_translation_enum(&translations)?;
392        println!("{have}");
393
394        let want = indoc::indoc! {r#"
395            #[derive(
396                Debug,
397                Clone,
398                PartialEq,
399                Eq,
400                PartialOrd,
401                Ord,
402                ::serde::Serialize,
403                ::serde::Deserialize,
404            )]
405            #[serde(untagged)]
406            pub enum Translation {
407                TestOne {},
408                TestTwo { #[serde(rename = "ArgTwo")] arg_two: i64 },
409            }
410            impl Translation {
411                pub fn key(&self) -> &'static str {
412                    match self {
413                        Self::TestOne { .. } => "test.one",
414                        Self::TestTwo { .. } => "test.two",
415                    }
416                }
417            }
418        "# };
419        let want = format!("{}\n{}", super::preamble(), want);
420        sim_assert_eq!(have: have, want: want);
421        Ok(())
422    }
423}